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
- `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

View File

@@ -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();