diff --git a/tui/README.md b/tui/README.md index cd8a532..3746bd5 100644 --- a/tui/README.md +++ b/tui/README.md @@ -37,6 +37,7 @@ Compatibility aliases: - `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer - `Esc` (in composer): exit input mode and focus sidebar - `Up` / `Down` (in sidebar): move highlight +- `Page Up` / `Page Down` (in transcript): scroll the transcript by one page - `Enter` in sidebar: load highlighted conversation/search - `Enter` in composer: send message/search - `n`: new chat draft diff --git a/tui/src/index.ts b/tui/src/index.ts index 880862e..d8b92ea 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -323,6 +323,35 @@ async function main() { const focusables = [sidebar, transcript, composer] as const; + function getTranscriptViewportHeight() { + const lpos = transcript.lpos; + if (lpos) { + return Math.max(1, lpos.yl - lpos.yi - Number(transcript.iheight ?? 0)); + } + + return Math.max(1, Number(transcript.height ?? 1) - Number(transcript.iheight ?? 0)); + } + + function isTranscriptNearBottom() { + const viewportHeight = getTranscriptViewportHeight(); + const maxScroll = Math.max(0, transcript.getScrollHeight() - viewportHeight); + if (maxScroll === 0) return true; + return maxScroll - transcript.getScroll() <= 1; + } + + function queueTranscriptScrollToBottomIfFollowing() { + if (isTranscriptNearBottom()) { + forceScrollToBottom = true; + } + } + + function scrollTranscriptByPage(direction: 1 | -1) { + forceScrollToBottom = false; + const pageSize = Math.max(1, getTranscriptViewportHeight() - 1); + transcript.scroll(direction * pageSize); + screen.render(); + } + function withSuppressedSidebarSelectEvents(fn: () => void) { suppressedSidebarSelectEvents += 1; try { @@ -684,7 +713,7 @@ async function main() { return items.some((item) => item.kind === selection.kind && item.id === selection.id); } - async function loadSelection(selection: SidebarSelection | null) { + async function loadSelection(selection: SidebarSelection | null, options?: { scrollToBottom?: boolean | undefined }) { if (!selection) { selectedChat = null; selectedSearch = null; @@ -714,12 +743,18 @@ async function main() { if (selectionKey(selectedItem) === requestedSelectionKey) { isLoadingSelection = false; } - forceScrollToBottom = true; + if (options?.scrollToBottom) { + forceScrollToBottom = true; + } updateUI(); } } - async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) { + async function refreshCollections(options?: { + preferredSelection?: SidebarSelection; + loadSelection?: boolean; + scrollToBottomOnLoad?: boolean | undefined; + }) { isLoadingCollections = true; updateUI(); @@ -748,7 +783,7 @@ async function main() { } if (options?.loadSelection) { - await loadSelection(selectedItem); + await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad }); } } @@ -797,7 +832,7 @@ async function main() { errorMessage = null; forceScrollToBottom = true; updateUI(); - await loadSelection(selectedItem); + await loadSelection(selectedItem, { scrollToBottom: true }); } function handleCreateChat() { @@ -966,7 +1001,7 @@ async function main() { }; } - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onDelta: (payload) => { @@ -982,7 +1017,7 @@ async function main() { if (!updated) return; pendingChatState = { ...pendingChatState, messages: nextMessages }; - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onDone: (payload) => { @@ -998,7 +1033,7 @@ async function main() { if (!updated) return; pendingChatState = { ...pendingChatState, messages: nextMessages }; - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onError: (payload) => { @@ -1011,15 +1046,17 @@ async function main() { throw new Error(streamErrorMessage); } + const shouldFollowTranscript = isTranscriptNearBottom(); + await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false }); const currentSelection = selectedItem; if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { - await loadSelection(currentSelection); + await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript }); } pendingChatState = null; - forceScrollToBottom = true; + forceScrollToBottom = shouldFollowTranscript; updateUI(); } @@ -1103,7 +1140,7 @@ async function main() { error: null, results: payload.results, }; - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onSearchError: (payload) => { @@ -1122,7 +1159,7 @@ async function main() { answerCitations: payload.answerCitations, answerError: null, }; - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onAnswerError: (payload) => { @@ -1135,7 +1172,7 @@ async function main() { if (runId !== searchRunCounter) return; selectedSearch = payload.search; selectedChat = null; - forceScrollToBottom = true; + queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onError: (payload) => { @@ -1154,11 +1191,13 @@ async function main() { } } + const shouldFollowTranscript = isTranscriptNearBottom(); + await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false }); const currentSelection = selectedItem; if (currentSelection?.kind === "search" && currentSelection.id === searchId) { - await loadSelection(currentSelection); + await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript }); } } @@ -1186,7 +1225,7 @@ async function main() { } if (selectedItem) { - await loadSelection(selectedItem); + await loadSelection(selectedItem, { scrollToBottom: true }); } } finally { isSending = false; @@ -1214,7 +1253,7 @@ async function main() { selectedSearch = null; } - await refreshCollections({ loadSelection: true }); + await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }); } function cycleProvider() { @@ -1262,6 +1301,14 @@ async function main() { updateUI(); }); + transcript.key(["pageup"], () => { + scrollTranscriptByPage(-1); + }); + + transcript.key(["pagedown"], () => { + scrollTranscriptByPage(1); + }); + sidebar.on("select item", (_item, index) => { if (suppressedSidebarSelectEvents > 0) return; const highlightedItem = getSidebarItems()[index]; @@ -1389,7 +1436,7 @@ async function main() { } await runAction(async () => { - await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]); + await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]); }); focusComposer();