feat(tui): search improvements (#8800)

This commit is contained in:
Cristina Poncela Cubeiro 2026-09-03 15:09:51 +02:00 • committed by GitHub
parent 0fdec07ba3
commit 7d399e7be8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 331 additions and 32 deletions

View file

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

View file

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

View file

@ -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,

View file

@ -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)).

View file

@ -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.

View file

@ -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)}┘`,
];
}
}

View file

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

View file

@ -124,6 +124,7 @@ export {
isFocusable,
isViewportTUI,
type OverlayAnchor,
type OverlayBounds,
type OverlayHandle,
type OverlayMargin,
type OverlayOptions,

View file

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

View file

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

View file

@ -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 = [

View file

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