{"version":3,"file":"scroll-view.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/scroll-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EACN,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,eAAe,EAEpB,KAAK,UAAU,EAEf,KAAK,GAAG,EAGR,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAIpE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAChC,EAAE,EAAE,GAAG,EACP,IAAI,EAAE,SAAS,EACf,SAAS,EAAE,UAAU,GAAG,MAAM,EAC9B,aAAa,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,OAAO,GAC1C,OAAO,CA6BT;AAiFD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAChC,EAAE,EAAE,GAAG,EACP,MAAM,EAAE,eAAe,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAAE,EACrF,WAAW,EAAE,kBAAkB,EAC/B,KAAK,EAAE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,aAAa,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,OAAO,CAAA;CAAE,GACtE,MAAM,IAAI,CA8HZ","sourcesContent":["/**\n * Reading back through the transcript.\n *\n * The TUI owns the mechanism — a pinned window over the line buffer, painted on\n * the alternate screen, described in `tui.ts` under `scrollOffset`. This file\n * owns the two things the TUI cannot know: which keys mean what, and what the\n * indicator on the bottom row should look like in the user's theme.\n *\n * ## Two scopes, and why the split is where it is\n *\n * At the prompt only four keys are live — page up, page down, and the two ends.\n * They are the keys every pager already taught, and none of them is a key the\n * prompt has any use for, so nothing is taken away to make room. Anything more\n * would be: the arrows walk prompt history, and a reader who loses that to\n * scrolling has traded one surprise for another.\n *\n * Once the view is pinned it captures keys, exactly as a picker does, and the\n * fuller set comes alive — line steps on the arrows, escape back to live. The\n * prompt is not in front of you at that point, so its keys are free.\n *\n * The capture is an input listener rather than editor actions because it has to\n * beat the focused editor to the arrows: an editor action runs only after the\n * editor has already decided the key was history navigation.\n *\n * ## Leaving\n *\n * Anything that is not a scroll key un-pins the view and then does what it\n * always did. That is one rule rather than a list of exceptions, and it is the\n * one that matches what people actually do — you stop reading by starting to\n * type, not by remembering to press escape first. Without it, typing into a\n * pinned view would echo into a prompt that is scrolled off-screen, which is\n * the exact class of \"where did my keystroke go\" this whole change exists to\n * remove.\n */\n\nimport {\n\ttype Component,\n\ttype Container,\n\ttype EditorComponent,\n\tisKeyRelease,\n\ttype Keybinding,\n\ttype ScrollStatus,\n\ttype TUI,\n\ttruncateToWidth,\n\tvisibleWidth,\n} from \"@kolisachint/hoocode-tui\";\nimport type { KeybindingsManager } from \"../../core/keybindings.js\";\nimport { keyText } from \"./components/keybinding-hints.js\";\nimport { theme } from \"./theme/theme.js\";\n\n/**\n * Pin the view to the previous or next thing the user said.\n *\n * The landmarks people navigate a session by are their own messages — \"where\n * did I ask about the renderer\" — and they are sparse enough that a few presses\n * cross a long transcript. Agent replies and tool blocks are deliberately not\n * stops: in a tool-heavy session they are dense enough that the key degrades\n * into paging with extra steps.\n *\n * The rows come from the render memo rather than from a re-render: a message's\n * offset inside the chat container, plus that container's offset at the root,\n * is its absolute row in the buffer the viewport windows over. Both are already\n * cached from the last frame, so a jump is a walk over line-array lengths.\n */\nexport function jumpToUserMessage(\n\tui: TUI,\n\tchat: Container,\n\tdirection: \"previous\" | \"next\",\n\tisUserMessage: (child: Component) => boolean,\n): boolean {\n\tconst width = ui.terminal.columns;\n\tconst rootOffsets = ui.childRowOffsets(width);\n\tconst chatOffsets = chat.childRowOffsets(width);\n\t// No memo yet (nothing rendered at this width): there is nothing to point at.\n\tif (!rootOffsets || !chatOffsets) return false;\n\n\tconst chatIndex = ui.children.indexOf(chat);\n\tif (chatIndex === -1) return false;\n\tconst base = rootOffsets[chatIndex];\n\n\tconst rows: number[] = [];\n\tfor (let i = 0; i < chat.children.length; i++) {\n\t\tif (isUserMessage(chat.children[i])) rows.push(base + chatOffsets[i]);\n\t}\n\tif (rows.length === 0) return false;\n\n\t// Where the eye is now: the top of the pinned window, or the bottom of the\n\t// transcript when live. Live counts as \"below the last message\", so the\n\t// first press of `previous` lands on the most recent one.\n\tconst position = ui.getScrollPosition();\n\tconst here = position ? position.top : Number.MAX_SAFE_INTEGER;\n\n\tconst target =\n\t\tdirection === \"previous\" ? [...rows].reverse().find((row) => row < here) : rows.find((row) => row > here);\n\tif (target === undefined) return false;\n\n\tui.scrollToRow(target);\n\treturn true;\n}\n\n/** What the pinned view answers to, in the order the keys are tried. */\nconst PINNED_BINDINGS: Array<[Keybinding, (ui: TUI) => void]> = [\n\t// Before the ends, so that a binding set where they share a key still gets\n\t// out rather than jumping somewhere.\n\t[\"app.scroll.exit\", (ui) => void ui.scrollToLive()],\n\t[\"app.scroll.top\", (ui) => void ui.scrollToTop()],\n\t[\"app.scroll.bottom\", (ui) => void ui.scrollToLive()],\n\t[\"app.scroll.pageUp\", (ui) => void ui.scrollByPages(-1)],\n\t[\"app.scroll.pageDown\", (ui) => void ui.scrollByPages(1)],\n\t[\"app.scroll.lineUp\", (ui) => void ui.scrollByLines(-1)],\n\t[\"app.scroll.lineDown\", (ui) => void ui.scrollByLines(1)],\n];\n\n/**\n * Whether `data` is ordinary typed text rather than a chord.\n *\n * The query line takes characters; everything else is a key. Control bytes and\n * escape sequences are never text, which is the whole distinction.\n */\nfunction isTypedText(data: string): boolean {\n\tif (data.length === 0) return false;\n\tfor (let i = 0; i < data.length; i++) {\n\t\tconst code = data.charCodeAt(i);\n\t\tif (code < 32 || code === 127) return false;\n\t}\n\treturn true;\n}\n\n/** Pinned-view keys that need more than the TUI to answer them. */\nconst PINNED_TURN_BINDINGS = [\"app.scroll.previousMessage\", \"app.scroll.nextMessage\"] as const;\n\n/**\n * The bottom row of a pinned view.\n *\n * It answers \"where am I\" first and \"how do I get out\" second, because the\n * first question is the one a reader has on every frame and the second is the\n * one they have once. The keys are read out of the live bindings rather than\n * written into the string, so a rebind is reflected here instead of quietly\n * turning the row into a lie.\n *\n * `atBottom` is never shown: reaching the bottom releases the pin, so a pinned\n * view is by definition somewhere above it.\n */\nfunction formatStatus(status: ScrollStatus): string {\n\t// While a search is running the indicator *is* the query line. Position is\n\t// not what you are asking at that moment; \"did it find anything\" is.\n\tif (status.search) {\n\t\tconst { query, count, index, typing } = status.search;\n\t\tconst hits = query.length === 0 ? \"\" : count === 0 ? \"  no matches\" : `  ${index}/${count}`;\n\t\t// A block where the caret would be, so an empty query still looks like\n\t\t// something you are expected to type into.\n\t\tconst caret = typing ? \"\\u2588\" : \"\";\n\t\tconst left = ` search: ${query}${caret}${hits}`;\n\t\tconst keys = typing\n\t\t\t? `${keyText(\"tui.select.confirm\")} keep · ${keyText(\"app.scroll.exit\")} cancel`\n\t\t\t: `${keyText(\"app.scroll.searchNext\")}/${keyText(\"app.scroll.searchPrevious\")} step · ${keyText(\"app.scroll.exit\")} done`;\n\t\tconst gap = status.width - visibleWidth(left) - visibleWidth(keys) - 2;\n\t\tconst body = gap >= 0 ? `${left}${\" \".repeat(gap + 1)}${keys} ` : `${left} `;\n\t\treturn theme.inverse(truncateToWidth(body, status.width, \"\", true));\n\t}\n\n\tconst position = `${status.top}–${status.bottom} of ${status.total}`;\n\tconst place = status.atTop ? \"start of session\" : `${Math.round((status.top / status.total) * 100)}%`;\n\tconst left = `${position}  ${place}`;\n\tconst keys = [\n\t\t`${keyText(\"app.scroll.lineUp\")}/${keyText(\"app.scroll.lineDown\")} line`,\n\t\t`${keyText(\"app.scroll.pageUp\")}/${keyText(\"app.scroll.pageDown\")} page`,\n\t\t`${keyText(\"app.scroll.top\")} top`,\n\t\t`${keyText(\"app.scroll.exit\")} live`,\n\t].join(\" · \");\n\n\t// A space of margin each side, and the keys only when they fit whole: a\n\t// half-printed key list is worse than none, because a truncated chord reads\n\t// as a different chord.\n\tconst gap = status.width - visibleWidth(left) - visibleWidth(keys) - 3;\n\tconst body = gap >= 0 ? ` ${left}${\" \".repeat(gap + 1)}${keys} ` : ` ${left} `;\n\treturn theme.inverse(truncateToWidth(body, status.width, \"\", true));\n}\n\n/**\n * Wire scrolling into a running interactive mode.\n *\n * Takes the mode's own `KeybindingsManager` rather than reading the process-wide\n * one: the keys have to resolve the same way here as they do inside the editor a\n * line above, and a listener that silently matched nothing — which is what the\n * unconfigured global resolves every `app.*` id to — would hand the pinned view's\n * keys back to the prompt without saying so.\n *\n * Returns the teardown for the input listener, matching the other listeners the\n * mode installs; the editor actions live as long as the editor does.\n */\nexport function installScrollView(\n\tui: TUI,\n\teditor: EditorComponent & { onAction(action: Keybinding, handler: () => void): void },\n\tkeybindings: KeybindingsManager,\n\tturns: { chat: Container; isUserMessage: (child: Component) => boolean },\n): () => void {\n\tui.setScrollStatusFormatter(formatStatus);\n\n\t// The wheel is answered wherever it is turned, so the gate is here rather\n\t// than on the keys: with a picker or a login dialog on screen, the prompt is\n\t// not what the user is looking at, and pinning would take that surface's\n\t// arrow keys away from it.\n\tui.canPinScroll = () => ui.focused === editor;\n\n\t// Live at the prompt. Paging down while already live returns false and does\n\t// nothing, which is what page-down at the bottom of a transcript should do.\n\teditor.onAction(\"app.scroll.pageUp\", () => void ui.scrollByPages(-1));\n\teditor.onAction(\"app.scroll.pageDown\", () => void ui.scrollByPages(1));\n\teditor.onAction(\"app.scroll.top\", () => void ui.scrollToTop());\n\teditor.onAction(\"app.scroll.bottom\", () => void ui.scrollToLive());\n\teditor.onAction(\"app.scroll.previousMessage\", () => {\n\t\tjumpToUserMessage(ui, turns.chat, \"previous\", turns.isUserMessage);\n\t});\n\teditor.onAction(\"app.scroll.nextMessage\", () => {\n\t\tjumpToUserMessage(ui, turns.chat, \"next\", turns.isUserMessage);\n\t});\n\n\t/** The query being typed, or null when no search is being composed. */\n\tlet typing: string | null = null;\n\n\tconst openSearch = (): void => {\n\t\ttyping = \"\";\n\t\tui.setScrollSearch(\"\", { typing: true });\n\t};\n\n\teditor.onAction(\"app.scroll.search\", () => {\n\t\t// From the prompt this has to pin first, at the bottom, so the backwards\n\t\t// search starts from the newest thing and works back.\n\t\tif (!ui.scrollPinned) ui.scrollToRow(Number.MAX_SAFE_INTEGER);\n\t\tif (!ui.scrollPinned) return;\n\t\topenSearch();\n\t});\n\n\treturn ui.addInputListener((data) => {\n\t\tif (!ui.scrollPinned) {\n\t\t\t// The view can un-pin without passing through here at all — a wheel\n\t\t\t// notch that reaches the bottom releases it. A half-typed query left\n\t\t\t// behind would then swallow the next keystrokes into a query line that\n\t\t\t// is not on screen, which is the exact failure this feature exists to\n\t\t\t// remove.\n\t\t\ttyping = null;\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// A key coming back up is not a decision to stop reading.\n\t\tif (isKeyRelease(data)) return { consume: true };\n\n\t\t// Likewise if something cleared the search out from under the query line.\n\t\tif (typing !== null && !ui.scrollSearchActive) typing = null;\n\n\t\t// The query line owns every printable character while it is open, so it\n\t\t// is asked before any key that a letter could also mean.\n\t\tif (typing !== null) {\n\t\t\tif (keybindings.matches(data, \"tui.select.confirm\")) {\n\t\t\t\ttyping = null;\n\t\t\t\t// Committing nothing leaves a search that matches nothing and\n\t\t\t\t// answers no keys — a dead state reachable by pressing enter twice.\n\t\t\t\t// An empty query is a cancelled one.\n\t\t\t\tif (ui.scrollSearchQuery.length === 0) ui.clearScrollSearch();\n\t\t\t\telse ui.commitScrollSearch();\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\tif (keybindings.matches(data, \"app.scroll.exit\")) {\n\t\t\t\ttyping = null;\n\t\t\t\tui.clearScrollSearch();\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\tif (keybindings.matches(data, \"tui.editor.deleteCharBackward\")) {\n\t\t\t\ttyping = typing.slice(0, -1);\n\t\t\t\tui.setScrollSearch(typing, { typing: true });\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\tif (isTypedText(data)) {\n\t\t\t\ttyping += data;\n\t\t\t\tui.setScrollSearch(typing, { typing: true });\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\t// A chord while composing: commit the query and let the chord through\n\t\t\t// to its usual meaning below.\n\t\t\ttyping = null;\n\t\t\tui.commitScrollSearch();\n\t\t}\n\n\t\tif (keybindings.matches(data, \"app.scroll.search\") || keybindings.matches(data, \"app.scroll.searchInView\")) {\n\t\t\topenSearch();\n\t\t\treturn { consume: true };\n\t\t}\n\t\tif (ui.scrollSearchActive) {\n\t\t\tif (keybindings.matches(data, \"app.scroll.searchNext\")) {\n\t\t\t\tui.scrollSearchStep(-1);\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\tif (keybindings.matches(data, \"app.scroll.searchPrevious\")) {\n\t\t\t\tui.scrollSearchStep(1);\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t\t// Escape with a committed search drops the search but keeps the view,\n\t\t\t// so a second press is what leaves. One escape, one thing undone.\n\t\t\tif (keybindings.matches(data, \"app.scroll.exit\")) {\n\t\t\t\tui.clearScrollSearch();\n\t\t\t\treturn { consume: true };\n\t\t\t}\n\t\t}\n\n\t\tfor (const [binding, act] of PINNED_BINDINGS) {\n\t\t\tif (!keybindings.matches(data, binding)) continue;\n\t\t\tact(ui);\n\t\t\treturn { consume: true };\n\t\t}\n\n\t\tfor (const binding of PINNED_TURN_BINDINGS) {\n\t\t\tif (!keybindings.matches(data, binding)) continue;\n\t\t\tconst direction = binding === \"app.scroll.previousMessage\" ? \"previous\" : \"next\";\n\t\t\tjumpToUserMessage(ui, turns.chat, direction, turns.isUserMessage);\n\t\t\treturn { consume: true };\n\t\t}\n\n\t\t// Anything else: back to live, then let the key do its usual job there.\n\t\tui.scrollToLive();\n\t\treturn undefined;\n\t});\n}\n"]}