From 7d399e7be83cb7ffb0236b2925ba21f2bc9efe18 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:09:51 +0200 Subject: [PATCH] feat(tui): search improvements (#8800) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/keybindings.md | 2 + .../src/modes/interactive/tui-renderer.ts | 1 + packages/tui/CHANGELOG.md | 4 + packages/tui/README.md | 3 +- packages/tui/src/alt-screen-search.ts | 103 ++++++++++++-- packages/tui/src/components/input.ts | 36 ++++- packages/tui/src/index.ts | 1 + packages/tui/src/tui-alt-screen.ts | 42 +++++- packages/tui/src/tui.ts | 18 +++ packages/tui/test/input.test.ts | 19 ++- packages/tui/test/tui-alt-screen.test.ts | 133 ++++++++++++++++-- 12 files changed, 331 insertions(+), 32 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b11fa0180..e2a220a36 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -32,6 +32,7 @@ ### Changed +- Changed fullscreen transcript search arrows to underline on hover and capitalized the search placeholder. - Changed selectors in `/thinking`, `/model`, `/scoped-models`, `/trust`, per-model thinking settings, and theme settings to keep active options marked while browsing. `/scoped-models` now uses consistent per-item toggles and strikes through unavailable models ([#8900](https://github.com/earendil-works/pi/pull/8900)). ### Fixed diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index ae2e65c2e..83a4939ac 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -90,6 +90,8 @@ These actions apply when interactive mode uses `--tui-mode fullscreen` and targe Fullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor. +The transcript search panel shows the configured previous/next shortcuts and clickable arrow controls. Press `tui.altScreen.search` again, or use `tui.altScreen.searchClose`, to close it. + | Key | Default mode | Fullscreen mode | |-----|--------------|-----------------| | `home`, `end` | Editor | Transcript | diff --git a/packages/coding-agent/src/modes/interactive/tui-renderer.ts b/packages/coding-agent/src/modes/interactive/tui-renderer.ts index c71bfa4f0..044d2ee72 100644 --- a/packages/coding-agent/src/modes/interactive/tui-renderer.ts +++ b/packages/coding-agent/src/modes/interactive/tui-renderer.ts @@ -24,6 +24,7 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)), searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), + searchNavigationButtonStyle: (text, hovered) => (hovered ? theme.underline(text) : text), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, copyOnSelect: options.fullscreenCopyOnSelect, diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 00c030ecc..736b5a31c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -13,6 +13,10 @@ - Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)). - Added a `TuiAltScreen` `copyOnSelect` option plus helpers to detect and copy the active fullscreen text selection programmatically ([#7720](https://github.com/earendil-works/pi/issues/7720)). +### Changed + +- Changed fullscreen transcript search to use a bordered, placeholder-based input with a muted result count, right-aligned clickable key-and-arrow buttons with configurable hover styling, and open-shortcut toggling. + ### Fixed - Fixed main-screen rendering crashing when image-heavy output exceeded V8's string length limit ([#8028](https://github.com/earendil-works/pi/issues/8028)). diff --git a/packages/tui/README.md b/packages/tui/README.md index 700d53850..29fc8c1f9 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. 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. +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 open or close its bordered search panel. The panel shows the configured previous/next shortcuts and provides clickable arrow controls; by default, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` move between matches, and `Escape` also closes search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting, while `searchNavigationButtonStyle` styles each arrow button and receives its hover state. 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. @@ -177,6 +177,7 @@ handle.unfocus(); // Release focus to normal fallback handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component handle.unfocus({ target: null }); // Release this overlay and leave focus empty handle.isFocused(); // Check if overlay has focus +handle.getBounds(); // Get last rendered terminal-relative bounds handle.unfocus(); // Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target. diff --git a/packages/tui/src/alt-screen-search.ts b/packages/tui/src/alt-screen-search.ts index 98926523f..7a04d4051 100644 --- a/packages/tui/src/alt-screen-search.ts +++ b/packages/tui/src/alt-screen-search.ts @@ -1,4 +1,5 @@ import { Input } from "./components/input.ts"; +import { getKeybindings } from "./keybindings.ts"; import type { Component, Focusable } from "./tui.ts"; import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts"; @@ -103,14 +104,28 @@ export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string } export class AltScreenSearchComponent implements Component, Focusable { - private readonly input = new Input(); + private readonly input = new Input({ + prompt: " ", + placeholder: "Find in transcript", + placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`, + }); private readonly onQueryChange: (query: string) => void; + private readonly navigationButtonStyle: (text: string, hovered: boolean) => string; private resultCount = 0; private resultIndex = -1; + private previousButtonStart = -1; + private previousButtonEnd = -1; + private nextButtonStart = -1; + private nextButtonEnd = -1; + private hoveredNavigationDirection: -1 | 1 | undefined; private _focused = false; - constructor(onQueryChange: (query: string) => void) { + constructor( + onQueryChange: (query: string) => void, + navigationButtonStyle: (text: string, hovered: boolean) => string = (text) => text, + ) { this.onQueryChange = onQueryChange; + this.navigationButtonStyle = navigationButtonStyle; } get focused(): boolean { @@ -127,6 +142,19 @@ export class AltScreenSearchComponent implements Component, Focusable { this.resultCount = count; } + getNavigationDirectionAt(row: number, column: number): -1 | 1 | undefined { + if (row !== 2) return undefined; + if (column >= this.previousButtonStart && column < this.previousButtonEnd) return -1; + if (column >= this.nextButtonStart && column < this.nextButtonEnd) return 1; + return undefined; + } + + setHoveredNavigationDirection(direction: -1 | 1 | undefined): boolean { + if (direction === this.hoveredNavigationDirection) return false; + this.hoveredNavigationDirection = direction; + return true; + } + handleInput(data: string): void { const previous = this.input.getValue(); this.input.handleInput(data); @@ -140,18 +168,69 @@ export class AltScreenSearchComponent implements Component, Focusable { render(width: number): string[] { const safeWidth = Math.max(1, width); - const label = " Find transcript"; + const innerWidth = Math.max(0, safeWidth - 2); + const formatKey = (key: string | undefined): string => + key + ? key + .split("+") + .map((part) => { + if (process.platform === "darwin" && part.toLowerCase() === "alt") return "Option"; + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join("+") + : "Unbound"; + const keybindings = getKeybindings(); + const previousKey = formatKey(keybindings.getKeys("tui.altScreen.searchPrevious")[0]); + const nextKey = formatKey(keybindings.getKeys("tui.altScreen.searchNext")[0]); const query = this.input.getValue(); - const status = !query + const result = !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)]; + ? "No matches" + : `${this.resultIndex + 1}/${this.resultCount}`; + const resultSpace = Math.max(0, innerWidth - 3); + const visibleResult = truncateToWidth(result, resultSpace, ""); + const resultText = visibleResult ? `\x1b[2m ${visibleResult} \x1b[22m` : ""; + const inputWidth = Math.max(0, innerWidth - visibleWidth(resultText)); + const inputLine = truncateToWidth(this.input.render(Math.max(1, inputWidth))[0] ?? "", inputWidth, ""); + const inputPadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(inputLine))); + const content = `${inputLine}${inputPadding}${resultText}`; + + let previousButton = `↑ ${previousKey}`; + let nextButton = `↓ ${nextKey}`; + let separator = " · "; + const outerGapWidth = 1; + const availableControlsWidth = Math.max(0, innerWidth - outerGapWidth * 2 - 1); + let controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton); + if (controlsWidth > availableControlsWidth) { + previousButton = "↑"; + nextButton = "↓"; + separator = " "; + controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton); + } + const showButtons = controlsWidth <= availableControlsWidth; + const renderedButtons = showButtons + ? this.navigationButtonStyle(previousButton, this.hoveredNavigationDirection === -1) + + separator + + this.navigationButtonStyle(nextButton, this.hoveredNavigationDirection === 1) + : ""; + const outerGapsWidth = showButtons ? outerGapWidth * 2 : 0; + const rightRuleWidth = renderedButtons && innerWidth > controlsWidth + outerGapsWidth ? 1 : 0; + const leftRuleWidth = Math.max( + 0, + innerWidth - (showButtons ? controlsWidth : 0) - outerGapsWidth - rightRuleWidth, + ); + const previousStart = 1 + leftRuleWidth + outerGapWidth; + this.previousButtonStart = showButtons ? previousStart : -1; + this.previousButtonEnd = showButtons ? previousStart + visibleWidth(previousButton) : -1; + this.nextButtonStart = showButtons ? this.previousButtonEnd + visibleWidth(separator) : -1; + this.nextButtonEnd = showButtons ? this.nextButtonStart + visibleWidth(nextButton) : -1; + + if (safeWidth === 1) return ["┌", "│", "└"]; + return [ + `┌${"─".repeat(innerWidth)}┐`, + `│${content}│`, + `└${"─".repeat(leftRuleWidth)}${renderedButtons ? " " : ""}${renderedButtons}${renderedButtons ? " " : ""}${"─".repeat(rightRuleWidth)}┘`, + ]; } } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 8943d59a8..1bc264530 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -3,7 +3,7 @@ import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; const segmenter = getGraphemeSegmenter(); @@ -13,12 +13,21 @@ interface InputState { cursor: number; } +export interface InputOptions { + prompt?: string; + placeholder?: string; + placeholderStyle?: (text: string) => string; +} + /** * Input component - single-line text input with horizontal scrolling */ export class Input implements Component, Focusable { private value: string = ""; private cursor: number = 0; // Cursor position in the value + private readonly prompt: string; + private readonly placeholder: string; + private readonly placeholderStyle: (text: string) => string; private renderedStartColumn = 0; public onSubmit?: (value: string) => void; public onEscape?: () => void; @@ -37,6 +46,12 @@ export class Input implements Component, Focusable { // Undo support private undoStack = new UndoStack(); + constructor(options: InputOptions = {}) { + this.prompt = options.prompt ?? "> "; + this.placeholder = options.placeholder ?? ""; + this.placeholderStyle = options.placeholderStyle ?? ((text) => text); + } + getValue(): string { return this.value; } @@ -396,11 +411,22 @@ export class Input implements Component, Focusable { render(width: number): string[] { // Calculate visible window - const prompt = "> "; - const availableWidth = width - prompt.length; + const availableWidth = width - visibleWidth(this.prompt); if (availableWidth <= 0) { - return [prompt]; + return [truncateToWidth(this.prompt, width, "")]; + } + + if (this.value.length === 0 && this.placeholder) { + const placeholder = truncateToWidth(this.placeholder, availableWidth, ""); + const graphemes = [...segmenter.segment(placeholder)]; + const atCursor = graphemes[0]?.segment ?? " "; + const afterCursor = placeholder.slice(atCursor.length); + const marker = this.focused ? CURSOR_MARKER : ""; + const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`; + const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor); + const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor))); + return [this.prompt + textWithCursor + padding]; } let visibleText = ""; @@ -461,7 +487,7 @@ export class Input implements Component, Focusable { // Calculate visual width const visualLength = visibleWidth(textWithCursor); const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); - const line = prompt + textWithCursor + padding; + const line = this.prompt + textWithCursor + padding; return [line]; } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 837e6216c..d5025a4e0 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -124,6 +124,7 @@ export { isFocusable, isViewportTUI, type OverlayAnchor, + type OverlayBounds, type OverlayHandle, type OverlayMargin, type OverlayOptions, diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index ff59cf34f..5996b52a7 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -162,6 +162,8 @@ export interface TuiAltScreenOptions { searchMatchStyle?: (text: string) => string; /** Style the current transcript search match. */ searchCurrentMatchStyle?: (text: string) => string; + /** Style a transcript search navigation button. */ + searchNavigationButtonStyle?: (text: string, hovered: boolean) => 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. */ @@ -221,6 +223,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly mouseEnabled: boolean; private readonly searchMatchStyle: (text: string) => string; private readonly searchCurrentMatchStyle: (text: string) => string; + private readonly searchNavigationButtonStyle: (text: string, hovered: boolean) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; private copyOnSelect: boolean; @@ -246,6 +249,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { 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.searchNavigationButtonStyle = options.searchNavigationButtonStyle ?? ((text) => text); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; this.copyOnSelect = options.copyOnSelect ?? true; @@ -472,12 +476,15 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } } - private openSearch(): void { + private toggleSearch(): void { if (this.activeSearch) { - this.activeSearch.overlay?.focus(); + this.closeSearch(); return; } - const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query)); + const component = new AltScreenSearchComponent( + (query) => this.updateSearchQuery(query), + this.searchNavigationButtonStyle, + ); const search: ActiveSearch = { component, query: "", @@ -490,7 +497,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { search.overlay = this.showOverlay(component, { anchor: "top-right", width: "40%", - minWidth: 24, + minWidth: 32, margin: 1, }); } @@ -521,6 +528,28 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); } + private getSearchNavigationDirectionAt(x: number, y: number): -1 | 1 | undefined { + const search = this.activeSearch; + const bounds = search?.overlay?.getBounds(); + if (!search || !bounds) return undefined; + if (x < bounds.col || x >= bounds.col + bounds.width || y < bounds.row || y >= bounds.row + bounds.height) { + return undefined; + } + return search.component.getNavigationDirectionAt(y - bounds.row, x - bounds.col); + } + + private handleSearchMouseEvent(event: SgrMouseEvent): boolean { + const search = this.activeSearch; + if (!search) return false; + const direction = this.getSearchNavigationDirectionAt(event.x, event.y); + if (search.component.setHoveredNavigationDirection(direction)) this.requestRender(); + if (direction === undefined || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) { + return false; + } + this.navigateSearch(direction); + return true; + } + private refreshSearch(layout: LayoutFrame): boolean { const search = this.activeSearch; if (!search) return false; @@ -602,6 +631,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionPressActive = false; this.stopSelectionAutoScroll(); this.stopScrollbarHover(); + if (this.activeSearch?.component.setHoveredNavigationDirection(undefined)) this.requestRender(); this.stopScrollbarDrag(); this.pressedUrl = undefined; this.selectionDragged = false; @@ -644,7 +674,7 @@ 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(); + if (!isRelease) this.toggleSearch(); return { consume: true }; } if (this.activeSearch?.overlay?.isFocused()) { @@ -848,6 +878,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return; } + if (this.handleSearchMouseEvent(raw)) return; + const overlay = this.dispatchMouseToOverlay(event); if (!overlay.hit) { const scrollbarHandled = this.handleScrollbarMouseEvent(raw); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 0a34756e2..92e26bb15 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -256,6 +256,14 @@ export interface OverlayUnfocusOptions { target: Component | null; } +/** Last rendered terminal-relative overlay rectangle. */ +export interface OverlayBounds { + row: number; + col: number; + width: number; + height: number; +} + /** * Handle returned by showOverlay for controlling the overlay */ @@ -272,6 +280,8 @@ export interface OverlayHandle { unfocus(options?: OverlayUnfocusOptions): void; /** Check if this overlay currently has focus */ isFocused(): boolean; + /** Get the most recent rendered bounds for a visible overlay. */ + getBounds(): OverlayBounds | undefined; } type OverlayStackEntry = { @@ -280,6 +290,7 @@ type OverlayStackEntry = { preFocus: Component | null; hidden: boolean; focusOrder: number; + bounds?: OverlayBounds; }; type RenderedOverlayLayout = { @@ -763,6 +774,10 @@ export abstract class TuiBase extends Container implements TUI { this.requestRender(); }, isFocused: () => this.focusedComponent === component, + getBounds: () => { + if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds) return undefined; + return { ...entry.bounds }; + }, }; } @@ -1268,6 +1283,8 @@ export abstract class TuiBase extends Container implements TUI { } const result = [...lines]; + for (const entry of this.overlayStack) entry.bounds = undefined; + // Pre-render all visible overlays and calculate positions const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = []; let minLinesNeeded = result.length; @@ -1291,6 +1308,7 @@ export abstract class TuiBase extends Container implements TUI { // Get final row/col with actual overlay height const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); + entry.bounds = { row, col, width, height: overlayLines.length }; rendered.push({ entry, overlayLines, row, col, w: width }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 5b83ec71d..e1f3f8172 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { Input } from "../src/components/input.ts"; -import { visibleWidth } from "../src/utils.ts"; +import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; describe("Input component", () => { it("submits value including backslash on Enter", () => { @@ -35,6 +35,23 @@ describe("Input component", () => { }); describe("render", () => { + it("supports a custom prompt and styled placeholder", () => { + const input = new Input({ + prompt: "", + placeholder: "Find transcript", + placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`, + }); + input.focused = true; + + const [empty] = input.render(20); + assert.ok(empty?.includes("\x1b[2m")); + assert.strictEqual(stripTerminalSequences(empty ?? "").trimEnd(), "Find transcript"); + + input.handleInput("n"); + const [populated] = input.render(20); + assert.strictEqual(stripTerminalSequences(populated ?? "").trimEnd(), "n"); + }); + it("does not overflow with wide CJK and fullwidth text", () => { const width = 93; const cases = [ diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 698f75d20..c7c00bf61 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; +import { AltScreenSearchComponent, findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { MouseRegion } from "../src/components/mouse-region.ts"; @@ -18,6 +18,7 @@ import { } from "../src/terminal-image.ts"; import type { TuiMouseEvent } from "../src/tui.ts"; import { TuiAltScreen } from "../src/tui-alt-screen.ts"; +import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; @@ -455,6 +456,121 @@ describe("TuiAltScreen", () => { ]); }); + it("renders transcript search with a muted placeholder and right-aligned controls", () => { + const component = new AltScreenSearchComponent(() => {}); + const rendered = component.render(48); + const lines = rendered.map((line) => stripTerminalSequences(line)); + + assert.strictEqual(lines.length, 3); + assert.ok(lines.every((line) => visibleWidth(line) === 48)); + assert.match(lines[0] ?? "", /^┌─+┐$/); + assert.match(lines[1] ?? "", /^│ Find in transcript +│$/); + assert.ok(rendered[1]?.includes("\x1b[2m")); + assert.match(lines[2] ?? "", /^└─+ ↑ Shift\+Enter · ↓ Enter ─┘$/); + const controls = lines[2] ?? ""; + assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("↑")), -1); + assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("Shift+Enter") + 5), -1); + assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("·")), undefined); + assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("↓")), 1); + assert.strictEqual(component.getNavigationDirectionAt(2, controls.lastIndexOf("Enter") + 2), 1); + + component.handleInput("n"); + component.setResult(0, 2); + const populatedRender = component.render(48); + const populated = populatedRender.map((line) => stripTerminalSequences(line)); + assert.ok(populated[1]?.includes("n")); + assert.ok(populated[1]?.includes("1/2")); + assert.ok(populatedRender[1]?.includes("\x1b[2m 1/2 \x1b[22m")); + assert.ok(!populated.some((line) => line.includes("Find in transcript"))); + }); + + it("navigates transcript search with hoverable arrow buttons and toggles it with its shortcut", async () => { + const terminal = new RecordingTerminal(120, 6); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + searchNavigationButtonStyle: (text, hovered) => `${hovered ? "\x1b[45m" : "\x1b[44m"}${text}\x1b[49m`, + }); + tui.addChild(new Text("needle one\nmiddle\nneedle two\nend", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + let viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("1/2"))); + assert.ok(viewport.some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); + + let arrowRow = viewport.findIndex((line) => line.includes("↑") && line.includes("↓")); + let arrowColumn = viewport[arrowRow]?.lastIndexOf("Enter") ?? -1; + assert.ok(arrowRow >= 0 && arrowColumn >= 0); + const hoverEventCount = terminal.events.length; + terminal.sendInput(`\x1b[<35;${arrowColumn + 1};${arrowRow + 1}M`); + await terminal.waitForRender(); + assert.ok( + terminal.events + .slice(hoverEventCount) + .some((event) => event.type === "write" && event.data.includes("\x1b[45m↓ Enter\x1b[49m")), + ); + terminal.sendInput(`\x1b[<0;${arrowColumn + 1};${arrowRow + 1}M`); + await terminal.waitForRender(); + viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("2/2"))); + assert.ok(viewport.some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); + + arrowRow = viewport.findIndex((line) => line.includes("↑") && line.includes("↓")); + arrowColumn = (viewport[arrowRow]?.indexOf("Shift+Enter") ?? -3) + 3; + assert.ok(arrowRow >= 0 && arrowColumn >= 0); + terminal.sendInput(`\x1b[<0;${arrowColumn + 1};${arrowRow + 1}M`); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("1/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); + + terminal.sendInput("\x1b[102;6u"); + await terminal.waitForRender(); + assert.ok(!terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); + tui.stop(); + }); + + it("does not treat transcript box drawing as search navigation buttons", async () => { + const terminal = new VirtualTerminal(80, 10); + const tui = new TuiAltScreen(terminal); + tui.addChild( + new Text( + [ + "needle one", + "middle", + "needle two", + "filler", + "┌────────────────────────────────────────┐", + "│ box │", + "└────────────────────────────────────────┘", + "end", + ].join("\n"), + 0, + 0, + ), + ); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + let viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("1/2"))); + assert.ok(!viewport.some((line) => line.includes("2/2"))); + + const boxBottomRow = viewport.findIndex((line) => line.startsWith("└")); + assert.ok(boxBottomRow >= 0); + terminal.sendInput(`\x1b[<0;24;${boxBottomRow + 1}M`); + await terminal.waitForRender(); + + viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("1/2"))); + assert.ok(!viewport.some((line) => line.includes("2/2"))); + tui.stop(); + }); + 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, { @@ -512,7 +628,8 @@ describe("TuiAltScreen", () => { 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("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); assert.deepStrictEqual(editorInputs, []); assert.ok( @@ -522,22 +639,22 @@ describe("TuiAltScreen", () => { 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"))); + assert.ok(terminal.getViewport().some((line) => line.includes("needle") && line.includes("2/2"))); 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("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("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.ok(!terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter"))); assert.deepStrictEqual(editorInputs, ["x"]); tui.stop(); @@ -1669,13 +1786,13 @@ describe("TuiAltScreen", () => { terminal.sendInput("\x1b[102;6u"); await terminal.waitForRender(); - assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.ok(terminal.getViewport().some((line) => line.includes("↑ ↓"))); terminal.sendInput("\x1b[5~"); terminal.sendInput("\x1b[<64;1;4M"); await terminal.waitForRender(); assert.ok(tui.viewportTop < topBefore); - assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.ok(terminal.getViewport().some((line) => line.includes("↑ ↓"))); tui.stop(); }); });