From 00121ed99939ba614ddda4c5739e49bc027d02d4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 11 Aug 2026 10:59:28 +0200 Subject: [PATCH] feat(tui): add fullscreen transcript search (#7913) Search rendered primary scroll-view content with configurable navigation and theme-aware highlighting. Preserve manual scrolling and buffer fragmented mouse input during search. --- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/keybindings.md | 4 + packages/coding-agent/docs/themes.md | 10 +- packages/coding-agent/docs/tui.md | 4 +- .../src/modes/interactive/interactive-mode.ts | 3 + .../src/modes/interactive/theme/dark.json | 2 + .../src/modes/interactive/theme/light.json | 2 + .../modes/interactive/theme/theme-schema.json | 10 +- .../src/modes/interactive/theme/theme.ts | 35 ++- .../coding-agent/test/scrollbar-theme.test.ts | 24 +- .../coding-agent/test/test-theme-colors.ts | 3 + packages/tui/CHANGELOG.md | 8 + packages/tui/README.md | 2 +- packages/tui/src/alt-screen-search.ts | 157 ++++++++++++ packages/tui/src/components/scroll-view.ts | 39 ++- packages/tui/src/index.ts | 7 +- packages/tui/src/keybindings.ts | 20 ++ packages/tui/src/stdin-buffer.ts | 10 +- packages/tui/src/terminal.ts | 2 +- packages/tui/src/tui-alt-screen.ts | 237 +++++++++++++++++- packages/tui/test/keybindings.test.ts | 4 + packages/tui/test/stdin-buffer.test.ts | 24 ++ packages/tui/test/tui-alt-screen.test.ts | 100 ++++++++ 23 files changed, 680 insertions(+), 32 deletions(-) create mode 100644 packages/tui/src/alt-screen-search.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 759c1c1a6..22fa86f70 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. ### Changed @@ -11,6 +12,10 @@ - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. - Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). +### Fixed + +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 97e5365a6..a7f493d9c 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -107,6 +107,10 @@ This routing remains configurable through the ordinary action bindings. For exam | `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | | `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | | `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | +| `tui.altScreen.search` | `ctrl+shift+f` | Search the rendered transcript | +| `tui.altScreen.searchNext` | `enter`, `ctrl+g` | Select the next search match while searching | +| `tui.altScreen.searchPrevious` | `shift+enter`, `ctrl+shift+g` | Select the previous search match while searching | +| `tui.altScreen.searchClose` | `escape` | Close transcript search | | `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript | | `tui.altScreen.bottom` | `end` | Scroll to the transcript end and follow new output | diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 2a00dee37..f9855f87f 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -72,6 +72,8 @@ vim ~/.pi/agent/themes/my-theme.json "thinkingText": "secondary", "selectedBg": "#2d2d30", "scrollbarThumb": "#555566", + "searchMatchBg": "#2d2d30", + "searchMatchText": "", "userMessageBg": "#2d2d30", "userMessageText": "", "customMessageBg": "#2d2d30", @@ -141,13 +143,13 @@ vim ~/.pi/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. -- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`; `scrollbarThumb` is optional and falls back to `selectedBg`. +- `colors` must define all 51 required tokens. `thinkingMax`, `scrollbarThumb`, and the two search highlight tokens are optional and use the fallbacks listed below. The `$schema` field enables editor auto-completion and validation. ## Color Tokens -Every theme must define all 51 required color tokens. `thinkingMax` and `scrollbarThumb` are optional for compatibility with existing themes; when omitted, they use `thinkingXhigh` and `selectedBg`, respectively. +Every theme must define all 51 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `scrollbarThumb` and `searchMatchBg` fall back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text. ### Core UI (11 colors) @@ -165,12 +167,14 @@ Every theme must define all 51 required color tokens. `thinkingMax` and `scrollb | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | -### Backgrounds & Content (11 required, 1 optional) +### Backgrounds & Content (11 required, 3 optional) | Token | Purpose | |-------|---------| | `selectedBg` | Selected line background | | `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` | +| `searchMatchBg` | Transcript search match background and current-match text; optional, falls back to `selectedBg` | +| `searchMatchText` | Transcript search match text and current-match background; optional, falls back to `text` | | `userMessageBg` | User message background | | `userMessageText` | User message text | | `customMessageBg` | Extension message background | diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index d9f789296..31dbf58b4 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -431,7 +431,7 @@ renderResult(result, options, theme, context) { | Category | Colors | |----------|--------| -| General | `text`, `accent`, `muted`, `dim` | +| General | `text`, `accent`, `muted`, `dim`, `searchMatchText` | | Status | `success`, `error`, `warning` | | Borders | `border`, `borderAccent`, `borderMuted` | | Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | @@ -444,7 +444,7 @@ renderResult(result, options, theme, context) { **Background colors** (`theme.bg(color, text)`): -`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` +`selectedBg`, `searchMatchBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` **For Markdown**, use `getMarkdownTheme()`: diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 943b9edbd..03d015a5a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -343,7 +343,10 @@ interface InteractiveTuiOptions { export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen { const terminal = options.terminal ?? new ProcessTerminal(); if (options.tuiMode === "fullscreen") { + const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text)); return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { + searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)), + searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, }); diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 9db9cbd8b..01d1e02a8 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -34,6 +34,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 74ef3d1c5..0fde42b8b 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -33,6 +33,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 039bdc15f..5348ab7de 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax and scrollbarThumb are optional and use compatible fallbacks)", + "description": "Theme color definitions (thinkingMax, scrollbarThumb, and search highlight colors are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -141,6 +141,14 @@ "$ref": "#/$defs/colorValue", "description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)" }, + "searchMatchBg": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)" + }, + "searchMatchText": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match text and current-match background (falls back to text when omitted)" + }, "userMessageBg": { "$ref": "#/$defs/colorValue", "description": "User message background" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c6bb899d4..6fa0c8c92 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -45,9 +45,11 @@ const ThemeJsonSchema = Type.Object({ dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 required, 1 optional) + // Backgrounds & Content Text (11 required, 3 optional) selectedBg: ColorValueSchema, scrollbarThumb: Type.Optional(ColorValueSchema), + searchMatchBg: Type.Optional(ColorValueSchema), + searchMatchText: Type.Optional(ColorValueSchema), userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, @@ -119,6 +121,7 @@ export type ThemeColor = | "dim" | "text" | "thinkingText" + | "searchMatchText" | "userMessageText" | "customMessageText" | "customMessageLabel" @@ -158,12 +161,16 @@ export type ThemeColor = export type ThemeBg = | "selectedBg" | "scrollbarThumb" + | "searchMatchBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; +type OptionalThemeColor = "thinkingMax" | "searchMatchText"; +type OptionalThemeBg = "scrollbarThumb" | "searchMatchBg"; + type ColorMode = "truecolor" | "256color"; // ============================================================================ @@ -321,13 +328,18 @@ function resolveThemeColors>( return resolved as Record; } -function withThemeColorFallbacks( - colors: ThemeJson["colors"], -): ThemeJson["colors"] & { thinkingMax: ColorValue; scrollbarThumb: ColorValue } { +function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { + thinkingMax: ColorValue; + scrollbarThumb: ColorValue; + searchMatchBg: ColorValue; + searchMatchText: ColorValue; +} { return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, + searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, + searchMatchText: colors.searchMatchText ?? colors.text, }; } @@ -344,9 +356,10 @@ export class Theme { private mode: ColorMode; constructor( - fgColors: Record, - bgColors: Record, string | number> & - Partial>, + fgColors: Record, string | number> & + Partial>, + bgColors: Record, string | number> & + Partial>, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { @@ -355,7 +368,11 @@ export class Theme { this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); - const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh }; + const colors = { + ...fgColors, + thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, + searchMatchText: fgColors.searchMatchText ?? fgColors.text, + }; for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); } @@ -363,6 +380,7 @@ export class Theme { const backgrounds = { ...bgColors, scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg, + searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg, }; for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) { this.bgColors.set(key, bgAnsi(value, mode)); @@ -615,6 +633,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string const bgColorKeys: Set = new Set([ "selectedBg", "scrollbarThumb", + "searchMatchBg", "userMessageBg", "customMessageBg", "toolPendingBg", diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts index 01dbdbcca..f07bb9c8a 100644 --- a/packages/coding-agent/test/scrollbar-theme.test.ts +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -27,7 +27,7 @@ afterEach(() => { } }); -describe("scrollbar theme color", () => { +describe("optional fullscreen theme colors", () => { it("falls back to selectedBg when scrollbarThumb is omitted", () => { const themeJson = loadDarkTheme(); themeJson.name = "legacy-scrollbar-theme"; @@ -45,4 +45,26 @@ describe("scrollbar theme color", () => { const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m"); }); + + it("falls back to existing selection and text colors for search highlights", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "legacy-search-theme"; + delete themeJson.colors.searchMatchBg; + delete themeJson.colors.searchMatchText; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe(loadedTheme.getBgAnsi("selectedBg")); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe(loadedTheme.getFgAnsi("text")); + }); + + it("uses explicitly configured search highlight colors", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "custom-search-theme"; + themeJson.colors.searchMatchBg = "#112233"; + themeJson.colors.searchMatchText = "#223344"; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe("\x1b[48;2;17;34;51m"); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe("\x1b[38;2;34;51;68m"); + }); }); diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts index 70da7b189..4e7181f35 100644 --- a/packages/coding-agent/test/test-theme-colors.ts +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -222,6 +222,9 @@ function cmdTheme(themeName: string): void { console.log("\n--- Backgrounds ---"); console.log("userMessageBg:", theme.bg("userMessageBg", " Sample ")); + const searchMatch = theme.bg("searchMatchBg", theme.fg("searchMatchText", " Sample ")); + console.log("searchMatch:", theme.underline(searchMatch)); + console.log("searchCurrentMatch:", theme.bold(theme.inverse(searchMatch))); console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample ")); console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample ")); console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample ")); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1b30480b7..ec81ca022 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,10 +2,18 @@ ## [Unreleased] +### Added + +- Added incremental primary-scroll-view search to the fullscreen TUI with configurable match styles, `Ctrl+Shift+F`, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. + ### Changed - Reduced alternate-screen per-frame allocation churn roughly 9-18x by painting full-width layout rows as direct line references instead of recompositing every visible row through ANSI/grapheme segmentation on each frame. +### Fixed + +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/tui/README.md b/packages/tui/README.md index dfcfc9ea5..58e829370 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -121,7 +121,7 @@ if (isViewportTUI(tui)) { } ``` -Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. +Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to search its rendered content, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` to move between matches, and `Escape` to close search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting. Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen. diff --git a/packages/tui/src/alt-screen-search.ts b/packages/tui/src/alt-screen-search.ts new file mode 100644 index 000000000..98926523f --- /dev/null +++ b/packages/tui/src/alt-screen-search.ts @@ -0,0 +1,157 @@ +import { Input } from "./components/input.ts"; +import type { Component, Focusable } from "./tui.ts"; +import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface SearchSourceSpan { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchSegment { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchMatch { + segments: AltScreenSearchSegment[]; +} + +function appendMappedText( + text: string, + span: SearchSourceSpan | undefined, + corpus: { text: string; source: Array }, +): void { + corpus.text += text; + for (let index = 0; index < text.length; index++) corpus.source.push(span); +} + +function buildSearchCorpus(lines: readonly string[]): { + text: string; + source: Array; +} { + const corpus: { text: string; source: Array } = { text: "", source: [] }; + let pendingSeparator = false; + + for (let row = 0; row < lines.length; row++) { + const line = stripTerminalSequences(lines[row] ?? ""); + let column = 0; + for (const grapheme of segmenter.segment(line)) { + const text = grapheme.segment; + const width = visibleWidth(text); + if (/^\s+$/u.test(text)) { + if (corpus.text.length > 0) pendingSeparator = true; + column += width; + continue; + } + if (pendingSeparator) { + appendMappedText(" ", undefined, corpus); + pendingSeparator = false; + } + appendMappedText(text, { row, startCol: column, endCol: column + width }, corpus); + column += width; + } + if (corpus.text.length > 0) pendingSeparator = true; + } + + return corpus; +} + +function normalizeQuery(query: string): string { + return query.replace(/\s+/gu, " ").trim(); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) return []; + + const corpus = buildSearchCorpus(lines); + const expression = new RegExp(escapeRegExp(normalizedQuery), "giu"); + const matches: AltScreenSearchMatch[] = []; + + for (const match of corpus.text.matchAll(expression)) { + const start = match.index; + const end = start + match[0].length; + const segments: AltScreenSearchSegment[] = []; + for (let index = start; index < end; index++) { + const span = corpus.source[index]; + if (!span) continue; + const previous = segments[segments.length - 1]; + if (previous && previous.row === span.row && span.startCol <= previous.endCol) { + previous.endCol = Math.max(previous.endCol, span.endCol); + } else { + segments.push({ ...span }); + } + } + if (segments.length > 0) matches.push({ segments }); + } + + return matches; +} + +export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string { + const first = match.segments[0]; + const last = match.segments[match.segments.length - 1]; + return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : ""; +} + +export class AltScreenSearchComponent implements Component, Focusable { + private readonly input = new Input(); + private readonly onQueryChange: (query: string) => void; + private resultCount = 0; + private resultIndex = -1; + private _focused = false; + + constructor(onQueryChange: (query: string) => void) { + this.onQueryChange = onQueryChange; + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + setResult(index: number, count: number): void { + this.resultIndex = index; + this.resultCount = count; + } + + handleInput(data: string): void { + const previous = this.input.getValue(); + this.input.handleInput(data); + const query = this.input.getValue(); + if (query !== previous) this.onQueryChange(query); + } + + invalidate(): void { + this.input.invalidate(); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const label = " Find transcript"; + const query = this.input.getValue(); + const status = !query + ? "" + : this.resultCount === 0 + ? "No matches " + : `${this.resultIndex + 1}/${this.resultCount} `; + const labelWidth = visibleWidth(label); + const statusWidth = visibleWidth(status); + const gap = " ".repeat(Math.max(1, safeWidth - labelWidth - statusWidth)); + const title = truncateToWidth(`${label}${gap}${status}`, safeWidth, ""); + const padding = " ".repeat(Math.max(0, safeWidth - visibleWidth(title))); + return [`\x1b[7m${title}${padding}\x1b[27m`, ...this.input.render(safeWidth)]; + } +} diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index f2c0b5fd7..a9a50f020 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -13,6 +13,11 @@ export interface ScrollViewOptions { scrollbarHideDelayMs?: number; } +export interface ScrollViewScrollToOptions { + /** Keep follow-end disabled even when the target is the current content end. */ + disableFollow?: boolean; +} + export class ScrollView extends Container { private readonly child: Component; private readonly followEnd: boolean; @@ -25,6 +30,7 @@ export class ScrollView extends Container { private contentHeight = 0; private currentViewportHeight = 0; private followingEnd: boolean; + private followSuppressedAtEnd = false; private requestRenderCallback: (() => void) | undefined; private transientScrollbarVisible = false; private scrollbarActive = false; @@ -110,14 +116,24 @@ export class ScrollView extends Container { this.markScrollbarActivity(); } - scrollTo(scrollTop: number): void { + scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void { const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop; const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); const next = Math.max(0, Math.min(maxScrollTop, requested)); - if (next === this.currentScrollTop) return; + const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop; + const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop; + if ( + next === this.currentScrollTop && + nextFollowingEnd === this.followingEnd && + nextFollowSuppressedAtEnd === this.followSuppressedAtEnd + ) { + return; + } + const moved = next !== this.currentScrollTop; this.currentScrollTop = next; - this.followingEnd = this.followEnd && next === maxScrollTop; - this.markScrollbarActivity(); + this.followingEnd = nextFollowingEnd; + this.followSuppressedAtEnd = nextFollowSuppressedAtEnd; + if (moved) this.markScrollbarActivity(); this.requestRenderCallback?.(); } @@ -128,12 +144,12 @@ export class ScrollView extends Container { const start = this.followingEnd ? maxScrollTop : this.currentScrollTop; const next = Math.max(0, Math.min(maxScrollTop, start + requested)); const moved = next - start; + const wasFollowingEnd = this.followingEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd && next === maxScrollTop; - if (moved !== 0) { - this.markScrollbarActivity(); - this.requestRenderCallback?.(); - } + this.followSuppressedAtEnd = false; + if (moved !== 0) this.markScrollbarActivity(); + if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.(); return requested - moved; } @@ -143,6 +159,7 @@ export class ScrollView extends Container { this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight); this.currentScrollTop = 0; this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -154,6 +171,7 @@ export class ScrollView extends Container { const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -167,7 +185,10 @@ export class ScrollView extends Container { const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); if (this.followingEnd) this.currentScrollTop = maxScrollTop; else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); - if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true; + if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; + if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { + this.followingEnd = true; + } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 00e9d414f..0d5a4a109 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -18,7 +18,12 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; -export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; +export { + ScrollView, + type ScrollViewOptions, + type ScrollViewScrollbar, + type ScrollViewScrollToOptions, +} from "./components/scroll-view.ts"; export { type SelectItem, SelectList, diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index b1a086d67..421657bfa 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -48,6 +48,10 @@ export interface Keybindings { "tui.altScreen.halfPageDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; + "tui.altScreen.search": true; + "tui.altScreen.searchNext": true; + "tui.altScreen.searchPrevious": true; + "tui.altScreen.searchClose": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true; } @@ -175,6 +179,22 @@ export const TUI_KEYBINDINGS = { defaultKeys: "ctrl+shift+down", description: "Jump to next semantic prompt", }, + "tui.altScreen.search": { + defaultKeys: "ctrl+shift+f", + description: "Search the primary scroll view", + }, + "tui.altScreen.searchNext": { + defaultKeys: ["enter", "ctrl+g"], + description: "Select the next search match", + }, + "tui.altScreen.searchPrevious": { + defaultKeys: ["shift+enter", "ctrl+shift+g"], + description: "Select the previous search match", + }, + "tui.altScreen.searchClose": { + defaultKeys: "escape", + description: "Close transcript search", + }, "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index a8b0b8478..b6c483fe9 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -20,6 +20,7 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; +const LONE_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -256,8 +257,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 10ms) - * After this time, the buffer is flushed even if incomplete + * Maximum time to wait for sequence completion (default: 50ms). + * A lone Escape uses at most 10ms to preserve keyboard responsiveness. */ timeout?: number; }; @@ -281,7 +282,7 @@ export class StdinBuffer extends EventEmitter { constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 10; + this.timeoutMs = options.timeout ?? 50; } public process(data: string | Buffer): void { @@ -376,13 +377,14 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + const timeoutMs = this.buffer === ESC ? Math.min(this.timeoutMs, LONE_ESCAPE_TIMEOUT_MS) : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { this.emitDataSequence(sequence); } - }, this.timeoutMs); + }, timeoutMs); } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 1014b80fc..68f8b1a35 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -183,7 +183,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + this.stdinBuffer = new StdinBuffer(); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index fa7063e4c..c3e9d6a27 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1,3 +1,9 @@ +import { + AltScreenSearchComponent, + type AltScreenSearchMatch, + findAltScreenSearchMatches, + getAltScreenSearchMatchKey, +} from "./alt-screen-search.ts"; import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; import { ScrollView } from "./components/scroll-view.ts"; import { getKeybindings } from "./keybindings.ts"; @@ -26,6 +32,7 @@ import { type Component, CURSOR_MARKER, compositeTuiLine, + type OverlayHandle, TuiBase, type TuiStopOptions, VIEWPORT_TUI, @@ -114,11 +121,34 @@ interface ScrollbarTarget { geometry: ScrollbarGeometry; } +type SearchSelectionMode = "query" | "retain" | "next" | "previous"; + +interface ActiveSearch { + component: AltScreenSearchComponent; + overlay?: OverlayHandle; + query: string; + matches: AltScreenSearchMatch[]; + selectedIndex: number; + selectedKey?: string; + anchorRow: number; + selectionMode: SearchSelectionMode; +} + +interface SearchHighlightRange { + startCol: number; + endCol: number; + current: boolean; +} + export interface TuiAltScreenOptions { /** Number of logical lines moved for each mouse-wheel event. */ wheelScrollLines?: number; /** Capture mouse events for viewport scrolling and application-owned text selection. */ mouse?: boolean; + /** Style a non-current transcript search match. */ + searchMatchStyle?: (text: string) => string; + /** Style the current transcript search match. */ + searchCurrentMatchStyle?: (text: string) => string; /** Open an OSC 8 hyperlink activated with a primary-button click. */ openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ @@ -153,10 +183,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private selectionPressActive = false; private scrollbarDrag?: ScrollbarDrag; private scrollbarHover?: ScrollView; + private activeSearch?: ActiveSearch; private pressedUrl?: string; private selectionDragged = false; private readonly wheelScrollLines: number; private readonly mouseEnabled: boolean; + private readonly searchMatchStyle: (text: string) => string; + private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; @@ -177,6 +210,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes = new AltScreenFlashContainer(() => this.requestRender()); this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); this.mouseEnabled = options.mouse ?? true; + this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); + this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; this.addInputListener((data) => this.handleViewportInput(data)); @@ -250,6 +285,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override beforeTerminalStop(_options: TuiStopOptions): void { + this.closeSearch(); this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -377,6 +413,113 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } } + private openSearch(): void { + if (this.activeSearch) { + this.activeSearch.overlay?.focus(); + return; + } + const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query)); + const search: ActiveSearch = { + component, + query: "", + matches: [], + selectedIndex: -1, + anchorRow: this.getPrimaryScrollView().scrollTop, + selectionMode: "query", + }; + this.activeSearch = search; + search.overlay = this.showOverlay(component, { + anchor: "top-right", + width: "40%", + minWidth: 24, + margin: 1, + }); + } + + private closeSearch(): void { + const search = this.activeSearch; + if (!search) return; + this.activeSearch = undefined; + search.overlay?.hide(); + this.requestRender(); + } + + private updateSearchQuery(query: string): void { + const search = this.activeSearch; + if (!search || query === search.query) return; + const selected = search.matches[search.selectedIndex]; + search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop; + search.query = query; + search.selectionMode = "query"; + search.component.setResult(-1, 0); + this.requestRender(); + } + + private navigateSearch(direction: -1 | 1): void { + const search = this.activeSearch; + if (!search?.query) return; + search.selectionMode = direction < 0 ? "previous" : "next"; + this.requestRender(); + } + + private refreshSearch(layout: LayoutFrame): boolean { + const search = this.activeSearch; + if (!search) return false; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + const lines = box?.scrollContentLines; + if (!lines || !search.query.trim()) { + search.matches = []; + search.selectedIndex = -1; + search.selectedKey = undefined; + search.selectionMode = "retain"; + search.component.setResult(-1, 0); + return false; + } + + const shouldRevealSelection = search.selectionMode !== "retain"; + const matches = findAltScreenSearchMatches(lines, search.query); + const exactIndex = search.selectedKey + ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey) + : -1; + let selectedIndex = -1; + if (matches.length > 0) { + if (search.selectionMode === "query") { + selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow); + if (selectedIndex < 0) selectedIndex = 0; + } else if (search.selectionMode === "next") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length; + } else if (search.selectionMode === "previous") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length; + } else { + selectedIndex = + exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1); + } + } + + search.matches = matches; + search.selectedIndex = selectedIndex; + search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined; + search.selectionMode = "retain"; + search.component.setResult(selectedIndex, matches.length); + if (!shouldRevealSelection) return false; + + const selected = matches[selectedIndex]; + const firstSegment = selected?.segments[0]; + const lastSegment = selected?.segments[selected.segments.length - 1]; + if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false; + const before = scrollView.scrollTop; + const visibleBottom = before + scrollView.viewportHeight - 1; + let target = before; + if (firstSegment.row < before || lastSegment.row > visibleBottom) { + target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3); + } + scrollView.scrollTo(target, { disableFollow: true }); + return scrollView.scrollTop !== before; + } + /** Show a transient message in the alternate-screen flash stack. */ flash(message: string, durationMs?: number): void { this.flashes.flash(message, durationMs); @@ -420,6 +563,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const keybindings = getKeybindings(); const isRelease = isKeyRelease(data); + if (keybindings.matches(data, "tui.altScreen.search")) { + if (!isRelease) this.openSearch(); + return { consume: true }; + } + if (this.activeSearch?.overlay?.isFocused()) { + if (keybindings.matches(data, "tui.altScreen.searchNext")) { + if (!isRelease) this.navigateSearch(1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchPrevious")) { + if (!isRelease) this.navigateSearch(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchClose")) { + if (!isRelease) this.closeSearch(); + return { consume: true }; + } + } if (keybindings.matches(data, "tui.altScreen.pageUp")) { if (!isRelease) { this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); @@ -896,6 +1057,76 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flash("Copied!"); } + private applySearchTextHighlight(text: string, current: boolean): string { + const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle; + let result = ""; + let plainStart = 0; + let index = 0; + while (index < text.length) { + const ansi = extractAnsiCode(text, index); + if (!ansi) { + index += 1; + continue; + } + if (index > plainStart) result += style(text.slice(plainStart, index)); + result += ansi.code; + index += ansi.length; + plainStart = index; + } + if (plainStart < text.length) result += style(text.slice(plainStart)); + return result; + } + + private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] { + const search = this.activeSearch; + if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + if (!box) return screen; + + const rangesByRow = new Map(); + const scrollbarColumn = getScrollbarGeometry(box)?.column; + const minRow = Math.max(0, box.rect.y, box.clip.y); + const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height); + const minColumn = Math.max(0, box.rect.x, box.clip.x); + const maxColumn = Math.min( + this.terminal.columns, + box.rect.x + box.rect.width, + box.clip.x + box.clip.width, + scrollbarColumn ?? Number.POSITIVE_INFINITY, + ); + for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) { + for (const segment of search.matches[matchIndex]!.segments) { + const row = box.rect.y + segment.row - scrollView.scrollTop; + if (row < minRow || row >= maxRow) continue; + const startCol = Math.max(minColumn, box.rect.x + segment.startCol); + const endCol = Math.min(maxColumn, box.rect.x + segment.endCol); + if (endCol <= startCol) continue; + const ranges = rangesByRow.get(row) ?? []; + ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex }); + rangesByRow.set(row, ranges); + } + } + + const result = [...screen]; + for (const [row, ranges] of rangesByRow) { + let line = result[row] ?? ""; + if (isImageLine(line)) continue; + const lineWidth = visibleWidth(line); + for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) { + const startCol = Math.min(range.startCol, lineWidth); + const endCol = Math.min(range.endCol, lineWidth); + if (endCol <= startCol) continue; + const before = sliceByColumn(line, 0, startCol, true); + const highlighted = sliceByColumn(line, startCol, endCol - startCol, true); + const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true); + line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`; + } + result[row] = line; + } + return result; + } + private applySelectionHighlight(text: string): string { let result = "\x1b[7m"; let index = 0; @@ -985,8 +1216,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); const root = this.layoutRoot ?? this.implicitScrollView; - const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + if (this.refreshSearch(nextLayout)) { + nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + } let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeOverlays(screen, width, height); if (screen.length > height) screen = screen.slice(screen.length - height); screen = this.applySelection(screen, nextLayout); diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index c79a024aa..29e4bf6a1 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -36,6 +36,10 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); }); diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index e72c14966..7c4997f00 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -133,6 +133,19 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { + const delayedBuffer = new StdinBuffer(); + const delayedSequences: string[] = []; + delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence)); + + delayedBuffer.process("\x1b["); + await wait(20); + assert.deepStrictEqual(delayedSequences, []); + delayedBuffer.process("<65;48;39M"); + assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]); + delayedBuffer.destroy(); + }); }); describe("Mixed Content", () => { @@ -314,6 +327,17 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b"]); }); + it("flushes a lone escape promptly with the longer default sequence timeout", async () => { + const defaultBuffer = new StdinBuffer(); + const defaultSequences: string[] = []; + defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence)); + + defaultBuffer.process("\x1b"); + await wait(20); + assert.deepStrictEqual(defaultSequences, ["\x1b"]); + defaultBuffer.destroy(); + }); + it("should handle lone escape character with explicit flush", () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index d3d9df32f..6612cd006 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { ScrollView } from "../src/components/scroll-view.ts"; @@ -379,6 +380,105 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("searches normalized rendered transcript text across rows", () => { + assert.deepStrictEqual(findAltScreenSearchMatches(["alpha QUICK", "brown fox"], "quick brown"), [ + { + segments: [ + { row: 0, startCol: 6, endCol: 11 }, + { row: 1, startCol: 0, endCol: 5 }, + ], + }, + ]); + }); + + it("uses configured styles for current and non-current search matches", async () => { + const terminal = new RecordingTerminal(60, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + searchMatchStyle: (text) => `\x1b[41m${text}\x1b[49m`, + searchCurrentMatchStyle: (text) => `\x1b[42m${text}\x1b[49m`, + }); + tui.addChild(new Text("needle first\nmiddle\nneedle second\nend", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[42mneedle\x1b[49m")), + ); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[41mneedle\x1b[49m")), + ); + tui.stop(); + }); + + it("searches the transcript with Ctrl+Shift+F and restores editor focus on close", async () => { + const terminal = new RecordingTerminal(60, 8); + const tui = new TuiAltScreen(terminal); + const transcriptText = new Text( + Array.from({ length: 12 }, (_, index) => { + if (index === 4) return "line 5 needle one"; + if (index === 9) return "line 10 needle two"; + return `line ${index + 1}`; + }).join("\n"), + 0, + 0, + ); + const transcript = new ScrollView(transcriptText, { follow: "end", primary: true }); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + assert.deepStrictEqual(editorInputs, []); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[1;7mneedle\x1b[22;27m")), + ); + + for (let index = 0; index < 6; index++) terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.ok(terminal.getViewport().some((line) => line.includes("> needle"))); + + terminal.sendInput("\x07"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("1/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 5 needle one"))); + + terminal.sendInput("\x1b[103;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + + terminal.sendInput("\x1b"); + terminal.sendInput("x"); + await terminal.waitForRender(); + assert.ok(!terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.deepStrictEqual(editorInputs, ["x"]); + + tui.stop(); + }); + it("scrolls the transcript by half a page with custom bindings", async () => { const originalKeybindings = getKeybindings(); const terminal = new VirtualTerminal(20, 10);