mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): search improvements (#8800)
This commit is contained in:
parent
0fdec07ba3
commit
7d399e7be8
12 changed files with 331 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}┘`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export {
|
|||
isFocusable,
|
||||
isViewportTUI,
|
||||
type OverlayAnchor,
|
||||
type OverlayBounds,
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue