pgup/pgdown in transcript

This commit is contained in:
2026-03-11 01:23:13 -07:00
parent 72b3a0902a
commit be6641de24
2 changed files with 65 additions and 17 deletions

View File

@@ -37,6 +37,7 @@ Compatibility aliases:
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer - `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
- `Esc` (in composer): exit input mode and focus sidebar - `Esc` (in composer): exit input mode and focus sidebar
- `Up` / `Down` (in sidebar): move highlight - `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 sidebar: load highlighted conversation/search
- `Enter` in composer: send message/search - `Enter` in composer: send message/search
- `n`: new chat draft - `n`: new chat draft

View File

@@ -323,6 +323,35 @@ async function main() {
const focusables = [sidebar, transcript, composer] as const; 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) { function withSuppressedSidebarSelectEvents(fn: () => void) {
suppressedSidebarSelectEvents += 1; suppressedSidebarSelectEvents += 1;
try { try {
@@ -684,7 +713,7 @@ async function main() {
return items.some((item) => item.kind === selection.kind && item.id === selection.id); 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) { if (!selection) {
selectedChat = null; selectedChat = null;
selectedSearch = null; selectedSearch = null;
@@ -714,12 +743,18 @@ async function main() {
if (selectionKey(selectedItem) === requestedSelectionKey) { if (selectionKey(selectedItem) === requestedSelectionKey) {
isLoadingSelection = false; isLoadingSelection = false;
} }
if (options?.scrollToBottom) {
forceScrollToBottom = true; forceScrollToBottom = true;
}
updateUI(); updateUI();
} }
} }
async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) { async function refreshCollections(options?: {
preferredSelection?: SidebarSelection;
loadSelection?: boolean;
scrollToBottomOnLoad?: boolean | undefined;
}) {
isLoadingCollections = true; isLoadingCollections = true;
updateUI(); updateUI();
@@ -748,7 +783,7 @@ async function main() {
} }
if (options?.loadSelection) { if (options?.loadSelection) {
await loadSelection(selectedItem); await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad });
} }
} }
@@ -797,7 +832,7 @@ async function main() {
errorMessage = null; errorMessage = null;
forceScrollToBottom = true; forceScrollToBottom = true;
updateUI(); updateUI();
await loadSelection(selectedItem); await loadSelection(selectedItem, { scrollToBottom: true });
} }
function handleCreateChat() { function handleCreateChat() {
@@ -966,7 +1001,7 @@ async function main() {
}; };
} }
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onDelta: (payload) => { onDelta: (payload) => {
@@ -982,7 +1017,7 @@ async function main() {
if (!updated) return; if (!updated) return;
pendingChatState = { ...pendingChatState, messages: nextMessages }; pendingChatState = { ...pendingChatState, messages: nextMessages };
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onDone: (payload) => { onDone: (payload) => {
@@ -998,7 +1033,7 @@ async function main() {
if (!updated) return; if (!updated) return;
pendingChatState = { ...pendingChatState, messages: nextMessages }; pendingChatState = { ...pendingChatState, messages: nextMessages };
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onError: (payload) => { onError: (payload) => {
@@ -1011,15 +1046,17 @@ async function main() {
throw new Error(streamErrorMessage); throw new Error(streamErrorMessage);
} }
const shouldFollowTranscript = isTranscriptNearBottom();
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false }); await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
const currentSelection = selectedItem; const currentSelection = selectedItem;
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
await loadSelection(currentSelection); await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
} }
pendingChatState = null; pendingChatState = null;
forceScrollToBottom = true; forceScrollToBottom = shouldFollowTranscript;
updateUI(); updateUI();
} }
@@ -1103,7 +1140,7 @@ async function main() {
error: null, error: null,
results: payload.results, results: payload.results,
}; };
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onSearchError: (payload) => { onSearchError: (payload) => {
@@ -1122,7 +1159,7 @@ async function main() {
answerCitations: payload.answerCitations, answerCitations: payload.answerCitations,
answerError: null, answerError: null,
}; };
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onAnswerError: (payload) => { onAnswerError: (payload) => {
@@ -1135,7 +1172,7 @@ async function main() {
if (runId !== searchRunCounter) return; if (runId !== searchRunCounter) return;
selectedSearch = payload.search; selectedSearch = payload.search;
selectedChat = null; selectedChat = null;
forceScrollToBottom = true; queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();
}, },
onError: (payload) => { onError: (payload) => {
@@ -1154,11 +1191,13 @@ async function main() {
} }
} }
const shouldFollowTranscript = isTranscriptNearBottom();
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false }); await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
const currentSelection = selectedItem; const currentSelection = selectedItem;
if (currentSelection?.kind === "search" && currentSelection.id === searchId) { if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
await loadSelection(currentSelection); await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
} }
} }
@@ -1186,7 +1225,7 @@ async function main() {
} }
if (selectedItem) { if (selectedItem) {
await loadSelection(selectedItem); await loadSelection(selectedItem, { scrollToBottom: true });
} }
} finally { } finally {
isSending = false; isSending = false;
@@ -1214,7 +1253,7 @@ async function main() {
selectedSearch = null; selectedSearch = null;
} }
await refreshCollections({ loadSelection: true }); await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
} }
function cycleProvider() { function cycleProvider() {
@@ -1262,6 +1301,14 @@ async function main() {
updateUI(); updateUI();
}); });
transcript.key(["pageup"], () => {
scrollTranscriptByPage(-1);
});
transcript.key(["pagedown"], () => {
scrollTranscriptByPage(1);
});
sidebar.on("select item", (_item, index) => { sidebar.on("select item", (_item, index) => {
if (suppressedSidebarSelectEvents > 0) return; if (suppressedSidebarSelectEvents > 0) return;
const highlightedItem = getSidebarItems()[index]; const highlightedItem = getSidebarItems()[index];
@@ -1389,7 +1436,7 @@ async function main() {
} }
await runAction(async () => { await runAction(async () => {
await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]); await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]);
}); });
focusComposer(); focusComposer();