chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
1161
third_party/pi/packages/tui/CHANGELOG.md
vendored
Normal file
1161
third_party/pi/packages/tui/CHANGELOG.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
903
third_party/pi/packages/tui/README.md
vendored
Normal file
903
third_party/pi/packages/tui/README.md
vendored
Normal file
@@ -0,0 +1,903 @@
|
||||
# @earendil-works/pi-tui
|
||||
|
||||
Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interchangeable Renderers**: Shared `TUI` interface with main-screen and alternate-screen implementations
|
||||
- **Differential Rendering**: Updates only changed lines or viewport rows
|
||||
- **Application-owned Scrolling**: Alternate-screen viewport supports mouse, trackpad, and keyboard navigation
|
||||
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
|
||||
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
|
||||
- **Component-based**: Simple Component interface with render() method
|
||||
- **Theme Support**: Components accept theme interfaces for customizable styling
|
||||
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
|
||||
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
|
||||
- **Autocomplete Support**: File paths and slash commands
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
// Create the default main-screen renderer through the shared TUI interface
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
// Add components
|
||||
tui.addChild(new Text("Welcome to my app!"));
|
||||
|
||||
import { defaultEditorTheme as editorTheme } from './test/test-themes.ts';
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.onSubmit = (text) => {
|
||||
console.log("Submitted:", text);
|
||||
tui.addChild(new Text(`You said: ${text}`));
|
||||
};
|
||||
tui.addChild(editor);
|
||||
|
||||
// Focus the editor so it receives keyboard input
|
||||
tui.setFocus(editor);
|
||||
|
||||
// In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit
|
||||
tui.addInputListener((data) => {
|
||||
if (matchesKey(data, 'ctrl+c')) {
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
tui.start();
|
||||
```
|
||||
|
||||
## Core API
|
||||
|
||||
### TUI interface and renderers
|
||||
|
||||
`TUI` is the shared interface for component management, focus, overlays, input, lifecycle, terminal queries, and rendering. Choose a concrete renderer only when constructing the application:
|
||||
|
||||
- `TuiMainScreen` renders into the main terminal buffer and preserves terminal scrollback.
|
||||
- `TuiAltScreen` renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document.
|
||||
|
||||
```typescript
|
||||
import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// To use an application-owned viewport in the alternate terminal buffer instead:
|
||||
// const tui: TUI = new TuiAltScreen(terminal);
|
||||
|
||||
tui.addChild(component);
|
||||
tui.removeChild(component);
|
||||
tui.start();
|
||||
tui.stop();
|
||||
tui.requestRender(); // Request a re-render
|
||||
|
||||
// Global debug key handler (Shift+Ctrl+D)
|
||||
tui.onDebug = () => console.log("Debug triggered");
|
||||
```
|
||||
|
||||
### Alternate-screen viewport layouts
|
||||
|
||||
`TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Container,
|
||||
isViewportTUI,
|
||||
ScrollView,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@earendil-works/pi-tui";
|
||||
|
||||
const transcript = new Container();
|
||||
transcript.addChild(new Text("History"));
|
||||
|
||||
const editorAndFooter = new VStack([
|
||||
editor,
|
||||
new Text("status"),
|
||||
]);
|
||||
|
||||
if (isViewportTUI(tui)) {
|
||||
tui.setLayoutRoot(new VStack([
|
||||
{
|
||||
component: new ScrollView(transcript, {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
overscroll: "chain",
|
||||
}),
|
||||
basis: 0,
|
||||
grow: 1,
|
||||
minSize: 1,
|
||||
},
|
||||
{
|
||||
component: editorAndFooter,
|
||||
basis: "auto",
|
||||
shrink: 1,
|
||||
minSize: 1,
|
||||
},
|
||||
]));
|
||||
}
|
||||
```
|
||||
|
||||
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. `TuiAltScreenOptions.scrollToEndIndicator` renders a clickable label centered on the last row of a `follow: "end"` primary scroll view while it is scrolled away from the end; clicking it resumes end-following.
|
||||
|
||||
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.
|
||||
|
||||
### Overlays
|
||||
|
||||
Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
|
||||
|
||||
```typescript
|
||||
// Show overlay with default options (centered, max 80 cols)
|
||||
const handle = tui.showOverlay(component);
|
||||
|
||||
// Show overlay with custom positioning and sizing
|
||||
// Values can be numbers (absolute) or percentage strings (e.g., "50%")
|
||||
const handle = tui.showOverlay(component, {
|
||||
// Sizing
|
||||
width: 60, // Fixed width in columns
|
||||
width: "80%", // Width as percentage of terminal
|
||||
minWidth: 40, // Minimum width floor
|
||||
maxHeight: 20, // Maximum height in rows
|
||||
maxHeight: "50%", // Maximum height as percentage of terminal
|
||||
|
||||
// Anchor-based positioning (default: 'center')
|
||||
anchor: 'bottom-right', // Position relative to anchor point
|
||||
offsetX: 2, // Horizontal offset from anchor
|
||||
offsetY: -1, // Vertical offset from anchor
|
||||
|
||||
// Percentage-based positioning (alternative to anchor)
|
||||
row: "25%", // Vertical position (0%=top, 100%=bottom)
|
||||
col: "50%", // Horizontal position (0%=left, 100%=right)
|
||||
|
||||
// Absolute positioning (overrides anchor/percent)
|
||||
row: 5, // Exact row position
|
||||
col: 10, // Exact column position
|
||||
|
||||
// Margin from terminal edges
|
||||
margin: 2, // All sides
|
||||
margin: { top: 1, right: 2, bottom: 1, left: 2 },
|
||||
|
||||
// Responsive visibility
|
||||
visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
|
||||
|
||||
// Focus behavior
|
||||
nonCapturing: true // Don't auto-focus when shown
|
||||
});
|
||||
|
||||
// OverlayHandle methods
|
||||
handle.hide(); // Permanently remove the overlay
|
||||
handle.setHidden(true); // Temporarily hide (can show again)
|
||||
handle.setHidden(false); // Show again after hiding
|
||||
handle.isHidden(); // Check if temporarily hidden
|
||||
handle.focus(); // Focus and bring to visual front
|
||||
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.
|
||||
|
||||
handle.unfocus({ target: null });
|
||||
// Overlay loses focus; no component receives input until focus is set again.
|
||||
|
||||
// A focused visible overlay reclaims keyboard input after temporary replacement UI
|
||||
// releases focus. If you want a specific component to receive input while overlays remain
|
||||
// visible, call handle.unfocus({ target: component }).
|
||||
|
||||
// Hide topmost overlay
|
||||
tui.hideOverlay();
|
||||
|
||||
// Check if any visible overlay is active
|
||||
tui.hasOverlay();
|
||||
```
|
||||
|
||||
**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'`
|
||||
|
||||
**Resolution order**:
|
||||
1. `minWidth` is applied as a floor after width calculation
|
||||
2. For position: absolute `row`/`col` > percentage `row`/`col` > `anchor`
|
||||
3. `margin` clamps final position to stay within terminal bounds
|
||||
4. `visible` callback controls whether overlay renders (called each frame)
|
||||
|
||||
### Component Interface
|
||||
|
||||
All components implement:
|
||||
|
||||
```typescript
|
||||
interface Component {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
||||
invalidate?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
|
||||
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
|
||||
| `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. |
|
||||
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
|
||||
|
||||
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
|
||||
|
||||
### Mouse Input
|
||||
|
||||
`TuiAltScreen` normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local `x`/`y`, absolute `screenX`/`screenY`, bounds, button, modifiers, click count, and wheel delta. `TuiMainScreen` does not capture mouse input because the terminal owns its scrollback.
|
||||
|
||||
```typescript
|
||||
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (event.type === "click" && event.button === "left") {
|
||||
this.expanded = !this.expanded;
|
||||
return { handled: true };
|
||||
}
|
||||
if (event.type === "press" && event.button === "left") {
|
||||
return { handled: true, capture: true, focus: true };
|
||||
}
|
||||
if (event.type === "drag") {
|
||||
this.updateFromPointer(event.x, event.y);
|
||||
return { handled: true, render: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Returning `handled` suppresses renderer-level fallback behavior. `capture` keeps subsequent drag and release events routed to the same component. `focus` requests keyboard focus. The optional `render` flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set `render: true` for a hover state that visibly changed, or `render: false` for a handled no-op. Render requests are coalesced and terminal output remains differential.
|
||||
|
||||
Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest `ScrollView` and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.
|
||||
|
||||
Use `MouseRegion` to add mouse behavior without changing a component's rendering:
|
||||
|
||||
```typescript
|
||||
const collapsible = new MouseRegion(content, (event) => {
|
||||
if (event.type !== "click" || event.button !== "left") return undefined;
|
||||
expanded = !expanded;
|
||||
return { handled: true };
|
||||
});
|
||||
```
|
||||
|
||||
`Container` and `Box` route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit `VStack`, `HStack`, and `ScrollView` layouts use the alternate-screen layout frame directly.
|
||||
|
||||
### Focusable Interface (IME Support)
|
||||
|
||||
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
|
||||
|
||||
```typescript
|
||||
import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";
|
||||
|
||||
class MyInput implements Component, Focusable {
|
||||
focused: boolean = false; // Set by TUI when focus changes
|
||||
|
||||
render(width: number): string[] {
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
// Emit marker right before the fake cursor
|
||||
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a `Focusable` component has focus, TUI:
|
||||
1. Sets `focused = true` on the component
|
||||
2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
|
||||
3. Positions the hardware terminal cursor at that location
|
||||
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
|
||||
|
||||
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
|
||||
**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
|
||||
|
||||
```typescript
|
||||
import { Container, type Focusable, Input } from "@earendil-works/pi-tui";
|
||||
|
||||
class SearchDialog extends Container implements Focusable {
|
||||
private searchInput: Input;
|
||||
|
||||
// Propagate focus to child input for IME cursor positioning
|
||||
private _focused = false;
|
||||
get focused(): boolean { return this._focused; }
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.searchInput.focused = value;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.searchInput = new Input();
|
||||
this.addChild(this.searchInput);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position.
|
||||
|
||||
## Built-in Components
|
||||
|
||||
### Container
|
||||
|
||||
Groups child components.
|
||||
|
||||
```typescript
|
||||
const container = new Container();
|
||||
container.addChild(component);
|
||||
container.removeChild(component);
|
||||
```
|
||||
|
||||
### Box
|
||||
|
||||
Container that applies padding and background color to all children.
|
||||
|
||||
```typescript
|
||||
const box = new Box(
|
||||
1, // paddingX (default: 1)
|
||||
1, // paddingY (default: 1)
|
||||
(text) => chalk.bgGray(text) // optional background function
|
||||
);
|
||||
box.addChild(new Text("Content"));
|
||||
box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
|
||||
```
|
||||
|
||||
### Text
|
||||
|
||||
Displays multi-line text with word wrapping and padding.
|
||||
|
||||
```typescript
|
||||
const text = new Text(
|
||||
"Hello World", // text content
|
||||
1, // paddingX (default: 1)
|
||||
1, // paddingY (default: 1)
|
||||
(text) => chalk.bgGray(text) // optional background function
|
||||
);
|
||||
text.setText("Updated text");
|
||||
text.setCustomBgFn((text) => chalk.bgBlue(text));
|
||||
```
|
||||
|
||||
### TruncatedText
|
||||
|
||||
Single-line text that truncates to fit viewport width. Useful for status lines and headers.
|
||||
|
||||
```typescript
|
||||
const truncated = new TruncatedText(
|
||||
"This is a very long line that will be truncated...",
|
||||
0, // paddingX (default: 0)
|
||||
0 // paddingY (default: 0)
|
||||
);
|
||||
```
|
||||
|
||||
### Input
|
||||
|
||||
Single-line text input with horizontal scrolling.
|
||||
|
||||
```typescript
|
||||
const input = new Input();
|
||||
input.onSubmit = (value) => console.log(value);
|
||||
input.setValue("initial");
|
||||
input.getValue();
|
||||
```
|
||||
|
||||
Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.
|
||||
|
||||
**Key Bindings:**
|
||||
- `Enter` - Submit
|
||||
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
||||
- `Ctrl+W` or `Alt+Backspace` - Delete word backwards
|
||||
- `Ctrl+U` - Delete to start of line
|
||||
- `Ctrl+K` - Delete to end of line
|
||||
- `Ctrl+Left` / `Ctrl+Right` - Word navigation
|
||||
- `Alt+Left` / `Alt+Right` - Word navigation
|
||||
- Arrow keys, Backspace, Delete work as expected
|
||||
|
||||
### Editor
|
||||
|
||||
Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height.
|
||||
|
||||
```typescript
|
||||
interface EditorTheme {
|
||||
borderColor: (str: string) => string;
|
||||
selectList: SelectListTheme;
|
||||
}
|
||||
|
||||
interface EditorOptions {
|
||||
paddingX?: number; // Horizontal padding (default: 0)
|
||||
}
|
||||
|
||||
const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling
|
||||
editor.onSubmit = (text) => console.log(text);
|
||||
editor.onChange = (text) => console.log("Changed:", text);
|
||||
editor.disableSubmit = true; // Disable submit temporarily
|
||||
editor.setAutocompleteProvider(provider);
|
||||
editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
|
||||
editor.setPaddingX(1); // Update horizontal padding dynamically
|
||||
editor.getPaddingX(); // Get current padding
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
|
||||
- Multi-line editing with word wrap
|
||||
- Slash command autocomplete (type `/`)
|
||||
- File path autocomplete (press `Tab`)
|
||||
- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
|
||||
- Horizontal lines above/below editor
|
||||
- Fake cursor rendering (hidden real cursor)
|
||||
|
||||
**Key Bindings:**
|
||||
- `Enter` - Submit
|
||||
- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
|
||||
- `Tab` - Autocomplete
|
||||
- `Ctrl+K` - Delete to end of line
|
||||
- `Ctrl+U` - Delete to start of line
|
||||
- `Ctrl+W` or `Alt+Backspace` - Delete word backwards
|
||||
- `Alt+D` or `Alt+Delete` - Delete word forwards
|
||||
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
||||
- `Ctrl+]` - Jump forward to character (awaits next keypress, then moves cursor to first occurrence)
|
||||
- `Ctrl+Alt+]` - Jump backward to character
|
||||
- Arrow keys, Backspace, Delete work as expected
|
||||
|
||||
### Markdown
|
||||
|
||||
Renders markdown with syntax highlighting and theming support.
|
||||
|
||||
```typescript
|
||||
interface MarkdownTheme {
|
||||
heading: (text: string) => string;
|
||||
link: (text: string) => string;
|
||||
linkUrl: (text: string) => string;
|
||||
code: (text: string) => string;
|
||||
codeBlock: (text: string) => string;
|
||||
codeBlockBorder: (text: string) => string;
|
||||
quote: (text: string) => string;
|
||||
quoteBorder: (text: string) => string;
|
||||
hr: (text: string) => string;
|
||||
listBullet: (text: string) => string;
|
||||
bold: (text: string) => string;
|
||||
italic: (text: string) => string;
|
||||
strikethrough: (text: string) => string;
|
||||
underline: (text: string) => string;
|
||||
highlightCode?: (code: string, lang?: string) => string[];
|
||||
}
|
||||
|
||||
interface DefaultTextStyle {
|
||||
color?: (text: string) => string;
|
||||
bgColor?: (text: string) => string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
strikethrough?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
const md = new Markdown(
|
||||
"# Hello\n\nSome **bold** text",
|
||||
1, // paddingX
|
||||
1, // paddingY
|
||||
theme, // MarkdownTheme
|
||||
defaultStyle // optional DefaultTextStyle
|
||||
);
|
||||
md.setText("Updated markdown");
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Headings, bold, italic, code blocks, lists, links, blockquotes
|
||||
- HTML tags rendered as plain text
|
||||
- Optional syntax highlighting via `highlightCode`
|
||||
- Padding support
|
||||
- Render caching for performance
|
||||
|
||||
### Loader
|
||||
|
||||
Animated loading spinner.
|
||||
|
||||
```typescript
|
||||
const loader = new Loader(
|
||||
tui, // TUI instance for render updates
|
||||
(s) => chalk.cyan(s), // spinner color function
|
||||
(s) => chalk.gray(s), // message color function
|
||||
"Loading..." // message (default: "Loading...")
|
||||
);
|
||||
loader.start();
|
||||
loader.setMessage("Still loading...");
|
||||
loader.stop();
|
||||
```
|
||||
|
||||
### CancellableLoader
|
||||
|
||||
Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
|
||||
|
||||
```typescript
|
||||
const loader = new CancellableLoader(
|
||||
tui, // TUI instance for render updates
|
||||
(s) => chalk.cyan(s), // spinner color function
|
||||
(s) => chalk.gray(s), // message color function
|
||||
"Working..." // message
|
||||
);
|
||||
loader.onAbort = () => done(null); // Called when user presses Escape
|
||||
doAsyncWork(loader.signal).then(done);
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
- `signal: AbortSignal` - Aborted when user presses Escape
|
||||
- `aborted: boolean` - Whether the loader was aborted
|
||||
- `onAbort?: () => void` - Callback when user presses Escape
|
||||
|
||||
### SelectList
|
||||
|
||||
Interactive selection list with keyboard navigation.
|
||||
|
||||
```typescript
|
||||
interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SelectListTheme {
|
||||
selectedPrefix: (text: string) => string;
|
||||
selectedText: (text: string) => string;
|
||||
description: (text: string) => string;
|
||||
scrollInfo: (text: string) => string;
|
||||
noMatch: (text: string) => string;
|
||||
}
|
||||
|
||||
const list = new SelectList(
|
||||
[
|
||||
{ value: "opt1", label: "Option 1", description: "First option" },
|
||||
{ value: "opt2", label: "Option 2", description: "Second option" },
|
||||
],
|
||||
5, // maxVisible
|
||||
theme // SelectListTheme
|
||||
);
|
||||
|
||||
list.onSelect = (item) => console.log("Selected:", item);
|
||||
list.onCancel = () => console.log("Cancelled");
|
||||
list.onSelectionChange = (item) => console.log("Highlighted:", item);
|
||||
list.setFilter("opt"); // Filter items
|
||||
```
|
||||
|
||||
**Controls:**
|
||||
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
||||
- Click: Select a row
|
||||
- Arrow keys: Navigate
|
||||
- Enter: Select
|
||||
- Escape: Cancel
|
||||
|
||||
### SettingsList
|
||||
|
||||
Settings panel with value cycling and submenus.
|
||||
|
||||
```typescript
|
||||
interface SettingItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
currentValue: string;
|
||||
values?: string[]; // If provided, Enter/Space cycles through these
|
||||
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
|
||||
}
|
||||
|
||||
interface SettingsListTheme {
|
||||
label: (text: string, selected: boolean) => string;
|
||||
value: (text: string, selected: boolean) => string;
|
||||
description: (text: string) => string;
|
||||
cursor: string;
|
||||
hint: (text: string) => string;
|
||||
}
|
||||
|
||||
const settings = new SettingsList(
|
||||
[
|
||||
{ id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
|
||||
{ id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
|
||||
],
|
||||
10, // maxVisible
|
||||
theme, // SettingsListTheme
|
||||
(id, newValue) => console.log(`${id} changed to ${newValue}`),
|
||||
() => console.log("Cancelled")
|
||||
);
|
||||
settings.updateValue("theme", "light");
|
||||
```
|
||||
|
||||
**Controls:**
|
||||
- Mouse move/wheel: Highlight rows in alternate-screen mode
|
||||
- Click: Activate a row
|
||||
- Arrow keys: Navigate
|
||||
- Enter/Space: Activate (cycle value or open submenu)
|
||||
- Escape: Cancel
|
||||
|
||||
### Spacer
|
||||
|
||||
Empty lines for vertical spacing.
|
||||
|
||||
```typescript
|
||||
const spacer = new Spacer(2); // 2 empty lines (default: 1)
|
||||
```
|
||||
|
||||
### Image
|
||||
|
||||
Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
|
||||
|
||||
```typescript
|
||||
interface ImageTheme {
|
||||
fallbackColor: (str: string) => string;
|
||||
}
|
||||
|
||||
interface ImageOptions {
|
||||
maxWidthCells?: number;
|
||||
maxHeightCells?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const image = new Image(
|
||||
base64Data, // base64-encoded image data
|
||||
"image/png", // MIME type
|
||||
theme, // ImageTheme
|
||||
options // optional ImageOptions
|
||||
);
|
||||
tui.addChild(image);
|
||||
```
|
||||
|
||||
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
|
||||
|
||||
#### Alternate-screen image compatibility
|
||||
|
||||
`TuiAltScreen` supports inline images and partial viewport cropping in terminals that implement the Kitty graphics protocol, including Kitty and Ghostty. iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content, `TuiAltScreen` renders image components as text placeholders in iTerm2. `TuiMainScreen` continues to render iTerm2 inline images normally.
|
||||
|
||||
## Autocomplete
|
||||
|
||||
### CombinedAutocompleteProvider
|
||||
|
||||
Supports both slash commands and file paths.
|
||||
|
||||
```typescript
|
||||
import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[
|
||||
{ name: "help", description: "Show help" },
|
||||
{ name: "clear", description: "Clear screen" },
|
||||
{ name: "delete", description: "Delete last message" },
|
||||
],
|
||||
process.cwd() // base path for file completion
|
||||
);
|
||||
|
||||
editor.setAutocompleteProvider(provider);
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Type `/` to see slash commands
|
||||
- Press `Tab` for file path completion
|
||||
- Works with `~/`, `./`, `../`, and `@` prefix
|
||||
- Filters to attachable files for `@` prefix
|
||||
|
||||
## Key Detection
|
||||
|
||||
Use `matchesKey()` with the `Key` helper for detecting keyboard input (supports Kitty keyboard protocol):
|
||||
|
||||
```typescript
|
||||
import { matchesKey, Key } from "@earendil-works/pi-tui";
|
||||
|
||||
if (matchesKey(data, Key.ctrl("c"))) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
submit();
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
cancel();
|
||||
} else if (matchesKey(data, Key.up)) {
|
||||
moveUp();
|
||||
}
|
||||
```
|
||||
|
||||
**Key identifiers** (use `Key.*` for autocomplete, or string literals):
|
||||
- Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
|
||||
- Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
|
||||
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
|
||||
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
|
||||
|
||||
## Rendering modes
|
||||
|
||||
`TuiMainScreen` uses three rendering strategies:
|
||||
|
||||
1. **First Render**: Output all lines without clearing scrollback
|
||||
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
|
||||
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
|
||||
|
||||
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
|
||||
|
||||
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
|
||||
|
||||
## Terminal Interface
|
||||
|
||||
The TUI works with any object implementing the `Terminal` interface:
|
||||
|
||||
```typescript
|
||||
interface Terminal {
|
||||
start(onInput: (data: string) => void, onResize: () => void): void;
|
||||
stop(): void;
|
||||
write(data: string): void;
|
||||
get columns(): number;
|
||||
get rows(): number;
|
||||
moveBy(lines: number): void;
|
||||
hideCursor(): void;
|
||||
showCursor(): void;
|
||||
clearLine(): void;
|
||||
clearFromCursor(): void;
|
||||
clearScreen(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in implementations:**
|
||||
- `ProcessTerminal` - Uses `process.stdin/stdout`
|
||||
- `VirtualTerminal` - For testing (uses `@xterm/headless`)
|
||||
|
||||
## Utilities
|
||||
|
||||
```typescript
|
||||
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
|
||||
// Get visible width of string (ignoring ANSI codes)
|
||||
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
|
||||
|
||||
// Truncate string to width (preserving ANSI codes, adds ellipsis)
|
||||
const truncated = truncateToWidth("Hello World", 8); // "Hello..."
|
||||
|
||||
// Truncate without ellipsis
|
||||
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
|
||||
|
||||
// Wrap text to width (preserving ANSI codes across line breaks)
|
||||
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
|
||||
// ["This is a long line", "that needs wrapping"]
|
||||
```
|
||||
|
||||
## Creating Custom Components
|
||||
|
||||
When creating custom components, **each line returned by `render()` must not exceed the `width` parameter**. The TUI will error if any line is wider than the terminal.
|
||||
|
||||
### Handling Input
|
||||
|
||||
Use `matchesKey()` with the `Key` helper for keyboard input:
|
||||
|
||||
```typescript
|
||||
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
|
||||
class MyInteractiveComponent implements Component {
|
||||
private selectedIndex = 0;
|
||||
private items = ["Option 1", "Option 2", "Option 3"];
|
||||
|
||||
public onSelect?: (index: number) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
this.onSelect?.(this.selectedIndex);
|
||||
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
||||
this.onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.items.map((item, i) => {
|
||||
const prefix = i === this.selectedIndex ? "> " : " ";
|
||||
return truncateToWidth(prefix + item, width);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Line Width
|
||||
|
||||
Use the provided utilities to ensure lines fit:
|
||||
|
||||
```typescript
|
||||
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
|
||||
class MyComponent implements Component {
|
||||
private text: string;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Option 1: Truncate long lines
|
||||
return [truncateToWidth(this.text, width)];
|
||||
|
||||
// Option 2: Check and pad to exact width
|
||||
const line = this.text;
|
||||
const visible = visibleWidth(line);
|
||||
if (visible > width) {
|
||||
return [truncateToWidth(line, width)];
|
||||
}
|
||||
// Pad to exact width (optional, for backgrounds)
|
||||
return [line + " ".repeat(width - visible)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ANSI Code Considerations
|
||||
|
||||
Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
|
||||
|
||||
- `visibleWidth()` ignores ANSI codes when calculating width
|
||||
- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
|
||||
|
||||
```typescript
|
||||
import chalk from "chalk";
|
||||
|
||||
const styled = chalk.red("Hello") + " " + chalk.blue("World");
|
||||
const width = visibleWidth(styled); // 11 (not counting ANSI codes)
|
||||
const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
For performance, components should cache their rendered output and only re-render when necessary:
|
||||
|
||||
```typescript
|
||||
class CachedComponent implements Component {
|
||||
private text: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines = [truncateToWidth(this.text, width)];
|
||||
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
See `test/chat-simple.ts` for a complete chat interface example with:
|
||||
- Markdown messages with custom background colors
|
||||
- Loading spinner during responses
|
||||
- Editor with autocomplete and slash commands
|
||||
- Spacers between messages
|
||||
|
||||
Run it:
|
||||
```bash
|
||||
npx tsx test/chat-simple.ts
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies (from monorepo root)
|
||||
npm install
|
||||
|
||||
# Run type checking
|
||||
npm run check
|
||||
|
||||
# Run the demo
|
||||
npx tsx test/chat-simple.ts
|
||||
```
|
||||
|
||||
### Debug logging
|
||||
|
||||
Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
|
||||
|
||||
```bash
|
||||
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts
|
||||
```
|
||||
20
third_party/pi/packages/tui/native/darwin/README.md
vendored
Normal file
20
third_party/pi/packages/tui/native/darwin/README.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Darwin native prebuilds
|
||||
|
||||
Build both macOS architectures from the repository root:
|
||||
|
||||
```sh
|
||||
npm --prefix packages/tui run build:native:darwin
|
||||
```
|
||||
|
||||
The build uses macOS 11.0 as the arm64 deployment target and macOS 10.15 as the x86_64 deployment target. On macOS, `build.sh` finds Apple clang and the active macOS SDK through `xcrun`. Either an Intel or Apple Silicon host can build both outputs.
|
||||
|
||||
A non-macOS host needs a complete Darwin cross-toolchain, including a macOS SDK and a Mach-O linker. For example, an osxcross installation can be selected with `CC` and `SDKROOT`:
|
||||
|
||||
```sh
|
||||
CC=/path/to/osxcross/clang SDKROOT=/path/to/MacOSX.sdk \
|
||||
npm --prefix packages/tui run build:native:darwin
|
||||
```
|
||||
|
||||
The SDK must be obtained and used in accordance with Apple's license. Plain Linux or Windows clang is not enough because the addon includes and links CoreGraphics.
|
||||
|
||||
Zig is not used here because it does not provide the Apple SDK or CoreGraphics framework stubs. It therefore does not make this build SDK-independent, and its clang driver does not currently handle this Mach-O bundle recipe as a drop-in replacement for Apple clang.
|
||||
62
third_party/pi/packages/tui/native/darwin/build.sh
vendored
Executable file
62
third_party/pi/packages/tui/native/darwin/build.sh
vendored
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source_file="$script_dir/src/darwin-modifiers.c"
|
||||
|
||||
if [[ -n "${CC:-}" ]]; then
|
||||
compiler="$CC"
|
||||
elif [[ "$(uname -s)" == "Darwin" ]] && command -v xcrun >/dev/null 2>&1; then
|
||||
compiler="$(xcrun --find clang)"
|
||||
else
|
||||
compiler="clang"
|
||||
fi
|
||||
|
||||
if ! command -v "$compiler" >/dev/null 2>&1; then
|
||||
echo "Darwin C compiler not found: $compiler" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sdk_flags=()
|
||||
if [[ -n "${SDKROOT:-}" ]]; then
|
||||
if [[ ! -d "$SDKROOT" ]]; then
|
||||
echo "SDKROOT does not exist: $SDKROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
sdk_flags=(-isysroot "$SDKROOT" "-F$SDKROOT/System/Library/Frameworks")
|
||||
elif [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
sdkroot="$(xcrun --sdk macosx --show-sdk-path)"
|
||||
sdk_flags=(-isysroot "$sdkroot" "-F$sdkroot/System/Library/Frameworks")
|
||||
fi
|
||||
|
||||
temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/pi-tui-darwin.XXXXXX")"
|
||||
trap 'rm -rf "$temporary_dir"' EXIT
|
||||
|
||||
build() {
|
||||
local arch="$1"
|
||||
local target="$2"
|
||||
local output_dir="$script_dir/prebuilds/darwin-$arch"
|
||||
local temporary_output="$temporary_dir/darwin-$arch/darwin-modifiers.node"
|
||||
|
||||
mkdir -p "$(dirname "$temporary_output")"
|
||||
"$compiler" \
|
||||
-std=c11 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-O2 \
|
||||
"--target=$target" \
|
||||
"${sdk_flags[@]}" \
|
||||
-bundle \
|
||||
-undefined dynamic_lookup \
|
||||
-framework CoreGraphics \
|
||||
"$source_file" \
|
||||
-o "$temporary_output"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
install -m 755 "$temporary_output" "$output_dir/darwin-modifiers.node"
|
||||
echo "Built $output_dir/darwin-modifiers.node"
|
||||
}
|
||||
|
||||
build arm64 arm64-apple-macos11.0
|
||||
build x64 x86_64-apple-macos10.15
|
||||
BIN
third_party/pi/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node
vendored
Executable file
BIN
third_party/pi/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node
vendored
Executable file
Binary file not shown.
BIN
third_party/pi/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node
vendored
Executable file
BIN
third_party/pi/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node
vendored
Executable file
Binary file not shown.
70
third_party/pi/packages/tui/native/darwin/src/darwin-modifiers.c
vendored
Normal file
70
third_party/pi/packages/tui/native/darwin/src/darwin-modifiers.c
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
#include <CoreGraphics/CoreGraphics.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#define NAPI_AUTO_LENGTH ((size_t)-1)
|
||||
|
||||
typedef void* napi_env;
|
||||
typedef void* napi_value;
|
||||
typedef void* napi_callback_info;
|
||||
typedef napi_value (*napi_callback)(napi_env, napi_callback_info);
|
||||
typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*);
|
||||
typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
|
||||
typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*);
|
||||
typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**);
|
||||
typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
|
||||
|
||||
static void* node_symbol(const char* name) {
|
||||
return dlsym(RTLD_DEFAULT, name);
|
||||
}
|
||||
|
||||
static CGEventFlags modifier_mask_for_name(const char* name) {
|
||||
if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift;
|
||||
if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand;
|
||||
if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl;
|
||||
if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) {
|
||||
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
|
||||
napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
|
||||
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
|
||||
|
||||
bool pressed = false;
|
||||
if (napi_get_cb_info && napi_get_value_string_utf8) {
|
||||
size_t argc = 1;
|
||||
napi_value args[1] = {0};
|
||||
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
|
||||
char name[16] = {0};
|
||||
size_t copied = 0;
|
||||
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
|
||||
CGEventFlags mask = modifier_mask_for_name(name);
|
||||
if (mask != 0) {
|
||||
CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState);
|
||||
pressed = (flags & mask) != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
napi_value result = 0;
|
||||
if (napi_get_boolean) napi_get_boolean(env, pressed, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) {
|
||||
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
|
||||
napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");
|
||||
|
||||
napi_value fn = 0;
|
||||
if (napi_create_function &&
|
||||
napi_set_named_property &&
|
||||
napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) {
|
||||
napi_set_named_property(env, exports, "isModifierPressed", fn);
|
||||
}
|
||||
|
||||
return exports;
|
||||
}
|
||||
22
third_party/pi/packages/tui/native/win32/README.md
vendored
Normal file
22
third_party/pi/packages/tui/native/win32/README.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Windows native prebuilds
|
||||
|
||||
Build both Windows architectures from the repository root:
|
||||
|
||||
```sh
|
||||
npm --prefix packages/tui run build:native:win32
|
||||
```
|
||||
|
||||
On Windows, the build uses the Microsoft C++ Build Tools from Visual Studio. `build.mjs` locates `VsDevCmd.bat`, initializes a developer environment for `amd64` and `arm64`, and builds the addon with `cl.exe`/`link.exe`.
|
||||
|
||||
Install the "Desktop development with C++" workload, or at minimum the MSVC toolset and Windows SDK components. No Node headers are required; the addon resolves N-API symbols from the host process.
|
||||
|
||||
For non-Windows cross-builds, or for custom Windows toolchains, set `PI_TUI_WIN32_TOOLCHAIN=mingw` and provide MinGW-compatible compilers:
|
||||
|
||||
```sh
|
||||
PI_TUI_WIN32_TOOLCHAIN=mingw \
|
||||
CC_X64=/path/to/x86_64-w64-mingw32-gcc \
|
||||
CC_ARM64=/path/to/aarch64-w64-mingw32-gcc \
|
||||
npm --prefix packages/tui run build:native:win32
|
||||
```
|
||||
|
||||
The addon intentionally avoids the C runtime and links only against `kernel32`.
|
||||
229
third_party/pi/packages/tui/native/win32/build.mjs
vendored
Normal file
229
third_party/pi/packages/tui/native/win32/build.mjs
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourceFile = path.join(scriptDir, "src", "win32-console-mode.c");
|
||||
const temporaryDir = mkdtempSync(path.join(tmpdir(), "pi-tui-win32-"));
|
||||
|
||||
const targets = [
|
||||
{
|
||||
arch: "x64",
|
||||
msvcArch: "x64",
|
||||
mingwTarget: "x86_64-w64-windows-gnu",
|
||||
prefixedCompilers: ["x86_64-w64-mingw32-gcc", "x86_64-w64-mingw32-clang"],
|
||||
envCompiler: "CC_X64",
|
||||
},
|
||||
{
|
||||
arch: "arm64",
|
||||
msvcArch: "arm64",
|
||||
mingwTarget: "aarch64-w64-windows-gnu",
|
||||
prefixedCompilers: ["aarch64-w64-mingw32-gcc", "aarch64-w64-mingw32-clang"],
|
||||
envCompiler: "CC_ARM64",
|
||||
},
|
||||
];
|
||||
|
||||
function removeTemporaryDir() {
|
||||
rmSync(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
process.on("exit", removeTemporaryDir);
|
||||
process.on("SIGINT", () => {
|
||||
removeTemporaryDir();
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync(command, ["--version"], { stdio: "ignore" });
|
||||
return !result.error || result.error.code !== "ENOENT";
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit", ...options });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
function quoteBatch(value) {
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function msvcHostArch() {
|
||||
return process.arch === "arm64" ? "arm64" : "x64";
|
||||
}
|
||||
|
||||
function findVisualStudioDevCmd() {
|
||||
const candidates = [];
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
const vswhere = path.join(programFilesX86, "Microsoft Visual Studio", "Installer", "vswhere.exe");
|
||||
if (existsSync(vswhere)) {
|
||||
const result = spawnSync(
|
||||
vswhere,
|
||||
[
|
||||
"-latest",
|
||||
"-products",
|
||||
"*",
|
||||
"-requires",
|
||||
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
|
||||
"-property",
|
||||
"installationPath",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
for (const line of result.stdout.split(/\r?\n/)) {
|
||||
const installationPath = line.trim();
|
||||
if (installationPath) candidates.push(path.join(installationPath, "Common7", "Tools", "VsDevCmd.bat"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const base of [process.env.ProgramFiles, programFilesX86]) {
|
||||
if (!base) continue;
|
||||
for (const edition of ["BuildTools", "Community", "Professional", "Enterprise"]) {
|
||||
candidates.push(path.join(base, "Microsoft Visual Studio", "2022", edition, "Common7", "Tools", "VsDevCmd.bat"));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function canUseMsvc(vsDevCmd) {
|
||||
const probeDir = path.join(temporaryDir, "msvc-probe");
|
||||
mkdirSync(probeDir, { recursive: true });
|
||||
const batchFile = path.join(probeDir, "probe.cmd");
|
||||
writeFileSync(
|
||||
batchFile,
|
||||
[
|
||||
"@echo off",
|
||||
`call ${quoteBatch(vsDevCmd)} -no_logo -arch=x64 -host_arch=${msvcHostArch()} >nul`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
"where cl.exe >nul 2>nul",
|
||||
].join("\r\n"),
|
||||
);
|
||||
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: probeDir, stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function resolveMingwCompiler(target) {
|
||||
const envCompiler = process.env[target.envCompiler];
|
||||
if (envCompiler) return { command: envCompiler, useTargetFlag: false };
|
||||
if (process.env.CC) return { command: process.env.CC, useTargetFlag: true };
|
||||
for (const command of target.prefixedCompilers) {
|
||||
if (commandExists(command)) return { command, useTargetFlag: false };
|
||||
}
|
||||
if (commandExists("clang")) return { command: "clang", useTargetFlag: true };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function installOutput(temporaryOutput, target) {
|
||||
const outputDir = path.join(scriptDir, "prebuilds", `win32-${target.arch}`);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const output = path.join(outputDir, "win32-console-mode.node");
|
||||
copyFileSync(temporaryOutput, output);
|
||||
chmodSync(output, 0o755);
|
||||
console.log(`Built ${path.relative(process.cwd(), output)}`);
|
||||
}
|
||||
|
||||
function buildWithMsvc(target, vsDevCmd) {
|
||||
const buildDir = path.join(temporaryDir, `msvc-${target.arch}`);
|
||||
const temporaryOutput = path.join(buildDir, "win32-console-mode.node");
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
const batchFile = path.join(buildDir, "build.cmd");
|
||||
writeFileSync(
|
||||
batchFile,
|
||||
[
|
||||
"@echo off",
|
||||
`call ${quoteBatch(vsDevCmd)} -no_logo -arch=${target.msvcArch} -host_arch=${msvcHostArch()}`,
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
[
|
||||
"cl",
|
||||
"/nologo",
|
||||
"/TC",
|
||||
"/std:c11",
|
||||
"/W4",
|
||||
"/O1",
|
||||
"/GS-",
|
||||
"/LD",
|
||||
`/Fe:${quoteBatch(temporaryOutput)}`,
|
||||
quoteBatch(sourceFile),
|
||||
"/link",
|
||||
"/NOENTRY",
|
||||
"/NODEFAULTLIB",
|
||||
"kernel32.lib",
|
||||
"/OPT:REF",
|
||||
"/OPT:ICF",
|
||||
].join(" "),
|
||||
"if errorlevel 1 exit /b %errorlevel%",
|
||||
].join("\r\n"),
|
||||
);
|
||||
|
||||
run("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: buildDir });
|
||||
installOutput(temporaryOutput, target);
|
||||
}
|
||||
|
||||
function buildWithMingw(target) {
|
||||
const compiler = resolveMingwCompiler(target);
|
||||
if (!compiler) {
|
||||
throw new Error(
|
||||
`No Windows cross-compiler found for ${target.arch}. Install Microsoft C++ Build Tools on Windows, or set ${target.envCompiler} to a MinGW-compatible compiler.`,
|
||||
);
|
||||
}
|
||||
|
||||
const buildDir = path.join(temporaryDir, `mingw-${target.arch}`);
|
||||
const temporaryOutput = path.join(buildDir, "win32-console-mode.node");
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
...(compiler.useTargetFlag ? [`--target=${target.mingwTarget}`] : []),
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Oz",
|
||||
"-shared",
|
||||
"-nostdlib",
|
||||
"-Wl,--no-entry",
|
||||
"-Wl,--strip-all",
|
||||
sourceFile,
|
||||
"-lkernel32",
|
||||
"-o",
|
||||
temporaryOutput,
|
||||
];
|
||||
|
||||
run(compiler.command, args, { cwd: buildDir });
|
||||
installOutput(temporaryOutput, target);
|
||||
}
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`Usage: npm --prefix packages/tui run build:native:win32
|
||||
|
||||
Builds win32-x64 and win32-arm64 native prebuilds.
|
||||
|
||||
Environment:
|
||||
PI_TUI_WIN32_TOOLCHAIN=msvc|mingw
|
||||
CC_X64=/path/to/x86_64-w64-mingw32-gcc
|
||||
CC_ARM64=/path/to/aarch64-w64-mingw32-gcc
|
||||
CC=/path/to/clang`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const requestedToolchain = process.env.PI_TUI_WIN32_TOOLCHAIN;
|
||||
if (requestedToolchain && requestedToolchain !== "msvc" && requestedToolchain !== "mingw") {
|
||||
throw new Error("PI_TUI_WIN32_TOOLCHAIN must be either 'msvc' or 'mingw'");
|
||||
}
|
||||
|
||||
const vsDevCmd = findVisualStudioDevCmd();
|
||||
const hasMsvc = vsDevCmd ? canUseMsvc(vsDevCmd) : false;
|
||||
const toolchain = requestedToolchain ?? (process.platform === "win32" && vsDevCmd ? "msvc" : "mingw");
|
||||
|
||||
if (toolchain === "msvc") {
|
||||
if (!vsDevCmd || !hasMsvc) {
|
||||
throw new Error("Microsoft C++ Build Tools not found. Install the Visual Studio C++ workload or set PI_TUI_WIN32_TOOLCHAIN=mingw.");
|
||||
}
|
||||
for (const target of targets) buildWithMsvc(target, vsDevCmd);
|
||||
} else {
|
||||
for (const target of targets) buildWithMingw(target);
|
||||
}
|
||||
BIN
third_party/pi/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node
vendored
Normal file
BIN
third_party/pi/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node
vendored
Normal file
Binary file not shown.
BIN
third_party/pi/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node
vendored
Normal file
BIN
third_party/pi/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node
vendored
Normal file
Binary file not shown.
119
third_party/pi/packages/tui/native/win32/src/win32-console-mode.c
vendored
Normal file
119
third_party/pi/packages/tui/native/win32/src/win32-console-mode.c
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
#include <windows.h>
|
||||
|
||||
#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT
|
||||
#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200
|
||||
#endif
|
||||
|
||||
#define NAPI_AUTO_LENGTH ((unsigned long long)-1)
|
||||
#define KEY_PRESSED_MASK 0x8000
|
||||
|
||||
typedef void* napi_env;
|
||||
typedef void* napi_value;
|
||||
typedef void* napi_callback_info;
|
||||
typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info);
|
||||
typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*);
|
||||
typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
|
||||
typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*);
|
||||
typedef int (__cdecl *napi_get_cb_info_fn)(napi_env, napi_callback_info, unsigned long long*, napi_value*, napi_value*, void**);
|
||||
typedef int (__cdecl *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, unsigned long long, unsigned long long*);
|
||||
typedef SHORT (WINAPI *get_async_key_state_fn)(int);
|
||||
|
||||
static void* node_symbol(const char* name) {
|
||||
HMODULE module = GetModuleHandleA(0);
|
||||
void* proc = module ? (void*)GetProcAddress(module, name) : 0;
|
||||
if (proc) return proc;
|
||||
|
||||
module = GetModuleHandleA("node.dll");
|
||||
return module ? (void*)GetProcAddress(module, name) : 0;
|
||||
}
|
||||
|
||||
static int string_equals(const char* left, const char* right) {
|
||||
while (*left && *right && *left == *right) {
|
||||
left++;
|
||||
right++;
|
||||
}
|
||||
return *left == 0 && *right == 0;
|
||||
}
|
||||
|
||||
static get_async_key_state_fn get_async_key_state_symbol(void) {
|
||||
static int loaded = 0;
|
||||
static get_async_key_state_fn get_async_key_state = 0;
|
||||
|
||||
if (!loaded) {
|
||||
HMODULE module = GetModuleHandleA("user32.dll");
|
||||
if (!module) module = LoadLibraryA("user32.dll");
|
||||
get_async_key_state = module ? (get_async_key_state_fn)GetProcAddress(module, "GetAsyncKeyState") : 0;
|
||||
loaded = 1;
|
||||
}
|
||||
|
||||
return get_async_key_state;
|
||||
}
|
||||
|
||||
static int is_key_pressed(int virtual_key) {
|
||||
get_async_key_state_fn get_async_key_state = get_async_key_state_symbol();
|
||||
return get_async_key_state && (((unsigned short)get_async_key_state(virtual_key)) & KEY_PRESSED_MASK) != 0;
|
||||
}
|
||||
|
||||
static int is_modifier_name_pressed(const char* name) {
|
||||
if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT);
|
||||
if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL);
|
||||
if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU);
|
||||
if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
|
||||
(void)info;
|
||||
|
||||
HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
|
||||
DWORD mode = 0;
|
||||
int enabled = handle != INVALID_HANDLE_VALUE &&
|
||||
GetConsoleMode(handle, &mode) &&
|
||||
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);
|
||||
|
||||
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
|
||||
napi_value result = 0;
|
||||
if (napi_get_boolean) napi_get_boolean(env, enabled, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) {
|
||||
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
|
||||
napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
|
||||
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
|
||||
|
||||
int pressed = 0;
|
||||
if (napi_get_cb_info && napi_get_value_string_utf8) {
|
||||
unsigned long long argc = 1;
|
||||
napi_value args[1] = {0};
|
||||
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
|
||||
char name[16] = {0};
|
||||
unsigned long long copied = 0;
|
||||
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
|
||||
pressed = is_modifier_name_pressed(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
napi_value result = 0;
|
||||
if (napi_get_boolean) napi_get_boolean(env, pressed, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void set_function_export(napi_env env, napi_value exports, const char* name, napi_callback callback) {
|
||||
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
|
||||
napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");
|
||||
|
||||
napi_value fn = 0;
|
||||
if (napi_create_function &&
|
||||
napi_set_named_property &&
|
||||
napi_create_function(env, name, NAPI_AUTO_LENGTH, callback, 0, &fn) == 0) {
|
||||
napi_set_named_property(env, exports, name, fn);
|
||||
}
|
||||
}
|
||||
|
||||
__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) {
|
||||
set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input);
|
||||
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
|
||||
return exports;
|
||||
}
|
||||
55
third_party/pi/packages/tui/package.json
vendored
Normal file
55
third_party/pi/packages/tui/package.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-tui",
|
||||
"version": "0.85.1",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"build:native:darwin": "native/darwin/build.sh",
|
||||
"build:native:win32": "node native/win32/build.mjs",
|
||||
"test": "node --test --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"native/win32/prebuilds/**/*.node",
|
||||
"native/win32/src/**/*.c",
|
||||
"native/win32/build.mjs",
|
||||
"native/win32/README.md",
|
||||
"native/darwin/prebuilds/**/*.node",
|
||||
"native/darwin/src/**/*.c",
|
||||
"native/darwin/build.sh",
|
||||
"native/darwin/README.md",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"tui",
|
||||
"terminal",
|
||||
"ui",
|
||||
"text-editor",
|
||||
"differential-rendering",
|
||||
"typescript",
|
||||
"cli"
|
||||
],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/tui"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"marked": "18.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "5.5.0",
|
||||
"chalk": "5.6.2"
|
||||
}
|
||||
}
|
||||
327
third_party/pi/packages/tui/src/alt-screen-search.ts
vendored
Normal file
327
third_party/pi/packages/tui/src/alt-screen-search.ts
vendored
Normal file
@@ -0,0 +1,327 @@
|
||||
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";
|
||||
|
||||
const segmenter = getGraphemeSegmenter();
|
||||
|
||||
interface SearchSourceSpan {
|
||||
textStart: number;
|
||||
textEnd: number;
|
||||
row: number;
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
linearColumns: boolean;
|
||||
}
|
||||
|
||||
interface SearchCorpus {
|
||||
text: string;
|
||||
spans: SearchSourceSpan[];
|
||||
}
|
||||
|
||||
export interface AltScreenSearchSegment {
|
||||
row: number;
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
export interface AltScreenSearchMatch {
|
||||
segments: AltScreenSearchSegment[];
|
||||
}
|
||||
|
||||
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
||||
|
||||
function buildSearchCorpus(lines: readonly string[]): SearchCorpus {
|
||||
const chunks: string[] = [];
|
||||
const spans: SearchSourceSpan[] = [];
|
||||
let textLength = 0;
|
||||
let pendingSeparator = false;
|
||||
|
||||
const appendSeparator = (): void => {
|
||||
if (!pendingSeparator) return;
|
||||
chunks.push(" ");
|
||||
textLength += 1;
|
||||
pendingSeparator = false;
|
||||
};
|
||||
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
const line = stripTerminalSequences(lines[row] ?? "");
|
||||
let column = 0;
|
||||
|
||||
// Rendered transcripts are overwhelmingly ASCII. Index complete non-space
|
||||
// runs at once instead of segmenting and allocating one mapping per cell.
|
||||
if (PRINTABLE_ASCII.test(line)) {
|
||||
let index = 0;
|
||||
while (index < line.length) {
|
||||
if (line.charCodeAt(index) === 0x20) {
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
column += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (end < line.length && line.charCodeAt(end) !== 0x20) end += 1;
|
||||
appendSeparator();
|
||||
const text = line.slice(index, end);
|
||||
chunks.push(text);
|
||||
spans.push({
|
||||
textStart: textLength,
|
||||
textEnd: textLength + text.length,
|
||||
row,
|
||||
startCol: column,
|
||||
endCol: column + text.length,
|
||||
linearColumns: true,
|
||||
});
|
||||
textLength += text.length;
|
||||
column += text.length;
|
||||
index = end;
|
||||
}
|
||||
} else {
|
||||
for (const grapheme of segmenter.segment(line)) {
|
||||
const text = grapheme.segment;
|
||||
const width = visibleWidth(text);
|
||||
if (/^\s+$/u.test(text)) {
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
column += width;
|
||||
continue;
|
||||
}
|
||||
appendSeparator();
|
||||
chunks.push(text);
|
||||
spans.push({
|
||||
textStart: textLength,
|
||||
textEnd: textLength + text.length,
|
||||
row,
|
||||
startCol: column,
|
||||
endCol: column + width,
|
||||
linearColumns: false,
|
||||
});
|
||||
textLength += text.length;
|
||||
column += width;
|
||||
}
|
||||
}
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
}
|
||||
|
||||
return { text: chunks.join(""), spans };
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function findSearchCorpusMatches(corpus: SearchCorpus, normalizedQuery: string): AltScreenSearchMatch[] {
|
||||
if (!normalizedQuery) return [];
|
||||
const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
|
||||
const matches: AltScreenSearchMatch[] = [];
|
||||
let spanIndex = 0;
|
||||
|
||||
for (const match of corpus.text.matchAll(expression)) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= start) spanIndex += 1;
|
||||
|
||||
const segments: AltScreenSearchSegment[] = [];
|
||||
for (let index = spanIndex; index < corpus.spans.length; index++) {
|
||||
const span = corpus.spans[index]!;
|
||||
if (span.textStart >= end) break;
|
||||
if (span.textEnd <= start) continue;
|
||||
const startCol = span.linearColumns
|
||||
? span.startCol + Math.max(start, span.textStart) - span.textStart
|
||||
: span.startCol;
|
||||
const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
|
||||
const previous = segments[segments.length - 1];
|
||||
if (previous && previous.row === span.row && startCol <= previous.endCol) {
|
||||
previous.endCol = Math.max(previous.endCol, endCol);
|
||||
} else {
|
||||
segments.push({ row: span.row, startCol, endCol });
|
||||
}
|
||||
}
|
||||
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= end) spanIndex += 1;
|
||||
if (segments.length > 0) matches.push({ segments });
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export interface AltScreenSearchResult {
|
||||
matches: AltScreenSearchMatch[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
|
||||
export class AltScreenSearchIndex {
|
||||
private sourceLines: string[] | undefined;
|
||||
private corpus: SearchCorpus | undefined;
|
||||
private normalizedQuery: string | undefined;
|
||||
private matches: AltScreenSearchMatch[] = [];
|
||||
|
||||
search(lines: readonly string[], query: string): AltScreenSearchResult {
|
||||
let sourceChanged = this.sourceLines?.length !== lines.length;
|
||||
if (!sourceChanged && this.sourceLines) {
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (this.sourceLines[index] === lines[index]) continue;
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged || !this.corpus) {
|
||||
this.sourceLines = Array.from(lines);
|
||||
this.corpus = buildSearchCorpus(lines);
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
|
||||
if (changed) {
|
||||
this.normalizedQuery = normalizedQuery;
|
||||
this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
|
||||
}
|
||||
return { matches: this.matches, changed };
|
||||
}
|
||||
}
|
||||
|
||||
export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] {
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
|
||||
}
|
||||
|
||||
export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string {
|
||||
const first = match.segments[0];
|
||||
const last = match.segments[match.segments.length - 1];
|
||||
return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : "";
|
||||
}
|
||||
|
||||
export class AltScreenSearchComponent implements Component, Focusable {
|
||||
private readonly input = new Input({
|
||||
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,
|
||||
navigationButtonStyle: (text: string, hovered: boolean) => string = (text) => text,
|
||||
) {
|
||||
this.onQueryChange = onQueryChange;
|
||||
this.navigationButtonStyle = navigationButtonStyle;
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.input.focused = value;
|
||||
}
|
||||
|
||||
setResult(index: number, count: number): void {
|
||||
this.resultIndex = index;
|
||||
this.resultCount = count;
|
||||
}
|
||||
|
||||
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);
|
||||
const query = this.input.getValue();
|
||||
if (query !== previous) this.onQueryChange(query);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const 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 result = !query
|
||||
? ""
|
||||
: this.resultCount === 0
|
||||
? "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)}┘`,
|
||||
];
|
||||
}
|
||||
}
|
||||
826
third_party/pi/packages/tui/src/autocomplete.ts
vendored
Normal file
826
third_party/pi/packages/tui/src/autocomplete.ts
vendored
Normal file
@@ -0,0 +1,826 @@
|
||||
import { spawn } from "child_process";
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { fuzzyFilter } from "./fuzzy.ts";
|
||||
|
||||
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
||||
|
||||
function toDisplayPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildFdPathQuery(query: string): string {
|
||||
const normalized = toDisplayPath(query);
|
||||
if (!normalized.includes("/")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const hasTrailingSeparator = normalized.endsWith("/");
|
||||
const trimmed = normalized.replace(/^\/+|\/+$/g, "");
|
||||
if (!trimmed) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const separatorPattern = "[\\\\/]";
|
||||
const segments = trimmed
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => escapeRegex(segment));
|
||||
if (segments.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let pattern = segments.join(separatorPattern);
|
||||
if (hasTrailingSeparator) {
|
||||
pattern += separatorPattern;
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
function findLastDelimiter(text: string): number {
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (PATH_DELIMITERS.has(text[i] ?? "")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findUnclosedQuoteStart(text: string): number | null {
|
||||
let inQuotes = false;
|
||||
let quoteStart = -1;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
if (inQuotes) {
|
||||
quoteStart = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inQuotes ? quoteStart : null;
|
||||
}
|
||||
|
||||
function isTokenStart(text: string, index: number): boolean {
|
||||
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
|
||||
}
|
||||
|
||||
function extractQuotedPrefix(text: string): string | null {
|
||||
const quoteStart = findUnclosedQuoteStart(text);
|
||||
if (quoteStart === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
|
||||
if (!isTokenStart(text, quoteStart - 1)) {
|
||||
return null;
|
||||
}
|
||||
return text.slice(quoteStart - 1);
|
||||
}
|
||||
|
||||
if (!isTokenStart(text, quoteStart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text.slice(quoteStart);
|
||||
}
|
||||
|
||||
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
|
||||
if (prefix.startsWith('@"')) {
|
||||
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
|
||||
}
|
||||
if (prefix.startsWith('"')) {
|
||||
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
|
||||
}
|
||||
if (prefix.startsWith("@")) {
|
||||
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
|
||||
}
|
||||
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
|
||||
}
|
||||
|
||||
function buildCompletionValue(
|
||||
path: string,
|
||||
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
|
||||
): string {
|
||||
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
|
||||
const prefix = options.isAtPrefix ? "@" : "";
|
||||
|
||||
if (!needsQuotes) {
|
||||
return `${prefix}${path}`;
|
||||
}
|
||||
|
||||
const openQuote = `${prefix}"`;
|
||||
const closeQuote = '"';
|
||||
return `${openQuote}${path}${closeQuote}`;
|
||||
}
|
||||
|
||||
// Use fd to walk directory tree (fast, respects .gitignore)
|
||||
async function walkDirectoryWithFd(
|
||||
baseDir: string,
|
||||
fdPath: string,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
signal: AbortSignal,
|
||||
maxDepth?: number,
|
||||
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||
const args = [
|
||||
"--base-directory",
|
||||
baseDir,
|
||||
"--max-results",
|
||||
String(maxResults),
|
||||
"--type",
|
||||
"f",
|
||||
"--type",
|
||||
"d",
|
||||
"--follow",
|
||||
"--hidden",
|
||||
"--exclude",
|
||||
".git",
|
||||
"--exclude",
|
||||
".git/*",
|
||||
"--exclude",
|
||||
".git/**",
|
||||
];
|
||||
|
||||
if (maxDepth !== undefined) {
|
||||
args.push("--max-depth", String(maxDepth));
|
||||
}
|
||||
|
||||
if (toDisplayPath(query).includes("/")) {
|
||||
args.push("--full-path");
|
||||
}
|
||||
|
||||
if (query) {
|
||||
args.push(buildFdPathQuery(query));
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(fdPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let resolved = false;
|
||||
|
||||
const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(results);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
child.stdout.setEncoding("utf-8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.on("error", () => {
|
||||
finish([]);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (signal.aborted || code !== 0 || !stdout) {
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const displayLine = toDisplayPath(line);
|
||||
const hasTrailingSeparator = displayLine.endsWith("/");
|
||||
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
||||
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
path: displayLine,
|
||||
isDirectory: hasTrailingSeparator,
|
||||
});
|
||||
}
|
||||
|
||||
finish(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface AutocompleteItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type Awaitable<T> = T | Promise<T>;
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
// Function to get argument completions for this command
|
||||
// Returns null if no argument completion is available
|
||||
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
||||
}
|
||||
|
||||
export interface AutocompleteSuggestions {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||
}
|
||||
|
||||
export interface AutocompleteProvider {
|
||||
/** Characters that should naturally trigger this provider at token boundaries. */
|
||||
triggerCharacters?: string[];
|
||||
|
||||
// Get autocomplete suggestions for current text/cursor position
|
||||
// Returns null if no suggestions available
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null>;
|
||||
|
||||
// Apply the selected item
|
||||
// Returns the new text and cursor position
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
};
|
||||
|
||||
// Check if file completion should trigger for explicit Tab completion
|
||||
shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
|
||||
}
|
||||
|
||||
// Combined provider that handles both slash commands and file paths
|
||||
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
private commands: (SlashCommand | AutocompleteItem)[];
|
||||
private basePath: string;
|
||||
private fdPath: string | null;
|
||||
|
||||
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {
|
||||
this.commands = commands;
|
||||
this.basePath = basePath;
|
||||
this.fdPath = fdPath;
|
||||
}
|
||||
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
const atPrefix = this.extractAtPrefix(textBeforeCursor);
|
||||
if (atPrefix) {
|
||||
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
||||
const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
|
||||
isQuotedPrefix,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: atPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.force && textBeforeCursor.startsWith("/")) {
|
||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
const prefix = textBeforeCursor.slice(1);
|
||||
const commandItems = this.commands.map((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
||||
const desc = cmd.description ?? "";
|
||||
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: fullDesc || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
|
||||
value: item.name,
|
||||
label: item.label,
|
||||
...(item.description && { description: item.description }),
|
||||
}));
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
prefix: textBeforeCursor,
|
||||
};
|
||||
}
|
||||
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex);
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
|
||||
|
||||
const command = this.commands.find((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
return name === commandName;
|
||||
});
|
||||
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
|
||||
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: argumentSuggestions,
|
||||
prefix: argumentText,
|
||||
};
|
||||
}
|
||||
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
|
||||
if (pathMatch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
||||
const afterCursor = currentLine.slice(cursorCol);
|
||||
const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"');
|
||||
const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"');
|
||||
const hasTrailingQuoteInItem = item.value.endsWith('"');
|
||||
const adjustedAfterCursor =
|
||||
isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor;
|
||||
|
||||
// Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
|
||||
// Slash commands are at the start of the line and don't contain path separators after the first /
|
||||
const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
|
||||
if (isSlashCommand) {
|
||||
// This is a command name completion
|
||||
const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing a file attachment (prefix starts with "@")
|
||||
if (prefix.startsWith("@")) {
|
||||
// This is a file attachment completion
|
||||
// Don't add space after directories so user can continue autocompleting
|
||||
const isDirectory = item.label.endsWith("/");
|
||||
const suffix = isDirectory ? "" : " ";
|
||||
const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
const hasTrailingQuote = item.value.endsWith('"');
|
||||
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + cursorOffset + suffix.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're in a slash command context (beforePrefix contains "/command ")
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
|
||||
// This is likely a command argument completion
|
||||
const newLine = beforePrefix + item.value + adjustedAfterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
const isDirectory = item.label.endsWith("/");
|
||||
const hasTrailingQuote = item.value.endsWith('"');
|
||||
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + cursorOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// For file paths, complete the path
|
||||
const newLine = beforePrefix + item.value + adjustedAfterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
const isDirectory = item.label.endsWith("/");
|
||||
const hasTrailingQuote = item.value.endsWith('"');
|
||||
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + cursorOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract @ prefix for fuzzy file suggestions
|
||||
private extractAtPrefix(text: string): string | null {
|
||||
const quotedPrefix = extractQuotedPrefix(text);
|
||||
if (quotedPrefix?.startsWith('@"')) {
|
||||
return quotedPrefix;
|
||||
}
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
|
||||
|
||||
if (text[tokenStart] === "@") {
|
||||
return text.slice(tokenStart);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract a path-like prefix from the text before cursor
|
||||
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
|
||||
const quotedPrefix = extractQuotedPrefix(text);
|
||||
if (quotedPrefix) {
|
||||
return quotedPrefix;
|
||||
}
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
|
||||
|
||||
// For forced extraction (Tab key), always return something
|
||||
if (forceExtract) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
|
||||
// Only return empty string if the text looks like it's starting a path context
|
||||
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// Return empty string only after a space (not for completely empty text)
|
||||
// Empty text should not trigger file suggestions - that's for forced Tab completion
|
||||
if (pathPrefix === "" && text.endsWith(" ")) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand home directory (~/) to actual home path
|
||||
private expandHomePath(path: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
const expandedPath = join(homedir(), path.slice(2));
|
||||
// Preserve trailing slash if original path had one
|
||||
return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath;
|
||||
} else if (path === "~") {
|
||||
return homedir();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
|
||||
const normalizedQuery = toDisplayPath(rawQuery);
|
||||
const slashIndex = normalizedQuery.lastIndexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
|
||||
const query = normalizedQuery.slice(slashIndex + 1);
|
||||
|
||||
let baseDir: string;
|
||||
if (displayBase.startsWith("~/")) {
|
||||
baseDir = this.expandHomePath(displayBase);
|
||||
} else if (displayBase.startsWith("/")) {
|
||||
baseDir = displayBase;
|
||||
} else {
|
||||
baseDir = join(this.basePath, displayBase);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(baseDir).isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { baseDir, query, displayBase };
|
||||
}
|
||||
|
||||
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
|
||||
const normalizedRelativePath = toDisplayPath(relativePath);
|
||||
if (displayBase === "/") {
|
||||
return `/${normalizedRelativePath}`;
|
||||
}
|
||||
return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
|
||||
}
|
||||
|
||||
// Get file/directory suggestions for a given path prefix
|
||||
private getFileSuggestions(prefix: string): AutocompleteItem[] {
|
||||
try {
|
||||
let searchDir: string;
|
||||
let searchPrefix: string;
|
||||
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
|
||||
let expandedPrefix = rawPrefix;
|
||||
|
||||
// Handle home directory expansion
|
||||
if (expandedPrefix.startsWith("~")) {
|
||||
expandedPrefix = this.expandHomePath(expandedPrefix);
|
||||
}
|
||||
|
||||
const isRootPrefix =
|
||||
rawPrefix === "" ||
|
||||
rawPrefix === "./" ||
|
||||
rawPrefix === "../" ||
|
||||
rawPrefix === "~" ||
|
||||
rawPrefix === "~/" ||
|
||||
rawPrefix === "/" ||
|
||||
(isAtPrefix && rawPrefix === "");
|
||||
|
||||
if (isRootPrefix) {
|
||||
// Complete from specified position
|
||||
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
}
|
||||
searchPrefix = "";
|
||||
} else if (rawPrefix.endsWith("/")) {
|
||||
// If prefix ends with /, show contents of that directory
|
||||
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
}
|
||||
searchPrefix = "";
|
||||
} else {
|
||||
// Split into directory and file prefix
|
||||
const dir = dirname(expandedPrefix);
|
||||
const file = basename(expandedPrefix);
|
||||
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||
searchDir = dir;
|
||||
} else {
|
||||
searchDir = join(this.basePath, dir);
|
||||
}
|
||||
searchPrefix = file;
|
||||
}
|
||||
|
||||
const entries = readdirSync(searchDir, { withFileTypes: true });
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if entry is a directory (or a symlink pointing to a directory)
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && entry.isSymbolicLink()) {
|
||||
try {
|
||||
const fullPath = join(searchDir, entry.name);
|
||||
isDirectory = statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
// Broken symlink or permission error - treat as file
|
||||
}
|
||||
}
|
||||
|
||||
let relativePath: string;
|
||||
const name = entry.name;
|
||||
const displayPrefix = rawPrefix;
|
||||
|
||||
if (displayPrefix.endsWith("/")) {
|
||||
// If prefix ends with /, append entry to the prefix
|
||||
relativePath = displayPrefix + name;
|
||||
} else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
|
||||
// Preserve ~/ format for home directory paths
|
||||
if (displayPrefix.startsWith("~/")) {
|
||||
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
|
||||
const dir = dirname(homeRelativeDir);
|
||||
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
|
||||
} else if (displayPrefix.startsWith("/")) {
|
||||
// Absolute path - construct properly
|
||||
const dir = dirname(displayPrefix);
|
||||
if (dir === "/") {
|
||||
relativePath = `/${name}`;
|
||||
} else {
|
||||
relativePath = `${dir}/${name}`;
|
||||
}
|
||||
} else {
|
||||
relativePath = join(dirname(displayPrefix), name);
|
||||
// path.join normalizes away ./ prefix, preserve it
|
||||
if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
|
||||
relativePath = `./${relativePath}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For standalone entries, preserve ~/ if original prefix was ~/
|
||||
if (displayPrefix.startsWith("~")) {
|
||||
relativePath = `~/${name}`;
|
||||
} else {
|
||||
relativePath = name;
|
||||
}
|
||||
}
|
||||
|
||||
relativePath = toDisplayPath(relativePath);
|
||||
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
|
||||
const value = buildCompletionValue(pathValue, {
|
||||
isDirectory,
|
||||
isAtPrefix,
|
||||
isQuotedPrefix,
|
||||
});
|
||||
|
||||
suggestions.push({
|
||||
value,
|
||||
label: name + (isDirectory ? "/" : ""),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort directories first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
const aIsDir = a.value.endsWith("/");
|
||||
const bIsDir = b.value.endsWith("/");
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
} catch (_e) {
|
||||
// Directory doesn't exist or not accessible
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Score an entry against the query (higher = better match)
|
||||
// isDirectory adds bonus to prioritize folders
|
||||
private scoreEntry(filePath: string, query: string, isDirectory: boolean): number {
|
||||
const fileName = basename(filePath);
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Exact filename match (highest)
|
||||
if (lowerFileName === lowerQuery) score = 100;
|
||||
// Filename starts with query
|
||||
else if (lowerFileName.startsWith(lowerQuery)) score = 80;
|
||||
// Substring match in filename
|
||||
else if (lowerFileName.includes(lowerQuery)) score = 50;
|
||||
// Substring match in full path
|
||||
else if (filePath.toLowerCase().includes(lowerQuery)) score = 30;
|
||||
|
||||
// Directories get a bonus to appear first
|
||||
if (isDirectory && score > 0) score += 10;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private async getBaseDirSuggestions(
|
||||
baseDir: string,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||
if (!this.fdPath || signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);
|
||||
}
|
||||
|
||||
// Fuzzy file search using fd (fast, respects .gitignore)
|
||||
private async getFuzzyFileSuggestions(
|
||||
query: string,
|
||||
options: { isQuotedPrefix: boolean; signal: AbortSignal },
|
||||
): Promise<AutocompleteItem[]> {
|
||||
if (!this.fdPath || options.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
||||
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
||||
const fdQuery = scopedQuery?.query ?? query;
|
||||
const baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);
|
||||
const recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
|
||||
const seenPaths = new Set(baseDirEntries.map((entry) => entry.path));
|
||||
const entries = [
|
||||
...baseDirEntries,
|
||||
...recursiveEntries.filter((entry) => {
|
||||
if (seenPaths.has(entry.path)) return false;
|
||||
seenPaths.add(entry.path);
|
||||
return true;
|
||||
}),
|
||||
];
|
||||
if (options.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scoredEntries = entries
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
|
||||
}))
|
||||
.filter((entry) => entry.score > 0);
|
||||
|
||||
scoredEntries.sort((a, b) => {
|
||||
const scoreDiff = b.score - a.score;
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
|
||||
const aDepth = toDisplayPath(a.path).split("/").filter(Boolean).length;
|
||||
const bDepth = toDisplayPath(b.path).split("/").filter(Boolean).length;
|
||||
const depthDiff = aDepth - bDepth;
|
||||
if (depthDiff !== 0) return depthDiff;
|
||||
|
||||
const lengthDiff = a.path.length - b.path.length;
|
||||
if (lengthDiff !== 0) return lengthDiff;
|
||||
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
const topEntries = scoredEntries.slice(0, 20);
|
||||
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
for (const { path: entryPath, isDirectory } of topEntries) {
|
||||
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
||||
const displayPath = scopedQuery
|
||||
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
|
||||
: pathWithoutSlash;
|
||||
const entryName = basename(pathWithoutSlash);
|
||||
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
|
||||
const value = buildCompletionValue(completionPath, {
|
||||
isDirectory,
|
||||
isAtPrefix: true,
|
||||
isQuotedPrefix: options.isQuotedPrefix,
|
||||
});
|
||||
|
||||
suggestions.push({
|
||||
value,
|
||||
label: entryName + (isDirectory ? "/" : ""),
|
||||
description: displayPath,
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should trigger file completion (called on Tab key)
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're typing a slash command at the start of the line
|
||||
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
51
third_party/pi/packages/tui/src/components/alt-screen-flash.ts
vendored
Normal file
51
third_party/pi/packages/tui/src/components/alt-screen-flash.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Component } from "../tui.ts";
|
||||
import { truncateToWidth } from "../utils.ts";
|
||||
|
||||
const DEFAULT_DURATION_MS = 1000;
|
||||
|
||||
interface FlashEntry {
|
||||
id: number;
|
||||
message: string;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** Transient messages composited by the alternate-screen renderer. */
|
||||
export class AltScreenFlashContainer implements Component {
|
||||
private readonly entries: FlashEntry[] = [];
|
||||
private nextId = 0;
|
||||
private readonly requestRender: () => void;
|
||||
|
||||
constructor(requestRender: () => void) {
|
||||
this.requestRender = requestRender;
|
||||
}
|
||||
|
||||
flash(message: string, durationMs = DEFAULT_DURATION_MS): void {
|
||||
const id = this.nextId++;
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const index = this.entries.findIndex((entry) => entry.id === id);
|
||||
if (index === -1) return;
|
||||
this.entries.splice(index, 1);
|
||||
this.requestRender();
|
||||
},
|
||||
Math.max(0, durationMs),
|
||||
);
|
||||
timer.unref();
|
||||
this.entries.push({ id, message, timer });
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const entry of this.entries) clearTimeout(entry.timer);
|
||||
this.entries.length = 0;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.entries.map((entry) => {
|
||||
const message = truncateToWidth(` ${entry.message} `, width, "");
|
||||
return `\x1b[7m${message}\x1b[27m`;
|
||||
});
|
||||
}
|
||||
}
|
||||
167
third_party/pi/packages/tui/src/components/box.ts
vendored
Normal file
167
third_party/pi/packages/tui/src/components/box.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
|
||||
|
||||
type RenderCache = {
|
||||
childLines: string[];
|
||||
width: number;
|
||||
bgSample: string | undefined;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Box component - a container that applies padding and background to all children
|
||||
*/
|
||||
export class Box implements Component {
|
||||
children: Component[] = [];
|
||||
private paddingX: number;
|
||||
private paddingY: number;
|
||||
private bgFn?: (text: string) => string;
|
||||
|
||||
// Cache for rendered output
|
||||
private cache?: RenderCache;
|
||||
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
|
||||
|
||||
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
|
||||
this.paddingX = paddingX;
|
||||
this.paddingY = paddingY;
|
||||
this.bgFn = bgFn;
|
||||
}
|
||||
|
||||
addChild(component: Component): void {
|
||||
this.children.push(component);
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
removeChild(component: Component): void {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
this.invalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children = [];
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
setBgFn(bgFn?: (text: string) => string): void {
|
||||
this.bgFn = bgFn;
|
||||
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.cache = undefined;
|
||||
}
|
||||
|
||||
private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean {
|
||||
const cache = this.cache;
|
||||
return (
|
||||
!!cache &&
|
||||
cache.width === width &&
|
||||
cache.bgSample === bgSample &&
|
||||
cache.childLines.length === childLines.length &&
|
||||
cache.childLines.every((line, i) => line === childLines[i])
|
||||
);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.invalidateCache();
|
||||
for (const child of this.children) {
|
||||
child.invalidate?.();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
const contentWidth = Math.max(1, event.width - this.paddingX * 2);
|
||||
const contentY = event.y - this.paddingY;
|
||||
const contentX = event.x - this.paddingX;
|
||||
if (contentY < 0 || contentX < 0 || contentX >= contentWidth) return undefined;
|
||||
|
||||
const mouseChildren =
|
||||
this.mouseLayout?.width === contentWidth
|
||||
? this.mouseLayout.children
|
||||
: this.children.map((component) => ({ component, height: component.render(contentWidth).length }));
|
||||
let childY = 0;
|
||||
for (const { component: child, height: childHeight } of mouseChildren) {
|
||||
if (contentY >= childY && contentY < childY + childHeight) {
|
||||
return dispatchMouseEvent(child, {
|
||||
...event,
|
||||
x: contentX,
|
||||
y: contentY - childY,
|
||||
width: contentWidth,
|
||||
height: childHeight,
|
||||
});
|
||||
}
|
||||
childY += childHeight;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.children.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
|
||||
// Render all children
|
||||
const childLines: string[] = [];
|
||||
const mouseChildren: Array<{ component: Component; height: number }> = [];
|
||||
for (const child of this.children) {
|
||||
const lines = child.render(contentWidth);
|
||||
mouseChildren.push({ component: child, height: lines.length });
|
||||
for (const line of lines) {
|
||||
childLines.push(leftPad + line);
|
||||
}
|
||||
}
|
||||
this.mouseLayout = { width: contentWidth, children: mouseChildren };
|
||||
|
||||
if (childLines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if bgFn output changed by sampling
|
||||
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
|
||||
|
||||
// Check cache validity
|
||||
if (this.matchCache(width, childLines, bgSample)) {
|
||||
return this.cache!.lines;
|
||||
}
|
||||
|
||||
// Apply background and padding
|
||||
const result: string[] = [];
|
||||
|
||||
// Top padding
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
result.push(this.applyBg("", width));
|
||||
}
|
||||
|
||||
// Content
|
||||
for (const line of childLines) {
|
||||
result.push(this.applyBg(line, width));
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
result.push(this.applyBg("", width));
|
||||
}
|
||||
|
||||
// Update cache
|
||||
this.cache = { childLines, width, bgSample, lines: result };
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyBg(line: string, width: number): string {
|
||||
const visLen = visibleWidth(line);
|
||||
const padNeeded = Math.max(0, width - visLen);
|
||||
const padded = line + " ".repeat(padNeeded);
|
||||
|
||||
if (this.bgFn) {
|
||||
return applyBackgroundToLine(padded, width, this.bgFn);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
}
|
||||
40
third_party/pi/packages/tui/src/components/cancellable-loader.ts
vendored
Normal file
40
third_party/pi/packages/tui/src/components/cancellable-loader.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
import { Loader } from "./loader.ts";
|
||||
|
||||
/**
|
||||
* Loader that can be cancelled with Escape.
|
||||
* Extends Loader with an AbortSignal for cancelling async operations.
|
||||
*
|
||||
* @example
|
||||
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
||||
* loader.onAbort = () => done(null);
|
||||
* doWork(loader.signal).then(done);
|
||||
*/
|
||||
export class CancellableLoader extends Loader {
|
||||
private abortController = new AbortController();
|
||||
|
||||
/** Called when user presses Escape */
|
||||
onAbort?: () => void;
|
||||
|
||||
/** AbortSignal that is aborted when user presses Escape */
|
||||
get signal(): AbortSignal {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
|
||||
/** Whether the loader was aborted */
|
||||
get aborted(): boolean {
|
||||
return this.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const kb = getKeybindings();
|
||||
if (kb.matches(data, "tui.select.cancel")) {
|
||||
this.abortController.abort();
|
||||
this.onAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
2461
third_party/pi/packages/tui/src/components/editor.ts
vendored
Normal file
2461
third_party/pi/packages/tui/src/components/editor.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
44
third_party/pi/packages/tui/src/components/h-stack.ts
vendored
Normal file
44
third_party/pi/packages/tui/src/components/h-stack.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { compositeTuiLine } from "../tui.ts";
|
||||
import { visibleWidth } from "../utils.ts";
|
||||
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
|
||||
|
||||
export class HStack extends Stack {
|
||||
protected readonly layoutType = "hstack" as const;
|
||||
|
||||
constructor(children: StackChild[] = [], options: StackOptions = {}) {
|
||||
super(children, options);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const viewport = { width: safeWidth, height: Number.MAX_SAFE_INTEGER };
|
||||
const entries = visibleStackEntries(this.entries, viewport);
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const intrinsicWidths = entries.map((entry) => {
|
||||
const lines = entry.component.render(safeWidth);
|
||||
return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||||
});
|
||||
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, this.gap);
|
||||
const rendered = entries.map((entry, index) =>
|
||||
widths[index] === 0 ? [] : entry.component.render(widths[index]!),
|
||||
);
|
||||
const height = rendered.reduce((max, lines) => Math.max(max, lines.length), 0);
|
||||
const result = Array.from({ length: height }, () => "");
|
||||
let x = 0;
|
||||
for (let index = 0; index < rendered.length; index++) {
|
||||
const lines = rendered[index]!;
|
||||
const childWidth = widths[index]!;
|
||||
let offset = 0;
|
||||
if (this.align === "center") offset = Math.floor((height - lines.length) / 2);
|
||||
else if (this.align === "end") offset = height - lines.length;
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
const target = row + offset;
|
||||
if (target < 0 || target >= result.length) continue;
|
||||
result[target] = compositeTuiLine(result[target]!, lines[row]!, x, childWidth, safeWidth);
|
||||
}
|
||||
x += childWidth + this.gap;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
127
third_party/pi/packages/tui/src/components/image.ts
vendored
Normal file
127
third_party/pi/packages/tui/src/components/image.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
allocateImageId,
|
||||
getCapabilities,
|
||||
getCellDimensions,
|
||||
getImageDimensions,
|
||||
type ImageDimensions,
|
||||
imageFallback,
|
||||
renderImage,
|
||||
} from "../terminal-image.ts";
|
||||
import type { Component } from "../tui.ts";
|
||||
import { truncateToWidth } from "../utils.ts";
|
||||
|
||||
export interface ImageTheme {
|
||||
fallbackColor: (str: string) => string;
|
||||
}
|
||||
|
||||
export interface ImageOptions {
|
||||
maxWidthCells?: number;
|
||||
maxHeightCells?: number;
|
||||
filename?: string;
|
||||
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
|
||||
imageId?: number;
|
||||
}
|
||||
|
||||
export class Image implements Component {
|
||||
private base64Data: string;
|
||||
private mimeType: string;
|
||||
private dimensions: ImageDimensions;
|
||||
private theme: ImageTheme;
|
||||
private options: ImageOptions;
|
||||
private imageId?: number;
|
||||
|
||||
private cachedLines?: string[];
|
||||
private cachedWidth?: number;
|
||||
|
||||
constructor(
|
||||
base64Data: string,
|
||||
mimeType: string,
|
||||
theme: ImageTheme,
|
||||
options: ImageOptions = {},
|
||||
dimensions?: ImageDimensions,
|
||||
) {
|
||||
this.base64Data = base64Data;
|
||||
this.mimeType = mimeType;
|
||||
this.theme = theme;
|
||||
this.options = options;
|
||||
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
|
||||
this.imageId = options.imageId;
|
||||
}
|
||||
|
||||
/** Get the Kitty image ID used by this image (if any). */
|
||||
getImageId(): number | undefined {
|
||||
return this.imageId;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedLines = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60));
|
||||
const cellDimensions = getCellDimensions();
|
||||
const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx));
|
||||
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
|
||||
|
||||
const caps = getCapabilities();
|
||||
let lines: string[];
|
||||
|
||||
if (caps.images) {
|
||||
if (caps.images === "kitty" && this.imageId === undefined) {
|
||||
this.imageId = allocateImageId();
|
||||
}
|
||||
const result = renderImage(this.base64Data, this.dimensions, {
|
||||
maxWidthCells: maxWidth,
|
||||
maxHeightCells: maxHeight,
|
||||
imageId: this.imageId,
|
||||
moveCursor: false,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Store the image ID for later cleanup
|
||||
if (result.imageId) {
|
||||
this.imageId = result.imageId;
|
||||
}
|
||||
|
||||
if (caps.images === "kitty") {
|
||||
// For Kitty: C=1 prevents cursor movement.
|
||||
// Don't need the cursor movement.
|
||||
lines = [result.sequence];
|
||||
|
||||
// Return `rows` lines so TUI accounts for image height.
|
||||
for (let i = 0; i < result.rows - 1; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
} else {
|
||||
// Return `rows` lines so TUI accounts for image height.
|
||||
// First (rows-1) lines are empty and cleared before the image is drawn.
|
||||
// Last line: move cursor back up, draw the image, then move back down
|
||||
// so TUI cursor accounting stays inside the scroll area.
|
||||
lines = [];
|
||||
for (let i = 0; i < result.rows - 1; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
const rowOffset = result.rows - 1;
|
||||
const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";
|
||||
lines.push(moveUp + result.sequence);
|
||||
}
|
||||
} else {
|
||||
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
|
||||
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
|
||||
}
|
||||
} else {
|
||||
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
|
||||
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
|
||||
}
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
494
third_party/pi/packages/tui/src/components/input.ts
vendored
Normal file
494
third_party/pi/packages/tui/src/components/input.ts
vendored
Normal file
@@ -0,0 +1,494 @@
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
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, truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
import { findWordBackward, findWordForward } from "../word-navigation.ts";
|
||||
|
||||
const segmenter = getGraphemeSegmenter();
|
||||
|
||||
interface InputState {
|
||||
value: string;
|
||||
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;
|
||||
|
||||
/** Focusable interface - set by TUI when focus changes */
|
||||
focused: boolean = false;
|
||||
|
||||
// Bracketed paste mode buffering
|
||||
private pasteBuffer: string = "";
|
||||
private isInPaste: boolean = false;
|
||||
|
||||
// Kill ring for Emacs-style kill/yank operations
|
||||
private killRing = new KillRing();
|
||||
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.cursor = Math.min(this.cursor, value.length);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Handle bracketed paste mode
|
||||
// Start of paste: \x1b[200~
|
||||
// End of paste: \x1b[201~
|
||||
|
||||
// Check if we're starting a bracketed paste
|
||||
if (data.includes("\x1b[200~")) {
|
||||
this.isInPaste = true;
|
||||
this.pasteBuffer = "";
|
||||
data = data.replace("\x1b[200~", "");
|
||||
}
|
||||
|
||||
// If we're in a paste, buffer the data
|
||||
if (this.isInPaste) {
|
||||
// Check if this chunk contains the end marker
|
||||
this.pasteBuffer += data;
|
||||
|
||||
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
||||
if (endIndex !== -1) {
|
||||
// Extract the pasted content
|
||||
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
||||
|
||||
// Process the complete paste
|
||||
this.handlePaste(pasteContent);
|
||||
|
||||
// Reset paste state
|
||||
this.isInPaste = false;
|
||||
|
||||
// Handle any remaining input after the paste marker
|
||||
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
||||
this.pasteBuffer = "";
|
||||
if (remaining) {
|
||||
this.handleInput(remaining);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const kb = getKeybindings();
|
||||
|
||||
// Escape/Cancel
|
||||
if (kb.matches(data, "tui.select.cancel")) {
|
||||
if (this.onEscape) this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo
|
||||
if (kb.matches(data, "tui.editor.undo")) {
|
||||
this.undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit
|
||||
if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
||||
if (this.onSubmit) this.onSubmit(this.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deletion
|
||||
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
||||
this.handleBackspace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.deleteCharForward")) {
|
||||
this.handleForwardDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
||||
this.deleteWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
||||
this.deleteWordForward();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
||||
this.deleteToLineStart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
|
||||
this.deleteToLineEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill ring actions
|
||||
if (kb.matches(data, "tui.editor.yank")) {
|
||||
this.yank();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "tui.editor.yankPop")) {
|
||||
this.yankPop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
if (kb.matches(data, "tui.editor.cursorLeft")) {
|
||||
this.lastAction = null;
|
||||
if (this.cursor > 0) {
|
||||
const beforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.cursorRight")) {
|
||||
this.lastAction = null;
|
||||
if (this.cursor < this.value.length) {
|
||||
const afterCursor = this.value.slice(this.cursor);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.cursorLineStart")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = this.value.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
|
||||
this.moveWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "tui.editor.cursorWordRight")) {
|
||||
this.moveWordForwards();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
|
||||
// Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
|
||||
// including plain printable characters. Decode before the control-char check
|
||||
// since CSI-u sequences contain \x1b which would be rejected.
|
||||
const kittyPrintable = decodeKittyPrintable(data);
|
||||
if (kittyPrintable !== undefined) {
|
||||
this.insertCharacter(kittyPrintable);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular character input - accept printable characters including Unicode,
|
||||
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
|
||||
const hasControlChars = [...data].some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
||||
});
|
||||
if (!hasControlChars) {
|
||||
this.insertCharacter(data);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (event.type !== "press" || event.button !== "left" || event.y !== 0) return undefined;
|
||||
const visibleColumn = Math.max(0, event.x - 2);
|
||||
const targetColumn = this.renderedStartColumn + visibleColumn;
|
||||
let currentColumn = 0;
|
||||
this.cursor = this.value.length;
|
||||
for (const grapheme of segmenter.segment(this.value)) {
|
||||
const nextColumn = currentColumn + visibleWidth(grapheme.segment);
|
||||
if (targetColumn < nextColumn) {
|
||||
this.cursor = grapheme.index;
|
||||
break;
|
||||
}
|
||||
currentColumn = nextColumn;
|
||||
}
|
||||
this.lastAction = null;
|
||||
return { handled: true, focus: true };
|
||||
}
|
||||
|
||||
private insertCharacter(char: string): void {
|
||||
// Undo coalescing: consecutive word chars coalesce into one undo unit
|
||||
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||
this.pushUndo();
|
||||
}
|
||||
this.lastAction = "type-word";
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
|
||||
this.cursor += char.length;
|
||||
}
|
||||
|
||||
private handleBackspace(): void {
|
||||
this.lastAction = null;
|
||||
if (this.cursor > 0) {
|
||||
this.pushUndo();
|
||||
const beforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
|
||||
this.cursor -= graphemeLength;
|
||||
}
|
||||
}
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
this.lastAction = null;
|
||||
if (this.cursor < this.value.length) {
|
||||
this.pushUndo();
|
||||
const afterCursor = this.value.slice(this.cursor);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteToLineStart(): void {
|
||||
if (this.cursor === 0) return;
|
||||
this.pushUndo();
|
||||
const deletedText = this.value.slice(0, this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
this.value = this.value.slice(this.cursor);
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
private deleteToLineEnd(): void {
|
||||
if (this.cursor >= this.value.length) return;
|
||||
this.pushUndo();
|
||||
const deletedText = this.value.slice(this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
this.value = this.value.slice(0, this.cursor);
|
||||
}
|
||||
|
||||
private deleteWordBackwards(): void {
|
||||
if (this.cursor === 0) return;
|
||||
|
||||
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const oldCursor = this.cursor;
|
||||
this.moveWordBackwards();
|
||||
const deleteFrom = this.cursor;
|
||||
this.cursor = oldCursor;
|
||||
|
||||
const deletedText = this.value.slice(deleteFrom, this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
|
||||
this.cursor = deleteFrom;
|
||||
}
|
||||
|
||||
private deleteWordForward(): void {
|
||||
if (this.cursor >= this.value.length) return;
|
||||
|
||||
// Save lastAction before cursor movement (moveWordForwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const oldCursor = this.cursor;
|
||||
this.moveWordForwards();
|
||||
const deleteTo = this.cursor;
|
||||
this.cursor = oldCursor;
|
||||
|
||||
const deletedText = this.value.slice(this.cursor, deleteTo);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
|
||||
}
|
||||
|
||||
private yank(): void {
|
||||
const text = this.killRing.peek();
|
||||
if (!text) return;
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
||||
this.cursor += text.length;
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
private yankPop(): void {
|
||||
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
// Delete the previously yanked text (still at end of ring before rotation)
|
||||
const prevText = this.killRing.peek() || "";
|
||||
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
|
||||
this.cursor -= prevText.length;
|
||||
|
||||
// Rotate and insert new entry
|
||||
this.killRing.rotate();
|
||||
const text = this.killRing.peek() || "";
|
||||
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
||||
this.cursor += text.length;
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
private pushUndo(): void {
|
||||
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
||||
}
|
||||
|
||||
private undo(): void {
|
||||
const snapshot = this.undoStack.pop();
|
||||
if (!snapshot) return;
|
||||
this.value = snapshot.value;
|
||||
this.cursor = snapshot.cursor;
|
||||
this.lastAction = null;
|
||||
}
|
||||
|
||||
private moveWordBackwards(): void {
|
||||
if (this.cursor === 0) return;
|
||||
this.lastAction = null;
|
||||
this.cursor = findWordBackward(this.value, this.cursor);
|
||||
}
|
||||
|
||||
private moveWordForwards(): void {
|
||||
if (this.cursor >= this.value.length) return;
|
||||
this.lastAction = null;
|
||||
this.cursor = findWordForward(this.value, this.cursor);
|
||||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
this.lastAction = null;
|
||||
this.pushUndo();
|
||||
|
||||
// Clean the pasted text - remove newlines and carriage returns
|
||||
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
|
||||
|
||||
// Insert at cursor position
|
||||
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
|
||||
this.cursor += cleanText.length;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Calculate visible window
|
||||
const availableWidth = width - visibleWidth(this.prompt);
|
||||
|
||||
if (availableWidth <= 0) {
|
||||
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 = "";
|
||||
let cursorDisplay = this.cursor;
|
||||
this.renderedStartColumn = 0;
|
||||
const totalWidth = visibleWidth(this.value);
|
||||
|
||||
if (totalWidth < availableWidth) {
|
||||
// Everything fits (leave room for cursor at end)
|
||||
visibleText = this.value;
|
||||
} else {
|
||||
// Need horizontal scrolling
|
||||
// Reserve one column for cursor if it's at the end
|
||||
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
|
||||
const cursorCol = visibleWidth(this.value.slice(0, this.cursor));
|
||||
|
||||
if (scrollWidth > 0) {
|
||||
const halfWidth = Math.floor(scrollWidth / 2);
|
||||
let startCol = 0;
|
||||
|
||||
if (cursorCol < halfWidth) {
|
||||
// Cursor near start
|
||||
startCol = 0;
|
||||
} else if (cursorCol > totalWidth - halfWidth) {
|
||||
// Cursor near end
|
||||
startCol = Math.max(0, totalWidth - scrollWidth);
|
||||
} else {
|
||||
// Cursor in middle
|
||||
startCol = Math.max(0, cursorCol - halfWidth);
|
||||
}
|
||||
|
||||
this.renderedStartColumn = startCol;
|
||||
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
|
||||
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
|
||||
cursorDisplay = beforeCursor.length;
|
||||
} else {
|
||||
visibleText = "";
|
||||
cursorDisplay = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Build line with fake cursor
|
||||
// Insert cursor character at cursor position
|
||||
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
|
||||
const cursorGrapheme = graphemes[0];
|
||||
|
||||
const beforeCursor = visibleText.slice(0, cursorDisplay);
|
||||
const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end
|
||||
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
|
||||
|
||||
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
|
||||
// Use inverse video to show cursor
|
||||
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
||||
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
|
||||
|
||||
// Calculate visual width
|
||||
const visualLength = visibleWidth(textWithCursor);
|
||||
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
|
||||
const line = this.prompt + textWithCursor + padding;
|
||||
|
||||
return [line];
|
||||
}
|
||||
}
|
||||
101
third_party/pi/packages/tui/src/components/loader.ts
vendored
Normal file
101
third_party/pi/packages/tui/src/components/loader.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { TUI } from "../tui.ts";
|
||||
import { Text } from "./text.ts";
|
||||
|
||||
export interface LoaderIndicatorOptions {
|
||||
/** Animation frames. Use an empty array to hide the indicator. */
|
||||
frames?: string[];
|
||||
/** Frame interval in milliseconds for animated indicators. */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const DEFAULT_INTERVAL_MS = 80;
|
||||
|
||||
/**
|
||||
* Loader component that updates with an optional spinning animation.
|
||||
*/
|
||||
export class Loader extends Text {
|
||||
private frames = [...DEFAULT_FRAMES];
|
||||
private intervalMs = DEFAULT_INTERVAL_MS;
|
||||
private currentFrame = 0;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private ui: TUI | null = null;
|
||||
private renderIndicatorVerbatim = false;
|
||||
private spinnerColorFn: (str: string) => string;
|
||||
private messageColorFn: (str: string) => string;
|
||||
private message: string = "Loading...";
|
||||
|
||||
constructor(
|
||||
ui: TUI,
|
||||
spinnerColorFn: (str: string) => string,
|
||||
messageColorFn: (str: string) => string,
|
||||
message: string = "Loading...",
|
||||
indicator?: LoaderIndicatorOptions,
|
||||
) {
|
||||
super("", 1, 0);
|
||||
this.ui = ui;
|
||||
this.spinnerColorFn = spinnerColorFn;
|
||||
this.messageColorFn = messageColorFn;
|
||||
this.message = message;
|
||||
this.setIndicator(indicator);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return ["", ...super.render(width)];
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.updateDisplay();
|
||||
this.restartAnimation();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(message: string): void {
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setIndicator(indicator?: LoaderIndicatorOptions): void {
|
||||
this.renderIndicatorVerbatim = indicator !== undefined;
|
||||
this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
|
||||
this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
|
||||
this.currentFrame = 0;
|
||||
this.start();
|
||||
}
|
||||
|
||||
private restartAnimation(): void {
|
||||
this.stop();
|
||||
if (this.frames.length <= 1) {
|
||||
return;
|
||||
}
|
||||
this.intervalId = setInterval(() => {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
||||
this.updateDisplay();
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
protected getRenderedIndicator(): string {
|
||||
const frame = this.frames[this.currentFrame] ?? "";
|
||||
return this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
const renderedFrame = this.getRenderedIndicator();
|
||||
const indicator = renderedFrame.length > 0 ? `${renderedFrame} ` : "";
|
||||
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
|
||||
if (this.ui) {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
1015
third_party/pi/packages/tui/src/components/markdown.ts
vendored
Normal file
1015
third_party/pi/packages/tui/src/components/markdown.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
33
third_party/pi/packages/tui/src/components/mouse-region.ts
vendored
Normal file
33
third_party/pi/packages/tui/src/components/mouse-region.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
type Component,
|
||||
dispatchMouseEvent,
|
||||
type TuiMouseDispatchResult,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
|
||||
export type MouseRegionHandler = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
|
||||
|
||||
/** Adds mouse handling to an existing component without changing its rendering. */
|
||||
export class MouseRegion implements Component {
|
||||
private readonly child: Component;
|
||||
private readonly onMouse: MouseRegionHandler;
|
||||
|
||||
constructor(child: Component, onMouse: MouseRegionHandler) {
|
||||
this.child = child;
|
||||
this.onMouse = onMouse;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.child.render(width);
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined {
|
||||
const childResult = dispatchMouseEvent(this.child, event);
|
||||
return childResult ?? this.onMouse(event);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.child.invalidate();
|
||||
}
|
||||
}
|
||||
224
third_party/pi/packages/tui/src/components/scroll-view.ts
vendored
Normal file
224
third_party/pi/packages/tui/src/components/scroll-view.ts
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts";
|
||||
import { type Component, Container } from "../tui.ts";
|
||||
|
||||
export type ScrollViewScrollbar = "hidden" | "auto" | "always";
|
||||
|
||||
export interface ScrollViewOptions {
|
||||
axis?: "vertical";
|
||||
follow?: "none" | "end";
|
||||
primary?: boolean;
|
||||
overscroll?: "chain" | "contain";
|
||||
scrollbar?: ScrollViewScrollbar;
|
||||
scrollbarTrackStyle?: (text: string) => string;
|
||||
scrollbarThumbStyle?: (text: string) => string;
|
||||
scrollbarHideDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface ScrollViewScrollToOptions {
|
||||
/** Keep follow-end disabled even when the target is the current content end. */
|
||||
disableFollow?: boolean;
|
||||
}
|
||||
|
||||
export class ScrollView extends Container {
|
||||
private readonly child: Component;
|
||||
readonly followEnd: boolean;
|
||||
readonly primary: boolean;
|
||||
readonly overscroll: "chain" | "contain";
|
||||
readonly scrollbarTrackStyle: (text: string) => string;
|
||||
readonly scrollbarThumbStyle: (text: string) => string;
|
||||
private currentScrollbar: ScrollViewScrollbar;
|
||||
private readonly scrollbarHideDelayMs: number;
|
||||
private currentScrollTop = 0;
|
||||
private contentHeight = 0;
|
||||
private currentViewportHeight = 0;
|
||||
private followingEnd: boolean;
|
||||
private followSuppressedAtEnd = false;
|
||||
private requestRenderCallback: (() => void) | undefined;
|
||||
private transientScrollbarVisible = false;
|
||||
private scrollbarActive = false;
|
||||
private scrollbarHideTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(component: Component, options: ScrollViewOptions = {}) {
|
||||
super();
|
||||
if (options.axis !== undefined && options.axis !== "vertical") {
|
||||
throw new Error(`Unsupported ScrollView axis: ${options.axis}`);
|
||||
}
|
||||
this.child = component;
|
||||
this.children.push(component);
|
||||
this.followEnd = (options.follow ?? "none") === "end";
|
||||
this.followingEnd = this.followEnd;
|
||||
this.primary = options.primary ?? false;
|
||||
this.overscroll = options.overscroll ?? "chain";
|
||||
this.currentScrollbar = options.scrollbar ?? "hidden";
|
||||
this.scrollbarTrackStyle = options.scrollbarTrackStyle ?? ((text) => `\x1b[90m${text}\x1b[39m`);
|
||||
this.scrollbarThumbStyle = options.scrollbarThumbStyle ?? ((text) => `\x1b[37m${text}\x1b[39m`);
|
||||
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
|
||||
}
|
||||
|
||||
get scrollTop(): number {
|
||||
return this.currentScrollTop;
|
||||
}
|
||||
|
||||
get isFollowingEnd(): boolean {
|
||||
return this.followingEnd;
|
||||
}
|
||||
|
||||
get viewportHeight(): number {
|
||||
return this.currentViewportHeight;
|
||||
}
|
||||
|
||||
get scrollbar(): ScrollViewScrollbar {
|
||||
return this.currentScrollbar;
|
||||
}
|
||||
|
||||
get isScrollbarVisible(): boolean {
|
||||
if (this.scrollbar === "always") return this.currentViewportHeight > 0;
|
||||
return (
|
||||
this.scrollbar === "auto" && this.contentHeight > this.currentViewportHeight && this.transientScrollbarVisible
|
||||
);
|
||||
}
|
||||
|
||||
get isScrollbarActive(): boolean {
|
||||
return this.scrollbarActive;
|
||||
}
|
||||
|
||||
setScrollbar(scrollbar: ScrollViewScrollbar): void {
|
||||
if (scrollbar === this.currentScrollbar) return;
|
||||
this.currentScrollbar = scrollbar;
|
||||
if (scrollbar !== "auto") this.hideTransientScrollbar();
|
||||
else if (this.scrollbarActive) this.markScrollbarActivity();
|
||||
this.requestRenderCallback?.();
|
||||
}
|
||||
|
||||
getContentWidth(width: number): number {
|
||||
return this.scrollbar === "always" && width > 1 ? width - 1 : width;
|
||||
}
|
||||
|
||||
private markScrollbarActivity(): void {
|
||||
if (this.scrollbar !== "auto" || this.contentHeight <= this.currentViewportHeight) return;
|
||||
this.transientScrollbarVisible = true;
|
||||
if (this.scrollbarHideTimer) {
|
||||
clearTimeout(this.scrollbarHideTimer);
|
||||
this.scrollbarHideTimer = undefined;
|
||||
}
|
||||
if (this.scrollbarActive) return;
|
||||
this.scrollbarHideTimer = setTimeout(() => {
|
||||
this.scrollbarHideTimer = undefined;
|
||||
this.transientScrollbarVisible = false;
|
||||
this.requestRenderCallback?.();
|
||||
}, this.scrollbarHideDelayMs);
|
||||
this.scrollbarHideTimer.unref();
|
||||
}
|
||||
|
||||
private hideTransientScrollbar(): void {
|
||||
this.transientScrollbarVisible = false;
|
||||
if (!this.scrollbarHideTimer) return;
|
||||
clearTimeout(this.scrollbarHideTimer);
|
||||
this.scrollbarHideTimer = undefined;
|
||||
}
|
||||
|
||||
setScrollbarActive(active: boolean): void {
|
||||
if (active === this.scrollbarActive) return;
|
||||
this.scrollbarActive = active;
|
||||
this.markScrollbarActivity();
|
||||
this.requestRenderCallback?.();
|
||||
}
|
||||
|
||||
scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void {
|
||||
const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop;
|
||||
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
|
||||
const next = Math.max(0, Math.min(maxScrollTop, requested));
|
||||
const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop;
|
||||
const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop;
|
||||
if (
|
||||
next === this.currentScrollTop &&
|
||||
nextFollowingEnd === this.followingEnd &&
|
||||
nextFollowSuppressedAtEnd === this.followSuppressedAtEnd
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const moved = next !== this.currentScrollTop;
|
||||
this.currentScrollTop = next;
|
||||
this.followingEnd = nextFollowingEnd;
|
||||
this.followSuppressedAtEnd = nextFollowSuppressedAtEnd;
|
||||
if (moved) this.markScrollbarActivity();
|
||||
this.requestRenderCallback?.();
|
||||
}
|
||||
|
||||
scrollBy(lines: number): number {
|
||||
const requested = Number.isFinite(lines) ? Math.trunc(lines) : 0;
|
||||
if (requested === 0) return 0;
|
||||
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
|
||||
const start = this.followingEnd ? maxScrollTop : this.currentScrollTop;
|
||||
const next = Math.max(0, Math.min(maxScrollTop, start + requested));
|
||||
const moved = next - start;
|
||||
const wasFollowingEnd = this.followingEnd;
|
||||
this.currentScrollTop = next;
|
||||
this.followingEnd = this.followEnd && next === maxScrollTop;
|
||||
this.followSuppressedAtEnd = false;
|
||||
if (moved !== 0) this.markScrollbarActivity();
|
||||
if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.();
|
||||
return requested - moved;
|
||||
}
|
||||
|
||||
scrollToStart(): void {
|
||||
const changed =
|
||||
this.currentScrollTop !== 0 ||
|
||||
this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight);
|
||||
this.currentScrollTop = 0;
|
||||
this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight;
|
||||
this.followSuppressedAtEnd = false;
|
||||
if (changed) {
|
||||
this.markScrollbarActivity();
|
||||
this.requestRenderCallback?.();
|
||||
}
|
||||
}
|
||||
|
||||
scrollToEnd(): void {
|
||||
const next = Math.max(0, this.contentHeight - this.currentViewportHeight);
|
||||
const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd;
|
||||
this.currentScrollTop = next;
|
||||
this.followingEnd = this.followEnd;
|
||||
this.followSuppressedAtEnd = false;
|
||||
if (changed) {
|
||||
this.markScrollbarActivity();
|
||||
this.requestRenderCallback?.();
|
||||
}
|
||||
}
|
||||
|
||||
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
|
||||
this.contentHeight = Math.max(0, Math.floor(contentHeight));
|
||||
this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));
|
||||
this.requestRenderCallback = requestRender;
|
||||
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
|
||||
if (this.followingEnd) this.currentScrollTop = maxScrollTop;
|
||||
else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
|
||||
if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;
|
||||
if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {
|
||||
this.followingEnd = true;
|
||||
}
|
||||
if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();
|
||||
}
|
||||
|
||||
override addChild(_component: Component): void {
|
||||
throw new Error("ScrollView has exactly one child");
|
||||
}
|
||||
|
||||
override removeChild(_component: Component): void {
|
||||
throw new Error("ScrollView child cannot be removed");
|
||||
}
|
||||
|
||||
override clear(): void {
|
||||
throw new Error("ScrollView child cannot be cleared");
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const contentWidth = this.getContentWidth(width);
|
||||
const lines = this.child.render(contentWidth);
|
||||
return contentWidth === width ? lines : lines.map((line) => `${line} `);
|
||||
}
|
||||
|
||||
[LAYOUT_NODE](): ScrollLayoutNode {
|
||||
return { type: "scroll", component: this.child, state: this };
|
||||
}
|
||||
}
|
||||
273
third_party/pi/packages/tui/src/components/select-list.ts
vendored
Normal file
273
third_party/pi/packages/tui/src/components/select-list.ts
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
|
||||
import { truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
|
||||
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
||||
const PRIMARY_COLUMN_GAP = 2;
|
||||
const MIN_DESCRIPTION_WIDTH = 10;
|
||||
|
||||
const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
|
||||
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SelectListTheme {
|
||||
selectedPrefix: (text: string) => string;
|
||||
selectedText: (text: string) => string;
|
||||
description: (text: string) => string;
|
||||
scrollInfo: (text: string) => string;
|
||||
noMatch: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface SelectListTruncatePrimaryContext {
|
||||
text: string;
|
||||
maxWidth: number;
|
||||
columnWidth: number;
|
||||
item: SelectItem;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface SelectListLayoutOptions {
|
||||
minPrimaryColumnWidth?: number;
|
||||
maxPrimaryColumnWidth?: number;
|
||||
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
||||
}
|
||||
|
||||
export class SelectList implements Component {
|
||||
private items: SelectItem[] = [];
|
||||
private filteredItems: SelectItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private mousePressedIndex: number | undefined;
|
||||
private maxVisible: number = 5;
|
||||
private theme: SelectListTheme;
|
||||
private layout: SelectListLayoutOptions;
|
||||
|
||||
public onSelect?: (item: SelectItem) => void;
|
||||
public onCancel?: () => void;
|
||||
public onSelectionChange?: (item: SelectItem) => void;
|
||||
|
||||
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
setFilter(filter: string): void {
|
||||
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
|
||||
// Reset selection when filter changes
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
setSelectedIndex(index: number): void {
|
||||
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(this.theme.noMatch(" No matching commands"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const primaryColumnWidth = this.getPrimaryColumnWidth();
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const { startIndex, endIndex } = this.getVisibleRange();
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = this.filteredItems[i];
|
||||
if (!item) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;
|
||||
lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));
|
||||
}
|
||||
|
||||
// Add scroll indicators if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
|
||||
// Truncate if too long for terminal
|
||||
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (this.filteredItems.length === 0) return undefined;
|
||||
if (event.type === "wheel" && event.wheelDelta) {
|
||||
const delta = event.wheelDelta < 0 ? -1 : 1;
|
||||
const previousIndex = this.selectedIndex;
|
||||
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
|
||||
if (this.selectedIndex !== previousIndex) this.notifySelectionChange();
|
||||
return { handled: true, render: this.selectedIndex !== previousIndex };
|
||||
}
|
||||
// Hover must not change selection: the visible range is centered on it.
|
||||
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
||||
const { startIndex, endIndex } = this.getVisibleRange();
|
||||
const itemIndex = startIndex + event.y;
|
||||
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
|
||||
|
||||
if (event.type === "press") {
|
||||
this.mousePressedIndex = itemIndex;
|
||||
if (this.selectedIndex !== itemIndex) {
|
||||
this.selectedIndex = itemIndex;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
return { handled: true, focus: true };
|
||||
}
|
||||
if (event.type === "click") {
|
||||
const clickedIndex = this.mousePressedIndex ?? itemIndex;
|
||||
this.mousePressedIndex = undefined;
|
||||
const changed = this.selectedIndex !== clickedIndex;
|
||||
this.selectedIndex = clickedIndex;
|
||||
if (changed) this.notifySelectionChange();
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem) this.onSelect?.(selectedItem);
|
||||
return { handled: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getKeybindings();
|
||||
// Up arrow - wrap to bottom when at top
|
||||
if (kb.matches(keyData, "tui.select.up")) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
// Down arrow - wrap to top when at bottom
|
||||
else if (kb.matches(keyData, "tui.select.down")) {
|
||||
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
// Enter
|
||||
else if (kb.matches(keyData, "tui.select.confirm")) {
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem && this.onSelect) {
|
||||
this.onSelect(selectedItem);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getVisibleRange(): { startIndex: number; endIndex: number } {
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||
);
|
||||
return {
|
||||
startIndex,
|
||||
endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length),
|
||||
};
|
||||
}
|
||||
|
||||
private renderItem(
|
||||
item: SelectItem,
|
||||
isSelected: boolean,
|
||||
width: number,
|
||||
descriptionSingleLine: string | undefined,
|
||||
primaryColumnWidth: number,
|
||||
): string {
|
||||
const prefix = isSelected ? "→ " : " ";
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
|
||||
if (descriptionSingleLine && width > 40) {
|
||||
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
|
||||
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
|
||||
const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
|
||||
const truncatedValueWidth = visibleWidth(truncatedValue);
|
||||
const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
|
||||
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
|
||||
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, "");
|
||||
if (isSelected) {
|
||||
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
||||
}
|
||||
|
||||
const descText = this.theme.description(spacing + truncatedDesc);
|
||||
return prefix + truncatedValue + descText;
|
||||
}
|
||||
}
|
||||
|
||||
const maxWidth = width - prefixWidth - 2;
|
||||
const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);
|
||||
if (isSelected) {
|
||||
return this.theme.selectedText(`${prefix}${truncatedValue}`);
|
||||
}
|
||||
|
||||
return prefix + truncatedValue;
|
||||
}
|
||||
|
||||
private getPrimaryColumnWidth(): number {
|
||||
const { min, max } = this.getPrimaryColumnBounds();
|
||||
const widestPrimary = this.filteredItems.reduce((widest, item) => {
|
||||
return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
|
||||
}, 0);
|
||||
|
||||
return clamp(widestPrimary, min, max);
|
||||
}
|
||||
|
||||
private getPrimaryColumnBounds(): { min: number; max: number } {
|
||||
const rawMin =
|
||||
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
|
||||
const rawMax =
|
||||
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
|
||||
|
||||
return {
|
||||
min: Math.max(1, Math.min(rawMin, rawMax)),
|
||||
max: Math.max(1, Math.max(rawMin, rawMax)),
|
||||
};
|
||||
}
|
||||
|
||||
private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
|
||||
const displayValue = this.getDisplayValue(item);
|
||||
const truncatedValue = this.layout.truncatePrimary
|
||||
? this.layout.truncatePrimary({
|
||||
text: displayValue,
|
||||
maxWidth,
|
||||
columnWidth,
|
||||
item,
|
||||
isSelected,
|
||||
})
|
||||
: truncateToWidth(displayValue, maxWidth, "");
|
||||
|
||||
return truncateToWidth(truncatedValue, maxWidth, "");
|
||||
}
|
||||
|
||||
private getDisplayValue(item: SelectItem): string {
|
||||
return item.label || item.value;
|
||||
}
|
||||
|
||||
private notifySelectionChange(): void {
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem && this.onSelectionChange) {
|
||||
this.onSelectionChange(selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
return item || null;
|
||||
}
|
||||
}
|
||||
328
third_party/pi/packages/tui/src/components/settings-list.ts
vendored
Normal file
328
third_party/pi/packages/tui/src/components/settings-list.ts
vendored
Normal file
@@ -0,0 +1,328 @@
|
||||
import { fuzzyFilter } from "../fuzzy.ts";
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
import { Input } from "./input.ts";
|
||||
|
||||
export interface SettingItem {
|
||||
/** Unique identifier for this setting */
|
||||
id: string;
|
||||
/** Display label (left side) */
|
||||
label: string;
|
||||
/** Optional description shown when selected */
|
||||
description?: string;
|
||||
/** Current value to display (right side) */
|
||||
currentValue: string;
|
||||
/** If provided, Enter/Space cycles through these values */
|
||||
values?: string[];
|
||||
/** If provided, Enter opens this submenu. Receives current value and done callback.
|
||||
* done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */
|
||||
submenu?: (
|
||||
currentValue: string,
|
||||
done: (selectedValue?: string, options?: { navigateTo?: string }) => void,
|
||||
) => Component;
|
||||
}
|
||||
|
||||
export interface SettingsListTheme {
|
||||
label: (text: string, selected: boolean) => string;
|
||||
value: (text: string, selected: boolean) => string;
|
||||
description: (text: string) => string;
|
||||
cursor: string;
|
||||
hint: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface SettingsListOptions {
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
export class SettingsList implements Component {
|
||||
private items: SettingItem[];
|
||||
private filteredItems: SettingItem[];
|
||||
private theme: SettingsListTheme;
|
||||
private selectedIndex = 0;
|
||||
private mousePressedIndex: number | undefined;
|
||||
private maxVisible: number;
|
||||
private onChange: (id: string, newValue: string) => void;
|
||||
private onCancel: () => void;
|
||||
private searchInput?: Input;
|
||||
private searchEnabled: boolean;
|
||||
|
||||
// Submenu state
|
||||
private submenuComponent: Component | null = null;
|
||||
private submenuItemIndex: number | null = null;
|
||||
private navigateAfterClose: string | null = null;
|
||||
|
||||
constructor(
|
||||
items: SettingItem[],
|
||||
maxVisible: number,
|
||||
theme: SettingsListTheme,
|
||||
onChange: (id: string, newValue: string) => void,
|
||||
onCancel: () => void,
|
||||
options: SettingsListOptions = {},
|
||||
) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
this.onChange = onChange;
|
||||
this.onCancel = onCancel;
|
||||
this.searchEnabled = options.enableSearch ?? false;
|
||||
if (this.searchEnabled) {
|
||||
this.searchInput = new Input();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an item's currentValue */
|
||||
updateValue(id: string, newValue: string): void {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.currentValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Move selection to the item with the given id (no-op if not found). */
|
||||
selectItem(id: string): void {
|
||||
const items = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
if (index !== -1) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.submenuComponent?.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// If submenu is active, render it instead
|
||||
if (this.submenuComponent) {
|
||||
return this.submenuComponent.render(width);
|
||||
}
|
||||
|
||||
return this.renderMainList(width);
|
||||
}
|
||||
|
||||
private renderMainList(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this.searchEnabled && this.searchInput) {
|
||||
lines.push(...this.searchInput.render(width));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (this.items.length === 0) {
|
||||
lines.push(this.theme.hint(" No settings available"));
|
||||
if (this.searchEnabled) {
|
||||
this.addHintLine(lines, width);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (displayItems.length === 0) {
|
||||
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
|
||||
this.addHintLine(lines, width);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
|
||||
|
||||
// Calculate max label width for alignment
|
||||
const maxLabelWidth = Math.min(36, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = displayItems[i];
|
||||
if (!item) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const prefix = isSelected ? this.theme.cursor : " ";
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
|
||||
// Pad label to align values
|
||||
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
||||
const labelText = this.theme.label(labelPadded, isSelected);
|
||||
|
||||
// Calculate space for value
|
||||
const separator = " ";
|
||||
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
||||
const valueMaxWidth = width - usedWidth - 2;
|
||||
|
||||
const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
||||
|
||||
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < displayItems.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
||||
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
||||
}
|
||||
|
||||
// Add description for selected item
|
||||
const selectedItem = displayItems[this.selectedIndex];
|
||||
if (selectedItem?.description) {
|
||||
lines.push("");
|
||||
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
||||
for (const line of wrappedDesc) {
|
||||
lines.push(this.theme.description(` ${line}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Add hint
|
||||
this.addHintLine(lines, width);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (this.submenuComponent) {
|
||||
const result = this.submenuComponent.handleMouse?.(event);
|
||||
return result ? { ...result, focus: true } : undefined;
|
||||
}
|
||||
|
||||
if (this.searchEnabled && this.searchInput) {
|
||||
if (event.y === 0) {
|
||||
const result = this.searchInput.handleMouse?.(event);
|
||||
return result ? { ...result, focus: true } : undefined;
|
||||
}
|
||||
if (event.y === 1) return undefined;
|
||||
}
|
||||
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (displayItems.length === 0) return undefined;
|
||||
if (event.type === "wheel" && event.wheelDelta) {
|
||||
const delta = event.wheelDelta < 0 ? -1 : 1;
|
||||
const previousIndex = this.selectedIndex;
|
||||
this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta));
|
||||
return { handled: true, render: this.selectedIndex !== previousIndex };
|
||||
}
|
||||
// Hover must not change selection: the visible range is centered on it.
|
||||
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
||||
|
||||
const rowOffset = this.searchEnabled ? 2 : 0;
|
||||
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
|
||||
const itemIndex = startIndex + event.y - rowOffset;
|
||||
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
|
||||
if (event.type === "press") {
|
||||
this.mousePressedIndex = itemIndex;
|
||||
this.selectedIndex = itemIndex;
|
||||
return { handled: true, focus: true };
|
||||
}
|
||||
if (event.type === "click") {
|
||||
this.selectedIndex = this.mousePressedIndex ?? itemIndex;
|
||||
this.mousePressedIndex = undefined;
|
||||
this.activateItem();
|
||||
return { handled: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If submenu is active, delegate all input to it
|
||||
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
||||
if (this.submenuComponent) {
|
||||
this.submenuComponent.handleInput?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Main list input handling
|
||||
const kb = getKeybindings();
|
||||
const displayItems = this.getDisplayItems();
|
||||
if (kb.matches(data, "tui.select.up")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
||||
} else if (kb.matches(data, "tui.select.down")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
} else if (
|
||||
kb.matches(data, "tui.select.confirm") ||
|
||||
(data === " " && (!this.searchEnabled || this.searchInput?.getValue().length === 0))
|
||||
) {
|
||||
this.activateItem();
|
||||
} else if (kb.matches(data, "tui.select.cancel")) {
|
||||
this.onCancel();
|
||||
} else if (this.searchEnabled && this.searchInput) {
|
||||
this.searchInput.handleInput(data);
|
||||
this.applyFilter(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private getDisplayItems(): SettingItem[] {
|
||||
return this.searchEnabled ? this.filteredItems : this.items;
|
||||
}
|
||||
|
||||
private getVisibleRange(displayItems: readonly SettingItem[]): { startIndex: number; endIndex: number } {
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
|
||||
);
|
||||
return { startIndex, endIndex: Math.min(startIndex + this.maxVisible, displayItems.length) };
|
||||
}
|
||||
|
||||
private activateItem(): void {
|
||||
const item = this.getDisplayItems()[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
if (item.submenu) {
|
||||
// Open submenu, passing current value so it can pre-select correctly
|
||||
this.submenuItemIndex = this.selectedIndex;
|
||||
this.submenuComponent = item.submenu(
|
||||
item.currentValue,
|
||||
(selectedValue?: string, options?: { navigateTo?: string }) => {
|
||||
if (selectedValue !== undefined) {
|
||||
item.currentValue = selectedValue;
|
||||
this.onChange(item.id, selectedValue);
|
||||
}
|
||||
if (options?.navigateTo) {
|
||||
this.navigateAfterClose = options.navigateTo;
|
||||
}
|
||||
this.closeSubmenu();
|
||||
},
|
||||
);
|
||||
} else if (item.values && item.values.length > 0) {
|
||||
// Cycle through values
|
||||
const currentIndex = item.values.indexOf(item.currentValue);
|
||||
const nextIndex = (currentIndex + 1) % item.values.length;
|
||||
const newValue = item.values[nextIndex];
|
||||
item.currentValue = newValue;
|
||||
this.onChange(item.id, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private closeSubmenu(): void {
|
||||
this.submenuComponent = null;
|
||||
if (this.navigateAfterClose !== null) {
|
||||
const id = this.navigateAfterClose;
|
||||
this.navigateAfterClose = null;
|
||||
this.submenuItemIndex = null;
|
||||
this.selectItem(id);
|
||||
// Open the target item's submenu automatically
|
||||
this.activateItem();
|
||||
} else if (this.submenuItemIndex !== null) {
|
||||
// Restore selection to the item that opened the submenu
|
||||
this.selectedIndex = this.submenuItemIndex;
|
||||
this.submenuItemIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
private addHintLine(lines: string[], width: number): void {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
this.theme.hint(
|
||||
this.searchEnabled
|
||||
? " Type to search · Enter/Space to change · Esc to cancel"
|
||||
: " Enter/Space to change · Esc to cancel",
|
||||
),
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
third_party/pi/packages/tui/src/components/spacer.ts
vendored
Normal file
28
third_party/pi/packages/tui/src/components/spacer.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Component } from "../tui.ts";
|
||||
|
||||
/**
|
||||
* Spacer component that renders empty lines
|
||||
*/
|
||||
export class Spacer implements Component {
|
||||
private lines: number;
|
||||
|
||||
constructor(lines: number = 1) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
setLines(lines: number): void {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(_width: number): string[] {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < this.lines; i++) {
|
||||
result.push("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
154
third_party/pi/packages/tui/src/components/stack.ts
vendored
Normal file
154
third_party/pi/packages/tui/src/components/stack.ts
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts";
|
||||
import { type Component, Container } from "../tui.ts";
|
||||
|
||||
export interface StackEntryOptions {
|
||||
basis?: number | "auto";
|
||||
grow?: number;
|
||||
shrink?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
visible?: (viewport: LayoutViewport) => boolean;
|
||||
}
|
||||
|
||||
export interface StackEntry extends StackEntryOptions {
|
||||
component: Component;
|
||||
}
|
||||
|
||||
export type StackChild = Component | StackEntry;
|
||||
|
||||
export interface StackOptions {
|
||||
gap?: number;
|
||||
align?: "stretch" | "start" | "center" | "end";
|
||||
}
|
||||
|
||||
function isStackEntry(child: StackChild): child is StackEntry {
|
||||
return !("render" in child);
|
||||
}
|
||||
|
||||
function normalizeSize(value: number | undefined, fallback: number): number {
|
||||
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
export abstract class Stack extends Container {
|
||||
protected readonly entries: StackLayoutEntry[] = [];
|
||||
protected readonly gap: number;
|
||||
protected readonly align: "stretch" | "start" | "center" | "end";
|
||||
protected abstract readonly layoutType: "vstack" | "hstack";
|
||||
|
||||
constructor(children: StackChild[] = [], options: StackOptions = {}) {
|
||||
super();
|
||||
this.gap = normalizeSize(options.gap, 0);
|
||||
this.align = options.align ?? "stretch";
|
||||
for (const child of children) {
|
||||
if (isStackEntry(child)) this.addChild(child.component, child);
|
||||
else this.addChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
override addChild(component: Component, options: StackEntryOptions = {}): void {
|
||||
super.addChild(component);
|
||||
this.entries.push({
|
||||
component,
|
||||
...(options.basis === undefined ? {} : { basis: options.basis }),
|
||||
...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }),
|
||||
...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }),
|
||||
...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }),
|
||||
...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }),
|
||||
...(options.visible === undefined ? {} : { visible: options.visible }),
|
||||
});
|
||||
}
|
||||
|
||||
override removeChild(component: Component): void {
|
||||
super.removeChild(component);
|
||||
const index = this.entries.findIndex((entry) => entry.component === component);
|
||||
if (index !== -1) this.entries.splice(index, 1);
|
||||
}
|
||||
|
||||
override clear(): void {
|
||||
super.clear();
|
||||
this.entries.length = 0;
|
||||
}
|
||||
|
||||
[LAYOUT_NODE](): StackLayoutNode {
|
||||
return {
|
||||
type: this.layoutType,
|
||||
entries: this.entries,
|
||||
gap: this.gap,
|
||||
align: this.align,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleStackEntries(
|
||||
entries: readonly StackLayoutEntry[],
|
||||
viewport: LayoutViewport,
|
||||
): StackLayoutEntry[] {
|
||||
return entries.filter((entry) => entry.visible?.(viewport) ?? true);
|
||||
}
|
||||
|
||||
function clampSize(size: number, entry: StackLayoutEntry): number {
|
||||
const min = Math.max(0, Math.floor(entry.minSize ?? 0));
|
||||
const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER));
|
||||
return Math.max(min, Math.min(max, Math.max(0, Math.floor(size))));
|
||||
}
|
||||
|
||||
function distribute(
|
||||
sizes: number[],
|
||||
entries: readonly StackLayoutEntry[],
|
||||
amount: number,
|
||||
mode: "grow" | "shrink",
|
||||
): void {
|
||||
let remaining = amount;
|
||||
while (remaining > 0) {
|
||||
const candidates = entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry, index }) => {
|
||||
if (mode === "grow") {
|
||||
return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0);
|
||||
});
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const totalWeight = candidates.reduce((sum, { entry, index }) => {
|
||||
return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!));
|
||||
}, 0);
|
||||
let distributed = 0;
|
||||
for (const { entry, index } of candidates) {
|
||||
if (remaining <= 0) break;
|
||||
const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!);
|
||||
const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight));
|
||||
const capacity =
|
||||
mode === "grow"
|
||||
? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]!
|
||||
: sizes[index]! - (entry.minSize ?? 0);
|
||||
const delta = Math.min(remaining, proposed, capacity);
|
||||
if (delta <= 0) continue;
|
||||
sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta);
|
||||
remaining -= delta;
|
||||
distributed += delta;
|
||||
}
|
||||
if (distributed === 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function allocateStackSizes(
|
||||
entries: readonly StackLayoutEntry[],
|
||||
intrinsicSizes: readonly number[],
|
||||
availableSize: number | undefined,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const sizes = entries.map((entry, index) =>
|
||||
clampSize(
|
||||
entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis,
|
||||
entry,
|
||||
),
|
||||
);
|
||||
if (availableSize === undefined) return sizes;
|
||||
|
||||
const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap);
|
||||
const total = sizes.reduce((sum, size) => sum + size, 0);
|
||||
if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow");
|
||||
else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink");
|
||||
return sizes;
|
||||
}
|
||||
107
third_party/pi/packages/tui/src/components/text.ts
vendored
Normal file
107
third_party/pi/packages/tui/src/components/text.ts
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Component } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* Text component - displays multi-line text with word wrapping
|
||||
*/
|
||||
export class Text implements Component {
|
||||
private text: string;
|
||||
private paddingX: number; // Left/right padding
|
||||
private paddingY: number; // Top/bottom padding
|
||||
private customBgFn?: (text: string) => string;
|
||||
|
||||
// Cache for rendered output
|
||||
private cachedText?: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
||||
this.text = text;
|
||||
this.paddingX = paddingX;
|
||||
this.paddingY = paddingY;
|
||||
this.customBgFn = customBgFn;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
||||
this.customBgFn = customBgFn;
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Check cache
|
||||
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
// Don't render anything if there's no actual text
|
||||
if (!this.text || this.text.trim() === "") {
|
||||
const result: string[] = [];
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Replace tabs with 3 spaces
|
||||
const normalizedText = this.text.replace(/\t/g, " ");
|
||||
|
||||
// Reduce margins when necessary so content and padding fit within the available width.
|
||||
const paddingX = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2)));
|
||||
const contentWidth = Math.max(1, width - paddingX * 2);
|
||||
|
||||
// Wrap text (this preserves ANSI codes but does NOT pad)
|
||||
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||
|
||||
// Add margins and background to each line
|
||||
const leftMargin = " ".repeat(paddingX);
|
||||
const rightMargin = " ".repeat(paddingX);
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
// Add margins
|
||||
const lineWithMargins = leftMargin + line + rightMargin;
|
||||
|
||||
// Apply background if specified (this also pads to full width)
|
||||
if (this.customBgFn) {
|
||||
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
|
||||
} else {
|
||||
// No background - just pad to width with spaces
|
||||
const visibleLen = visibleWidth(lineWithMargins);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||
}
|
||||
}
|
||||
|
||||
// Add top/bottom padding (empty lines)
|
||||
const emptyLine = " ".repeat(width);
|
||||
const emptyLines: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine;
|
||||
emptyLines.push(line);
|
||||
}
|
||||
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = result;
|
||||
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
}
|
||||
65
third_party/pi/packages/tui/src/components/truncated-text.ts
vendored
Normal file
65
third_party/pi/packages/tui/src/components/truncated-text.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Component } from "../tui.ts";
|
||||
import { truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* Text component that truncates to fit viewport width
|
||||
*/
|
||||
export class TruncatedText implements Component {
|
||||
private text: string;
|
||||
private paddingX: number;
|
||||
private paddingY: number;
|
||||
|
||||
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
|
||||
this.text = text;
|
||||
this.paddingX = paddingX;
|
||||
this.paddingY = paddingY;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
// Empty line padded to width
|
||||
const emptyLine = " ".repeat(width);
|
||||
|
||||
// Add vertical padding above
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
result.push(emptyLine);
|
||||
}
|
||||
|
||||
// Calculate available width after horizontal padding
|
||||
const availableWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
// Take only the first line (stop at newline)
|
||||
let singleLineText = this.text;
|
||||
const newlineIndex = this.text.indexOf("\n");
|
||||
if (newlineIndex !== -1) {
|
||||
singleLineText = this.text.substring(0, newlineIndex);
|
||||
}
|
||||
|
||||
// Truncate text if needed (accounting for ANSI codes)
|
||||
const displayText = truncateToWidth(singleLineText, availableWidth);
|
||||
|
||||
// Add horizontal padding
|
||||
const leftPadding = " ".repeat(this.paddingX);
|
||||
const rightPadding = " ".repeat(this.paddingX);
|
||||
const lineWithPadding = leftPadding + displayText + rightPadding;
|
||||
|
||||
// Pad line to exactly width characters
|
||||
const lineVisibleWidth = visibleWidth(lineWithPadding);
|
||||
const paddingNeeded = Math.max(0, width - lineVisibleWidth);
|
||||
const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
|
||||
|
||||
result.push(finalLine);
|
||||
|
||||
// Add vertical padding below
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
result.push(emptyLine);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
33
third_party/pi/packages/tui/src/components/v-stack.ts
vendored
Normal file
33
third_party/pi/packages/tui/src/components/v-stack.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
|
||||
|
||||
export class VStack extends Stack {
|
||||
protected readonly layoutType = "vstack" as const;
|
||||
|
||||
constructor(children: StackChild[] = [], options: StackOptions = {}) {
|
||||
super(children, options);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER };
|
||||
const entries = visibleStackEntries(this.entries, viewport);
|
||||
const rendered = entries.map((entry) => entry.component.render(viewport.width));
|
||||
const sizes = allocateStackSizes(
|
||||
entries,
|
||||
rendered.map((lines) => lines.length),
|
||||
undefined,
|
||||
this.gap,
|
||||
);
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
if (index > 0) {
|
||||
for (let gap = 0; gap < this.gap; gap++) lines.push("");
|
||||
}
|
||||
const childLines = rendered[index]!.slice(0, sizes[index]);
|
||||
lines.push(...childLines);
|
||||
for (let padding = childLines.length; padding < sizes[index]!; padding++) lines.push("");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export type { StackChild, StackEntry, StackEntryOptions, StackOptions } from "./stack.ts";
|
||||
74
third_party/pi/packages/tui/src/editor-component.ts
vendored
Normal file
74
third_party/pi/packages/tui/src/editor-component.ts
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AutocompleteProvider } from "./autocomplete.ts";
|
||||
import type { Component } from "./tui.ts";
|
||||
|
||||
/**
|
||||
* Interface for custom editor components.
|
||||
*
|
||||
* This allows extensions to provide their own editor implementation
|
||||
* (e.g., vim mode, emacs mode, custom keybindings) while maintaining
|
||||
* compatibility with the core application.
|
||||
*/
|
||||
export interface EditorComponent extends Component {
|
||||
// =========================================================================
|
||||
// Core text access (required)
|
||||
// =========================================================================
|
||||
|
||||
/** Get the current text content */
|
||||
getText(): string;
|
||||
|
||||
/** Set the text content */
|
||||
setText(text: string): void;
|
||||
|
||||
/** Handle raw terminal input (key presses, paste sequences, etc.) */
|
||||
handleInput(data: string): void;
|
||||
|
||||
// =========================================================================
|
||||
// Callbacks (required)
|
||||
// =========================================================================
|
||||
|
||||
/** Called when user submits (e.g., Enter key) */
|
||||
onSubmit?: (text: string) => void;
|
||||
|
||||
/** Called when text changes */
|
||||
onChange?: (text: string) => void;
|
||||
|
||||
// =========================================================================
|
||||
// History support (optional)
|
||||
// =========================================================================
|
||||
|
||||
/** Add text to history for up/down navigation */
|
||||
addToHistory?(text: string): void;
|
||||
|
||||
// =========================================================================
|
||||
// Advanced text manipulation (optional)
|
||||
// =========================================================================
|
||||
|
||||
/** Insert text at current cursor position */
|
||||
insertTextAtCursor?(text: string): void;
|
||||
|
||||
/**
|
||||
* Get text with any markers expanded (e.g., paste markers).
|
||||
* Falls back to getText() if not implemented.
|
||||
*/
|
||||
getExpandedText?(): string;
|
||||
|
||||
// =========================================================================
|
||||
// Autocomplete support (optional)
|
||||
// =========================================================================
|
||||
|
||||
/** Set the autocomplete provider */
|
||||
setAutocompleteProvider?(provider: AutocompleteProvider): void;
|
||||
|
||||
// =========================================================================
|
||||
// Appearance (optional)
|
||||
// =========================================================================
|
||||
|
||||
/** Border color function */
|
||||
borderColor?: (str: string) => string;
|
||||
|
||||
/** Set horizontal padding */
|
||||
setPaddingX?(padding: number): void;
|
||||
|
||||
/** Set max visible items in autocomplete dropdown */
|
||||
setAutocompleteMaxVisible?(maxVisible: number): void;
|
||||
}
|
||||
137
third_party/pi/packages/tui/src/fuzzy.ts
vendored
Normal file
137
third_party/pi/packages/tui/src/fuzzy.ts
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Fuzzy matching utilities.
|
||||
* Matches if all query characters appear in order (not necessarily consecutive).
|
||||
* Lower score = better match.
|
||||
*/
|
||||
|
||||
export interface FuzzyMatch {
|
||||
matches: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
|
||||
if (normalizedQuery.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
}
|
||||
|
||||
if (normalizedQuery.length > textLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) {
|
||||
if (textLower[i] === normalizedQuery[queryIndex]) {
|
||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
||||
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryIndex < normalizedQuery.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
if (normalizedQuery === textLower) {
|
||||
score -= 100;
|
||||
}
|
||||
|
||||
return { matches: true, score };
|
||||
};
|
||||
|
||||
const primaryMatch = matchQuery(queryLower);
|
||||
if (primaryMatch.matches) {
|
||||
return primaryMatch;
|
||||
}
|
||||
|
||||
const alphaNumericMatch = queryLower.match(/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/);
|
||||
const numericAlphaMatch = queryLower.match(/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/);
|
||||
const swappedQuery = alphaNumericMatch
|
||||
? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
|
||||
: numericAlphaMatch
|
||||
? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
|
||||
: "";
|
||||
|
||||
if (!swappedQuery) {
|
||||
return primaryMatch;
|
||||
}
|
||||
|
||||
const swappedMatch = matchQuery(swappedQuery);
|
||||
if (!swappedMatch.matches) {
|
||||
return primaryMatch;
|
||||
}
|
||||
|
||||
return { matches: true, score: swappedMatch.score + 5 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort items by fuzzy match quality (best matches first).
|
||||
* Supports whitespace- and slash-separated tokens: all tokens must match.
|
||||
*/
|
||||
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const tokens = query
|
||||
.trim()
|
||||
.split(/[\s/]+/)
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const results: { item: T; totalScore: number }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const text = getText(item);
|
||||
let totalScore = 0;
|
||||
let allMatch = true;
|
||||
|
||||
for (const token of tokens) {
|
||||
const match = fuzzyMatch(token, text);
|
||||
if (match.matches) {
|
||||
totalScore += match.score;
|
||||
} else {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
results.push({ item, totalScore });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.totalScore - b.totalScore);
|
||||
return results.map((r) => r.item);
|
||||
}
|
||||
154
third_party/pi/packages/tui/src/index.ts
vendored
Normal file
154
third_party/pi/packages/tui/src/index.ts
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
// Core TUI interfaces and classes
|
||||
|
||||
export { Marked, type Token, type Tokens } from "marked";
|
||||
// Autocomplete support
|
||||
export {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
CombinedAutocompleteProvider,
|
||||
type SlashCommand,
|
||||
} from "./autocomplete.ts";
|
||||
// Components
|
||||
export { Box } from "./components/box.ts";
|
||||
export { CancellableLoader } from "./components/cancellable-loader.ts";
|
||||
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts";
|
||||
export { HStack } from "./components/h-stack.ts";
|
||||
export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
|
||||
export { Input } from "./components/input.ts";
|
||||
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
|
||||
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
|
||||
export { MouseRegion, type MouseRegionHandler } from "./components/mouse-region.ts";
|
||||
export {
|
||||
ScrollView,
|
||||
type ScrollViewOptions,
|
||||
type ScrollViewScrollbar,
|
||||
type ScrollViewScrollToOptions,
|
||||
} from "./components/scroll-view.ts";
|
||||
export {
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListLayoutOptions,
|
||||
type SelectListTheme,
|
||||
type SelectListTruncatePrimaryContext,
|
||||
} from "./components/select-list.ts";
|
||||
export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts";
|
||||
export { Spacer } from "./components/spacer.ts";
|
||||
export { Text } from "./components/text.ts";
|
||||
export { TruncatedText } from "./components/truncated-text.ts";
|
||||
export {
|
||||
type StackChild,
|
||||
type StackEntry,
|
||||
type StackEntryOptions,
|
||||
type StackOptions,
|
||||
VStack,
|
||||
} from "./components/v-stack.ts";
|
||||
// Editor component interface (for custom editors)
|
||||
export type { EditorComponent } from "./editor-component.ts";
|
||||
// Fuzzy matching
|
||||
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.ts";
|
||||
// Keybindings
|
||||
export {
|
||||
getKeybindings,
|
||||
type Keybinding,
|
||||
type KeybindingConflict,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingDefinitions,
|
||||
type Keybindings,
|
||||
type KeybindingsConfig,
|
||||
KeybindingsManager,
|
||||
setKeybindings,
|
||||
TUI_KEYBINDINGS,
|
||||
} from "./keybindings.ts";
|
||||
// Keyboard input handling
|
||||
export {
|
||||
decodeKittyPrintable,
|
||||
isKeyRelease,
|
||||
isKeyRepeat,
|
||||
isKittyProtocolActive,
|
||||
Key,
|
||||
type KeyEventType,
|
||||
type KeyId,
|
||||
matchesKey,
|
||||
parseKey,
|
||||
setKittyProtocolActive,
|
||||
} from "./keys.ts";
|
||||
// LaTeX rendering
|
||||
export { type RenderLatexOptions, renderLatex } from "./latex.ts";
|
||||
// Input buffering for batch splitting
|
||||
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
|
||||
// Terminal interface and implementations
|
||||
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||
// Terminal colors
|
||||
export {
|
||||
parseOsc11BackgroundColor,
|
||||
parseTerminalColorSchemeReport,
|
||||
type RgbColor,
|
||||
type TerminalColorScheme,
|
||||
} from "./terminal-colors.ts";
|
||||
// Terminal image support
|
||||
export {
|
||||
allocateImageId,
|
||||
type CellDimensions,
|
||||
calculateImageRows,
|
||||
deleteAllKittyImages,
|
||||
deleteKittyImage,
|
||||
detectCapabilities,
|
||||
encodeITerm2,
|
||||
encodeKitty,
|
||||
getCapabilities,
|
||||
getCellDimensions,
|
||||
getGifDimensions,
|
||||
getImageDimensions,
|
||||
getJpegDimensions,
|
||||
getPngDimensions,
|
||||
getWebpDimensions,
|
||||
hyperlink,
|
||||
type ImageDimensions,
|
||||
type ImageProtocol,
|
||||
type ImageRenderOptions,
|
||||
imageFallback,
|
||||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
type TerminalCapabilities,
|
||||
} from "./terminal-image.ts";
|
||||
export {
|
||||
type Component,
|
||||
Container,
|
||||
CURSOR_MARKER,
|
||||
compositeTuiLine,
|
||||
type Focusable,
|
||||
isFocusable,
|
||||
isViewportTUI,
|
||||
type OverlayAnchor,
|
||||
type OverlayBounds,
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type OverlayUnfocusOptions,
|
||||
type SizeValue,
|
||||
type TUI,
|
||||
type TuiInputListener,
|
||||
type TuiInputListenerResult,
|
||||
type TuiMode,
|
||||
type TuiMouseButton,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
type TuiMouseEventType,
|
||||
type TuiStopOptions,
|
||||
type ViewportTUI,
|
||||
} from "./tui.ts";
|
||||
export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts";
|
||||
export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.ts";
|
||||
// Utilities
|
||||
export {
|
||||
getOsc8LinkAtColumn,
|
||||
sliceByColumn,
|
||||
stripTerminalSequences,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "./utils.ts";
|
||||
320
third_party/pi/packages/tui/src/keybindings.ts
vendored
Normal file
320
third_party/pi/packages/tui/src/keybindings.ts
vendored
Normal file
@@ -0,0 +1,320 @@
|
||||
import { type KeyId, matchesKey } from "./keys.ts";
|
||||
|
||||
/**
|
||||
* Global keybinding registry.
|
||||
* Downstream packages can add keybindings via declaration merging.
|
||||
*/
|
||||
export interface Keybindings {
|
||||
// Editor navigation and editing
|
||||
"tui.editor.cursorUp": true;
|
||||
"tui.editor.cursorDown": true;
|
||||
"tui.editor.historyPrevious": true;
|
||||
"tui.editor.historyNext": true;
|
||||
"tui.editor.cursorLeft": true;
|
||||
"tui.editor.cursorRight": true;
|
||||
"tui.editor.cursorWordLeft": true;
|
||||
"tui.editor.cursorWordRight": true;
|
||||
"tui.editor.cursorLineStart": true;
|
||||
"tui.editor.cursorLineEnd": true;
|
||||
"tui.editor.jumpForward": true;
|
||||
"tui.editor.jumpBackward": true;
|
||||
"tui.editor.pageUp": true;
|
||||
"tui.editor.pageDown": true;
|
||||
"tui.editor.deleteCharBackward": true;
|
||||
"tui.editor.deleteCharForward": true;
|
||||
"tui.editor.deleteWordBackward": true;
|
||||
"tui.editor.deleteWordForward": true;
|
||||
"tui.editor.deleteToLineStart": true;
|
||||
"tui.editor.deleteToLineEnd": true;
|
||||
"tui.editor.yank": true;
|
||||
"tui.editor.yankPop": true;
|
||||
"tui.editor.undo": true;
|
||||
// Generic input actions
|
||||
"tui.input.newLine": true;
|
||||
"tui.input.submit": true;
|
||||
"tui.input.tab": true;
|
||||
"tui.input.copy": true;
|
||||
// Generic selection actions
|
||||
"tui.select.up": true;
|
||||
"tui.select.down": true;
|
||||
"tui.select.pageUp": true;
|
||||
"tui.select.pageDown": true;
|
||||
"tui.select.confirm": true;
|
||||
"tui.select.cancel": true;
|
||||
// Alternate-screen viewport navigation
|
||||
"tui.altScreen.pageUp": true;
|
||||
"tui.altScreen.pageDown": true;
|
||||
"tui.altScreen.halfPageUp": true;
|
||||
"tui.altScreen.halfPageDown": true;
|
||||
"tui.altScreen.lineUp": true;
|
||||
"tui.altScreen.lineDown": true;
|
||||
"tui.altScreen.previousPrompt": true;
|
||||
"tui.altScreen.nextPrompt": true;
|
||||
"tui.altScreen.search": true;
|
||||
"tui.altScreen.searchNext": true;
|
||||
"tui.altScreen.searchPrevious": true;
|
||||
"tui.altScreen.searchClose": true;
|
||||
"tui.altScreen.top": true;
|
||||
"tui.altScreen.bottom": true;
|
||||
}
|
||||
|
||||
export type Keybinding = keyof Keybindings;
|
||||
|
||||
export interface KeybindingDefinition {
|
||||
defaultKeys: KeyId | KeyId[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
|
||||
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
|
||||
|
||||
export const TUI_KEYBINDINGS = {
|
||||
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
|
||||
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
|
||||
"tui.editor.historyPrevious": {
|
||||
defaultKeys: [],
|
||||
description: "Select previous prompt history entry",
|
||||
},
|
||||
"tui.editor.historyNext": {
|
||||
defaultKeys: [],
|
||||
description: "Select next prompt history entry",
|
||||
},
|
||||
"tui.editor.cursorLeft": {
|
||||
defaultKeys: ["left", "ctrl+b"],
|
||||
description: "Move cursor left",
|
||||
},
|
||||
"tui.editor.cursorRight": {
|
||||
defaultKeys: ["right", "ctrl+f"],
|
||||
description: "Move cursor right",
|
||||
},
|
||||
"tui.editor.cursorWordLeft": {
|
||||
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
|
||||
description: "Move cursor word left",
|
||||
},
|
||||
"tui.editor.cursorWordRight": {
|
||||
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
|
||||
description: "Move cursor word right",
|
||||
},
|
||||
"tui.editor.cursorLineStart": {
|
||||
defaultKeys: ["home", "ctrl+home", "ctrl+a"],
|
||||
description: "Move to line start",
|
||||
},
|
||||
"tui.editor.cursorLineEnd": {
|
||||
defaultKeys: ["end", "ctrl+end", "ctrl+e"],
|
||||
description: "Move to line end",
|
||||
},
|
||||
"tui.editor.jumpForward": {
|
||||
defaultKeys: "ctrl+]",
|
||||
description: "Jump forward to character",
|
||||
},
|
||||
"tui.editor.jumpBackward": {
|
||||
defaultKeys: "ctrl+alt+]",
|
||||
description: "Jump backward to character",
|
||||
},
|
||||
"tui.editor.pageUp": { defaultKeys: ["pageUp", "ctrl+pageUp"], description: "Page up" },
|
||||
"tui.editor.pageDown": { defaultKeys: ["pageDown", "ctrl+pageDown"], description: "Page down" },
|
||||
"tui.editor.deleteCharBackward": {
|
||||
defaultKeys: "backspace",
|
||||
description: "Delete character backward",
|
||||
},
|
||||
"tui.editor.deleteCharForward": {
|
||||
defaultKeys: ["delete", "ctrl+d"],
|
||||
description: "Delete character forward",
|
||||
},
|
||||
"tui.editor.deleteWordBackward": {
|
||||
defaultKeys: ["ctrl+w", "alt+backspace"],
|
||||
description: "Delete word backward",
|
||||
},
|
||||
"tui.editor.deleteWordForward": {
|
||||
defaultKeys: ["alt+d", "alt+delete"],
|
||||
description: "Delete word forward",
|
||||
},
|
||||
"tui.editor.deleteToLineStart": {
|
||||
defaultKeys: "ctrl+u",
|
||||
description: "Delete to line start",
|
||||
},
|
||||
"tui.editor.deleteToLineEnd": {
|
||||
defaultKeys: "ctrl+k",
|
||||
description: "Delete to line end",
|
||||
},
|
||||
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
|
||||
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
|
||||
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
|
||||
"tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
|
||||
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
|
||||
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
|
||||
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
|
||||
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
|
||||
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
|
||||
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
|
||||
"tui.select.pageDown": {
|
||||
defaultKeys: "pageDown",
|
||||
description: "Selection page down",
|
||||
},
|
||||
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
|
||||
"tui.select.cancel": {
|
||||
defaultKeys: ["escape", "ctrl+c"],
|
||||
description: "Cancel selection",
|
||||
},
|
||||
// These intentionally shadow the unmodified editor bindings in fullscreen mode.
|
||||
"tui.altScreen.pageUp": {
|
||||
defaultKeys: "pageUp",
|
||||
description: "Scroll viewport up one page",
|
||||
},
|
||||
"tui.altScreen.pageDown": {
|
||||
defaultKeys: "pageDown",
|
||||
description: "Scroll viewport down one page",
|
||||
},
|
||||
"tui.altScreen.halfPageUp": {
|
||||
defaultKeys: [],
|
||||
description: "Scroll viewport up half a page",
|
||||
},
|
||||
"tui.altScreen.halfPageDown": {
|
||||
defaultKeys: [],
|
||||
description: "Scroll viewport down half a page",
|
||||
},
|
||||
"tui.altScreen.lineUp": {
|
||||
defaultKeys: [],
|
||||
description: "Scroll viewport up one line",
|
||||
},
|
||||
"tui.altScreen.lineDown": {
|
||||
defaultKeys: [],
|
||||
description: "Scroll viewport down one line",
|
||||
},
|
||||
"tui.altScreen.previousPrompt": {
|
||||
defaultKeys: ["ctrl+shift+up", "ctrl+up"],
|
||||
description: "Jump to previous semantic prompt",
|
||||
},
|
||||
"tui.altScreen.nextPrompt": {
|
||||
defaultKeys: ["ctrl+shift+down", "ctrl+down"],
|
||||
description: "Jump to next semantic prompt",
|
||||
},
|
||||
"tui.altScreen.search": {
|
||||
defaultKeys: "ctrl+shift+f",
|
||||
description: "Search the primary scroll view",
|
||||
},
|
||||
"tui.altScreen.searchNext": {
|
||||
defaultKeys: ["enter", "ctrl+g"],
|
||||
description: "Select the next search match",
|
||||
},
|
||||
"tui.altScreen.searchPrevious": {
|
||||
defaultKeys: ["shift+enter", "ctrl+shift+g"],
|
||||
description: "Select the previous search match",
|
||||
},
|
||||
"tui.altScreen.searchClose": {
|
||||
defaultKeys: "escape",
|
||||
description: "Close transcript search",
|
||||
},
|
||||
"tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" },
|
||||
"tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" },
|
||||
} as const satisfies KeybindingDefinitions;
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: KeyId;
|
||||
keybindings: string[];
|
||||
}
|
||||
|
||||
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
||||
if (keys === undefined) return [];
|
||||
const keyList = Array.isArray(keys) ? keys : [keys];
|
||||
const seen = new Set<KeyId>();
|
||||
const result: KeyId[] = [];
|
||||
for (const key of keyList) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class KeybindingsManager {
|
||||
private definitions: KeybindingDefinitions;
|
||||
private userBindings: KeybindingsConfig;
|
||||
private keysById = new Map<Keybinding, KeyId[]>();
|
||||
private conflicts: KeybindingConflict[] = [];
|
||||
|
||||
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
|
||||
this.definitions = definitions;
|
||||
this.userBindings = userBindings;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.keysById.clear();
|
||||
this.conflicts = [];
|
||||
|
||||
const userClaims = new Map<KeyId, Set<Keybinding>>();
|
||||
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
|
||||
if (!(keybinding in this.definitions)) continue;
|
||||
for (const key of normalizeKeys(keys)) {
|
||||
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
|
||||
claimants.add(keybinding as Keybinding);
|
||||
userClaims.set(key, claimants);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, keybindings] of userClaims) {
|
||||
if (keybindings.size > 1) {
|
||||
this.conflicts.push({ key, keybindings: [...keybindings] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, definition] of Object.entries(this.definitions)) {
|
||||
const userKeys = this.userBindings[id];
|
||||
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
|
||||
this.keysById.set(id as Keybinding, keys);
|
||||
}
|
||||
}
|
||||
|
||||
matches(data: string, keybinding: Keybinding): boolean {
|
||||
const keys = this.keysById.get(keybinding) ?? [];
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getKeys(keybinding: Keybinding): KeyId[] {
|
||||
return [...(this.keysById.get(keybinding) ?? [])];
|
||||
}
|
||||
|
||||
getDefinition(keybinding: Keybinding): KeybindingDefinition {
|
||||
return this.definitions[keybinding];
|
||||
}
|
||||
|
||||
getConflicts(): KeybindingConflict[] {
|
||||
return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
|
||||
}
|
||||
|
||||
setUserBindings(userBindings: KeybindingsConfig): void {
|
||||
this.userBindings = userBindings;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
getUserBindings(): KeybindingsConfig {
|
||||
return { ...this.userBindings };
|
||||
}
|
||||
|
||||
getResolvedBindings(): KeybindingsConfig {
|
||||
const resolved: KeybindingsConfig = {};
|
||||
for (const id of Object.keys(this.definitions)) {
|
||||
const keys = this.keysById.get(id as Keybinding) ?? [];
|
||||
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
let globalKeybindings: KeybindingsManager | null = null;
|
||||
|
||||
export function setKeybindings(keybindings: KeybindingsManager): void {
|
||||
globalKeybindings = keybindings;
|
||||
}
|
||||
|
||||
export function getKeybindings(): KeybindingsManager {
|
||||
if (!globalKeybindings) {
|
||||
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
}
|
||||
return globalKeybindings;
|
||||
}
|
||||
1401
third_party/pi/packages/tui/src/keys.ts
vendored
Normal file
1401
third_party/pi/packages/tui/src/keys.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
46
third_party/pi/packages/tui/src/kill-ring.ts
vendored
Normal file
46
third_party/pi/packages/tui/src/kill-ring.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Ring buffer for Emacs-style kill/yank operations.
|
||||
*
|
||||
* Tracks killed (deleted) text entries. Consecutive kills can accumulate
|
||||
* into a single entry. Supports yank (paste most recent) and yank-pop
|
||||
* (cycle through older entries).
|
||||
*/
|
||||
export class KillRing {
|
||||
private ring: string[] = [];
|
||||
|
||||
/**
|
||||
* Add text to the kill ring.
|
||||
*
|
||||
* @param text - The killed text to add
|
||||
* @param opts - Push options
|
||||
* @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
|
||||
* @param opts.accumulate - Merge with the most recent entry instead of creating a new one
|
||||
*/
|
||||
push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
|
||||
if (!text) return;
|
||||
|
||||
if (opts.accumulate && this.ring.length > 0) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.push(opts.prepend ? text + last : last + text);
|
||||
} else {
|
||||
this.ring.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get most recent entry without modifying the ring. */
|
||||
peek(): string | undefined {
|
||||
return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined;
|
||||
}
|
||||
|
||||
/** Move last entry to front (for yank-pop cycling). */
|
||||
rotate(): void {
|
||||
if (this.ring.length > 1) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.unshift(last);
|
||||
}
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.ring.length;
|
||||
}
|
||||
}
|
||||
1394
third_party/pi/packages/tui/src/latex.ts
vendored
Normal file
1394
third_party/pi/packages/tui/src/latex.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
51
third_party/pi/packages/tui/src/layout-node.ts
vendored
Normal file
51
third_party/pi/packages/tui/src/layout-node.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Component } from "./tui.ts";
|
||||
|
||||
export const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
||||
|
||||
export interface LayoutViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface StackLayoutEntry {
|
||||
component: Component;
|
||||
basis?: number | "auto";
|
||||
grow?: number;
|
||||
shrink?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
visible?: (viewport: LayoutViewport) => boolean;
|
||||
}
|
||||
|
||||
export interface StackLayoutNode {
|
||||
type: "vstack" | "hstack";
|
||||
entries: readonly StackLayoutEntry[];
|
||||
gap: number;
|
||||
align: "stretch" | "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export interface ScrollLayoutState {
|
||||
readonly scrollTop: number;
|
||||
readonly primary: boolean;
|
||||
readonly overscroll: "chain" | "contain";
|
||||
readonly viewportHeight: number;
|
||||
getContentWidth(width: number): number;
|
||||
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void;
|
||||
}
|
||||
|
||||
export interface ScrollLayoutNode {
|
||||
type: "scroll";
|
||||
component: Component;
|
||||
state: ScrollLayoutState;
|
||||
}
|
||||
|
||||
export type LayoutNode = StackLayoutNode | ScrollLayoutNode;
|
||||
|
||||
export interface LayoutComponent extends Component {
|
||||
[LAYOUT_NODE](): LayoutNode;
|
||||
}
|
||||
|
||||
export function getLayoutNode(component: Component): LayoutNode | undefined {
|
||||
const candidate = component as Partial<LayoutComponent>;
|
||||
return typeof candidate[LAYOUT_NODE] === "function" ? candidate[LAYOUT_NODE]() : undefined;
|
||||
}
|
||||
449
third_party/pi/packages/tui/src/layout.ts
vendored
Normal file
449
third_party/pi/packages/tui/src/layout.ts
vendored
Normal file
@@ -0,0 +1,449 @@
|
||||
import type { ScrollView } from "./components/scroll-view.ts";
|
||||
import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts";
|
||||
import { getLayoutNode } from "./layout-node.ts";
|
||||
import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
|
||||
import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts";
|
||||
import {
|
||||
extractAnsiCode,
|
||||
getActiveBackgroundAnsi,
|
||||
getGraphemeCellRange,
|
||||
sliceByColumn,
|
||||
visibleWidth,
|
||||
} from "./utils.ts";
|
||||
|
||||
const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
|
||||
|
||||
export interface LayoutRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LayoutBox {
|
||||
component: Component;
|
||||
rect: LayoutRect;
|
||||
clip: LayoutRect;
|
||||
children: LayoutBox[];
|
||||
parent?: LayoutBox;
|
||||
lines?: readonly string[];
|
||||
lineOffset?: number;
|
||||
scrollView?: ScrollView;
|
||||
scrollContentLines?: readonly string[];
|
||||
layer: number;
|
||||
}
|
||||
|
||||
export interface LayoutFrame {
|
||||
root: LayoutBox;
|
||||
width: number;
|
||||
height: number;
|
||||
lines: string[];
|
||||
primaryScrollView?: ScrollView;
|
||||
}
|
||||
|
||||
export interface ScrollbarGeometry {
|
||||
column: number;
|
||||
trackTop: number;
|
||||
trackHeight: number;
|
||||
thumbTop: number;
|
||||
thumbHeight: number;
|
||||
maxScrollTop: number;
|
||||
}
|
||||
|
||||
interface LayoutContext {
|
||||
viewport: { width: number; height: number };
|
||||
renderCache: Map<Component, Map<number, string[]>>;
|
||||
requestRender: () => void;
|
||||
primaryScrollView: ScrollView | undefined;
|
||||
}
|
||||
|
||||
function intersect(a: LayoutRect, b: LayoutRect): LayoutRect {
|
||||
const x = Math.max(a.x, b.x);
|
||||
const y = Math.max(a.y, b.y);
|
||||
const right = Math.min(a.x + a.width, b.x + b.width);
|
||||
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
||||
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
|
||||
}
|
||||
|
||||
function renderCached(context: LayoutContext, component: Component, width: number): string[] {
|
||||
const safeWidth = Math.max(1, Math.floor(width));
|
||||
let widths = context.renderCache.get(component);
|
||||
if (!widths) {
|
||||
widths = new Map<number, string[]>();
|
||||
context.renderCache.set(component, widths);
|
||||
}
|
||||
let lines = widths.get(safeWidth);
|
||||
if (!lines) {
|
||||
lines = component.render(safeWidth);
|
||||
widths.set(safeWidth, lines);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function measureHeight(context: LayoutContext, component: Component, width: number): number {
|
||||
return renderCached(context, component, width).length;
|
||||
}
|
||||
|
||||
function measureWidth(context: LayoutContext, component: Component, width: number): number {
|
||||
return renderCached(context, component, width).reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||||
}
|
||||
|
||||
function withParent(box: LayoutBox, parent: LayoutBox): LayoutBox {
|
||||
box.parent = parent;
|
||||
return box;
|
||||
}
|
||||
|
||||
function translateBox(box: LayoutBox, deltaY: number): void {
|
||||
box.rect.y += deltaY;
|
||||
for (const child of box.children) translateBox(child, deltaY);
|
||||
}
|
||||
|
||||
function updateClips(box: LayoutBox, parentClip: LayoutRect): void {
|
||||
box.clip = intersect(parentClip, box.rect);
|
||||
for (const child of box.children) updateClips(child, box.clip);
|
||||
}
|
||||
|
||||
function layoutComponent(
|
||||
context: LayoutContext,
|
||||
component: Component,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number | undefined,
|
||||
clip: LayoutRect,
|
||||
): LayoutBox {
|
||||
const safeWidth = Math.max(1, Math.floor(width));
|
||||
const node = getLayoutNode(component);
|
||||
if (!node) {
|
||||
const lines = renderCached(context, component, safeWidth);
|
||||
const allocatedHeight = height === undefined ? lines.length : Math.max(0, Math.floor(height));
|
||||
let lineOffset = 0;
|
||||
if (lines.length > allocatedHeight && allocatedHeight > 0) {
|
||||
const cursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER));
|
||||
if (cursorLine >= allocatedHeight) lineOffset = cursorLine - allocatedHeight + 1;
|
||||
}
|
||||
return {
|
||||
component,
|
||||
rect: { x, y, width: safeWidth, height: allocatedHeight },
|
||||
clip: intersect(clip, { x, y, width: safeWidth, height: allocatedHeight }),
|
||||
children: [],
|
||||
lines,
|
||||
lineOffset,
|
||||
layer: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === "scroll") {
|
||||
const previousScrollTop = node.state.scrollTop;
|
||||
const contentWidth = node.state.getContentWidth(safeWidth);
|
||||
const childBox = layoutComponent(
|
||||
context,
|
||||
node.component,
|
||||
x,
|
||||
y - previousScrollTop,
|
||||
contentWidth,
|
||||
undefined,
|
||||
clip,
|
||||
);
|
||||
const contentHeight = childBox.rect.height;
|
||||
const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));
|
||||
node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);
|
||||
translateBox(childBox, previousScrollTop - node.state.scrollTop);
|
||||
const scrollView = node.state as ScrollView;
|
||||
if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView;
|
||||
const rect = { x, y, width: safeWidth, height: viewportHeight };
|
||||
const childClip = intersect(clip, rect);
|
||||
const box: LayoutBox = {
|
||||
component,
|
||||
rect,
|
||||
clip: childClip,
|
||||
children: [childBox],
|
||||
scrollView,
|
||||
scrollContentLines: renderCached(context, node.component, contentWidth),
|
||||
layer: 0,
|
||||
};
|
||||
childBox.parent = box;
|
||||
updateClips(childBox, childClip);
|
||||
return box;
|
||||
}
|
||||
|
||||
const entries = visibleStackEntries(node.entries, context.viewport);
|
||||
const gapTotal = Math.max(0, entries.length - 1) * node.gap;
|
||||
if (node.type === "vstack") {
|
||||
const intrinsicHeights = entries.map((entry) =>
|
||||
typeof entry.basis === "number" ? entry.basis : measureHeight(context, entry.component, safeWidth),
|
||||
);
|
||||
const sizes = allocateStackSizes(entries, intrinsicHeights, height, node.gap);
|
||||
const naturalHeight = sizes.reduce((sum, size) => sum + size, 0) + gapTotal;
|
||||
const allocatedHeight = height === undefined ? naturalHeight : Math.max(0, Math.floor(height));
|
||||
const rect = { x, y, width: safeWidth, height: allocatedHeight };
|
||||
const box: LayoutBox = {
|
||||
component,
|
||||
rect,
|
||||
clip: intersect(clip, rect),
|
||||
children: [],
|
||||
layer: 0,
|
||||
};
|
||||
let childY = y;
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
box.children.push(
|
||||
withParent(
|
||||
layoutComponent(context, entries[index]!.component, x, childY, safeWidth, sizes[index]!, box.clip),
|
||||
box,
|
||||
),
|
||||
);
|
||||
childY += sizes[index]! + node.gap;
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
const intrinsicWidths = entries.map((entry) =>
|
||||
typeof entry.basis === "number" ? entry.basis : measureWidth(context, entry.component, safeWidth),
|
||||
);
|
||||
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, node.gap);
|
||||
const intrinsicHeights = entries.map((entry, index) =>
|
||||
measureHeight(context, entry.component, Math.max(1, widths[index]!)),
|
||||
);
|
||||
const allocatedHeight =
|
||||
height === undefined
|
||||
? intrinsicHeights.reduce((max, childHeight) => Math.max(max, childHeight), 0)
|
||||
: Math.max(0, height);
|
||||
const rect = { x, y, width: safeWidth, height: allocatedHeight };
|
||||
const box: LayoutBox = {
|
||||
component,
|
||||
rect,
|
||||
clip: intersect(clip, rect),
|
||||
children: [],
|
||||
layer: 0,
|
||||
};
|
||||
let childX = x;
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const naturalChildHeight = intrinsicHeights[index]!;
|
||||
const childHeight = node.align === "stretch" ? allocatedHeight : Math.min(allocatedHeight, naturalChildHeight);
|
||||
let childY = y;
|
||||
if (node.align === "center") childY += Math.floor((allocatedHeight - childHeight) / 2);
|
||||
else if (node.align === "end") childY += allocatedHeight - childHeight;
|
||||
const childWidth = widths[index]!;
|
||||
if (childWidth === 0) {
|
||||
box.children.push({
|
||||
component: entries[index]!.component,
|
||||
rect: { x: childX, y: childY, width: 0, height: childHeight },
|
||||
clip: { x: childX, y: childY, width: 0, height: 0 },
|
||||
children: [],
|
||||
parent: box,
|
||||
layer: 0,
|
||||
});
|
||||
} else {
|
||||
box.children.push(
|
||||
withParent(
|
||||
layoutComponent(context, entries[index]!.component, childX, childY, childWidth, childHeight, box.clip),
|
||||
box,
|
||||
),
|
||||
);
|
||||
}
|
||||
childX += childWidth + node.gap;
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function replaceScrollbarCell(
|
||||
line: string,
|
||||
column: number,
|
||||
totalWidth: number,
|
||||
replacement: string,
|
||||
preserveTargetBackground: boolean,
|
||||
): string {
|
||||
if (isImageLine(line)) return line;
|
||||
|
||||
const graphemeRange = getGraphemeCellRange(line, column);
|
||||
const start = graphemeRange?.start ?? column;
|
||||
const end = graphemeRange?.end ?? column + 1;
|
||||
const before = sliceByColumn(line, 0, start, true);
|
||||
const target = sliceByColumn(line, start, end - start, true);
|
||||
const after = sliceByColumn(line, end, Math.max(0, totalWidth - end), true);
|
||||
|
||||
let targetPrefix = "";
|
||||
let targetIndex = 0;
|
||||
while (targetIndex < target.length) {
|
||||
const ansi = extractAnsiCode(target, targetIndex);
|
||||
if (!ansi) break;
|
||||
targetPrefix += ansi.code;
|
||||
targetIndex += ansi.length;
|
||||
}
|
||||
const beforePadding = " ".repeat(Math.max(0, start - visibleWidth(before)));
|
||||
const cellPaddingBefore = " ".repeat(Math.max(0, column - start));
|
||||
const cellPaddingAfter = " ".repeat(Math.max(0, end - column - 1));
|
||||
const targetStyle = `\x1b[0m\x1b]8;;\x07${preserveTargetBackground ? getActiveBackgroundAnsi(targetPrefix) : ""}`;
|
||||
return `${before}${beforePadding}${targetStyle}${cellPaddingBefore}${replacement}${cellPaddingAfter}${after}`;
|
||||
}
|
||||
|
||||
export function getScrollbarGeometry(box: LayoutBox, includeHiddenAuto = false): ScrollbarGeometry | undefined {
|
||||
if (!box.scrollView || box.rect.width <= 0 || box.rect.height <= 0) return undefined;
|
||||
|
||||
const contentHeight = box.children[0]?.rect.height ?? box.scrollContentLines?.length ?? 0;
|
||||
const trackHeight = box.rect.height;
|
||||
const canRevealHiddenAuto = includeHiddenAuto && box.scrollView.scrollbar === "auto" && contentHeight > trackHeight;
|
||||
if (!box.scrollView.isScrollbarVisible && !canRevealHiddenAuto) return undefined;
|
||||
|
||||
const minThumbHeight = Math.min(2, trackHeight);
|
||||
const thumbHeight = Math.max(
|
||||
minThumbHeight,
|
||||
Math.min(trackHeight, Math.round((trackHeight * trackHeight) / contentHeight)),
|
||||
);
|
||||
const maxScrollTop = Math.max(0, contentHeight - trackHeight);
|
||||
const maxThumbTop = trackHeight - thumbHeight;
|
||||
const thumbOffset = maxScrollTop === 0 ? 0 : Math.round((box.scrollView.scrollTop / maxScrollTop) * maxThumbTop);
|
||||
const column = box.rect.x + box.rect.width - 1;
|
||||
if (column < box.clip.x || column >= box.clip.x + box.clip.width) return undefined;
|
||||
|
||||
return {
|
||||
column,
|
||||
trackTop: box.rect.y,
|
||||
trackHeight,
|
||||
thumbTop: box.rect.y + thumbOffset,
|
||||
thumbHeight,
|
||||
maxScrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): void {
|
||||
const geometry = getScrollbarGeometry(box);
|
||||
if (!geometry || !box.scrollView) return;
|
||||
|
||||
for (let offset = 0; offset < geometry.trackHeight; offset++) {
|
||||
const row = geometry.trackTop + offset;
|
||||
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
|
||||
const isThumb = row >= geometry.thumbTop && row < geometry.thumbTop + geometry.thumbHeight;
|
||||
const replacement = isThumb
|
||||
? box.scrollView.scrollbarThumbStyle(box.scrollView.isScrollbarActive ? "█" : "┃")
|
||||
: box.scrollView.scrollbarTrackStyle("│");
|
||||
screen[row] = replaceScrollbarCell(
|
||||
screen[row] ?? "",
|
||||
geometry.column,
|
||||
totalWidth,
|
||||
replacement,
|
||||
box.scrollView.scrollbar !== "always",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
|
||||
if (box.lines) {
|
||||
const offset = box.lineOffset ?? 0;
|
||||
const firstRow = Math.max(box.rect.y, box.clip.y, 0);
|
||||
const lastRow = Math.min(box.rect.y + box.rect.height, box.clip.y + box.clip.height, screen.length);
|
||||
for (let row = firstRow; row < lastRow; row++) {
|
||||
const sourceLine = box.lines[offset + row - box.rect.y];
|
||||
if (sourceLine === undefined) continue;
|
||||
let line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
|
||||
const imageMetadata = getKittyImageMetadata(line);
|
||||
if (imageMetadata) {
|
||||
const clipBottom = Math.min(screen.length, box.clip.y + box.clip.height);
|
||||
const visibleRows = Math.min(imageMetadata.rows, clipBottom - row);
|
||||
if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows);
|
||||
}
|
||||
// Fast path: a full-width box painting onto an untouched row can use the
|
||||
// source line reference directly. Compositing here would rebuild the row
|
||||
// string through ANSI/grapheme segmentation every frame; padding is
|
||||
// unnecessary because rows are written with erase-line and the final
|
||||
// width clamp still truncates over-wide lines.
|
||||
if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) {
|
||||
screen[row] = line;
|
||||
} else {
|
||||
screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of box.children) paintBox(child, screen, totalWidth);
|
||||
|
||||
if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) {
|
||||
for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) {
|
||||
const imageLine = box.scrollContentLines[imageRow] ?? "";
|
||||
const metadata = getKittyImageMetadata(imageLine);
|
||||
if (metadata) {
|
||||
const hiddenRows = box.scrollView.scrollTop - imageRow;
|
||||
if (hiddenRows < metadata.rows) {
|
||||
const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows);
|
||||
const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
|
||||
if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (imageLine !== "") break;
|
||||
}
|
||||
}
|
||||
|
||||
paintScrollbar(box, screen, totalWidth);
|
||||
}
|
||||
|
||||
export function renderLayoutFrame(
|
||||
root: Component,
|
||||
width: number,
|
||||
height: number,
|
||||
requestRender: () => void,
|
||||
): LayoutFrame {
|
||||
const safeWidth = Math.max(1, Math.floor(width));
|
||||
const safeHeight = Math.max(1, Math.floor(height));
|
||||
const context: LayoutContext = {
|
||||
viewport: { width: safeWidth, height: safeHeight },
|
||||
renderCache: new Map(),
|
||||
requestRender,
|
||||
primaryScrollView: undefined,
|
||||
};
|
||||
const rootBox = layoutComponent(context, root, 0, 0, safeWidth, safeHeight, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
});
|
||||
const lines = Array.from({ length: safeHeight }, () => "");
|
||||
paintBox(rootBox, lines, safeWidth);
|
||||
return {
|
||||
root: rootBox,
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
lines,
|
||||
...(context.primaryScrollView === undefined ? {} : { primaryScrollView: context.primaryScrollView }),
|
||||
};
|
||||
}
|
||||
|
||||
function containsPoint(rect: LayoutRect, x: number, y: number): boolean {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
|
||||
/** Return the visual hit path from the deepest component to the layout root. */
|
||||
export function getLayoutBoxesAt(frame: LayoutFrame, x: number, y: number): LayoutBox[] {
|
||||
const result: Array<{ box: LayoutBox; depth: number }> = [];
|
||||
const visit = (box: LayoutBox, depth: number): void => {
|
||||
if (!containsPoint(box.clip, x, y)) return;
|
||||
result.push({ box, depth });
|
||||
for (const child of box.children) visit(child, depth + 1);
|
||||
};
|
||||
visit(frame.root, 0);
|
||||
result.sort((a, b) => b.box.layer - a.box.layer || b.depth - a.depth);
|
||||
return result.map(({ box }) => box);
|
||||
}
|
||||
|
||||
export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined {
|
||||
const visit = (box: LayoutBox): LayoutBox | undefined => {
|
||||
if (box.scrollView === scrollView) return box;
|
||||
for (const child of box.children) {
|
||||
const match = visit(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return visit(frame.root);
|
||||
}
|
||||
|
||||
export function getScrollViewsAt(frame: LayoutFrame, x: number, y: number): ScrollView[] {
|
||||
const result: Array<{ scrollView: ScrollView; depth: number }> = [];
|
||||
const visit = (box: LayoutBox, depth: number): void => {
|
||||
if (!containsPoint(box.clip, x, y)) return;
|
||||
if (box.scrollView && containsPoint(box.rect, x, y)) result.push({ scrollView: box.scrollView, depth });
|
||||
for (const child of box.children) visit(child, depth + 1);
|
||||
};
|
||||
visit(frame.root, 0);
|
||||
result.sort((a, b) => b.depth - a.depth);
|
||||
return result.map((entry) => entry.scrollView);
|
||||
}
|
||||
59
third_party/pi/packages/tui/src/native-modifiers.ts
vendored
Normal file
59
third_party/pi/packages/tui/src/native-modifiers.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { getNativeModuleCandidates } from "./native-module-path.ts";
|
||||
|
||||
const cjsRequire = createRequire(import.meta.url);
|
||||
|
||||
export type ModifierKey = "shift" | "command" | "control" | "option";
|
||||
|
||||
type NativeModifiersHelper = {
|
||||
isModifierPressed: (name: ModifierKey) => boolean;
|
||||
};
|
||||
|
||||
let nativeModifiersHelper: NativeModifiersHelper | null | undefined;
|
||||
|
||||
function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed;
|
||||
return typeof candidate === "function";
|
||||
}
|
||||
|
||||
function loadNativeModifiersHelper(): NativeModifiersHelper | undefined {
|
||||
if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined;
|
||||
nativeModifiersHelper = null;
|
||||
const arch = process.arch;
|
||||
if (arch !== "x64" && arch !== "arm64") return undefined;
|
||||
|
||||
let nativePath: string;
|
||||
if (process.platform === "darwin") {
|
||||
nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node");
|
||||
} else if (process.platform === "win32") {
|
||||
nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const modulePath of getNativeModuleCandidates(nativePath)) {
|
||||
try {
|
||||
const helper = cjsRequire(modulePath) as unknown;
|
||||
if (isNativeModifiersHelper(helper)) {
|
||||
nativeModifiersHelper = helper;
|
||||
return helper;
|
||||
}
|
||||
} catch {
|
||||
// Try the next possible packaging location.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isNativeModifierPressed(key: ModifierKey): boolean {
|
||||
const helper = loadNativeModifiersHelper();
|
||||
if (!helper) return false;
|
||||
try {
|
||||
return helper.isModifierPressed(key) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
31
third_party/pi/packages/tui/src/native-module-path.ts
vendored
Normal file
31
third_party/pi/packages/tui/src/native-module-path.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const moduleRequire = createRequire(import.meta.url);
|
||||
const TUI_PACKAGE_NAME = "@earendil-works/pi-tui";
|
||||
|
||||
export interface NativeModuleCandidateOptions {
|
||||
moduleUrl?: string;
|
||||
execPath?: string;
|
||||
resolvePackage?: (specifier: string) => string;
|
||||
}
|
||||
|
||||
export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] {
|
||||
const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url));
|
||||
const candidates: string[] = [];
|
||||
|
||||
try {
|
||||
const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME);
|
||||
candidates.push(join(dirname(packageEntry), "..", nativePath));
|
||||
} catch {
|
||||
// Standalone binaries do not have an installed TUI package.
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
join(moduleDir, "..", nativePath),
|
||||
join(moduleDir, nativePath),
|
||||
join(dirname(options.execPath ?? process.execPath), nativePath),
|
||||
);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
444
third_party/pi/packages/tui/src/stdin-buffer.ts
vendored
Normal file
444
third_party/pi/packages/tui/src/stdin-buffer.ts
vendored
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* StdinBuffer buffers input and emits complete sequences.
|
||||
*
|
||||
* This is necessary because stdin data events can arrive in partial chunks,
|
||||
* especially for escape sequences like mouse events. Without buffering,
|
||||
* partial sequences can be misinterpreted as regular keypresses.
|
||||
*
|
||||
* For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
|
||||
* - Event 1: `\x1b`
|
||||
* - Event 2: `[<35`
|
||||
* - Event 3: `;20;5m`
|
||||
*
|
||||
* The buffer accumulates these until a complete sequence is detected.
|
||||
* Call the `process()` method to feed input data.
|
||||
*
|
||||
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
||||
* MIT License - Copyright (c) 2025 opentui
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const DEFAULT_SEQUENCE_TIMEOUT_MS = 50;
|
||||
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
|
||||
/**
|
||||
* Check if a string is a complete escape sequence or needs more data
|
||||
*/
|
||||
function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
|
||||
if (!data.startsWith(ESC)) {
|
||||
return "not-escape";
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
const afterEsc = data.slice(1);
|
||||
|
||||
// CSI sequences: ESC [
|
||||
if (afterEsc.startsWith("[")) {
|
||||
// Check for old-style mouse sequence: ESC[M + 3 bytes
|
||||
if (afterEsc.startsWith("[M")) {
|
||||
// Old-style mouse needs ESC[M + 3 bytes = 6 total
|
||||
return data.length >= 6 ? "complete" : "incomplete";
|
||||
}
|
||||
return isCompleteCsiSequence(data);
|
||||
}
|
||||
|
||||
// OSC sequences: ESC ]
|
||||
if (afterEsc.startsWith("]")) {
|
||||
return isCompleteOscSequence(data);
|
||||
}
|
||||
|
||||
// DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
|
||||
if (afterEsc.startsWith("P")) {
|
||||
return isCompleteDcsSequence(data);
|
||||
}
|
||||
|
||||
// APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
|
||||
if (afterEsc.startsWith("_")) {
|
||||
return isCompleteApcSequence(data);
|
||||
}
|
||||
|
||||
// SS3 sequences: ESC O
|
||||
if (afterEsc.startsWith("O")) {
|
||||
// ESC O followed by a single character
|
||||
return afterEsc.length >= 2 ? "complete" : "incomplete";
|
||||
}
|
||||
|
||||
// Meta key sequences: ESC followed by a single character
|
||||
if (afterEsc.length === 1) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
// Unknown escape sequence - treat as complete
|
||||
return "complete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CSI sequence is complete
|
||||
* CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
|
||||
*/
|
||||
function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
|
||||
if (!data.startsWith(`${ESC}[`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
// Need at least ESC [ and one more character
|
||||
if (data.length < 3) {
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
const payload = data.slice(2);
|
||||
|
||||
// CSI sequences end with a byte in the range 0x40-0x7E (@-~)
|
||||
// This includes all letters and several special characters
|
||||
const lastChar = payload[payload.length - 1];
|
||||
const lastCharCode = lastChar.charCodeAt(0);
|
||||
|
||||
if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
|
||||
// Special handling for SGR mouse sequences
|
||||
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
|
||||
if (payload.startsWith("<")) {
|
||||
// Must have format: <digits;digits;digits[Mm]
|
||||
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
|
||||
if (mouseMatch) {
|
||||
return "complete";
|
||||
}
|
||||
// If it ends with M or m but doesn't match the pattern, still incomplete
|
||||
if (lastChar === "M" || lastChar === "m") {
|
||||
// Check if we have the right structure
|
||||
const parts = payload.slice(1, -1).split(";");
|
||||
if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
|
||||
return "complete";
|
||||
}
|
||||
}
|
||||
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
return "complete";
|
||||
}
|
||||
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OSC sequence is complete
|
||||
* OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
|
||||
*/
|
||||
function isCompleteOscSequence(data: string): "complete" | "incomplete" {
|
||||
if (!data.startsWith(`${ESC}]`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
// OSC sequences end with ST (ESC \) or BEL (\x07)
|
||||
if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DCS (Device Control String) sequence is complete
|
||||
* DCS sequences: ESC P ... ST (where ST is ESC \)
|
||||
* Used for XTVersion responses like ESC P >| ... ESC \
|
||||
*/
|
||||
function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
|
||||
if (!data.startsWith(`${ESC}P`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
// DCS sequences end with ST (ESC \)
|
||||
if (data.endsWith(`${ESC}\\`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if APC (Application Program Command) sequence is complete
|
||||
* APC sequences: ESC _ ... ST (where ST is ESC \)
|
||||
* Used for Kitty graphics responses like ESC _ G ... ESC \
|
||||
*/
|
||||
function isCompleteApcSequence(data: string): "complete" | "incomplete" {
|
||||
if (!data.startsWith(`${ESC}_`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
// APC sequences end with ST (ESC \)
|
||||
if (data.endsWith(`${ESC}\\`)) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Split accumulated buffer into complete sequences
|
||||
*/
|
||||
function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
|
||||
const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const codepoint = parseInt(match[1]!, 10);
|
||||
return codepoint >= 32 ? codepoint : undefined;
|
||||
}
|
||||
|
||||
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
|
||||
const sequences: string[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
const remaining = buffer.slice(pos);
|
||||
|
||||
// Try to extract a sequence starting at this position
|
||||
if (remaining.startsWith(ESC)) {
|
||||
// Find the end of this escape sequence
|
||||
let seqEnd = 1;
|
||||
while (seqEnd <= remaining.length) {
|
||||
const candidate = remaining.slice(0, seqEnd);
|
||||
const status = isCompleteSequence(candidate);
|
||||
|
||||
if (status === "complete") {
|
||||
// WezTerm with enable_kitty_keyboard sends the Escape key press as a
|
||||
// raw '\x1b' byte (simple text path in encode_kitty, ignoring
|
||||
// DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u
|
||||
// sequence. These arrive concatenated as '\x1b\x1b[27;...u'.
|
||||
// The buffer would normally treat '\x1b\x1b' as a complete meta-key
|
||||
// sequence (ESC + single char), leaving '[27;...u' to be typed as
|
||||
// plain text. If the character immediately following '\x1b\x1b'
|
||||
// would begin a new escape sequence, emit only the first ESC and
|
||||
// restart from the second.
|
||||
if (candidate === "\x1b\x1b") {
|
||||
const nextChar = remaining[seqEnd];
|
||||
if (
|
||||
nextChar === "[" || // CSI
|
||||
nextChar === "]" || // OSC
|
||||
nextChar === "O" || // SS3
|
||||
nextChar === "P" || // DCS
|
||||
nextChar === "_" // APC
|
||||
) {
|
||||
sequences.push(ESC);
|
||||
pos += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
sequences.push(candidate);
|
||||
pos += seqEnd;
|
||||
break;
|
||||
} else if (status === "incomplete") {
|
||||
seqEnd++;
|
||||
} else {
|
||||
// Should not happen when starting with ESC
|
||||
sequences.push(candidate);
|
||||
pos += seqEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (seqEnd > remaining.length) {
|
||||
return { sequences, remainder: remaining };
|
||||
}
|
||||
} else {
|
||||
// Not an escape sequence - take a single character
|
||||
sequences.push(remaining[0]!);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
return { sequences, remainder: "" };
|
||||
}
|
||||
|
||||
export type StdinBufferOptions = {
|
||||
/**
|
||||
* Maximum time to wait for an incomplete sequence such as CSI or mouse
|
||||
* (default: 50ms).
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Maximum time to wait after a lone ESC before treating it as Escape
|
||||
* (default: 10ms). Increase for high-latency Alt+key input (SSH).
|
||||
*/
|
||||
escapeTimeout?: number;
|
||||
};
|
||||
|
||||
export type StdinBufferEventMap = {
|
||||
data: [string];
|
||||
paste: [string];
|
||||
};
|
||||
|
||||
/**
|
||||
* Buffers stdin input and emits complete sequences via the 'data' event.
|
||||
* Handles partial escape sequences that arrive across multiple chunks.
|
||||
*/
|
||||
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
||||
private buffer: string = "";
|
||||
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly escapeTimeoutMs: number;
|
||||
private pasteMode: boolean = false;
|
||||
private pasteBuffer: string = "";
|
||||
private pendingKittyPrintableCodepoint: number | undefined;
|
||||
|
||||
constructor(options: StdinBufferOptions = {}) {
|
||||
super();
|
||||
this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS;
|
||||
this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
public process(data: string | Buffer): void {
|
||||
// Clear any pending timeout
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
// Handle high-byte conversion (for compatibility with parseKeypress)
|
||||
// If buffer has single byte > 127, convert to ESC + (byte - 128)
|
||||
let str: string;
|
||||
if (Buffer.isBuffer(data)) {
|
||||
if (data.length === 1 && data[0]! > 127) {
|
||||
const byte = data[0]! - 128;
|
||||
str = `\x1b${String.fromCharCode(byte)}`;
|
||||
} else {
|
||||
str = data.toString();
|
||||
}
|
||||
} else {
|
||||
str = data;
|
||||
}
|
||||
|
||||
if (str.length === 0 && this.buffer.length === 0) {
|
||||
this.emitDataSequence("");
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffer += str;
|
||||
|
||||
if (this.pasteMode) {
|
||||
this.pasteBuffer += this.buffer;
|
||||
this.buffer = "";
|
||||
|
||||
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
||||
if (endIndex !== -1) {
|
||||
const pastedContent = this.pasteBuffer.slice(0, endIndex);
|
||||
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
||||
|
||||
this.pasteMode = false;
|
||||
this.pasteBuffer = "";
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
|
||||
this.emit("paste", pastedContent);
|
||||
|
||||
if (remaining.length > 0) {
|
||||
this.process(remaining);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START);
|
||||
if (startIndex !== -1) {
|
||||
if (startIndex > 0) {
|
||||
const beforePaste = this.buffer.slice(0, startIndex);
|
||||
const result = extractCompleteSequences(beforePaste);
|
||||
for (const sequence of result.sequences) {
|
||||
this.emitDataSequence(sequence);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length);
|
||||
this.pasteMode = true;
|
||||
this.pasteBuffer = this.buffer;
|
||||
this.buffer = "";
|
||||
|
||||
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
||||
if (endIndex !== -1) {
|
||||
const pastedContent = this.pasteBuffer.slice(0, endIndex);
|
||||
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
||||
|
||||
this.pasteMode = false;
|
||||
this.pasteBuffer = "";
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
|
||||
this.emit("paste", pastedContent);
|
||||
|
||||
if (remaining.length > 0) {
|
||||
this.process(remaining);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = extractCompleteSequences(this.buffer);
|
||||
this.buffer = result.remainder;
|
||||
|
||||
for (const sequence of result.sequences) {
|
||||
this.emitDataSequence(sequence);
|
||||
}
|
||||
|
||||
if (this.buffer.length > 0) {
|
||||
const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs;
|
||||
this.timeout = setTimeout(() => {
|
||||
const flushed = this.flush();
|
||||
|
||||
for (const sequence of flushed) {
|
||||
this.emitDataSequence(sequence);
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
private emitDataSequence(sequence: string): void {
|
||||
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
||||
if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) {
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
|
||||
this.emit("data", sequence);
|
||||
}
|
||||
|
||||
flush(): string[] {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
if (this.buffer.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sequences = [this.buffer];
|
||||
this.buffer = "";
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
return sequences;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
this.buffer = "";
|
||||
this.pasteMode = false;
|
||||
this.pasteBuffer = "";
|
||||
this.pendingKittyPrintableCodepoint = undefined;
|
||||
}
|
||||
|
||||
getBuffer(): string {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
73
third_party/pi/packages/tui/src/terminal-colors.ts
vendored
Normal file
73
third_party/pi/packages/tui/src/terminal-colors.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export type TerminalColorScheme = "dark" | "light";
|
||||
|
||||
function hexToRgb(hex: string): RgbColor {
|
||||
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function parseOscHexChannel(channel: string): number | undefined {
|
||||
if (!/^[0-9a-f]+$/i.test(channel)) {
|
||||
return undefined;
|
||||
}
|
||||
const max = 16 ** channel.length - 1;
|
||||
if (max <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round((parseInt(channel, 16) / max) * 255);
|
||||
}
|
||||
|
||||
const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
|
||||
const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/;
|
||||
|
||||
export function isOsc11BackgroundColorResponse(data: string): boolean {
|
||||
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
|
||||
}
|
||||
|
||||
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
|
||||
const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = match[1].trim();
|
||||
if (value.startsWith("#")) {
|
||||
const hex = value.slice(1);
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
return hexToRgb(value);
|
||||
}
|
||||
if (/^[0-9a-f]{12}$/i.test(hex)) {
|
||||
const r = parseOscHexChannel(hex.slice(0, 4));
|
||||
const g = parseOscHexChannel(hex.slice(4, 8));
|
||||
const b = parseOscHexChannel(hex.slice(8, 12));
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rgbValue = value.replace(/^rgba?:/i, "");
|
||||
const [red, green, blue] = rgbValue.split("/");
|
||||
if (red === undefined || green === undefined || blue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const r = parseOscHexChannel(red);
|
||||
const g = parseOscHexChannel(green);
|
||||
const b = parseOscHexChannel(blue);
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
|
||||
export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined {
|
||||
const match = data.match(COLOR_SCHEME_REPORT_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1] === "2" ? "light" : "dark";
|
||||
}
|
||||
696
third_party/pi/packages/tui/src/terminal-image.ts
vendored
Normal file
696
third_party/pi/packages/tui/src/terminal-image.ts
vendored
Normal file
@@ -0,0 +1,696 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export type ImageProtocol = "kitty" | "iterm2" | null;
|
||||
|
||||
export interface TerminalCapabilities {
|
||||
images: ImageProtocol;
|
||||
trueColor: boolean;
|
||||
hyperlinks: boolean;
|
||||
}
|
||||
|
||||
export interface CellDimensions {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
export interface ImageRenderOptions {
|
||||
maxWidthCells?: number;
|
||||
maxHeightCells?: number;
|
||||
preserveAspectRatio?: boolean;
|
||||
/** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
|
||||
imageId?: number;
|
||||
/** Whether Kitty should apply its default cursor movement after placement. */
|
||||
moveCursor?: boolean;
|
||||
}
|
||||
|
||||
let cachedCapabilities: TerminalCapabilities | null = null;
|
||||
let capabilityOverrides: Partial<TerminalCapabilities> = {};
|
||||
|
||||
// Default cell dimensions - updated by TUI when terminal responds to query
|
||||
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
|
||||
|
||||
export function getCellDimensions(): CellDimensions {
|
||||
return cellDimensions;
|
||||
}
|
||||
|
||||
export function setCellDimensions(dims: CellDimensions): void {
|
||||
cellDimensions = dims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the attached tmux client forwards OSC 8 hyperlinks to the
|
||||
* outer terminal. tmux only re-emits them when its `client_termfeatures` lists
|
||||
* `hyperlinks`, and strips them otherwise. On any error fallbacks `false`.
|
||||
*/
|
||||
function probeTmuxHyperlinks(): boolean {
|
||||
try {
|
||||
const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", {
|
||||
encoding: "utf8",
|
||||
timeout: 250,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return termfeatures
|
||||
.split(",")
|
||||
.map((feature) => feature.trim())
|
||||
.includes("hyperlinks");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities {
|
||||
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
||||
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
|
||||
const term = process.env.TERM?.toLowerCase() || "";
|
||||
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
||||
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||
const isWindowsConsole = process.platform === "win32";
|
||||
|
||||
// Emit OSC 8 hyperlinks only when tmux confirms it forwards.
|
||||
// Image protocols are unreliable under tmux, so leave `images: null`.
|
||||
if (process.env.TMUX || term.startsWith("tmux")) {
|
||||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() };
|
||||
}
|
||||
|
||||
// screen does not forward OSC 8 hyperlinks, so keep them off there.
|
||||
if (term.startsWith("screen")) {
|
||||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||
}
|
||||
|
||||
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
// Warp supports the Kitty graphics protocol and OSC 8 hyperlinks.
|
||||
if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
|
||||
return { images: "iterm2", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (process.env.WT_SESSION) {
|
||||
return { images: null, trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (termProgram === "alacritty" || termProgram === "vscode" || termProgram === "zed") {
|
||||
return { images: null, trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (terminalEmulator === "jetbrains-jediterm") {
|
||||
return { images: null, trueColor: true, hyperlinks: false };
|
||||
}
|
||||
|
||||
// Windows Terminal does not always set WT_SESSION, for example when it hosts
|
||||
// a cmd.exe launched directly from Win+R. Modern Windows consoles support
|
||||
// truecolor; keep hyperlinks off unless we positively detected support above.
|
||||
if (isWindowsConsole) {
|
||||
return { images: null, trueColor: true, hyperlinks: false };
|
||||
}
|
||||
|
||||
// Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
|
||||
// text" on terminals that swallow it, which means the URL disappears from
|
||||
// the rendered output. Default to the legacy `text (url)` behavior unless we
|
||||
// have positively identified a hyperlink-capable terminal above.
|
||||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||
}
|
||||
|
||||
function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined {
|
||||
return value === "1" ? true : value === "0" ? false : undefined;
|
||||
}
|
||||
|
||||
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
|
||||
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
|
||||
const detected = detectCapabilitiesFromEnvironment(
|
||||
hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks,
|
||||
);
|
||||
const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase();
|
||||
const images =
|
||||
imageProtocol === "kitty" || imageProtocol === "iterm2"
|
||||
? imageProtocol
|
||||
: imageProtocol === "none" || imageProtocol === "0"
|
||||
? null
|
||||
: undefined;
|
||||
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
|
||||
return {
|
||||
...detected,
|
||||
...(images !== undefined ? { images } : {}),
|
||||
...(trueColor !== undefined ? { trueColor } : {}),
|
||||
...(hyperlinks !== undefined ? { hyperlinks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCapabilities(): TerminalCapabilities {
|
||||
if (!cachedCapabilities) {
|
||||
const hyperlinks = capabilityOverrides.hyperlinks;
|
||||
cachedCapabilities = {
|
||||
...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
|
||||
...capabilityOverrides,
|
||||
};
|
||||
}
|
||||
return cachedCapabilities;
|
||||
}
|
||||
|
||||
export function resetCapabilitiesCache(): void {
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
/** Override selected auto-detected capabilities. */
|
||||
export function setCapabilityOverrides(overrides: Partial<TerminalCapabilities>): void {
|
||||
if (
|
||||
capabilityOverrides.images === overrides.images &&
|
||||
capabilityOverrides.trueColor === overrides.trueColor &&
|
||||
capabilityOverrides.hyperlinks === overrides.hyperlinks
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilityOverrides = { ...overrides };
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
|
||||
export function setCapabilities(caps: TerminalCapabilities): void {
|
||||
cachedCapabilities = caps;
|
||||
}
|
||||
|
||||
const KITTY_PREFIX = "\x1b_G";
|
||||
const ITERM2_PREFIX = "\x1b]1337;File=";
|
||||
|
||||
export function isImageLine(line: string): boolean {
|
||||
// Fast path: sequence at line start (single-row images)
|
||||
if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
// Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
|
||||
return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random image ID for Kitty graphics protocol.
|
||||
* Uses random IDs to avoid collisions between different module instances
|
||||
* (e.g., main app vs extensions).
|
||||
*/
|
||||
export function allocateImageId(): number {
|
||||
// Use random ID in range [1, 0xffffffff] to avoid collisions
|
||||
return Math.floor(Math.random() * 0xfffffffe) + 1;
|
||||
}
|
||||
|
||||
export function encodeKitty(
|
||||
base64Data: string,
|
||||
options: {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
imageId?: number;
|
||||
/** Whether Kitty should apply its default cursor movement after placement. Default: true. */
|
||||
moveCursor?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const CHUNK_SIZE = 4096;
|
||||
|
||||
const params: string[] = ["a=T", "f=100", "q=2"];
|
||||
|
||||
if (options.moveCursor === false) params.push("C=1");
|
||||
if (options.columns) params.push(`c=${options.columns}`);
|
||||
if (options.rows) params.push(`r=${options.rows}`);
|
||||
if (options.imageId) params.push(`i=${options.imageId}`);
|
||||
|
||||
if (base64Data.length <= CHUNK_SIZE) {
|
||||
return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let offset = 0;
|
||||
let isFirst = true;
|
||||
|
||||
while (offset < base64Data.length) {
|
||||
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
|
||||
const isLast = offset + CHUNK_SIZE >= base64Data.length;
|
||||
|
||||
if (isFirst) {
|
||||
chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
|
||||
isFirst = false;
|
||||
} else if (isLast) {
|
||||
chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
|
||||
} else {
|
||||
chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
|
||||
}
|
||||
|
||||
offset += CHUNK_SIZE;
|
||||
}
|
||||
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Kitty graphics image by ID.
|
||||
* Uses uppercase 'I' to also free the image data.
|
||||
*/
|
||||
export function deleteKittyImage(imageId: number): string {
|
||||
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all visible Kitty graphics images.
|
||||
* Uses uppercase 'A' to also free the image data.
|
||||
*/
|
||||
export function deleteAllKittyImages(): string {
|
||||
return "\x1b_Ga=d,d=A,q=2\x1b\\";
|
||||
}
|
||||
|
||||
/** Delete all visible Kitty placements while retaining their uploaded image data. */
|
||||
export function deleteAllKittyPlacements(): string {
|
||||
return "\x1b_Ga=d,d=a,q=2\x1b\\";
|
||||
}
|
||||
|
||||
export function encodeITerm2(
|
||||
base64Data: string,
|
||||
options: {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
name?: string;
|
||||
preserveAspectRatio?: boolean;
|
||||
inline?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const params: string[] = [
|
||||
`inline=${options.inline !== false ? 1 : 0}`,
|
||||
`size=${Buffer.byteLength(base64Data, "base64")}`,
|
||||
];
|
||||
|
||||
if (options.width !== undefined) params.push(`width=${options.width}`);
|
||||
if (options.height !== undefined) params.push(`height=${options.height}`);
|
||||
if (options.name) {
|
||||
const nameBase64 = Buffer.from(options.name).toString("base64");
|
||||
params.push(`name=${nameBase64}`);
|
||||
}
|
||||
if (options.preserveAspectRatio === false) {
|
||||
params.push("preserveAspectRatio=0");
|
||||
}
|
||||
|
||||
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
|
||||
}
|
||||
|
||||
export interface ImageCellSize {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface KittyImageMetadata extends ImageCellSize {
|
||||
imageId: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
interface RegisteredKittyImageMetadata extends KittyImageMetadata {
|
||||
transmissionGeneration: number;
|
||||
}
|
||||
|
||||
export interface KittyImagePlacement {
|
||||
imageId: number;
|
||||
transmissionGeneration: number;
|
||||
transmissionBytes: number;
|
||||
estimatedDecodedBytes: number;
|
||||
sequence: string;
|
||||
replacementLine: string;
|
||||
}
|
||||
|
||||
const kittyImageMetadata = new Map<number, RegisteredKittyImageMetadata>();
|
||||
let kittyTransmissionGeneration = 0;
|
||||
|
||||
export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
|
||||
kittyTransmissionGeneration += 1;
|
||||
kittyImageMetadata.delete(metadata.imageId);
|
||||
kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration });
|
||||
if (kittyImageMetadata.size > 1000) {
|
||||
const oldestImageId = kittyImageMetadata.keys().next().value;
|
||||
if (oldestImageId !== undefined) kittyImageMetadata.delete(oldestImageId);
|
||||
}
|
||||
}
|
||||
|
||||
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
if (!controls) return undefined;
|
||||
const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1];
|
||||
return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10));
|
||||
}
|
||||
|
||||
export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined {
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!metadata) return undefined;
|
||||
return {
|
||||
imageId: metadata.imageId,
|
||||
columns: metadata.columns,
|
||||
rows: metadata.rows,
|
||||
widthPx: metadata.widthPx,
|
||||
heightPx: metadata.heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
const KITTY_PLACEMENT_CONTROL_KEYS = new Set([
|
||||
"i",
|
||||
"p",
|
||||
"x",
|
||||
"y",
|
||||
"w",
|
||||
"h",
|
||||
"X",
|
||||
"Y",
|
||||
"c",
|
||||
"r",
|
||||
"C",
|
||||
"U",
|
||||
"z",
|
||||
"P",
|
||||
"Q",
|
||||
"H",
|
||||
"V",
|
||||
]);
|
||||
|
||||
/** Build a placement-only command for an image line emitted by {@link renderImage}. */
|
||||
export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined {
|
||||
const match = /\x1b_G([^;]*);/.exec(line);
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!match || !metadata) return undefined;
|
||||
|
||||
let commandStart = match.index;
|
||||
let commandControls = match[1];
|
||||
let transmissionEnd: number;
|
||||
while (true) {
|
||||
const terminator = line.indexOf("\x1b\\", commandStart + KITTY_PREFIX.length);
|
||||
if (terminator === -1) return undefined;
|
||||
transmissionEnd = terminator + 2;
|
||||
if (!/(?:^|,)m=1(?:,|$)/.test(commandControls)) break;
|
||||
commandStart = transmissionEnd;
|
||||
if (!line.startsWith(KITTY_PREFIX, commandStart)) return undefined;
|
||||
const controlsEnd = line.indexOf(";", commandStart + KITTY_PREFIX.length);
|
||||
if (controlsEnd === -1) return undefined;
|
||||
commandControls = line.slice(commandStart + KITTY_PREFIX.length, controlsEnd);
|
||||
}
|
||||
|
||||
const controls = match[1]
|
||||
.split(",")
|
||||
.filter((control) => KITTY_PLACEMENT_CONTROL_KEYS.has(control.split("=", 1)[0] ?? ""));
|
||||
const sequence = `\x1b_Ga=p,q=2,${controls.join(",")}\x1b\\`;
|
||||
return {
|
||||
imageId: metadata.imageId,
|
||||
transmissionGeneration: metadata.transmissionGeneration,
|
||||
transmissionBytes: transmissionEnd - match.index,
|
||||
estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4,
|
||||
sequence,
|
||||
replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string {
|
||||
const metadata = getKittyImageMetadata(line);
|
||||
const match = /\x1b_G([^;]*);/.exec(line);
|
||||
if (!metadata || !match || hiddenRows < 0 || hiddenRows >= metadata.rows || visibleRows <= 0) return line;
|
||||
const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows);
|
||||
if (hiddenRows === 0 && croppedRows === metadata.rows) return line;
|
||||
const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows);
|
||||
const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / metadata.rows);
|
||||
const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY);
|
||||
const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control));
|
||||
controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`);
|
||||
return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
|
||||
}
|
||||
|
||||
export function calculateImageCellSize(
|
||||
imageDimensions: ImageDimensions,
|
||||
maxWidthCells: number,
|
||||
maxHeightCells?: number,
|
||||
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
|
||||
): ImageCellSize {
|
||||
const maxWidth = Math.max(1, Math.floor(maxWidthCells));
|
||||
const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
|
||||
const imageWidth = Math.max(1, imageDimensions.widthPx);
|
||||
const imageHeight = Math.max(1, imageDimensions.heightPx);
|
||||
|
||||
const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth;
|
||||
const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight;
|
||||
const scale = Math.min(widthScale, heightScale);
|
||||
|
||||
const scaledWidthPx = imageWidth * scale;
|
||||
const scaledHeightPx = imageHeight * scale;
|
||||
const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
|
||||
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
|
||||
|
||||
return {
|
||||
columns: Math.max(1, Math.min(maxWidth, columns)),
|
||||
rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateImageRows(
|
||||
imageDimensions: ImageDimensions,
|
||||
targetWidthCells: number,
|
||||
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
|
||||
): number {
|
||||
return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows;
|
||||
}
|
||||
|
||||
export function getPngDimensions(base64Data: string): ImageDimensions | null {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
if (buffer.length < 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
|
||||
return { widthPx: width, heightPx: height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getJpegDimensions(base64Data: string): ImageDimensions | null {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
if (buffer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
while (offset < buffer.length - 9) {
|
||||
if (buffer[offset] !== 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
|
||||
if (marker >= 0xc0 && marker <= 0xc2) {
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
return { widthPx: width, heightPx: height };
|
||||
}
|
||||
|
||||
if (offset + 3 >= buffer.length) {
|
||||
return null;
|
||||
}
|
||||
const length = buffer.readUInt16BE(offset + 2);
|
||||
if (length < 2) {
|
||||
return null;
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGifDimensions(base64Data: string): ImageDimensions | null {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
if (buffer.length < 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sig = buffer.slice(0, 6).toString("ascii");
|
||||
if (sig !== "GIF87a" && sig !== "GIF89a") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = buffer.readUInt16LE(6);
|
||||
const height = buffer.readUInt16LE(8);
|
||||
|
||||
return { widthPx: width, heightPx: height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebpDimensions(base64Data: string): ImageDimensions | null {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
if (buffer.length < 30) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const riff = buffer.slice(0, 4).toString("ascii");
|
||||
const webp = buffer.slice(8, 12).toString("ascii");
|
||||
if (riff !== "RIFF" || webp !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(12, 16).toString("ascii");
|
||||
if (chunk === "VP8 ") {
|
||||
if (buffer.length < 30) return null;
|
||||
const width = buffer.readUInt16LE(26) & 0x3fff;
|
||||
const height = buffer.readUInt16LE(28) & 0x3fff;
|
||||
return { widthPx: width, heightPx: height };
|
||||
} else if (chunk === "VP8L") {
|
||||
if (buffer.length < 25) return null;
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = (bits & 0x3fff) + 1;
|
||||
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||
return { widthPx: width, heightPx: height };
|
||||
} else if (chunk === "VP8X") {
|
||||
if (buffer.length < 30) return null;
|
||||
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
||||
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
||||
return { widthPx: width, heightPx: height };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
|
||||
if (mimeType === "image/png") {
|
||||
return getPngDimensions(base64Data);
|
||||
}
|
||||
if (mimeType === "image/jpeg") {
|
||||
return getJpegDimensions(base64Data);
|
||||
}
|
||||
if (mimeType === "image/gif") {
|
||||
return getGifDimensions(base64Data);
|
||||
}
|
||||
if (mimeType === "image/webp") {
|
||||
return getWebpDimensions(base64Data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderImage(
|
||||
base64Data: string,
|
||||
imageDimensions: ImageDimensions,
|
||||
options: ImageRenderOptions = {},
|
||||
): { sequence: string; columns: number; rows: number; imageId?: number } | null {
|
||||
const caps = getCapabilities();
|
||||
|
||||
if (!caps.images) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxWidth = options.maxWidthCells ?? 80;
|
||||
const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions());
|
||||
|
||||
if (caps.images === "kitty") {
|
||||
if (options.imageId !== undefined) {
|
||||
registerKittyImageMetadata({
|
||||
imageId: options.imageId,
|
||||
columns: size.columns,
|
||||
rows: size.rows,
|
||||
widthPx: imageDimensions.widthPx,
|
||||
heightPx: imageDimensions.heightPx,
|
||||
});
|
||||
}
|
||||
const sequence = encodeKitty(base64Data, {
|
||||
columns: size.columns,
|
||||
rows: size.rows,
|
||||
imageId: options.imageId,
|
||||
moveCursor: options.moveCursor,
|
||||
});
|
||||
return { sequence, columns: size.columns, rows: size.rows, imageId: options.imageId };
|
||||
}
|
||||
|
||||
if (caps.images === "iterm2") {
|
||||
const sequence = encodeITerm2(base64Data, {
|
||||
width: size.columns,
|
||||
height: "auto",
|
||||
preserveAspectRatio: options.preserveAspectRatio ?? true,
|
||||
});
|
||||
return { sequence, columns: size.columns, rows: size.rows };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in an OSC 8 hyperlink sequence.
|
||||
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
|
||||
* (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
|
||||
* In terminals that do not support OSC 8, the escape sequences are ignored
|
||||
* and only the plain text is displayed.
|
||||
*
|
||||
* @param text - The visible text to display
|
||||
* @param url - The URL to link to
|
||||
*/
|
||||
export function hyperlink(text: string, url: string): string {
|
||||
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
/** Shorten home-prefixed absolute paths to ~/... for compact display. */
|
||||
function shortenImagePath(filename: string): string {
|
||||
const home = homedir();
|
||||
if (home && (filename === home || filename.startsWith(`${home}/`) || filename.startsWith(`${home}\\`))) {
|
||||
return `~${filename.slice(home.length)}`;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text fallback when the terminal cannot render inline images.
|
||||
* Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are
|
||||
* available, linked to file:// so the full path remains openable.
|
||||
*/
|
||||
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
|
||||
const parts: string[] = [];
|
||||
if (filename) {
|
||||
const display = shortenImagePath(filename);
|
||||
if (getCapabilities().hyperlinks && isAbsolute(filename)) {
|
||||
parts.push(hyperlink(display, pathToFileURL(filename).href));
|
||||
} else {
|
||||
parts.push(display);
|
||||
}
|
||||
}
|
||||
parts.push(`[${mimeType}]`);
|
||||
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
|
||||
return `[Image: ${parts.join(" ")}]`;
|
||||
}
|
||||
565
third_party/pi/packages/tui/src/terminal.ts
vendored
Normal file
565
third_party/pi/packages/tui/src/terminal.ts
vendored
Normal file
@@ -0,0 +1,565 @@
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { setKittyProtocolActive } from "./keys.ts";
|
||||
import { isNativeModifierPressed } from "./native-modifiers.ts";
|
||||
import { getNativeModuleCandidates } from "./native-module-path.ts";
|
||||
import { StdinBuffer } from "./stdin-buffer.ts";
|
||||
|
||||
const cjsRequire = createRequire(import.meta.url);
|
||||
|
||||
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
||||
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
||||
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07";
|
||||
const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u";
|
||||
const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7;
|
||||
const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150;
|
||||
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`;
|
||||
|
||||
export type KeyboardProtocolNegotiationSequence =
|
||||
| { type: "kitty-flags"; flags: number }
|
||||
| { type: "device-attributes" };
|
||||
|
||||
export function parseKeyboardProtocolNegotiationSequence(
|
||||
sequence: string,
|
||||
): KeyboardProtocolNegotiationSequence | undefined {
|
||||
const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/);
|
||||
if (kittyFlags) {
|
||||
return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) };
|
||||
}
|
||||
if (/^\x1b\[\?[\d;]*c$/.test(sequence)) {
|
||||
return { type: "device-attributes" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean {
|
||||
return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence);
|
||||
}
|
||||
|
||||
export function isAppleTerminalSession(): boolean {
|
||||
return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh terminal dimensions on POSIX platforms by sending SIGWINCH to this process.
|
||||
* Best-effort: some environments (restricted seccomp or LSM policies) return EACCES
|
||||
* for `kill(2)`; in that case the dimensions refresh is skipped rather than crashing.
|
||||
*/
|
||||
export function refreshTerminalDimensions(): void {
|
||||
if (process.platform === "win32" || process.pid <= 0) return;
|
||||
try {
|
||||
process.kill(process.pid, "SIGWINCH");
|
||||
} catch {
|
||||
// Signal delivery not permitted in this environment; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNativeShiftEnterInput(
|
||||
data: string,
|
||||
shouldDetectNativeShiftEnter: boolean,
|
||||
isShiftPressed: boolean,
|
||||
): string {
|
||||
if (shouldDetectNativeShiftEnter && data === "\r" && isShiftPressed) return NATIVE_SHIFT_ENTER_SEQUENCE;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string {
|
||||
return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal terminal interface for TUI
|
||||
*/
|
||||
export interface Terminal {
|
||||
// Start the terminal with input and resize handlers
|
||||
start(onInput: (data: string) => void, onResize: () => void): void;
|
||||
|
||||
// Stop the terminal and restore state
|
||||
stop(): void;
|
||||
|
||||
/**
|
||||
* Drain stdin before exiting to prevent Kitty key release events from
|
||||
* leaking to the parent shell over slow SSH connections.
|
||||
* @param maxMs - Maximum time to drain (default: 1000ms)
|
||||
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
|
||||
*/
|
||||
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
||||
|
||||
// Write output to terminal
|
||||
write(data: string): void;
|
||||
|
||||
// Get terminal dimensions
|
||||
get columns(): number;
|
||||
get rows(): number;
|
||||
|
||||
// Whether Kitty keyboard protocol is active
|
||||
get kittyProtocolActive(): boolean;
|
||||
|
||||
// Cursor positioning (relative to current position)
|
||||
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
||||
|
||||
// Cursor visibility
|
||||
hideCursor(): void; // Hide the cursor
|
||||
showCursor(): void; // Show the cursor
|
||||
|
||||
// Clear operations
|
||||
clearLine(): void; // Clear current line
|
||||
clearFromCursor(): void; // Clear from cursor to end of screen
|
||||
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
||||
|
||||
// Title operations
|
||||
setTitle(title: string): void; // Set terminal window title
|
||||
|
||||
// Progress indicator (OSC 9;4)
|
||||
setProgress(active: boolean): void;
|
||||
}
|
||||
|
||||
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
|
||||
const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100;
|
||||
|
||||
/**
|
||||
* Resolve how long to wait for the rest of an escape sequence before
|
||||
* dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus
|
||||
* another byte, so high-latency transports need a longer reassembly window.
|
||||
*/
|
||||
export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const configured = Number(env.PI_TUI_ESC_TIMEOUT);
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
return configured;
|
||||
}
|
||||
if (env.SSH_CONNECTION || env.SSH_TTY) {
|
||||
return DEFAULT_SSH_ESCAPE_TIMEOUT_MS;
|
||||
}
|
||||
return DEFAULT_ESCAPE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real terminal using process.stdin/stdout
|
||||
*/
|
||||
export class ProcessTerminal implements Terminal {
|
||||
private wasRaw = false;
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private _kittyProtocolActive = false;
|
||||
private _modifyOtherKeysActive = false;
|
||||
private keyboardProtocolPushed = false;
|
||||
private keyboardProtocolNegotiationBuffer = "";
|
||||
private keyboardProtocolBufferFlushTimer?: ReturnType<typeof setTimeout>;
|
||||
private stdinBuffer?: StdinBuffer;
|
||||
private stdinDataHandler?: (data: string) => void;
|
||||
private progressInterval?: ReturnType<typeof setInterval>;
|
||||
private writeLogPath = (() => {
|
||||
const env = process.env.PI_TUI_WRITE_LOG || "";
|
||||
if (!env) return "";
|
||||
try {
|
||||
if (fs.statSync(env).isDirectory()) {
|
||||
const now = new Date();
|
||||
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
return path.join(env, `tui-${ts}-${process.pid}.log`);
|
||||
}
|
||||
} catch {
|
||||
// Not an existing directory - use as-is (file path)
|
||||
}
|
||||
return env;
|
||||
})();
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
return this._kittyProtocolActive;
|
||||
}
|
||||
|
||||
get modifyOtherKeysActive(): boolean {
|
||||
return this._modifyOtherKeysActive;
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
|
||||
// Save previous state and enable raw mode
|
||||
this.wasRaw = process.stdin.isRaw || false;
|
||||
if (process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
|
||||
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
||||
process.stdout.write("\x1b[?2004h");
|
||||
|
||||
// Set up resize handler immediately
|
||||
process.stdout.on("resize", this.resizeHandler);
|
||||
|
||||
// Refresh terminal dimensions - they may be stale after suspend/resume
|
||||
// (SIGWINCH is lost while process is stopped). Unix only, best-effort.
|
||||
refreshTerminalDimensions();
|
||||
|
||||
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
||||
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
||||
// events that lose modifier information. Must run AFTER setRawMode(true)
|
||||
// since that resets console mode flags.
|
||||
this.enableWindowsVTInput();
|
||||
|
||||
// Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response.
|
||||
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||
this.queryAndEnableKittyProtocol();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up StdinBuffer to split batched input into individual sequences.
|
||||
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
||||
*
|
||||
* Also watches for Kitty protocol response and enables it when detected.
|
||||
* This is done here (after stdinBuffer parsing) rather than on raw stdin
|
||||
* to handle the case where the response arrives split across multiple events.
|
||||
*/
|
||||
private setupStdinBuffer(): void {
|
||||
this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() });
|
||||
|
||||
// Forward individual sequences to the input handler
|
||||
this.stdinBuffer.on("data", (sequence) => {
|
||||
const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence);
|
||||
if (negotiationSequence === "pending") {
|
||||
this.scheduleKeyboardProtocolNegotiationBufferFlush();
|
||||
return; // Wait briefly for the rest of a split Kitty response.
|
||||
}
|
||||
if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.forwardInputSequence(sequence);
|
||||
});
|
||||
|
||||
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
||||
this.stdinBuffer.on("paste", (content) => {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(`\x1b[200~${content}\x1b[201~`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handler that pipes stdin data through the buffer
|
||||
this.stdinDataHandler = (data: string) => {
|
||||
this.stdinBuffer!.process(data);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query terminal for Kitty keyboard protocol support and enable it if available.
|
||||
*
|
||||
* Kitty's progressive enhancement detection requires requesting the desired
|
||||
* flags before querying them. The trailing DA query is a sentinel supported by
|
||||
* terminals that do not know Kitty keyboard protocol; receiving DA before a
|
||||
* Kitty response enables modifyOtherKeys fallback without a startup timeout.
|
||||
*
|
||||
* The requested flags are:
|
||||
* - 1 = disambiguate escape codes
|
||||
* - 2 = report event types (press/repeat/release)
|
||||
* - 4 = report alternate keys (shifted key, base layout key)
|
||||
*/
|
||||
private queryAndEnableKittyProtocol(): void {
|
||||
this.setupStdinBuffer();
|
||||
process.stdin.on("data", this.stdinDataHandler!);
|
||||
this.keyboardProtocolPushed = true;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY);
|
||||
}
|
||||
|
||||
private handleKeyboardProtocolNegotiationSequence(
|
||||
negotiationSequence: KeyboardProtocolNegotiationSequence | undefined,
|
||||
): boolean {
|
||||
if (!negotiationSequence) return false;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
if (negotiationSequence.type === "kitty-flags") {
|
||||
if (negotiationSequence.flags !== 0) {
|
||||
this.disableModifyOtherKeys();
|
||||
if (!this._kittyProtocolActive) {
|
||||
this._kittyProtocolActive = true;
|
||||
setKittyProtocolActive(true);
|
||||
}
|
||||
} else {
|
||||
this.enableModifyOtherKeys();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._kittyProtocolActive) {
|
||||
this.enableModifyOtherKeys();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private readKeyboardProtocolNegotiationSequence(
|
||||
sequence: string,
|
||||
): KeyboardProtocolNegotiationSequence | "pending" | undefined {
|
||||
if (this.keyboardProtocolNegotiationBuffer) {
|
||||
const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence;
|
||||
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence);
|
||||
if (negotiationSequence) {
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
return negotiationSequence;
|
||||
}
|
||||
if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) {
|
||||
this.setKeyboardProtocolNegotiationBuffer(bufferedSequence);
|
||||
return "pending";
|
||||
}
|
||||
this.flushKeyboardProtocolNegotiationBufferAsInput();
|
||||
}
|
||||
|
||||
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence);
|
||||
if (negotiationSequence) return negotiationSequence;
|
||||
if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) {
|
||||
this.setKeyboardProtocolNegotiationBuffer(sequence);
|
||||
return "pending";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setKeyboardProtocolNegotiationBuffer(sequence: string): void {
|
||||
this.clearKeyboardProtocolNegotiationBufferFlushTimer();
|
||||
this.keyboardProtocolNegotiationBuffer = sequence;
|
||||
}
|
||||
|
||||
private clearKeyboardProtocolNegotiationBuffer(): void {
|
||||
this.clearKeyboardProtocolNegotiationBufferFlushTimer();
|
||||
this.keyboardProtocolNegotiationBuffer = "";
|
||||
}
|
||||
|
||||
private flushKeyboardProtocolNegotiationBufferAsInput(): void {
|
||||
if (!this.keyboardProtocolNegotiationBuffer) return;
|
||||
const sequence = this.keyboardProtocolNegotiationBuffer;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
this.forwardInputSequence(sequence);
|
||||
}
|
||||
|
||||
private scheduleKeyboardProtocolNegotiationBufferFlush(): void {
|
||||
if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return;
|
||||
this.keyboardProtocolBufferFlushTimer = setTimeout(() => {
|
||||
this.keyboardProtocolBufferFlushTimer = undefined;
|
||||
this.flushKeyboardProtocolNegotiationBufferAsInput();
|
||||
}, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private clearKeyboardProtocolNegotiationBufferFlushTimer(): void {
|
||||
if (!this.keyboardProtocolBufferFlushTimer) return;
|
||||
clearTimeout(this.keyboardProtocolBufferFlushTimer);
|
||||
this.keyboardProtocolBufferFlushTimer = undefined;
|
||||
}
|
||||
|
||||
private forwardInputSequence(sequence: string): void {
|
||||
if (!this.inputHandler) return;
|
||||
const shouldDetectNativeShiftEnter =
|
||||
sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32");
|
||||
const input = normalizeNativeShiftEnterInput(
|
||||
sequence,
|
||||
shouldDetectNativeShiftEnter,
|
||||
shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"),
|
||||
);
|
||||
this.inputHandler(input);
|
||||
}
|
||||
|
||||
private enableModifyOtherKeys(): void {
|
||||
if (this._kittyProtocolActive || this._modifyOtherKeysActive) return;
|
||||
process.stdout.write("\x1b[>4;2m");
|
||||
this._modifyOtherKeysActive = true;
|
||||
}
|
||||
|
||||
private disableModifyOtherKeys(): void {
|
||||
if (!this._modifyOtherKeysActive) return;
|
||||
process.stdout.write("\x1b[>4;0m");
|
||||
this._modifyOtherKeysActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin
|
||||
* console handle so the terminal sends VT sequences for modified keys
|
||||
* (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW
|
||||
* discards modifier state and Shift+Tab arrives as plain \t.
|
||||
*/
|
||||
private enableWindowsVTInput(): void {
|
||||
if (process.platform !== "win32") return;
|
||||
try {
|
||||
const arch = process.arch;
|
||||
if (arch !== "x64" && arch !== "arm64") return;
|
||||
|
||||
// Dynamic require so non-Windows and bundled/browser paths never load the
|
||||
// native helper. Installed packages resolve it from pi-tui; standalone
|
||||
// binaries resolve the copy next to the executable.
|
||||
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
|
||||
for (const modulePath of getNativeModuleCandidates(nativePath)) {
|
||||
try {
|
||||
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
|
||||
helper.enableVirtualTerminalInput?.();
|
||||
return;
|
||||
} catch {
|
||||
// Try the next possible packaging location.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Native helper not available — Shift+Tab won't be distinguishable from Tab.
|
||||
}
|
||||
}
|
||||
|
||||
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
||||
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
if (shouldDisableKittyProtocol) {
|
||||
// Disable Kitty keyboard protocol first so any late key releases
|
||||
// do not generate new Kitty escape sequences.
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.keyboardProtocolPushed = false;
|
||||
this._kittyProtocolActive = false;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
this.disableModifyOtherKeys();
|
||||
|
||||
const previousHandler = this.inputHandler;
|
||||
this.inputHandler = undefined;
|
||||
|
||||
let lastDataTime = Date.now();
|
||||
const onData = () => {
|
||||
lastDataTime = Date.now();
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
const endTime = Date.now() + maxMs;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
const timeLeft = endTime - now;
|
||||
if (timeLeft <= 0) break;
|
||||
if (now - lastDataTime >= idleMs) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft)));
|
||||
}
|
||||
} finally {
|
||||
process.stdin.removeListener("data", onData);
|
||||
this.inputHandler = previousHandler;
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.clearProgressInterval()) {
|
||||
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
||||
}
|
||||
|
||||
// Disable bracketed paste mode
|
||||
process.stdout.write("\x1b[?2004l");
|
||||
|
||||
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
|
||||
// Disable Kitty keyboard protocol if not already done by drainInput()
|
||||
if (shouldDisableKittyProtocol) {
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.keyboardProtocolPushed = false;
|
||||
this._kittyProtocolActive = false;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
this.disableModifyOtherKeys();
|
||||
|
||||
// Clean up StdinBuffer
|
||||
if (this.stdinBuffer) {
|
||||
this.stdinBuffer.destroy();
|
||||
this.stdinBuffer = undefined;
|
||||
}
|
||||
|
||||
// Remove event handlers
|
||||
if (this.stdinDataHandler) {
|
||||
process.stdin.removeListener("data", this.stdinDataHandler);
|
||||
this.stdinDataHandler = undefined;
|
||||
}
|
||||
this.inputHandler = undefined;
|
||||
if (this.resizeHandler) {
|
||||
process.stdout.removeListener("resize", this.resizeHandler);
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
||||
// re-interpreted after raw mode is disabled. This fixes a race condition
|
||||
// where Ctrl+D could close the parent shell over SSH.
|
||||
process.stdin.pause();
|
||||
|
||||
// Restore raw mode state
|
||||
if (process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(this.wasRaw);
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
process.stdout.write(data);
|
||||
if (this.writeLogPath) {
|
||||
try {
|
||||
fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
|
||||
} catch {
|
||||
// Ignore logging errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return process.stdout.columns || Number(process.env.COLUMNS) || 80;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return process.stdout.rows || Number(process.env.LINES) || 24;
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) {
|
||||
// Move down
|
||||
process.stdout.write(`\x1b[${lines}B`);
|
||||
} else if (lines < 0) {
|
||||
// Move up
|
||||
process.stdout.write(`\x1b[${-lines}A`);
|
||||
}
|
||||
// lines === 0: no movement
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
process.stdout.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
process.stdout.write("\x1b[?25h");
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
process.stdout.write("\x1b[K");
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
process.stdout.write("\x1b[J");
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
// OSC 0;title BEL - set terminal window title
|
||||
process.stdout.write(`\x1b]0;${title}\x07`);
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
if (active) {
|
||||
// OSC 9;4;3 - indeterminate progress
|
||||
process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
||||
if (!this.progressInterval) {
|
||||
this.progressInterval = setInterval(() => {
|
||||
process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
||||
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
|
||||
}
|
||||
} else {
|
||||
this.clearProgressInterval();
|
||||
// OSC 9;4;0 - clear progress
|
||||
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
||||
}
|
||||
}
|
||||
|
||||
private clearProgressInterval(): boolean {
|
||||
if (!this.progressInterval) return false;
|
||||
clearInterval(this.progressInterval);
|
||||
this.progressInterval = undefined;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1721
third_party/pi/packages/tui/src/tui-alt-screen.ts
vendored
Normal file
1721
third_party/pi/packages/tui/src/tui-alt-screen.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
655
third_party/pi/packages/tui/src/tui-main-screen.ts
vendored
Normal file
655
third_party/pi/packages/tui/src/tui-main-screen.ts
vendored
Normal file
@@ -0,0 +1,655 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { deleteKittyImage, isImageLine } from "./terminal-image.ts";
|
||||
import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts";
|
||||
import { visibleWidth } from "./utils.ts";
|
||||
|
||||
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
||||
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit.
|
||||
*
|
||||
* `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving
|
||||
* surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves;
|
||||
* the final `flush()` writes any remainder, including the end sequence.
|
||||
*/
|
||||
class BoundedTerminalWriter {
|
||||
private buffer = "";
|
||||
private writtenChars = 0;
|
||||
private readonly write: (data: string) => void;
|
||||
|
||||
constructor(write: (data: string) => void) {
|
||||
this.write = write;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append.
|
||||
* @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs.
|
||||
*/
|
||||
append(value: string): void {
|
||||
let offset = 0;
|
||||
while (offset < value.length) {
|
||||
const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
|
||||
if (capacity === 0) {
|
||||
this.flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = Math.min(value.length, offset + capacity);
|
||||
if (
|
||||
end < value.length &&
|
||||
value.charCodeAt(end - 1) >= 0xd800 &&
|
||||
value.charCodeAt(end - 1) <= 0xdbff &&
|
||||
value.charCodeAt(end) >= 0xdc00 &&
|
||||
value.charCodeAt(end) <= 0xdfff
|
||||
) {
|
||||
end--;
|
||||
}
|
||||
if (end === offset) {
|
||||
this.flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
this.buffer += value.slice(offset, end);
|
||||
offset = end;
|
||||
if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the current chunk, if any, and retain only its character count for debug output. */
|
||||
flush(): void {
|
||||
if (!this.buffer) return;
|
||||
this.write(this.buffer);
|
||||
this.writtenChars += this.buffer.length;
|
||||
this.buffer = "";
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.writtenChars + this.buffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
interface KittyImageHeader {
|
||||
ids: number[];
|
||||
rows: number;
|
||||
}
|
||||
|
||||
function parseKittyImageHeader(line: string): KittyImageHeader | undefined {
|
||||
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
||||
if (sequenceStart === -1) return undefined;
|
||||
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
|
||||
const paramsEnd = line.indexOf(";", paramsStart);
|
||||
if (paramsEnd === -1) return undefined;
|
||||
|
||||
const ids: number[] = [];
|
||||
let rows = 1;
|
||||
for (const param of line.slice(paramsStart, paramsEnd).split(",")) {
|
||||
const [key, value] = param.split("=", 2);
|
||||
if (value === undefined) continue;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue;
|
||||
if (key === "i") ids.push(numberValue);
|
||||
else if (key === "r") rows = numberValue;
|
||||
}
|
||||
return { ids, rows };
|
||||
}
|
||||
|
||||
function extractKittyImageIds(line: string): number[] {
|
||||
return parseKittyImageHeader(line)?.ids ?? [];
|
||||
}
|
||||
|
||||
function extractKittyImageRows(line: string): number {
|
||||
return parseKittyImageHeader(line)?.rows ?? 1;
|
||||
}
|
||||
|
||||
function isTermuxSession(): boolean {
|
||||
return Boolean(process.env.TERMUX_VERSION);
|
||||
}
|
||||
|
||||
export interface TuiMainScreenRenderState {
|
||||
previousLines: string[];
|
||||
previousWidth: number;
|
||||
previousHeight: number;
|
||||
cursorRow: number;
|
||||
hardwareCursorRow: number;
|
||||
maxLinesRendered: number;
|
||||
previousViewportTop: number;
|
||||
}
|
||||
|
||||
/** TUI implementation that renders into the terminal's main screen and scrollback. */
|
||||
export class TuiMainScreen extends TuiBase implements TUI {
|
||||
readonly mode = "regular" as const;
|
||||
private previousLines: string[] = [];
|
||||
private previousKittyImageIds = new Set<number>();
|
||||
private previousWidth = 0;
|
||||
private previousHeight = 0;
|
||||
private cursorRow = 0;
|
||||
private hardwareCursorRow = 0;
|
||||
private maxLinesRendered = 0;
|
||||
private previousViewportTop = 0;
|
||||
|
||||
captureRenderState(): TuiMainScreenRenderState {
|
||||
return {
|
||||
previousLines: [...this.previousLines],
|
||||
previousWidth: this.previousWidth,
|
||||
previousHeight: this.previousHeight,
|
||||
cursorRow: this.cursorRow,
|
||||
hardwareCursorRow: this.hardwareCursorRow,
|
||||
maxLinesRendered: this.maxLinesRendered,
|
||||
previousViewportTop: this.previousViewportTop,
|
||||
};
|
||||
}
|
||||
|
||||
restoreRenderState(state: TuiMainScreenRenderState): void {
|
||||
this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line));
|
||||
this.previousKittyImageIds = new Set();
|
||||
this.previousWidth = state.previousWidth;
|
||||
this.previousHeight = state.previousHeight;
|
||||
this.cursorRow = state.cursorRow;
|
||||
this.hardwareCursorRow = state.hardwareCursorRow;
|
||||
this.maxLinesRendered = state.maxLinesRendered;
|
||||
this.previousViewportTop = state.previousViewportTop;
|
||||
}
|
||||
|
||||
protected override resetRenderState(): void {
|
||||
this.previousLines = [];
|
||||
this.previousWidth = -1;
|
||||
this.previousHeight = -1;
|
||||
this.cursorRow = 0;
|
||||
this.hardwareCursorRow = 0;
|
||||
this.maxLinesRendered = 0;
|
||||
this.previousViewportTop = 0;
|
||||
}
|
||||
|
||||
protected override beforeTerminalStop(options: TuiStopOptions): void {
|
||||
if (options.preserveScreen || this.previousLines.length === 0) return;
|
||||
this.terminal.write(" ");
|
||||
const targetRow = this.previousLines.length;
|
||||
const lineDiff = targetRow - this.hardwareCursorRow;
|
||||
if (lineDiff > 0) this.terminal.write(`\x1b[${lineDiff}B`);
|
||||
else if (lineDiff < 0) this.terminal.write(`\x1b[${-lineDiff}A`);
|
||||
this.terminal.write("\r\n");
|
||||
}
|
||||
|
||||
private collectKittyImageIds(lines: string[]): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (const line of lines) {
|
||||
for (const id of extractKittyImageIds(line)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private deleteKittyImages(ids: Iterable<number>): string {
|
||||
let buffer = "";
|
||||
for (const id of ids) {
|
||||
buffer += deleteKittyImage(id);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number {
|
||||
const rows = extractKittyImageRows(lines[index] ?? "");
|
||||
if (rows <= 1) return 1;
|
||||
|
||||
const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
|
||||
let reservedRows = 1;
|
||||
while (reservedRows < maxRows) {
|
||||
const line = lines[index + reservedRows] ?? "";
|
||||
if (isImageLine(line) || visibleWidth(line) > 0) break;
|
||||
reservedRows++;
|
||||
}
|
||||
return reservedRows;
|
||||
}
|
||||
|
||||
private expandChangedRangeForKittyImages(
|
||||
firstChanged: number,
|
||||
lastChanged: number,
|
||||
newLines: string[],
|
||||
): { firstChanged: number; lastChanged: number } {
|
||||
let expandedFirstChanged = firstChanged;
|
||||
let expandedLastChanged = lastChanged;
|
||||
const expandForLines = (lines: string[]): void => {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (extractKittyImageIds(lines[i]).length === 0) continue;
|
||||
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
|
||||
if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
|
||||
expandedFirstChanged = Math.min(expandedFirstChanged, i);
|
||||
expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expandForLines(this.previousLines);
|
||||
expandForLines(newLines);
|
||||
return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
|
||||
}
|
||||
|
||||
private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
|
||||
if (firstChanged < 0 || lastChanged < firstChanged) return "";
|
||||
|
||||
const ids = new Set<number>();
|
||||
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
|
||||
for (let i = firstChanged; i <= maxLine; i++) {
|
||||
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return this.deleteKittyImages(ids);
|
||||
}
|
||||
|
||||
protected doRender(): void {
|
||||
if (this.stopped) return;
|
||||
const width = this.terminal.columns;
|
||||
const height = this.terminal.rows;
|
||||
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
||||
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
||||
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
||||
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
|
||||
let viewportTop = prevViewportTop;
|
||||
let hardwareCursorRow = this.hardwareCursorRow;
|
||||
const computeLineDiff = (targetRow: number): number => {
|
||||
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
||||
const targetScreenRow = targetRow - viewportTop;
|
||||
return targetScreenRow - currentScreenRow;
|
||||
};
|
||||
|
||||
// Render all components to get new lines
|
||||
let newLines = this.render(width);
|
||||
|
||||
// Composite overlays into the rendered lines (before differential compare)
|
||||
if (this.hasOverlayEntries) {
|
||||
newLines = this.compositeOverlays(newLines, width, height);
|
||||
}
|
||||
|
||||
// Extract cursor position before applying line resets (marker must be found first)
|
||||
const cursorPos = this.extractCursorPosition(newLines, height);
|
||||
|
||||
newLines = this.applyLineResets(newLines);
|
||||
|
||||
// Helper to clear scrollback and viewport and render all new lines
|
||||
const fullRender = (clear: boolean): void => {
|
||||
this.fullRedrawCount += 1;
|
||||
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
||||
output.append("\x1b[?2026h"); // Begin synchronized output
|
||||
if (clear) {
|
||||
output.append(this.deleteKittyImages(this.previousKittyImageIds));
|
||||
output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback
|
||||
}
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) output.append("\r\n");
|
||||
const line = newLines[i];
|
||||
const isImage = isImageLine(line);
|
||||
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
|
||||
if (imageReservedRows > 1 && imageReservedRows <= height) {
|
||||
for (let row = 1; row < imageReservedRows; row++) {
|
||||
output.append("\r\n");
|
||||
}
|
||||
output.append(`\x1b[${imageReservedRows - 1}A`);
|
||||
output.append(line);
|
||||
output.append(`\x1b[${imageReservedRows - 1}B`);
|
||||
i += imageReservedRows - 1;
|
||||
continue;
|
||||
}
|
||||
output.append(line);
|
||||
}
|
||||
output.append("\x1b[?2026l"); // End synchronized output
|
||||
output.flush();
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = this.cursorRow;
|
||||
// Reset max lines when clearing, otherwise track growth
|
||||
if (clear) {
|
||||
this.maxLinesRendered = newLines.length;
|
||||
} else {
|
||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||
}
|
||||
const bufferLength = Math.max(height, newLines.length);
|
||||
this.previousViewportTop = Math.max(0, bufferLength - height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
};
|
||||
|
||||
const redrawLogDirectory = process.env.PI_TUI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined;
|
||||
const logRedraw = (reason: string): void => {
|
||||
if (redrawLogDirectory === undefined) return;
|
||||
const logPath = path.join(redrawLogDirectory, "pi-tui-debug.log");
|
||||
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, msg);
|
||||
};
|
||||
|
||||
// First render - just output everything without clearing (assumes clean screen)
|
||||
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
||||
logRedraw("first render");
|
||||
fullRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Width changes always need a full re-render because wrapping changes.
|
||||
if (widthChanged) {
|
||||
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
||||
// but Termux changes height when the software keyboard shows or hides.
|
||||
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
||||
if (heightChanged && !isTermuxSession()) {
|
||||
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
||||
// (overlays need the padding, so only do this when no overlays are active)
|
||||
// Configurable via setClearOnShrink()
|
||||
if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) {
|
||||
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first and last changed lines
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
||||
const newLine = i < newLines.length ? newLines[i] : "";
|
||||
|
||||
if (oldLine !== newLine) {
|
||||
if (firstChanged === -1) {
|
||||
firstChanged = i;
|
||||
}
|
||||
lastChanged = i;
|
||||
}
|
||||
}
|
||||
const appendedLines = newLines.length > this.previousLines.length;
|
||||
if (appendedLines) {
|
||||
if (firstChanged === -1) {
|
||||
firstChanged = this.previousLines.length;
|
||||
}
|
||||
lastChanged = newLines.length - 1;
|
||||
}
|
||||
if (firstChanged !== -1) {
|
||||
const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
|
||||
firstChanged = expandedRange.firstChanged;
|
||||
lastChanged = expandedRange.lastChanged;
|
||||
}
|
||||
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
||||
|
||||
// No changes - but still need to update hardware cursor position if it moved
|
||||
if (firstChanged === -1) {
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousViewportTop = prevViewportTop;
|
||||
this.previousHeight = height;
|
||||
return;
|
||||
}
|
||||
|
||||
// All changes are in deleted lines (nothing to render, just clear)
|
||||
if (firstChanged >= newLines.length) {
|
||||
if (this.previousLines.length > newLines.length) {
|
||||
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
||||
output.append("\x1b[?2026h");
|
||||
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
|
||||
// Move to end of new content (clamp to 0 for empty content)
|
||||
const targetRow = Math.max(0, newLines.length - 1);
|
||||
if (targetRow < prevViewportTop) {
|
||||
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
const lineDiff = computeLineDiff(targetRow);
|
||||
if (lineDiff > 0) output.append(`\x1b[${lineDiff}B`);
|
||||
else if (lineDiff < 0) output.append(`\x1b[${-lineDiff}A`);
|
||||
output.append("\r");
|
||||
// Clear extra lines without scrolling
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
if (extraLines > height) {
|
||||
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
const clearStartOffset = newLines.length === 0 ? 0 : 1;
|
||||
if (extraLines > 0 && clearStartOffset > 0) {
|
||||
output.append(`\x1b[${clearStartOffset}B`);
|
||||
}
|
||||
for (let i = 0; i < extraLines; i++) {
|
||||
output.append("\r\x1b[2K");
|
||||
if (i < extraLines - 1) output.append("\x1b[1B");
|
||||
}
|
||||
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
|
||||
if (moveBack > 0) {
|
||||
output.append(`\x1b[${moveBack}A`);
|
||||
}
|
||||
output.append("\x1b[?2026l");
|
||||
output.flush();
|
||||
this.cursorRow = targetRow;
|
||||
this.hardwareCursorRow = targetRow;
|
||||
}
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
this.previousViewportTop = prevViewportTop;
|
||||
return;
|
||||
}
|
||||
|
||||
// Differential rendering can only touch what was actually visible.
|
||||
// If the first changed line is above the previous viewport, we need a full redraw.
|
||||
if (firstChanged < prevViewportTop) {
|
||||
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from first changed line to end
|
||||
// Keep updates wrapped in synchronized output while writing bounded chunks.
|
||||
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
||||
output.append("\x1b[?2026h"); // Begin synchronized output
|
||||
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
|
||||
const prevViewportBottom = prevViewportTop + height - 1;
|
||||
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
||||
if (moveTargetRow > prevViewportBottom) {
|
||||
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
||||
const moveToBottom = height - 1 - currentScreenRow;
|
||||
if (moveToBottom > 0) {
|
||||
output.append(`\x1b[${moveToBottom}B`);
|
||||
}
|
||||
const scroll = moveTargetRow - prevViewportBottom;
|
||||
output.append("\r\n".repeat(scroll));
|
||||
prevViewportTop += scroll;
|
||||
viewportTop += scroll;
|
||||
hardwareCursorRow = moveTargetRow;
|
||||
}
|
||||
|
||||
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
||||
const lineDiff = computeLineDiff(moveTargetRow);
|
||||
if (lineDiff > 0) {
|
||||
output.append(`\x1b[${lineDiff}B`); // Move down
|
||||
} else if (lineDiff < 0) {
|
||||
output.append(`\x1b[${-lineDiff}A`); // Move up
|
||||
}
|
||||
|
||||
output.append(appendStart ? "\r\n" : "\r"); // Move to column 0
|
||||
|
||||
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
||||
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
||||
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
||||
for (let i = firstChanged; i <= renderEnd; i++) {
|
||||
if (i > firstChanged) output.append("\r\n");
|
||||
const line = newLines[i];
|
||||
const isImage = isImageLine(line);
|
||||
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
|
||||
if (imageReservedRows > 1) {
|
||||
const imageStartScreenRow = i - viewportTop;
|
||||
if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
|
||||
logRedraw(
|
||||
`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`,
|
||||
);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
output.append("\x1b[2K");
|
||||
for (let row = 1; row < imageReservedRows; row++) {
|
||||
output.append("\r\n\x1b[2K");
|
||||
}
|
||||
output.append(`\x1b[${imageReservedRows - 1}A`);
|
||||
output.append(line);
|
||||
output.append(`\x1b[${imageReservedRows - 1}B`);
|
||||
i += imageReservedRows - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.append("\x1b[2K"); // Clear current line
|
||||
if (!isImage && visibleWidth(line) > width) {
|
||||
// Log all lines to crash file for debugging
|
||||
const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");
|
||||
const crashData = [
|
||||
`Crash at ${new Date().toISOString()}`,
|
||||
`Terminal width: ${width}`,
|
||||
`Line ${i} visible width: ${visibleWidth(line)}`,
|
||||
"",
|
||||
"=== All rendered lines ===",
|
||||
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
|
||||
"",
|
||||
].join("\n");
|
||||
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
||||
fs.writeFileSync(crashLogPath, crashData);
|
||||
|
||||
// Clean up terminal state before throwing
|
||||
this.stop();
|
||||
|
||||
const errorMsg = [
|
||||
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
||||
"",
|
||||
"This is likely caused by a custom TUI component not truncating its output.",
|
||||
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
||||
"",
|
||||
`Debug log written to: ${crashLogPath}`,
|
||||
].join("\n");
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
output.append(line);
|
||||
}
|
||||
|
||||
// Track where cursor ended up after rendering
|
||||
let finalCursorRow = renderEnd;
|
||||
|
||||
// If we had more lines before, clear them and move cursor back
|
||||
if (this.previousLines.length > newLines.length) {
|
||||
// Move to end of new content first if we stopped before it
|
||||
if (renderEnd < newLines.length - 1) {
|
||||
const moveDown = newLines.length - 1 - renderEnd;
|
||||
output.append(`\x1b[${moveDown}B`);
|
||||
finalCursorRow = newLines.length - 1;
|
||||
}
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
||||
output.append("\r\n\x1b[2K");
|
||||
}
|
||||
// Move cursor back to end of new content
|
||||
output.append(`\x1b[${extraLines}A`);
|
||||
}
|
||||
|
||||
output.append("\x1b[?2026l"); // End synchronized output
|
||||
|
||||
if (process.env.PI_TUI_DEBUG === "1") {
|
||||
const debugDir = "/tmp/tui";
|
||||
fs.mkdirSync(debugDir, { recursive: true });
|
||||
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
||||
const debugData = [
|
||||
`firstChanged: ${firstChanged}`,
|
||||
`viewportTop: ${viewportTop}`,
|
||||
`cursorRow: ${this.cursorRow}`,
|
||||
`height: ${height}`,
|
||||
`lineDiff: ${lineDiff}`,
|
||||
`hardwareCursorRow: ${hardwareCursorRow}`,
|
||||
`renderEnd: ${renderEnd}`,
|
||||
`finalCursorRow: ${finalCursorRow}`,
|
||||
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
||||
`newLines.length: ${newLines.length}`,
|
||||
`previousLines.length: ${this.previousLines.length}`,
|
||||
"",
|
||||
"=== newLines ===",
|
||||
JSON.stringify(newLines, null, 2),
|
||||
"",
|
||||
"=== previousLines ===",
|
||||
JSON.stringify(this.previousLines, null, 2),
|
||||
"",
|
||||
"=== buffer ===",
|
||||
`[${output.length} chars written in bounded chunks]`,
|
||||
].join("\n");
|
||||
fs.writeFileSync(debugPath, debugData);
|
||||
}
|
||||
|
||||
output.flush();
|
||||
|
||||
// Track cursor position for next render
|
||||
// cursorRow tracks end of content (for viewport calculation)
|
||||
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = finalCursorRow;
|
||||
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
||||
|
||||
// Position hardware cursor for IME
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the hardware cursor for IME candidate window.
|
||||
* @param cursorPos The cursor position extracted from rendered output, or null
|
||||
* @param totalLines Total number of rendered lines
|
||||
*/
|
||||
private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
||||
if (!cursorPos || totalLines <= 0) {
|
||||
this.terminal.hideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp cursor position to valid range
|
||||
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
||||
const targetCol = Math.max(0, cursorPos.col);
|
||||
|
||||
// Move cursor from current position to target
|
||||
const rowDelta = targetRow - this.hardwareCursorRow;
|
||||
let buffer = "";
|
||||
if (rowDelta > 0) {
|
||||
buffer += `\x1b[${rowDelta}B`; // Move down
|
||||
} else if (rowDelta < 0) {
|
||||
buffer += `\x1b[${-rowDelta}A`; // Move up
|
||||
}
|
||||
// Move to absolute column (1-indexed)
|
||||
buffer += `\x1b[${targetCol + 1}G`;
|
||||
|
||||
if (buffer) {
|
||||
this.terminal.write(buffer);
|
||||
}
|
||||
|
||||
this.hardwareCursorRow = targetRow;
|
||||
if (this.getShowHardwareCursor()) {
|
||||
this.terminal.showCursor();
|
||||
} else {
|
||||
this.terminal.hideCursor();
|
||||
}
|
||||
}
|
||||
}
|
||||
1456
third_party/pi/packages/tui/src/tui.ts
vendored
Normal file
1456
third_party/pi/packages/tui/src/tui.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
28
third_party/pi/packages/tui/src/undo-stack.ts
vendored
Normal file
28
third_party/pi/packages/tui/src/undo-stack.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Generic undo stack with clone-on-push semantics.
|
||||
*
|
||||
* Stores deep clones of state snapshots. Popped snapshots are returned
|
||||
* directly (no re-cloning) since they are already detached.
|
||||
*/
|
||||
export class UndoStack<S> {
|
||||
private stack: S[] = [];
|
||||
|
||||
/** Push a deep clone of the given state onto the stack. */
|
||||
push(state: S): void {
|
||||
this.stack.push(structuredClone(state));
|
||||
}
|
||||
|
||||
/** Pop and return the most recent snapshot, or undefined if empty. */
|
||||
pop(): S | undefined {
|
||||
return this.stack.pop();
|
||||
}
|
||||
|
||||
/** Remove all snapshots. */
|
||||
clear(): void {
|
||||
this.stack.length = 0;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.stack.length;
|
||||
}
|
||||
}
|
||||
1337
third_party/pi/packages/tui/src/utils.ts
vendored
Normal file
1337
third_party/pi/packages/tui/src/utils.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
117
third_party/pi/packages/tui/src/word-navigation.ts
vendored
Normal file
117
third_party/pi/packages/tui/src/word-navigation.ts
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts";
|
||||
|
||||
const wordSegmenter = getWordSegmenter();
|
||||
|
||||
/**
|
||||
* Options for word navigation functions.
|
||||
* When omitted, uses the default Intl.Segmenter word segmentation.
|
||||
*/
|
||||
export interface WordNavigationOptions {
|
||||
/** Custom segmenter returning word segments for the given text. */
|
||||
segment?: (text: string) => Iterable<Intl.SegmentData>;
|
||||
/** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */
|
||||
isAtomicSegment?: (segment: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cursor position after moving one word backward from `cursor` in `text`.
|
||||
* Skips trailing whitespace, then stops at the next word/punctuation boundary.
|
||||
*
|
||||
* Pure function - does not mutate any state.
|
||||
*/
|
||||
export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number {
|
||||
if (cursor <= 0) return 0;
|
||||
|
||||
const textBeforeCursor = text.slice(0, cursor);
|
||||
const segmentFn = options?.segment;
|
||||
const isAtomic = options?.isAtomicSegment;
|
||||
const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)];
|
||||
let newCursor = cursor;
|
||||
|
||||
// Skip trailing whitespace
|
||||
while (
|
||||
segments.length > 0 &&
|
||||
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
|
||||
isWhitespaceChar(segments[segments.length - 1]?.segment || "")
|
||||
) {
|
||||
newCursor -= segments.pop()?.segment.length || 0;
|
||||
}
|
||||
|
||||
if (segments.length === 0) return newCursor;
|
||||
|
||||
const last = segments[segments.length - 1]!;
|
||||
|
||||
if (isAtomic?.(last.segment)) {
|
||||
// Skip one atomic segment.
|
||||
newCursor -= last.segment.length;
|
||||
} else if (last.isWordLike) {
|
||||
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
|
||||
const segment = last.segment;
|
||||
const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))];
|
||||
if (matches.length <= 0) {
|
||||
newCursor -= segment.length;
|
||||
} else {
|
||||
const lastMatch = matches[matches.length - 1]!;
|
||||
newCursor -= segment.length - (lastMatch.index + lastMatch[0].length);
|
||||
}
|
||||
} else {
|
||||
// Skip non-word non-whitespace run (punctuation)
|
||||
while (
|
||||
segments.length > 0 &&
|
||||
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
|
||||
!segments[segments.length - 1]?.isWordLike &&
|
||||
!isWhitespaceChar(segments[segments.length - 1]?.segment || "")
|
||||
) {
|
||||
newCursor -= segments.pop()?.segment.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return newCursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cursor position after moving one word forward from `cursor` in `text`.
|
||||
* Skips leading whitespace, then stops at the next word/punctuation boundary.
|
||||
*
|
||||
* Pure function - does not mutate any state.
|
||||
*/
|
||||
export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number {
|
||||
if (cursor >= text.length) return text.length;
|
||||
|
||||
const textAfterCursor = text.slice(cursor);
|
||||
const segmentFn = options?.segment;
|
||||
const isAtomic = options?.isAtomicSegment;
|
||||
const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor);
|
||||
const iterator = segments[Symbol.iterator]();
|
||||
let next = iterator.next();
|
||||
let newCursor = cursor;
|
||||
|
||||
// Skip leading whitespace
|
||||
while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
||||
newCursor += next.value.segment.length;
|
||||
next = iterator.next();
|
||||
}
|
||||
|
||||
if (next.done) return newCursor;
|
||||
|
||||
if (isAtomic?.(next.value.segment)) {
|
||||
// Skip one atomic segment.
|
||||
newCursor += next.value.segment.length;
|
||||
} else if (next.value.isWordLike) {
|
||||
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
|
||||
newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length;
|
||||
} else {
|
||||
// Skip non-word non-whitespace run (punctuation)
|
||||
while (
|
||||
!next.done &&
|
||||
!isAtomic?.(next.value.segment) &&
|
||||
!next.value.isWordLike &&
|
||||
!isWhitespaceChar(next.value.segment)
|
||||
) {
|
||||
newCursor += next.value.segment.length;
|
||||
next = iterator.next();
|
||||
}
|
||||
}
|
||||
|
||||
return newCursor;
|
||||
}
|
||||
166
third_party/pi/packages/tui/test/alt-screen-large-transcript-bench.ts
vendored
Normal file
166
third_party/pi/packages/tui/test/alt-screen-large-transcript-bench.ts
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Alternate-screen large-transcript benchmark.
|
||||
*
|
||||
* Mirrors the fullscreen transcript shape used by the coding agent:
|
||||
*
|
||||
* ScrollView
|
||||
* └── document Container
|
||||
* ├── header Container
|
||||
* ├── resources Container
|
||||
* └── chat Container
|
||||
* └── 2,500 alternating user/assistant Markdown components
|
||||
*
|
||||
* Measures warm steady frames, one-line scrolling, streaming updates to the
|
||||
* final assistant message, and width changes that invalidate Markdown caches.
|
||||
*
|
||||
* Run from the repository root:
|
||||
* node --experimental-strip-types packages/tui/test/alt-screen-large-transcript-bench.ts
|
||||
*/
|
||||
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import { Container } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
|
||||
const COLUMNS = 80;
|
||||
const ROWS = 50;
|
||||
const COMPONENT_COUNT = 2_500;
|
||||
const WARMUP_FRAMES = 20;
|
||||
|
||||
const theme: MarkdownTheme = {
|
||||
heading: (text) => text,
|
||||
link: (text) => text,
|
||||
linkUrl: (text) => text,
|
||||
code: (text) => text,
|
||||
codeBlock: (text) => text,
|
||||
codeBlockBorder: (text) => text,
|
||||
quote: (text) => text,
|
||||
quoteBorder: (text) => text,
|
||||
hr: (text) => text,
|
||||
listBullet: (text) => text,
|
||||
bold: (text) => text,
|
||||
italic: (text) => text,
|
||||
strikethrough: (text) => text,
|
||||
underline: (text) => text,
|
||||
};
|
||||
|
||||
class NullTerminal implements Terminal {
|
||||
columns = COLUMNS;
|
||||
rows = ROWS;
|
||||
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(_data: string): void {}
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
const userMarkdown = [
|
||||
"## User request",
|
||||
"",
|
||||
"Please inspect this implementation carefully and report the relevant behavior.",
|
||||
"",
|
||||
"- preserve correctness",
|
||||
"- include concrete evidence",
|
||||
].join("\n");
|
||||
|
||||
const assistantMarkdown = [
|
||||
"## Analysis",
|
||||
"",
|
||||
"This response contains representative prose with **inline styles**, a [link](https://example.com), and enough words to wrap across terminal rows at realistic widths. The implementation should preserve all observable behavior while avoiding unnecessary allocation.",
|
||||
"",
|
||||
"1. First item with explanatory text and a concrete example.",
|
||||
"2. Second item with more explanatory text and another example.",
|
||||
"3. Third item that ensures list rendering is represented.",
|
||||
"",
|
||||
"```ts",
|
||||
"function render(width: number): string[] {",
|
||||
" const lines: string[] = [];",
|
||||
" for (const child of children) {",
|
||||
" const childLines = child.render(width);",
|
||||
" for (const line of childLines) lines.push(line);",
|
||||
" }",
|
||||
" const height = lines.length;",
|
||||
" const start = Math.max(0, from);",
|
||||
" const end = Math.min(height, start + count);",
|
||||
" const visible = lines.slice(start, end);",
|
||||
" updateLayout(height, viewportHeight);",
|
||||
" return visible;",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"> A quoted conclusion with enough text to exercise wrapping and inline rendering in the real Markdown component.",
|
||||
"",
|
||||
"Final paragraph with a concise recommendation and a note about correctness tests.",
|
||||
].join("\n");
|
||||
|
||||
const header = new Container();
|
||||
header.addChild(new Text("pi benchmark\nheader line", 0, 0));
|
||||
const resources = new Container();
|
||||
resources.addChild(new Text("resource", 0, 0));
|
||||
const chat = new Container();
|
||||
let streamingMarkdown: Markdown | undefined;
|
||||
for (let index = 0; index < COMPONENT_COUNT; index++) {
|
||||
const markdown = new Markdown(index % 2 === 0 ? userMarkdown : assistantMarkdown, 1, 0, theme);
|
||||
chat.addChild(markdown);
|
||||
if (index === COMPONENT_COUNT - 1) streamingMarkdown = markdown;
|
||||
}
|
||||
if (!streamingMarkdown) throw new Error("Benchmark requires at least one assistant Markdown component");
|
||||
|
||||
const document = new Container();
|
||||
document.addChild(header);
|
||||
document.addChild(resources);
|
||||
document.addChild(chat);
|
||||
|
||||
const transcript = new ScrollView(document, { follow: "none", primary: true });
|
||||
const dock = new VStack([new Text("editor\nfooter", 0, 0)]);
|
||||
const root = new VStack([
|
||||
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", minSize: 1 },
|
||||
]);
|
||||
const terminal = new NullTerminal();
|
||||
const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench");
|
||||
tui.setLayoutRoot(root);
|
||||
tui.start();
|
||||
tui.renderNow();
|
||||
|
||||
const totalLines = document.render(terminal.columns).length;
|
||||
transcript.scrollTo(Math.floor(totalLines / 2));
|
||||
for (let frame = 0; frame < WARMUP_FRAMES; frame++) tui.renderNow();
|
||||
|
||||
function measure(name: string, frames: number, beforeFrame: (frame: number) => void): void {
|
||||
const start = performance.now();
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
beforeFrame(frame);
|
||||
tui.renderNow();
|
||||
}
|
||||
const millisecondsPerFrame = (performance.now() - start) / frames;
|
||||
console.log(`${name}: ${millisecondsPerFrame.toFixed(3)} ms/frame (${frames} frames)`);
|
||||
}
|
||||
|
||||
console.log(`components=${COMPONENT_COUNT} lines=${totalLines} viewport=${terminal.columns}x${terminal.rows}`);
|
||||
measure("steady", 200, () => {});
|
||||
transcript.scrollTo(Math.floor(totalLines / 2));
|
||||
measure("scroll", 200, () => transcript.scrollBy(1));
|
||||
measure("streaming", 100, (frame) => {
|
||||
streamingMarkdown.setText(`${assistantMarkdown}\n\nstream ${"x".repeat(frame + 1)}`);
|
||||
});
|
||||
measure("resize", 10, (frame) => {
|
||||
terminal.columns = frame % 2 === 0 ? COLUMNS - 1 : COLUMNS;
|
||||
});
|
||||
|
||||
tui.stop();
|
||||
578
third_party/pi/packages/tui/test/autocomplete.test.ts
vendored
Normal file
578
third_party/pi/packages/tui/test/autocomplete.test.ts
vendored
Normal file
@@ -0,0 +1,578 @@
|
||||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, it, test } from "node:test";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.ts";
|
||||
|
||||
const resolveFdPath = (): string | null => {
|
||||
const command = process.platform === "win32" ? "where" : "which";
|
||||
const result = spawnSync(command, ["fd"], { encoding: "utf-8" });
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
|
||||
return firstLine ? firstLine.trim() : null;
|
||||
};
|
||||
|
||||
type FolderStructure = {
|
||||
dirs?: string[];
|
||||
files?: Record<string, string>;
|
||||
};
|
||||
|
||||
const setupFolder = (baseDir: string, structure: FolderStructure = {}): void => {
|
||||
const dirs = structure.dirs ?? [];
|
||||
const files = structure.files ?? {};
|
||||
|
||||
dirs.forEach((dir) => {
|
||||
mkdirSync(join(baseDir, dir), { recursive: true });
|
||||
});
|
||||
Object.entries(files).forEach(([filePath, contents]) => {
|
||||
const fullPath = join(baseDir, filePath);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents);
|
||||
});
|
||||
};
|
||||
|
||||
const fdPath = resolveFdPath();
|
||||
const isFdInstalled = Boolean(fdPath);
|
||||
|
||||
const requireFdPath = (): string => {
|
||||
if (!fdPath) {
|
||||
throw new Error("fd is not available");
|
||||
}
|
||||
return fdPath;
|
||||
};
|
||||
|
||||
const getSuggestions = (
|
||||
provider: CombinedAutocompleteProvider,
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
force: boolean = false,
|
||||
) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force });
|
||||
|
||||
describe("CombinedAutocompleteProvider", () => {
|
||||
describe("extractPathPrefix", () => {
|
||||
it("extracts / from 'hey /' when forced", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["hey /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 5; // After the "/"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for root directory");
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts /A from '/A' when forced", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/A"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 2; // After the "A"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
console.log("Result:", result);
|
||||
// This might return null if /A doesn't match anything, which is fine
|
||||
// We're mainly testing that the prefix extraction works
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not trigger for slash commands", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/model"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 6; // After "model"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
||||
});
|
||||
|
||||
it("triggers for absolute paths after slash command argument", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/command /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 10; // After the second "/"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.notEqual(result, null, "Should trigger for absolute paths in command arguments");
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
|
||||
let rootDir = "";
|
||||
let baseDir = "";
|
||||
let outsideDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-"));
|
||||
baseDir = join(rootDir, "cwd");
|
||||
outsideDir = join(rootDir, "outside");
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns all files and folders for empty @ query", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value).sort();
|
||||
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
|
||||
});
|
||||
|
||||
test("matches file with extension in query", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"file.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@file.txt";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@file.txt"));
|
||||
});
|
||||
|
||||
test("filters are case insensitive", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@re";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value).sort();
|
||||
assert.deepStrictEqual(values, ["@README.md"]);
|
||||
});
|
||||
|
||||
test("ranks directories before files", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"src.txt": "text",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@src";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const firstValue = result?.items[0]?.value;
|
||||
const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt");
|
||||
assert.strictEqual(firstValue, "@src/");
|
||||
assert.ok(hasSrcFile);
|
||||
});
|
||||
|
||||
test("returns nested file paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/index.ts": "export {};\n",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@index";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@src/index.ts"));
|
||||
});
|
||||
|
||||
test("matches deeply nested paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"packages/tui/src/autocomplete.ts": "export {};",
|
||||
"packages/ai/src/autocomplete.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@tui/src/auto";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
|
||||
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
|
||||
});
|
||||
|
||||
test("matches directory in middle of path with --full-path", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/components/Button.tsx": "export {};",
|
||||
"src/utils/helpers.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@components/";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@src/components/Button.tsx"));
|
||||
assert.ok(!values?.includes("@src/utils/helpers.ts"));
|
||||
});
|
||||
|
||||
test("scopes fuzzy search to relative directories and searches recursively", async () => {
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"nested/alpha.ts": "export {};",
|
||||
"nested/deeper/also-alpha.ts": "export {};",
|
||||
"nested/deeper/zzz.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@../outside/a";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
|
||||
assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts"));
|
||||
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
|
||||
});
|
||||
|
||||
test("ranks shallower same-score @ matches before deeper matches", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile", "scope/projects"],
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@scope/pro";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.strictEqual(values[0], "@scope/projects/");
|
||||
assert.ok(values.includes("@scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile/"));
|
||||
});
|
||||
|
||||
test("includes scoped direct children when recursive @ matches are flooded", async () => {
|
||||
const floodedDirs = Array.from(
|
||||
{ length: 250 },
|
||||
(_, index) =>
|
||||
`scope/a${String(index + 1).padStart(3, "0")}/venv/lib/python3.12/site-packages/pkg/core/profile`,
|
||||
);
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["scope/projects", ...floodedDirs],
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@scope/pro";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.strictEqual(values[0], "@scope/projects/");
|
||||
assert.ok(
|
||||
values.some((value) => value.includes("/profile/")),
|
||||
"Should keep deep fuzzy matches after direct children",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes paths with spaces for @ suggestions", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@my";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('@"my folder/"'));
|
||||
});
|
||||
|
||||
test("includes hidden paths but excludes .git", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: [".pi", ".github", ".git"],
|
||||
files: {
|
||||
".pi/config.json": "{}",
|
||||
".github/workflows/ci.yml": "name: ci",
|
||||
".git/config": "[core]",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@.pi/"));
|
||||
assert.ok(values.includes("@.github/"));
|
||||
assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/")));
|
||||
});
|
||||
|
||||
test("follows symlinked directories for fuzzy @ search", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"dir/some_file.txt": "real",
|
||||
},
|
||||
});
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"some_file.txt": "symlinked",
|
||||
},
|
||||
});
|
||||
symlinkSync("../outside", join(baseDir, "symlinked_dir"));
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@some";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@dir/some_file.txt"));
|
||||
assert.ok(values.includes("@symlinked_dir/some_file.txt"));
|
||||
});
|
||||
|
||||
test("returns symlinked directories when matching their name", async () => {
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"nested/file.txt": "symlinked",
|
||||
},
|
||||
});
|
||||
symlinkSync("../outside", join(baseDir, "symlinked_dir"));
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@symlinked";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@symlinked_dir/"));
|
||||
});
|
||||
|
||||
test("returns symlinked files without requiring type l", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"original.txt": "content",
|
||||
},
|
||||
});
|
||||
const linkPath = join(baseDir, "link.txt");
|
||||
symlinkSync("original.txt", linkPath);
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@link";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@link.txt"));
|
||||
});
|
||||
|
||||
test("returns the same @ suggestions when the cwd path contains the query", async () => {
|
||||
const normalBaseDir = join(rootDir, "cwd-normal");
|
||||
const queryInPathBaseDir = join(rootDir, "cwd-plan-repro");
|
||||
mkdirSync(normalBaseDir, { recursive: true });
|
||||
mkdirSync(queryInPathBaseDir, { recursive: true });
|
||||
|
||||
const structure = {
|
||||
dirs: ["packages/coding-agent/examples/extensions/plan-mode"],
|
||||
files: {
|
||||
"packages/coding-agent/examples/extensions/plan-mode/README.md": "readme",
|
||||
"packages/tui/docs/plan.md": "plan",
|
||||
},
|
||||
};
|
||||
setupFolder(normalBaseDir, structure);
|
||||
setupFolder(queryInPathBaseDir, structure);
|
||||
|
||||
const query = "@plan";
|
||||
const normalProvider = new CombinedAutocompleteProvider([], normalBaseDir, requireFdPath());
|
||||
const queryInPathProvider = new CombinedAutocompleteProvider([], queryInPathBaseDir, requireFdPath());
|
||||
|
||||
const normalResult = await getSuggestions(normalProvider, [query], 0, query.length);
|
||||
const queryInPathResult = await getSuggestions(queryInPathProvider, [query], 0, query.length);
|
||||
|
||||
const normalize = (result: Awaited<ReturnType<typeof getSuggestions>>) =>
|
||||
(result?.items ?? []).map((item) => `${item.label} :: ${item.description ?? ""}`).sort();
|
||||
|
||||
assert.deepStrictEqual(normalize(queryInPathResult), normalize(normalResult));
|
||||
assert.ok(
|
||||
normalize(normalResult).includes("plan-mode/ :: packages/coding-agent/examples/extensions/plan-mode"),
|
||||
);
|
||||
assert.ok(normalize(normalResult).includes("plan.md :: packages/tui/docs/plan.md"));
|
||||
});
|
||||
|
||||
test("continues autocomplete inside quoted @ paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
"my folder/other.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = '@"my folder/"';
|
||||
const result = await getSuggestions(provider, [line], 0, line.length - 1);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('@"my folder/test.txt"'));
|
||||
assert.ok(values?.includes('@"my folder/other.txt"'));
|
||||
});
|
||||
|
||||
test("applies quoted @ completion without duplicating closing quote", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = '@"my folder/te"';
|
||||
const cursorCol = line.length - 1;
|
||||
const result = await getSuggestions(provider, [line], 0, cursorCol);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for quoted @ path");
|
||||
const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"');
|
||||
assert.ok(item, "Should find test.txt suggestion");
|
||||
|
||||
const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix);
|
||||
assert.strictEqual(applied.lines[0], '@"my folder/test.txt" ');
|
||||
});
|
||||
});
|
||||
|
||||
describe("dot-slash path completion", () => {
|
||||
let baseDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("preserves ./ prefix when completing paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"update.sh": "#!/bin/bash",
|
||||
"utils.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "./up";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for ./ path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`);
|
||||
});
|
||||
|
||||
test("preserves ./ prefix for directory completions", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"src/index.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "./sr";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for ./ directory path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted path completion", () => {
|
||||
let baseDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("quotes paths with spaces for direct completion", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "my";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for path completion");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('"my folder/"'));
|
||||
});
|
||||
|
||||
test("continues completion inside quoted paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
"my folder/other.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = '"my folder/"';
|
||||
const result = await getSuggestions(provider, [line], 0, line.length - 1, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('"my folder/test.txt"'));
|
||||
assert.ok(values?.includes('"my folder/other.txt"'));
|
||||
});
|
||||
|
||||
test("applies quoted completion without duplicating closing quote", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = '"my folder/te"';
|
||||
const cursorCol = line.length - 1;
|
||||
const result = await getSuggestions(provider, [line], 0, cursorCol, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for quoted path");
|
||||
const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"');
|
||||
assert.ok(item, "Should find test.txt suggestion");
|
||||
|
||||
const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix);
|
||||
assert.strictEqual(applied.lines[0], '"my folder/test.txt"');
|
||||
});
|
||||
});
|
||||
});
|
||||
237
third_party/pi/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts
vendored
Normal file
237
third_party/pi/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Bug regression test for isImageLine() crash scenario
|
||||
*
|
||||
* Bug: When isImageLine() used startsWith() and terminal doesn't support images,
|
||||
* it would return false for lines containing image escape sequences, causing TUI to
|
||||
* crash with "Rendered line exceeds terminal width" error.
|
||||
*
|
||||
* Fix: Changed to use includes() to detect escape sequences anywhere in the line.
|
||||
*
|
||||
* This test demonstrates:
|
||||
* 1. The bug scenario with the old implementation
|
||||
* 2. That the fix works correctly
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
describe("Bug regression: isImageLine() crash with image escape sequences", () => {
|
||||
describe("Bug scenario: Terminal without image support", () => {
|
||||
it("old implementation would return false, causing crash", () => {
|
||||
/**
|
||||
* OLD IMPLEMENTATION (buggy):
|
||||
* ```typescript
|
||||
* export function isImageLine(line: string): boolean {
|
||||
* const prefix = getImageEscapePrefix();
|
||||
* return prefix !== null && line.startsWith(prefix);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When terminal doesn't support images:
|
||||
* - getImageEscapePrefix() returns null
|
||||
* - isImageLine() returns false even for lines containing image sequences
|
||||
* - TUI performs width check on line containing 300KB+ of base64 data
|
||||
* - Crash: "Rendered line exceeds terminal width (304401 > 115)"
|
||||
*/
|
||||
|
||||
// Simulate old implementation behavior
|
||||
const oldIsImageLine = (line: string, imageEscapePrefix: string | null): boolean => {
|
||||
return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix);
|
||||
};
|
||||
|
||||
// When terminal doesn't support images, prefix is null
|
||||
const terminalWithoutImageSupport = null;
|
||||
|
||||
// Line containing image escape sequence with text before it (common bug scenario)
|
||||
const lineWithImageSequence =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
|
||||
|
||||
// Old implementation would return false (BUG!)
|
||||
const oldResult = oldIsImageLine(lineWithImageSequence, terminalWithoutImageSupport);
|
||||
assert.strictEqual(
|
||||
oldResult,
|
||||
false,
|
||||
"Bug: old implementation returns false for line containing image sequence when terminal has no image support",
|
||||
);
|
||||
});
|
||||
|
||||
it("new implementation returns true correctly", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
// Line containing image escape sequence with text before it
|
||||
const lineWithImageSequence =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
|
||||
|
||||
// New implementation should return true (FIX!)
|
||||
const newResult = isImageLine(lineWithImageSequence);
|
||||
assert.strictEqual(newResult, true, "Fix: new implementation returns true for line containing image sequence");
|
||||
});
|
||||
|
||||
it("new implementation detects Kitty sequences in any position", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
const scenarios = [
|
||||
"At start: \x1b_Ga=T,f=100,data...\x1b\\",
|
||||
"Prefix \x1b_Ga=T,data...\x1b\\",
|
||||
"Suffix text \x1b_Ga=T,data...\x1b\\ suffix",
|
||||
"Middle \x1b_Ga=T,data...\x1b\\ more text",
|
||||
// Very long line (simulating 300KB+ crash scenario)
|
||||
`Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`,
|
||||
];
|
||||
|
||||
for (const line of scenarios) {
|
||||
assert.strictEqual(isImageLine(line), true, `Should detect Kitty sequence in: ${line.slice(0, 50)}...`);
|
||||
}
|
||||
});
|
||||
|
||||
it("new implementation detects iTerm2 sequences in any position", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
const scenarios = [
|
||||
"At start: \x1b]1337;File=size=100,100:base64...\x07",
|
||||
"Prefix \x1b]1337;File=inline=1:data==\x07",
|
||||
"Suffix text \x1b]1337;File=inline=1:data==\x07 suffix",
|
||||
"Middle \x1b]1337;File=inline=1:data==\x07 more text",
|
||||
// Very long line (simulating 304KB crash scenario)
|
||||
`Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`,
|
||||
];
|
||||
|
||||
for (const line of scenarios) {
|
||||
assert.strictEqual(isImageLine(line), true, `Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: Tool execution scenario", () => {
|
||||
/**
|
||||
* This simulates what happens when the `read` tool reads an image file.
|
||||
* The tool result contains both text and image content:
|
||||
*
|
||||
* ```typescript
|
||||
* {
|
||||
* content: [
|
||||
* { type: "text", text: "Read image file [image/jpeg]\n800x600" },
|
||||
* { type: "image", data: "base64...", mimeType: "image/jpeg" }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When this is rendered, the image component creates escape sequences.
|
||||
* If isImageLine() doesn't detect them, TUI crashes.
|
||||
*/
|
||||
|
||||
it("detects image sequences in read tool output", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
// Simulate output when read tool processes an image
|
||||
// The line might have text from the read result plus the image escape sequence
|
||||
const toolOutputLine = "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07";
|
||||
|
||||
assert.strictEqual(isImageLine(toolOutputLine), true, "Should detect image sequence in tool output line");
|
||||
});
|
||||
|
||||
it("detects Kitty sequences from Image component", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
// Kitty image component creates multi-line output with escape sequences
|
||||
const kittyLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
|
||||
assert.strictEqual(isImageLine(kittyLine), true, "Should detect Kitty image component output");
|
||||
});
|
||||
|
||||
it("handles ANSI codes before image sequences", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
// Line might have styling (error, warning, etc.) before image data
|
||||
const lines = [
|
||||
"\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07",
|
||||
"\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\",
|
||||
"\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m",
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(
|
||||
isImageLine(line),
|
||||
true,
|
||||
`Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Crash scenario simulation", () => {
|
||||
it("does NOT crash on very long lines with image sequences", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
/**
|
||||
* Simulate the exact crash scenario:
|
||||
* - Line is 304,401 characters (the crash log showed 58649 > 115)
|
||||
* - Contains image escape sequence somewhere in the middle
|
||||
* - Old implementation would return false, causing TUI to do width check
|
||||
* - New implementation returns true, skipping width check (preventing crash)
|
||||
*/
|
||||
|
||||
const base64Char = "A".repeat(100);
|
||||
const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:";
|
||||
|
||||
// Build a line that would cause the crash
|
||||
const crashLine =
|
||||
"Output: " +
|
||||
iterm2Sequence +
|
||||
base64Char.repeat(3040) + // ~304,000 chars
|
||||
" end of output";
|
||||
|
||||
// Verify line is very long
|
||||
assert(crashLine.length > 300000, "Test line should be > 300KB");
|
||||
|
||||
// New implementation should detect it (prevents crash)
|
||||
const detected = isImageLine(crashLine);
|
||||
assert.strictEqual(detected, true, "Should detect image sequence in very long line, preventing TUI crash");
|
||||
});
|
||||
|
||||
it("handles lines exactly matching crash log dimensions", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
/**
|
||||
* Crash log showed: line 58649 chars wide, terminal width 115
|
||||
* Let's create a line with similar characteristics
|
||||
*/
|
||||
|
||||
const targetWidth = 58649;
|
||||
const prefix = "Text";
|
||||
const sequence = "\x1b_Ga=T,f=100";
|
||||
const suffix = "End";
|
||||
const padding = "A".repeat(targetWidth - prefix.length - sequence.length - suffix.length);
|
||||
const line = `${prefix}${sequence}${padding}${suffix}`;
|
||||
|
||||
assert.strictEqual(line.length, 58649);
|
||||
assert.strictEqual(isImageLine(line), true, "Should detect image sequence in 58649-char line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative cases: Don't false positive", () => {
|
||||
it("does not detect images in regular long text", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
// Very long line WITHOUT image sequences
|
||||
const longText = "A".repeat(100000);
|
||||
|
||||
assert.strictEqual(isImageLine(longText), false, "Should not detect images in plain long text");
|
||||
});
|
||||
|
||||
it("does not detect images in lines with file paths", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.ts");
|
||||
|
||||
const filePaths = [
|
||||
"/path/to/1337/image.jpg",
|
||||
"/usr/local/bin/File_converter",
|
||||
"~/Documents/1337File_backup.png",
|
||||
"./_G_test_file.txt",
|
||||
];
|
||||
|
||||
for (const path of filePaths) {
|
||||
assert.strictEqual(isImageLine(path), false, `Should not falsely detect image sequence in path: ${path}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
130
third_party/pi/packages/tui/test/chat-simple.ts
vendored
Normal file
130
third_party/pi/packages/tui/test/chat-simple.ts
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Simple chat interface demo using tui.ts
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.ts";
|
||||
import { Editor } from "../src/components/editor.ts";
|
||||
import { Loader } from "../src/components/loader.ts";
|
||||
import { Markdown } from "../src/components/markdown.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
// Create TUI
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
// Create chat container with some initial messages
|
||||
tui.addChild(
|
||||
new Text("Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit."),
|
||||
);
|
||||
|
||||
// Create editor with autocomplete
|
||||
const editor = new Editor(tui, defaultEditorTheme);
|
||||
|
||||
// Set up autocomplete provider with slash commands and file completion
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[
|
||||
{ name: "delete", description: "Delete the last message" },
|
||||
{ name: "clear", description: "Clear all messages" },
|
||||
],
|
||||
process.cwd(),
|
||||
);
|
||||
editor.setAutocompleteProvider(autocompleteProvider);
|
||||
|
||||
tui.addChild(editor);
|
||||
|
||||
// Focus the editor
|
||||
tui.setFocus(editor);
|
||||
|
||||
// Track if we're waiting for bot response
|
||||
let isResponding = false;
|
||||
|
||||
// Handle message submission
|
||||
editor.onSubmit = (value: string) => {
|
||||
// Prevent submission if already responding
|
||||
if (isResponding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Handle slash commands
|
||||
if (trimmed === "/delete") {
|
||||
const children = tui.children;
|
||||
// Remove component before editor (if there are any besides the initial text)
|
||||
if (children.length > 3) {
|
||||
// children[0] = "Welcome to Simple Chat!"
|
||||
// children[1] = "Type your messages below..."
|
||||
// children[2...n-1] = messages
|
||||
// children[n] = editor
|
||||
children.splice(children.length - 2, 1);
|
||||
}
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "/clear") {
|
||||
const children = tui.children;
|
||||
// Remove all messages but keep the welcome text and editor
|
||||
children.splice(2, children.length - 3);
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
isResponding = true;
|
||||
editor.disableSubmit = true;
|
||||
|
||||
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
|
||||
|
||||
const children = tui.children;
|
||||
children.splice(children.length - 1, 0, userMessage);
|
||||
|
||||
const loader = new Loader(
|
||||
tui,
|
||||
(s) => chalk.cyan(s),
|
||||
(s) => chalk.dim(s),
|
||||
"Thinking...",
|
||||
);
|
||||
children.splice(children.length - 1, 0, loader);
|
||||
|
||||
tui.requestRender();
|
||||
|
||||
setTimeout(() => {
|
||||
tui.removeChild(loader);
|
||||
|
||||
// Simulate a response
|
||||
const responses = [
|
||||
"That's interesting! Tell me more.",
|
||||
"I see what you mean.",
|
||||
"Fascinating perspective!",
|
||||
"Could you elaborate on that?",
|
||||
"That makes sense to me.",
|
||||
"I hadn't thought of it that way.",
|
||||
"Great point!",
|
||||
"Thanks for sharing that.",
|
||||
];
|
||||
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
||||
|
||||
// Add assistant message with no background (transparent)
|
||||
const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme);
|
||||
children.splice(children.length - 1, 0, botMessage);
|
||||
|
||||
// Re-enable submit
|
||||
isResponding = false;
|
||||
editor.disableSubmit = false;
|
||||
|
||||
// Request render
|
||||
tui.requestRender();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the TUI
|
||||
tui.start();
|
||||
43
third_party/pi/packages/tui/test/editor-history-keybindings.test.ts
vendored
Normal file
43
third_party/pi/packages/tui/test/editor-history-keybindings.test.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { Editor } from "../src/components/editor.ts";
|
||||
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { defaultEditorTheme } from "./test-themes.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
|
||||
});
|
||||
|
||||
describe("Editor prompt history keybindings", () => {
|
||||
it("browses history directly without first moving the cursor", () => {
|
||||
setKeybindings(
|
||||
new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.editor.historyPrevious": "ctrl+p",
|
||||
"tui.editor.historyNext": "ctrl+n",
|
||||
}),
|
||||
);
|
||||
const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme);
|
||||
editor.addToHistory("older prompt");
|
||||
editor.addToHistory("newer\nmultiline prompt");
|
||||
editor.setText("draft");
|
||||
editor.handleInput("\x1b[D");
|
||||
editor.handleInput("\x1b[D");
|
||||
|
||||
editor.handleInput("\x10"); // Ctrl+P
|
||||
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
|
||||
|
||||
editor.handleInput("\x10"); // Ctrl+P
|
||||
assert.strictEqual(editor.getText(), "older prompt");
|
||||
|
||||
editor.handleInput("\x0e"); // Ctrl+N
|
||||
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 16 });
|
||||
|
||||
editor.handleInput("\x0e"); // Ctrl+N
|
||||
assert.strictEqual(editor.getText(), "draft");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
|
||||
});
|
||||
});
|
||||
4165
third_party/pi/packages/tui/test/editor.test.ts
vendored
Normal file
4165
third_party/pi/packages/tui/test/editor.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
112
third_party/pi/packages/tui/test/fuzzy.test.ts
vendored
Normal file
112
third_party/pi/packages/tui/test/fuzzy.test.ts
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.ts";
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("empty query matches everything with score 0", () => {
|
||||
const result = fuzzyMatch("", "anything");
|
||||
assert.strictEqual(result.matches, true);
|
||||
assert.strictEqual(result.score, 0);
|
||||
});
|
||||
|
||||
it("query longer than text does not match", () => {
|
||||
const result = fuzzyMatch("longquery", "short");
|
||||
assert.strictEqual(result.matches, false);
|
||||
});
|
||||
|
||||
it("exact match has good score", () => {
|
||||
const result = fuzzyMatch("test", "test");
|
||||
assert.strictEqual(result.matches, true);
|
||||
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
|
||||
});
|
||||
|
||||
it("characters must appear in order", () => {
|
||||
const matchInOrder = fuzzyMatch("abc", "aXbXc");
|
||||
assert.strictEqual(matchInOrder.matches, true);
|
||||
|
||||
const matchOutOfOrder = fuzzyMatch("abc", "cba");
|
||||
assert.strictEqual(matchOutOfOrder.matches, false);
|
||||
});
|
||||
|
||||
it("case insensitive matching", () => {
|
||||
const result = fuzzyMatch("ABC", "abc");
|
||||
assert.strictEqual(result.matches, true);
|
||||
|
||||
const result2 = fuzzyMatch("abc", "ABC");
|
||||
assert.strictEqual(result2.matches, true);
|
||||
});
|
||||
|
||||
it("consecutive matches score better than scattered matches", () => {
|
||||
const consecutive = fuzzyMatch("foo", "foobar");
|
||||
const scattered = fuzzyMatch("foo", "f_o_o_bar");
|
||||
|
||||
assert.strictEqual(consecutive.matches, true);
|
||||
assert.strictEqual(scattered.matches, true);
|
||||
assert.ok(consecutive.score < scattered.score);
|
||||
});
|
||||
|
||||
it("word boundary matches score better", () => {
|
||||
const atBoundary = fuzzyMatch("fb", "foo-bar");
|
||||
const notAtBoundary = fuzzyMatch("fb", "afbx");
|
||||
|
||||
assert.strictEqual(atBoundary.matches, true);
|
||||
assert.strictEqual(notAtBoundary.matches, true);
|
||||
assert.ok(atBoundary.score < notAtBoundary.score);
|
||||
});
|
||||
|
||||
it("matches swapped alpha numeric tokens", () => {
|
||||
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
|
||||
assert.strictEqual(result.matches, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyFilter", () => {
|
||||
it("empty query returns all items unchanged", () => {
|
||||
const items = ["apple", "banana", "cherry"];
|
||||
const result = fuzzyFilter(items, "", (x: string) => x);
|
||||
assert.deepStrictEqual(result, items);
|
||||
});
|
||||
|
||||
it("filters out non-matching items", () => {
|
||||
const items = ["apple", "banana", "cherry"];
|
||||
const result = fuzzyFilter(items, "an", (x: string) => x);
|
||||
assert.ok(result.includes("banana"));
|
||||
assert.ok(!result.includes("apple"));
|
||||
assert.ok(!result.includes("cherry"));
|
||||
});
|
||||
|
||||
it("sorts results by match quality", () => {
|
||||
const items = ["a_p_p", "app", "application"];
|
||||
const result = fuzzyFilter(items, "app", (x: string) => x);
|
||||
|
||||
// "app" should be first (exact consecutive match at start)
|
||||
assert.strictEqual(result[0], "app");
|
||||
});
|
||||
|
||||
it("prioritizes exact matches over longer prefix matches", () => {
|
||||
const items = ["clone", "cl"];
|
||||
const result = fuzzyFilter(items, "cl", (x: string) => x);
|
||||
|
||||
assert.deepStrictEqual(result, ["cl", "clone"]);
|
||||
});
|
||||
|
||||
it("works with custom getText function", () => {
|
||||
const items = [
|
||||
{ name: "foo", id: 1 },
|
||||
{ name: "bar", id: 2 },
|
||||
{ name: "foobar", id: 3 },
|
||||
];
|
||||
const result = fuzzyFilter(items, "foo", (item: { name: string; id: number }) => item.name);
|
||||
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.ok(result.map((r) => r.name).includes("foo"));
|
||||
assert.ok(result.map((r) => r.name).includes("foobar"));
|
||||
});
|
||||
|
||||
it("matches slash-separated provider/model queries against reordered text", () => {
|
||||
const item = { id: "gpt-5.5", provider: "openai-codex" };
|
||||
const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`);
|
||||
|
||||
assert.deepStrictEqual(result, [item]);
|
||||
});
|
||||
});
|
||||
57
third_party/pi/packages/tui/test/image-test.ts
vendored
Normal file
57
third_party/pi/packages/tui/test/image-test.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import { Spacer } from "../src/components/spacer.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import { getCapabilities, getImageDimensions } from "../src/terminal-image.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
|
||||
const testImagePath = process.argv[2] || "/tmp/test-image.png";
|
||||
|
||||
console.log("Terminal capabilities:", getCapabilities());
|
||||
console.log("Loading image from:", testImagePath);
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
try {
|
||||
imageBuffer = readFileSync(testImagePath);
|
||||
} catch (_e) {
|
||||
console.error(`Failed to load image: ${testImagePath}`);
|
||||
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const dims = getImageDimensions(base64Data, "image/png");
|
||||
|
||||
console.log("Image dimensions:", dims);
|
||||
console.log("");
|
||||
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
tui.addChild(new Text("Image Rendering Test", 1, 1));
|
||||
tui.addChild(new Spacer(1));
|
||||
|
||||
if (dims) {
|
||||
tui.addChild(
|
||||
new Image(base64Data, "image/png", { fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` }, { maxWidthCells: 60 }, dims),
|
||||
);
|
||||
} else {
|
||||
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
|
||||
}
|
||||
|
||||
tui.addChild(new Spacer(1));
|
||||
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
|
||||
|
||||
const editor = {
|
||||
handleInput(data: string) {
|
||||
if (data.charCodeAt(0) === 3) {
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
tui.setFocus(editor as any);
|
||||
tui.start();
|
||||
664
third_party/pi/packages/tui/test/input.test.ts
vendored
Normal file
664
third_party/pi/packages/tui/test/input.test.ts
vendored
Normal file
@@ -0,0 +1,664 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
describe("Input component", () => {
|
||||
it("submits value including backslash on Enter", () => {
|
||||
const input = new Input();
|
||||
let submitted: string | undefined;
|
||||
|
||||
input.onSubmit = (value) => {
|
||||
submitted = value;
|
||||
};
|
||||
|
||||
// Type hello, then backslash, then Enter
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\\");
|
||||
input.handleInput("\r");
|
||||
|
||||
// Input is single-line, no backslash+Enter workaround
|
||||
assert.strictEqual(submitted, "hello\\");
|
||||
});
|
||||
|
||||
it("inserts backslash as regular character", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("\\");
|
||||
input.handleInput("x");
|
||||
|
||||
assert.strictEqual(input.getValue(), "\\x");
|
||||
});
|
||||
|
||||
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 = [
|
||||
"가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다",
|
||||
"これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお",
|
||||
"这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm",
|
||||
];
|
||||
const cursorPositions = [
|
||||
{ label: "start", move: (_input: Input) => {} },
|
||||
{
|
||||
label: "middle",
|
||||
move: (input: Input) => {
|
||||
for (let i = 0; i < 10; i++) input.handleInput("\x1b[C");
|
||||
},
|
||||
},
|
||||
{ label: "end", move: (input: Input) => input.handleInput("\x05") },
|
||||
];
|
||||
|
||||
for (const text of cases) {
|
||||
for (const { label, move } of cursorPositions) {
|
||||
const input = new Input();
|
||||
input.setValue(text);
|
||||
input.focused = true;
|
||||
move(input);
|
||||
|
||||
const [line] = input.render(width);
|
||||
assert.ok(line);
|
||||
assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text} at ${label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the cursor visible when horizontally scrolling wide text", () => {
|
||||
const input = new Input();
|
||||
const width = 20;
|
||||
const text = "가나다라마바사아자차카타파하";
|
||||
input.setValue(text);
|
||||
input.focused = true;
|
||||
input.handleInput("\x01");
|
||||
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
|
||||
|
||||
const [line] = input.render(width);
|
||||
assert.ok(line);
|
||||
assert.ok(visibleWidth(line) <= width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kill ring", () => {
|
||||
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo bar baz");
|
||||
// Move cursor to end
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
// Move to beginning and yank
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "bazfoo bar ");
|
||||
});
|
||||
|
||||
it("Ctrl+W preserves ASCII punctuation boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo.bar");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), "foo.");
|
||||
|
||||
input.setValue("foo:bar");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), "foo:");
|
||||
});
|
||||
|
||||
it("Ctrl+W handles Unicode word boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
// "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界
|
||||
input.setValue("你好世界。你好,世界");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "你好世界。你好,");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes ","
|
||||
assert.strictEqual(input.getValue(), "你好世界。你好");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "你好世界。");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "。"
|
||||
assert.strictEqual(input.getValue(), "你好世界");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "你好");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("Ctrl+U saves deleted text to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
// Move cursor to after "hello "
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x15"); // Ctrl+U - deletes "hello "
|
||||
assert.strictEqual(input.getValue(), "world");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+K saves deleted text to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+Y does nothing when kill ring is empty", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("test");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "test");
|
||||
});
|
||||
|
||||
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
|
||||
const input = new Input();
|
||||
|
||||
// Create kill ring with multiple entries
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "first"
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "second"
|
||||
input.setValue("third");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "third"
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
|
||||
assert.strictEqual(input.getValue(), "third");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "first"
|
||||
assert.strictEqual(input.getValue(), "first");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles back to "third"
|
||||
assert.strictEqual(input.getValue(), "third");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if not preceded by yank", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("test");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "test"
|
||||
input.setValue("other");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
|
||||
// Type something to break the yank chain
|
||||
input.handleInput("x");
|
||||
assert.strictEqual(input.getValue(), "otherx");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "otherx");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if kill ring has one entry", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("only");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "only"
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "only"
|
||||
assert.strictEqual(input.getValue(), "only");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "only");
|
||||
});
|
||||
|
||||
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("one two three");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "three"
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "two "
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "one "
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "one two three");
|
||||
});
|
||||
|
||||
it("non-delete actions break kill accumulation", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo bar baz");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
input.handleInput("x"); // Typing breaks accumulation
|
||||
assert.strictEqual(input.getValue(), "foo bar x");
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry)
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - most recent is "x"
|
||||
assert.strictEqual(input.getValue(), "foo bar x");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycle to "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar baz");
|
||||
});
|
||||
|
||||
it("non-yank actions break Alt+Y chain", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
input.setValue("");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
input.handleInput("x"); // Breaks yank chain
|
||||
assert.strictEqual(input.getValue(), "secondx");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "secondx");
|
||||
});
|
||||
|
||||
it("kill ring rotation persists after cycling", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "first"
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "second"
|
||||
input.setValue("third");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "third"
|
||||
input.setValue("");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
// Break chain and start fresh
|
||||
input.handleInput("x");
|
||||
input.setValue("");
|
||||
|
||||
// New yank should get "second" (now at end after rotation)
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
});
|
||||
|
||||
it("backward deletions prepend, forward deletions append during accumulation", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("prefix|suffix");
|
||||
// Position cursor at "|"
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6
|
||||
|
||||
input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward)
|
||||
assert.strictEqual(input.getValue(), "prefix");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "prefix|suffix");
|
||||
});
|
||||
|
||||
it("Alt+D deletes word forward and saves to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world test");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
|
||||
assert.strictEqual(input.getValue(), " world test");
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes " world"
|
||||
assert.strictEqual(input.getValue(), " test");
|
||||
|
||||
// Yank should get accumulated text
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world test");
|
||||
});
|
||||
|
||||
it("Alt+D preserves ASCII punctuation boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo.bar baz");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "foo"
|
||||
assert.strictEqual(input.getValue(), ".bar baz");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "."
|
||||
assert.strictEqual(input.getValue(), "bar baz");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), " baz");
|
||||
});
|
||||
|
||||
it("Alt+D handles Unicode word boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
// "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界
|
||||
input.setValue("你好世界。你好,世界");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "世界。你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "。你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "。"
|
||||
assert.strictEqual(input.getValue(), "你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), ",世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes ","
|
||||
assert.strictEqual(input.getValue(), "世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("handles yank in middle of text", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("word");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "word"
|
||||
input.setValue("hello world");
|
||||
// Move to middle (after "hello ")
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello wordworld");
|
||||
});
|
||||
|
||||
it("handles yank-pop in middle of text", () => {
|
||||
const input = new Input();
|
||||
|
||||
// Create two kill ring entries
|
||||
input.setValue("FIRST");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
|
||||
input.setValue("SECOND");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
|
||||
|
||||
// Set up "hello world" and position cursor after "hello "
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND"
|
||||
assert.strictEqual(input.getValue(), "hello SECONDworld");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST"
|
||||
assert.strictEqual(input.getValue(), "hello FIRSTworld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Undo", () => {
|
||||
it("does nothing when undo stack is empty", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("coalesces consecutive word characters into one undo unit", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
|
||||
// Undo removes " world"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
|
||||
// Undo removes "hello"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes spaces one at a time", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput(" ");
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes backspace", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\x7f"); // Backspace
|
||||
assert.strictEqual(input.getValue(), "hell");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
});
|
||||
|
||||
it("undoes forward delete", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\x01"); // Ctrl+A - go to start
|
||||
input.handleInput("\x1b[C"); // Right arrow
|
||||
input.handleInput("\x1b[3~"); // Delete key
|
||||
assert.strictEqual(input.getValue(), "hllo");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+W (delete word backward)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+K (delete to line end)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x0b"); // Ctrl+K
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+U (delete to line start)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(input.getValue(), "world");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes yank", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("\x17"); // Ctrl+W - delete "hello "
|
||||
input.handleInput("\x19"); // Ctrl+Y - yank
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes paste atomically", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
|
||||
|
||||
// Simulate bracketed paste
|
||||
input.handleInput("\x1b[200~beep boop\x1b[201~");
|
||||
assert.strictEqual(input.getValue(), "hellobeep boop world");
|
||||
|
||||
// Single undo should restore entire pre-paste state
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Alt+D (delete word forward)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
|
||||
assert.strictEqual(input.getValue(), " world");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("cursor movement starts new undo unit", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("a");
|
||||
input.handleInput("b");
|
||||
input.handleInput("c");
|
||||
input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("d");
|
||||
input.handleInput("e");
|
||||
assert.strictEqual(input.getValue(), "abcde");
|
||||
|
||||
// Undo removes "de" (typed after movement)
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "abc");
|
||||
|
||||
// Undo removes "abc"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
});
|
||||
});
|
||||
124
third_party/pi/packages/tui/test/key-tester.ts
vendored
Executable file
124
third_party/pi/packages/tui/test/key-tester.ts
vendored
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
import { matchesKey } from "../src/keys.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { truncateToWidth } from "../src/utils.ts";
|
||||
|
||||
/**
|
||||
* Simple key code logger component
|
||||
*/
|
||||
class KeyLogger implements Component {
|
||||
private log: string[] = [];
|
||||
private maxLines = 20;
|
||||
private tui: TUI;
|
||||
private terminal: ProcessTerminal;
|
||||
|
||||
constructor(tui: TUI, terminal: ProcessTerminal) {
|
||||
this.tui = tui;
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Handle Ctrl+C (raw or Kitty protocol) for exit
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.tui.stop();
|
||||
console.log("\nExiting...");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Convert to various representations
|
||||
const hex = Buffer.from(data).toString("hex");
|
||||
const charCodes = Array.from(data)
|
||||
.map((c) => c.charCodeAt(0))
|
||||
.join(", ");
|
||||
const repr = data
|
||||
.replace(/\x1b/g, "\\x1b")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\t/g, "\\t")
|
||||
.replace(/\x7f/g, "\\x7f");
|
||||
|
||||
const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`;
|
||||
|
||||
this.log.push(logLine);
|
||||
|
||||
// Keep only last N lines
|
||||
if (this.log.length > this.maxLines) {
|
||||
this.log.shift();
|
||||
}
|
||||
|
||||
// Request re-render to show the new log entry
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
private protocolName(): string {
|
||||
if (this.terminal.kittyProtocolActive) return "kitty";
|
||||
if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys";
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
private fit(line: string, width: number): string {
|
||||
return truncateToWidth(line, width).padEnd(width);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push("=".repeat(width));
|
||||
lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width));
|
||||
lines.push(this.fit(`Protocol: ${this.protocolName()}`, width));
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("");
|
||||
|
||||
// Log entries
|
||||
for (const entry of this.log) {
|
||||
lines.push(this.fit(entry, width));
|
||||
}
|
||||
|
||||
// Fill remaining space
|
||||
const remaining = Math.max(0, 25 - lines.length);
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
lines.push("".padEnd(width));
|
||||
}
|
||||
|
||||
// Footer
|
||||
lines.push("=".repeat(width));
|
||||
lines.push(this.fit("Test these:", width));
|
||||
lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width));
|
||||
lines.push(this.fit(" - Alt/Option + Enter", width));
|
||||
lines.push(this.fit(" - Option/Alt + Backspace", width));
|
||||
lines.push(this.fit(" - Cmd/Ctrl + Backspace", width));
|
||||
lines.push(this.fit(" - Regular Backspace", width));
|
||||
lines.push("=".repeat(width));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up TUI
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const logger = new KeyLogger(tui, terminal);
|
||||
|
||||
tui.addChild(logger);
|
||||
tui.setFocus(logger);
|
||||
|
||||
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
|
||||
process.on("SIGINT", () => {
|
||||
tui.stop();
|
||||
console.log("\nExiting...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the TUI
|
||||
tui.start();
|
||||
|
||||
// Protocol negotiation completes asynchronously after the first render.
|
||||
// Refresh briefly/continuously so the displayed protocol state is not stale.
|
||||
setInterval(() => tui.requestRender(), 100);
|
||||
81
third_party/pi/packages/tui/test/keybindings.test.ts
vendored
Normal file
81
third_party/pi/packages/tui/test/keybindings.test.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.ts";
|
||||
|
||||
describe("KeybindingsManager", () => {
|
||||
it("binds Ctrl+J as a default newline alias", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.input.newLine"), ["shift+enter", "ctrl+j"]);
|
||||
assert.strictEqual(keybindings.matches("\n", "tui.input.newLine"), true);
|
||||
assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true);
|
||||
});
|
||||
|
||||
it("binds modified and unmodified editor viewport navigation", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+home", "ctrl+a"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+end", "ctrl+e"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageUp"), ["pageUp", "ctrl+pageUp"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]);
|
||||
});
|
||||
|
||||
it("leaves dedicated prompt history navigation unbound by default", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyPrevious"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyNext"), []);
|
||||
});
|
||||
|
||||
it("binds unmodified terminal viewport shortcuts to alternate-screen navigation", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up", "ctrl+up"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down", "ctrl+down"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]);
|
||||
});
|
||||
|
||||
it("does not evict selector confirm when input submit is rebound", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.input.submit": ["enter", "ctrl+enter"],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.input.submit"), ["enter", "ctrl+enter"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.select.confirm"), ["enter"]);
|
||||
});
|
||||
|
||||
it("does not evict cursor bindings when another action reuses the same key", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.select.up": ["up", "ctrl+p"],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.select.up"), ["up", "ctrl+p"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorUp"), ["up"]);
|
||||
});
|
||||
|
||||
it("still reports direct user binding conflicts without evicting defaults", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.input.submit": "ctrl+x",
|
||||
"tui.select.confirm": "ctrl+x",
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getConflicts(), [
|
||||
{
|
||||
key: "ctrl+x",
|
||||
keybindings: ["tui.input.submit", "tui.select.confirm"],
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLeft"), ["left", "ctrl+b"]);
|
||||
});
|
||||
});
|
||||
633
third_party/pi/packages/tui/test/keys.test.ts
vendored
Normal file
633
third_party/pi/packages/tui/test/keys.test.ts
vendored
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Tests for keyboard input handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
decodeKittyPrintable,
|
||||
decodePrintableKey,
|
||||
Key,
|
||||
matchesKey,
|
||||
parseKey,
|
||||
setKittyProtocolActive,
|
||||
} from "../src/keys.ts";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, fn: () => void): void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function withEnvVars(vars: Record<string, string | undefined>, fn: () => void): void {
|
||||
const entries = Object.entries(vars);
|
||||
const run = (index: number): void => {
|
||||
if (index >= entries.length) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
const [name, value] = entries[index]!;
|
||||
withEnv(name, value, () => run(index + 1));
|
||||
};
|
||||
run(0);
|
||||
}
|
||||
|
||||
describe("matchesKey", () => {
|
||||
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
|
||||
// Kitty protocol flag 4 (Report alternate keys) sends:
|
||||
// CSI codepoint:shifted:base ; modifier:event u
|
||||
// Where base is the key in standard PC-101 layout
|
||||
|
||||
it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99
|
||||
// Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5)
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100
|
||||
const cyrillicCtrlD = "\x1b[1074::100;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122
|
||||
const cyrillicCtrlZ = "\x1b[1103::122;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+Shift+p with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112
|
||||
// ctrl=4, shift=1, +1 = 6
|
||||
const cyrillicCtrlShiftP = "\x1b[1079::112;6u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should still match direct codepoint when no base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Latin ctrl+c without base layout key (terminal doesn't support flag 4)
|
||||
const latinCtrlC = "\x1b[99;5u";
|
||||
assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match super-modified Kitty bindings, including combined modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false);
|
||||
assert.strictEqual(parseKey("\x1b[107;9u"), "super+k");
|
||||
assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter");
|
||||
assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k");
|
||||
assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match digit bindings via Kitty CSI-u", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[49u", "1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false);
|
||||
assert.strictEqual(parseKey("\x1b[49u"), "1");
|
||||
assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[57400u", "1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57410u", "/"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57417u", "left"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true);
|
||||
assert.strictEqual(parseKey("\x1b[57399u"), "0");
|
||||
assert.strictEqual(parseKey("\x1b[57409u"), ".");
|
||||
assert.strictEqual(parseKey("\x1b[57413u"), "+");
|
||||
assert.strictEqual(parseKey("\x1b[57416u"), ",");
|
||||
assert.strictEqual(parseKey("\x1b[57417u"), "left");
|
||||
assert.strictEqual(parseKey("\x1b[57418u"), "right");
|
||||
assert.strictEqual(parseKey("\x1b[57419u"), "up");
|
||||
assert.strictEqual(parseKey("\x1b[57420u"), "down");
|
||||
assert.strictEqual(parseKey("\x1b[57421u"), "pageUp");
|
||||
assert.strictEqual(parseKey("\x1b[57422u"), "pageDown");
|
||||
assert.strictEqual(parseKey("\x1b[57423u"), "home");
|
||||
assert.strictEqual(parseKey("\x1b[57424u"), "end");
|
||||
assert.strictEqual(parseKey("\x1b[57425u"), "insert");
|
||||
assert.strictEqual(parseKey("\x1b[57426u"), "delete");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle shifted key in format", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Format with shifted key: CSI codepoint:shifted:base;modifier u
|
||||
// Latin 'c' with shifted 'C' (67) and base 'c' (99)
|
||||
const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2
|
||||
assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle event type in format", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Format with event type: CSI codepoint::base;modifier:event u
|
||||
// Cyrillic ctrl+c release event (event type 3)
|
||||
const releaseEvent = "\x1b[1089::99;5:3u";
|
||||
assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle full format with shifted key, base key, and event type", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Full format: CSI codepoint:shifted:base;modifier:event u
|
||||
// Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event
|
||||
// Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99
|
||||
// ctrl=4, shift=1, +1 = 6, repeat event = 2
|
||||
const fullFormat = "\x1b[1089:1057:99;6:2u";
|
||||
assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for Latin letters even when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
|
||||
const dvorakCtrlK = "\x1b[107::118;5u";
|
||||
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
|
||||
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for symbol keys even when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
|
||||
const dvorakCtrlSlash = "\x1b[47::91;5u";
|
||||
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
|
||||
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should not match wrong key even with base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с with base 'c' should NOT match ctrl+d
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should not match wrong modifiers even with base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с should NOT match ctrl+shift+c
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyOtherKeys matching", () => {
|
||||
it("should match xterm modifyOtherKeys Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+d", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+z", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Enter variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Tab variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;9~", "shift+tab"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;9~", "ctrl+tab"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;9~", "alt+tab"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;2;9~"), "shift+tab");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;9~"), "ctrl+tab");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Backspace variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Escape", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Space variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;32~"), "space");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys symbol combos", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys digit combos", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1");
|
||||
assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys shifted uppercase letters", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;69~", "shift+e"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;6;69~", "ctrl+shift+e"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;2;69~"), "shift+e");
|
||||
assert.strictEqual(parseKey("\x1b[27;6;69~"), "shift+ctrl+e");
|
||||
});
|
||||
|
||||
it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true);
|
||||
assert.strictEqual(parseKey("\x1b[104;7u"), "ctrl+alt+h");
|
||||
});
|
||||
|
||||
it("should match Ctrl+Alt+letter via xterm modifyOtherKeys", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;7;104~", "ctrl+alt+h"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;7;104~"), "ctrl+alt+h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy key matching", () => {
|
||||
it("should match legacy Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+c sends ASCII 3 (ETX)
|
||||
assert.strictEqual(matchesKey("\x03", "ctrl+c"), true);
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+d", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+d sends ASCII 4 (EOT)
|
||||
assert.strictEqual(matchesKey("\x04", "ctrl+d"), true);
|
||||
});
|
||||
|
||||
it("should match escape key", () => {
|
||||
assert.strictEqual(matchesKey("\x1b", "escape"), true);
|
||||
});
|
||||
|
||||
it("should match legacy linefeed as enter", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\n", "enter"), true);
|
||||
assert.strictEqual(parseKey("\n"), "enter");
|
||||
});
|
||||
|
||||
it("should treat linefeed as shift+enter when kitty active", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
|
||||
assert.strictEqual(matchesKey("\n", "enter"), false);
|
||||
assert.strictEqual(parseKey("\n"), "shift+enter");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should parse ctrl+space", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
|
||||
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+symbol", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals
|
||||
assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true);
|
||||
assert.strictEqual(parseKey("\x1c"), "ctrl+\\");
|
||||
// Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals
|
||||
assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true);
|
||||
assert.strictEqual(parseKey("\x1d"), "ctrl+]");
|
||||
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
|
||||
// Ctrl+- is on the same physical key on US keyboards
|
||||
assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true);
|
||||
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
|
||||
assert.strictEqual(parseKey("\x1f"), "ctrl+-");
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+Alt+symbol", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC)
|
||||
assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+[");
|
||||
// Ctrl+Alt+\ sends ESC followed by ASCII 28
|
||||
assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\");
|
||||
// Ctrl+Alt+] sends ESC followed by ASCII 29
|
||||
assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]");
|
||||
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
|
||||
// Ctrl+- is on the same physical key on US keyboards
|
||||
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true);
|
||||
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
|
||||
});
|
||||
|
||||
it("should treat raw 0x08 as plain backspace outside Windows Terminal", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnv("WT_SESSION", undefined, () => {
|
||||
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
|
||||
assert.strictEqual(parseKey("\x7f"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
|
||||
assert.strictEqual(parseKey("\x08"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should treat raw 0x08 as ctrl+backspace in local Windows Terminal", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnvVars(
|
||||
{
|
||||
WT_SESSION: "test-session",
|
||||
SSH_CONNECTION: undefined,
|
||||
SSH_CLIENT: undefined,
|
||||
SSH_TTY: undefined,
|
||||
},
|
||||
() => {
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), false);
|
||||
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat raw 0x08 as plain backspace in Windows Terminal over SSH", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnvVars(
|
||||
{
|
||||
WT_SESSION: "test-session",
|
||||
SSH_CONNECTION: "1 2 3 4",
|
||||
SSH_CLIENT: "1 2 3",
|
||||
SSH_TTY: "/dev/pts/1",
|
||||
},
|
||||
() => {
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), true);
|
||||
assert.strictEqual(parseKey("\x08"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
|
||||
assert.strictEqual(parseKey("\x1b "), "alt+space");
|
||||
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
|
||||
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
|
||||
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
|
||||
assert.strictEqual(parseKey("\x1bB"), "alt+left");
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
|
||||
assert.strictEqual(parseKey("\x1bF"), "alt+right");
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
|
||||
assert.strictEqual(parseKey("\x1ba"), "alt+a");
|
||||
assert.strictEqual(matchesKey("\x1b1", "alt+1"), true);
|
||||
assert.strictEqual(parseKey("\x1b1"), "alt+1");
|
||||
assert.strictEqual(matchesKey("\x1b,", "alt+,"), true);
|
||||
assert.strictEqual(parseKey("\x1b,"), "alt+,");
|
||||
assert.strictEqual(matchesKey("\x1b.", "alt+."), true);
|
||||
assert.strictEqual(parseKey("\x1b."), "alt+.");
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
|
||||
assert.strictEqual(parseKey("\x1by"), "alt+y");
|
||||
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
|
||||
assert.strictEqual(parseKey("\x1bz"), "alt+z");
|
||||
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
|
||||
assert.strictEqual(parseKey("\x1b "), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
|
||||
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
|
||||
assert.strictEqual(parseKey("\x1b\x03"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
|
||||
assert.strictEqual(parseKey("\x1bB"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
|
||||
assert.strictEqual(parseKey("\x1bF"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
|
||||
assert.strictEqual(parseKey("\x1ba"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b1", "alt+1"), false);
|
||||
assert.strictEqual(parseKey("\x1b1"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b,", "alt+,"), false);
|
||||
assert.strictEqual(parseKey("\x1b,"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b.", "alt+."), false);
|
||||
assert.strictEqual(parseKey("\x1b."), undefined);
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
|
||||
assert.strictEqual(parseKey("\x1by"), undefined);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match arrow keys", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[A", "up"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[B", "down"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[C", "right"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[D", "left"), true);
|
||||
});
|
||||
|
||||
it("should match SS3 arrows and home/end", () => {
|
||||
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
|
||||
});
|
||||
|
||||
it("should match xterm Ctrl-modified viewport navigation", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[1;5H", "ctrl+home"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[1;5F", "ctrl+end"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[5;5~", "ctrl+pageUp"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[6;5~", "ctrl+pageDown"), true);
|
||||
assert.strictEqual(parseKey("\x1b[1;5H"), "ctrl+home");
|
||||
assert.strictEqual(parseKey("\x1b[1;5F"), "ctrl+end");
|
||||
assert.strictEqual(parseKey("\x1b[5;5~"), "ctrl+pageUp");
|
||||
assert.strictEqual(parseKey("\x1b[6;5~"), "ctrl+pageDown");
|
||||
});
|
||||
|
||||
it("should match legacy function keys and clear", () => {
|
||||
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
|
||||
});
|
||||
|
||||
it("should match alt+arrows", () => {
|
||||
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bp", "up"), false);
|
||||
});
|
||||
|
||||
it("should match rxvt modifier sequences", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeKittyPrintable", () => {
|
||||
it("should decode Kitty keypad functional keys to printable characters", () => {
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), ".");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "=");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ",");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodePrintableKey", () => {
|
||||
it("should decode printable xterm modifyOtherKeys sequences", () => {
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;69~"), "E");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;196~"), "Ä");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;32~"), " ");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;13~"), undefined);
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;6;69~"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKey", () => {
|
||||
describe("Kitty protocol with alternate keys", () => {
|
||||
it("should return Latin key name when base layout key is present", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с with base layout 'c'
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for Latin letters when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
|
||||
const dvorakCtrlK = "\x1b[107::118;5u";
|
||||
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for symbol keys when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
|
||||
const dvorakCtrlSlash = "\x1b[47::91;5u";
|
||||
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should return key name from codepoint when no base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
const latinCtrlC = "\x1b[99;5u";
|
||||
assert.strictEqual(parseKey(latinCtrlC), "ctrl+c");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should parse shifted uppercase CSI-u letters as shift+letter", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[69;2u", "shift+e"), true);
|
||||
assert.strictEqual(parseKey("\x1b[69;2u"), "shift+e");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(parseKey("\x1b[99;17u"), undefined);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy key parsing", () => {
|
||||
it("should parse legacy Ctrl+letter", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(parseKey("\x03"), "ctrl+c");
|
||||
assert.strictEqual(parseKey("\x04"), "ctrl+d");
|
||||
});
|
||||
|
||||
it("should parse special keys", () => {
|
||||
assert.strictEqual(parseKey("\x1b"), "escape");
|
||||
assert.strictEqual(parseKey("\t"), "tab");
|
||||
assert.strictEqual(parseKey("\r"), "enter");
|
||||
assert.strictEqual(parseKey("\n"), "enter");
|
||||
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
||||
assert.strictEqual(parseKey(" "), "space");
|
||||
assert.strictEqual(parseKey("1"), "1");
|
||||
assert.strictEqual(matchesKey("1", "1"), true);
|
||||
});
|
||||
|
||||
it("should parse arrow keys", () => {
|
||||
assert.strictEqual(parseKey("\x1b[A"), "up");
|
||||
assert.strictEqual(parseKey("\x1b[B"), "down");
|
||||
assert.strictEqual(parseKey("\x1b[C"), "right");
|
||||
assert.strictEqual(parseKey("\x1b[D"), "left");
|
||||
});
|
||||
|
||||
it("should parse SS3 arrows and home/end", () => {
|
||||
assert.strictEqual(parseKey("\x1bOA"), "up");
|
||||
assert.strictEqual(parseKey("\x1bOB"), "down");
|
||||
assert.strictEqual(parseKey("\x1bOC"), "right");
|
||||
assert.strictEqual(parseKey("\x1bOD"), "left");
|
||||
assert.strictEqual(parseKey("\x1bOH"), "home");
|
||||
assert.strictEqual(parseKey("\x1bOF"), "end");
|
||||
});
|
||||
|
||||
it("should parse legacy function and modifier sequences", () => {
|
||||
assert.strictEqual(parseKey("\x1bOP"), "f1");
|
||||
assert.strictEqual(parseKey("\x1b[24~"), "f12");
|
||||
assert.strictEqual(parseKey("\x1b[E"), "clear");
|
||||
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
|
||||
assert.strictEqual(parseKey("\x1bp"), "alt+up");
|
||||
});
|
||||
|
||||
it("should parse double bracket pageUp", () => {
|
||||
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
|
||||
});
|
||||
});
|
||||
});
|
||||
505
third_party/pi/packages/tui/test/latex.test.ts
vendored
Normal file
505
third_party/pi/packages/tui/test/latex.test.ts
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderLatex } from "../src/index.ts";
|
||||
|
||||
type LatexCase = readonly [source: string, expected: string];
|
||||
|
||||
function defineCases(cases: readonly LatexCase[]): void {
|
||||
for (const [source, expected] of cases) {
|
||||
it(`renders ${JSON.stringify(source)}`, () => {
|
||||
assert.strictEqual(renderLatex(source), expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("renderLatex", () => {
|
||||
describe("Jacobian conjecture session using dollar delimiters", () => {
|
||||
defineCases([
|
||||
[String.raw`\mathbb{C}^3 \to \mathbb{C}^3`, "ℂ³ → ℂ³"],
|
||||
[
|
||||
String.raw`\{3x+2y,\; 27x^2-4z-1,\; x(x-1)(x+1)\} \quad\Rightarrow\quad x \in \{0, \pm 1\},`,
|
||||
"{3x+2y, 27x²-4z-1, x(x-1)(x+1)} ⇒ x ∈ {0, ± 1},",
|
||||
],
|
||||
[String.raw`F_1 = -\frac{1}{4x^2}.`, "F₁ = -1/(4x²)."],
|
||||
["-2", "-2"],
|
||||
["(0,0,-1/4)", "(0,0,-1/4)"],
|
||||
["(1,-3/2,13/2)", "(1,-3/2,13/2)"],
|
||||
["(1,1,1)", "(1,1,1)"],
|
||||
["(2,1,0)", "(2,1,0)"],
|
||||
["(-1/4, 0, 0)", "(-1/4, 0, 0)"],
|
||||
[String.raw`\{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)\}`, "{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)}"],
|
||||
["(2,1,1)", "(2,1,1)"],
|
||||
["(7/3,-2/5,11/7)", "(7/3,-2/5,11/7)"],
|
||||
[String.raw`\{y - p(x),\; q(x)\}`, "{y - p(x), q(x)}"],
|
||||
[String.raw`\deg q = 3`, "deg q = 3"],
|
||||
[String.raw`[\mathbb{C}(x,y,z):\mathbb{C}(F_1,F_2,F_3)] = 3`, "[ℂ(x,y,z):ℂ(F₁,F₂,F₃)] = 3"],
|
||||
["u = 1+xy", "u = 1+xy"],
|
||||
["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"],
|
||||
["F_1 = uG", "F₁ = uG"],
|
||||
["F_2 = y + 3xG", "F₂ = y + 3xG"],
|
||||
["x=0", "x = 0"],
|
||||
["F_2 = F_3 = 0", "F₂ = F₃ = 0"],
|
||||
["xy = -3/2", "xy = -3/2"],
|
||||
["x^2 z = 13/2", "x² z = 13/2"],
|
||||
[String.raw`\mathbb{C}^*`, "ℂ^*"],
|
||||
[String.raw`s \mapsto (s,\, -\tfrac{3}{2s},\, \tfrac{13}{2s^2})`, "s ↦ (s, -3/(2s), 13/(2s²))"],
|
||||
["X", "X"],
|
||||
[String.raw`p_\pm`, "p_±"],
|
||||
["F(-x,-y,z) = (F_1, -F_2, -F_3)", "F(-x,-y,z) = (F₁, -F₂, -F₃)"],
|
||||
["p_0", "p₀"],
|
||||
[String.raw`s \to \infty`, "s → ∞"],
|
||||
["(0,0,0)", "(0,0,0)"],
|
||||
[String.raw`\Rightarrow`, "⇒"],
|
||||
[String.raw`\ge 2`, "≥ 2"],
|
||||
[String.raw`\ge 3`, "≥ 3"],
|
||||
["1", "1"],
|
||||
[String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"],
|
||||
["4+3xy", "4+3xy"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("satellite calculation session using bracket delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} = 0.001\ \text{W/m}^2`,
|
||||
"E ≈ (0.1 lux)/(100 lm/W) = 0.001 W/m²",
|
||||
],
|
||||
[String.raw`\boxed{1\ \text{milliwatt per square metre}}`, "[1 milliwatt per square metre]"],
|
||||
[String.raw`5\ \text{km}^2 = 5{,}000{,}000\ \text{m}^2`, "5 km² = 5,000,000 m²"],
|
||||
[
|
||||
String.raw`P_{\text{light}} = 0.001 \times 5{,}000{,}000
|
||||
= \boxed{5{,}000\ \text{W}}`,
|
||||
"P_light = 0.001 × 5,000,000 = [5,000 W]",
|
||||
],
|
||||
[
|
||||
String.raw`P_{\text{electric}} = 5\ \text{kW} \times 0.2
|
||||
= \boxed{1\ \text{kW}}`,
|
||||
"P_electric = 5 kW × 0.2 = [1 kW]",
|
||||
],
|
||||
[String.raw`\pi(2.5\ \text{km})^2 = 19.6\ \text{km}^2`, "π(2.5 km)² = 19.6 km²"],
|
||||
[
|
||||
String.raw`0.001\ \text{W/m}^2 \times 19.6 \times 10^6\ \text{m}^2
|
||||
\approx \boxed{20\ \text{kW optical}}`,
|
||||
"0.001 W/m² × 19.6 × 10⁶ m² ≈ [20 kW optical]",
|
||||
],
|
||||
[
|
||||
String.raw`1\ \text{kW} \times \frac{1}{3600}\ \text{hour}
|
||||
= \boxed{0.28\ \text{Wh}}`,
|
||||
"1 kW × 1/3600 hour = [0.28 Wh]",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Jacobian conjecture sessions using parenthesis and bracket delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`,
|
||||
"det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\
|
||||
F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\
|
||||
F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0).
|
||||
\end{aligned}`,
|
||||
"F(0,0,-1/4) = (-1/4,0,0),\nF(1,-3/2,13/2) = (-1/4,0,0),\nF(-1,3/2,13/2) = (-1/4,0,0).",
|
||||
],
|
||||
["F=(F_1,F_2,F_3)", "F = (F₁,F₂,F₃)"],
|
||||
["F", "F"],
|
||||
["3", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Jacobian matrix session using dollar delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`J = \begin{pmatrix}
|
||||
\frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} & \frac{\partial f_1}{\partial z} \\
|
||||
\frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\
|
||||
\frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z}
|
||||
\end{pmatrix}`,
|
||||
"J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n ⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n ⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
f_1 &= (1+xy)^3 z + y^2(1+xy)(4+3xy) \\
|
||||
f_2 &= y + 3x(1+xy)^2 z + 3xy^2(4+3xy) \\
|
||||
f_3 &= 2x - 3x^2y - x^3z
|
||||
\end{aligned}`,
|
||||
"f₁ = (1+xy)³ z + y²(1+xy)(4+3xy)\nf₂ = y + 3x(1+xy)² z + 3xy²(4+3xy)\nf₃ = 2x - 3x²y - x³z",
|
||||
],
|
||||
["x, y, z", "x, y, z"],
|
||||
["(x, y, z)", "(x, y, z)"],
|
||||
[String.raw`(0,\; 0,\; -\tfrac14)`, "(0, 0, -1/4)"],
|
||||
[String.raw`(-\tfrac14,\; 0,\; 0)`, "(-1/4, 0, 0)"],
|
||||
[String.raw`(1,\; -\tfrac32,\; \tfrac{13}{2})`, "(1, -3/2, 13/2)"],
|
||||
[String.raw`(-1,\; \tfrac32,\; \tfrac{13}{2})`, "(-1, 3/2, 13/2)"],
|
||||
[String.raw`(-\frac14, 0, 0)`, "(-1/4, 0, 0)"],
|
||||
[String.raw`F: \mathbb{C}^3 \to \mathbb{C}^3`, "F: ℂ³ → ℂ³"],
|
||||
[
|
||||
String.raw`F(0,0,-\tfrac14) = F(1,-\tfrac32,\tfrac{13}{2}) = F(-1,\tfrac32,\tfrac{13}{2}) = (-\tfrac14, 0, 0)`,
|
||||
"F(0,0,-1/4) = F(1,-3/2,13/2) = F(-1,3/2,13/2) = (-1/4, 0, 0)",
|
||||
],
|
||||
[String.raw`\mathbb{C}^3`, "ℂ³"],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
f_1 &= \frac{f_1^{\text{ut}}(u,t)}{x^2}, \quad
|
||||
f_2 = \frac{f_2^{\text{ut}}(u,t)}{x}, \quad
|
||||
f_3 = x\,(2 - 3u - t)
|
||||
\end{aligned}`,
|
||||
"f₁ = (f₁ᵘᵗ(u,t))/(x²), f₂ = (f₂ᵘᵗ(u,t))/x, f₃ = x (2 - 3u - t)",
|
||||
],
|
||||
[String.raw`\det J_F`, "det J_F"],
|
||||
[String.raw`(-\tfrac14, 0, 0)`, "(-1/4, 0, 0)"],
|
||||
["u = xy", "u = xy"],
|
||||
["t = x^2z", "t = x²z"],
|
||||
[String.raw`x \neq 0`, "x ≠ 0"],
|
||||
[String.raw`f_1^{\text{ut}}, f_2^{\text{ut}}`, "f₁ᵘᵗ, f₂ᵘᵗ"],
|
||||
["u,t", "u,t"],
|
||||
["x", "x"],
|
||||
["x, x^2", "x, x²"],
|
||||
[String.raw`\mathbb{C}^n \to \mathbb{C}^n`, "ℂⁿ → ℂⁿ"],
|
||||
[String.raw`n \geq 2`, "n ≥ 2"],
|
||||
[String.raw`\mathbb{P}^3`, "ℙ³"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("extended formulas from a renderer stress-test session", () => {
|
||||
defineCases([
|
||||
[String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"],
|
||||
[
|
||||
String.raw`\boxed{
|
||||
\mathcal{Z}(\beta)
|
||||
=
|
||||
\int_{\mathcal M}
|
||||
\exp\!\left(
|
||||
-\beta\left[
|
||||
\frac12 g^{ij}(x)\,\partial_i\phi\,\partial_j\phi
|
||||
+V(\phi)
|
||||
\right]\right)
|
||||
\mathcal D\phi
|
||||
}`,
|
||||
"[Z(β) = ∫_M exp( -β[ 1/2 gⁱʲ(x) ∂ᵢϕ ∂ⱼϕ +V(ϕ) ]) Dϕ]",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
\nabla_\mu T^{\mu\nu}
|
||||
&=
|
||||
\frac{1}{\sqrt{-g}}
|
||||
\partial_\mu\!\left(\sqrt{-g}\,T^{\mu\nu}\right)
|
||||
+\Gamma^\nu_{\mu\lambda}T^{\mu\lambda}
|
||||
=0, \\[4pt]
|
||||
R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu}
|
||||
&=
|
||||
\frac{8\pi G}{c^4}T_{\mu\nu}.
|
||||
\end{aligned}`,
|
||||
"∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).",
|
||||
],
|
||||
[
|
||||
String.raw`f(z)
|
||||
=
|
||||
\frac{1}{2\pi i}
|
||||
\oint_{\gamma}
|
||||
\frac{f(\zeta)}{\zeta-z}\,d\zeta,
|
||||
\qquad
|
||||
\det\!\begin{pmatrix}
|
||||
\lambda-a & -b & 0\\
|
||||
-c & \lambda-d & -e\\
|
||||
0 & -f & \lambda-g
|
||||
\end{pmatrix}
|
||||
=0.`,
|
||||
[
|
||||
"f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.",
|
||||
`${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`,
|
||||
`${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`,
|
||||
].join("\n"),
|
||||
],
|
||||
[
|
||||
String.raw`\Psi(x,t)=
|
||||
\sum_{n=1}^{\infty}
|
||||
\underbrace{
|
||||
c_n
|
||||
\sqrt{\frac{2}{L}}
|
||||
\sin\!\left(\frac{n\pi x}{L}\right)
|
||||
}_{\text{spatial eigenmode}}
|
||||
\exp\!\left(-\frac{i\hbar n^2\pi^2}{2mL^2}t\right),
|
||||
\qquad
|
||||
|\Psi(x,t)|^2
|
||||
=
|
||||
\begin{cases}
|
||||
\Psi^\ast\Psi, & 0<x<L,\\
|
||||
0, & \text{otherwise}.
|
||||
\end{cases}`,
|
||||
"Ψ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎧ Ψ^∗Ψ if 0 < x < L,\n⎩ 0 otherwise.",
|
||||
],
|
||||
[String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "x = (-b±√(b²-4ac))/(2a)"],
|
||||
[String.raw`\int_0^\infty e^{-x^2}\,dx=\frac{\sqrt{\pi}}{2}`, "∫₀^∞ e^(-x²) dx = (√π)/2"],
|
||||
[String.raw`e^{i\theta}=\cos\theta+i\sin\theta`, "e^(iθ) = cos θ+i sin θ"],
|
||||
[String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}=\frac{\pi^2}{6}`, "∑ₙ₌₁^∞1/(n²) = π²/6"],
|
||||
[String.raw`\lim_{x\to 0}\frac{\sin x}{x}=1`, "lim[x→0] (sin x)/x = 1"],
|
||||
[
|
||||
String.raw`\lim_{n\to\infty}
|
||||
\left(1+\frac{1}{n}\right)^n=e`,
|
||||
"lim[n→∞] (1+1/n)ⁿ = e",
|
||||
],
|
||||
[
|
||||
String.raw`\int_0^1 \frac{x^2}{1+x^3}\,dx
|
||||
=\frac{1}{3}\ln 2`,
|
||||
"∫₀¹ x²/(1+x³) dx = 1/3 ln 2",
|
||||
],
|
||||
[
|
||||
String.raw`\sum_{k=1}^{n}\frac{k}{k+1}
|
||||
=n+1-H_{n+1}`,
|
||||
"∑ₖ₌₁ⁿk/(k+1) = n+1-Hₙ₊₁",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{
|
||||
\displaystyle \frac{x^2+1}{x-1}
|
||||
-
|
||||
\displaystyle \frac{2x}{x+1}
|
||||
}{
|
||||
\displaystyle \frac{x}{x^2-1}
|
||||
}`,
|
||||
"((x²+1)/(x-1) - 2x/(x+1))/(x/(x²-1))",
|
||||
],
|
||||
[
|
||||
String.raw`\lim_{x\to 0}
|
||||
\frac{
|
||||
\displaystyle \frac{\sin x}{x}-1
|
||||
}{
|
||||
\displaystyle \frac{e^x-1}{x}-1
|
||||
}
|
||||
=0`,
|
||||
"lim[x→0] ((sin x)/x-1)/((eˣ-1)/x-1) = 0",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{
|
||||
1+\displaystyle\frac{1}{1+\frac{1}{x}}
|
||||
}{
|
||||
1-\displaystyle\frac{1}{1-\frac{1}{x}}
|
||||
}`,
|
||||
"(1+1/(1+1/x))/(1-1/(1-1/x))",
|
||||
],
|
||||
[
|
||||
String.raw`\sum_{n=1}^{\infty}
|
||||
\frac{
|
||||
\displaystyle \frac{1}{n}-\frac{1}{n+1}
|
||||
}{
|
||||
\displaystyle 1+\frac{1}{n^2}
|
||||
}`,
|
||||
"∑ₙ₌₁^∞ (1/n-1/(n+1))/(1+1/(n²))",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders common symbols, roots, sums, and integrals", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx = \sqrt{\pi}`),
|
||||
"∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx = √π",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders common accents and binomial notation", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\binom{n}{k}+\vec{x}+\hat{y}+\overline{AB}`),
|
||||
"(n choose k)+x⃗+ŷ+overline(AB)",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders extended symbols and negated relations", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\epsilon+\varepsilon+\varsigma+\varkappa+\oplus+\otimes+\therefore+\because`),
|
||||
"ϵ+ε+ς+ϰ+⊕+⊗+∴+∵",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`A\not\subseteq B,\quad x\not\in X`), "A ⊈ B, x ∉ X");
|
||||
});
|
||||
|
||||
it("renders relational algebra join operators", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`R\bowtie S,\quad R\Join S`), "R ⋈ S, R ⋈ S");
|
||||
assert.strictEqual(renderLatex(String.raw`R\ltimes S,\quad R\rtimes S`), "R ⋉ S, R ⋊ S");
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`R\leftouterjoin S,\quad R\rightouterjoin S,\quad R\fullouterjoin S`),
|
||||
"R ⟕ S, R ⟖ S, R ⟗ S",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders delimiter commands and invisible delimiters", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`),
|
||||
"|x|+‖v‖+dy/(dx)|ₓ₌₀",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x > 0 }");
|
||||
});
|
||||
|
||||
it("renders named, modular, overlaid, and underlaid operators", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)");
|
||||
assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a ≡ b (mod n)");
|
||||
assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ");
|
||||
});
|
||||
|
||||
it("renders indexed roots and additional accents and wrappers", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sqrt[2]{x}+\sqrt[3]{x}+\sqrt[4]{x}+\sqrt[n]{x}+\sqrt[k]{x+1}`),
|
||||
"√x+∛x+∜x+ⁿ√x+ᵏ√(x+1)",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`),
|
||||
"x́+ỳ+widehat(xyz)+overrightarrow(AB)",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x");
|
||||
});
|
||||
|
||||
it("renders additional display environments", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`),
|
||||
"a = b\n= c",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`),
|
||||
"a = b c = d\ne = f g = h",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses natural case conditions and aligns matrix columns", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`),
|
||||
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`),
|
||||
"⎛ 1 │ 200 ⎞\n⎝ 3000 │ 4 ⎠",
|
||||
);
|
||||
});
|
||||
|
||||
it("composes matrices with fractions and adjacent matrices", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`R\left(\frac{\pi}{4}\right)
|
||||
=
|
||||
\begin{pmatrix}
|
||||
\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\
|
||||
\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2}
|
||||
\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
" π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`\mathbf w
|
||||
=
|
||||
R\left(\frac{\pi}{4}\right)
|
||||
\begin{pmatrix}1\\0\end{pmatrix}
|
||||
=
|
||||
\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
" π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
"Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }),
|
||||
" n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes relation, multiplication, and named-operator spacing", () => {
|
||||
for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) {
|
||||
assert.strictEqual(renderLatex(source), "x = y");
|
||||
}
|
||||
assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀");
|
||||
assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0");
|
||||
assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B");
|
||||
assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π");
|
||||
assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x");
|
||||
assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)");
|
||||
});
|
||||
|
||||
it("treats a backslash followed by a line ending as control space", () => {
|
||||
const source = String.raw`\boxed{
|
||||
(1,1,1),\ (1,1,2),\ (1,2,5),\ (1,5,13),\ (2,5,29),\
|
||||
(1,13,34),\ (1,34,89)
|
||||
}.`;
|
||||
assert.strictEqual(
|
||||
renderLatex(source, { display: true }),
|
||||
"[(1,1,1), (1,1,2), (1,2,5), (1,5,13), (2,5,29), (1,13,34), (1,34,89)].",
|
||||
);
|
||||
assert.strictEqual(renderLatex("a\\\r\nb"), "a b");
|
||||
});
|
||||
|
||||
it("stacks operator limits in display mode", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0");
|
||||
assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X");
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`, {
|
||||
display: true,
|
||||
}),
|
||||
"arg max f(x)\n x∈X",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\int\nolimits_0^1 f(x)\,dx`, { display: true }), "∫₀¹ f(x) dx");
|
||||
assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0");
|
||||
});
|
||||
|
||||
it("uses the middle brace for intermediate case rows", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`),
|
||||
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0",
|
||||
);
|
||||
});
|
||||
|
||||
it("stacks fractions in display mode", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, {
|
||||
display: true,
|
||||
}),
|
||||
" -b±√(b²-4ac)\nx = ────────────\n 2a",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1");
|
||||
assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2");
|
||||
});
|
||||
|
||||
it("keeps nested display fractions linear", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
String.raw`\frac{\frac{x^2+1}{x-1}-\frac{2x}{x+1}}{\frac{x}{x^2-1}}`,
|
||||
"(x²+1)/(x-1)-2x/(x+1)\n─────────────────────\n x/(x²-1)",
|
||||
],
|
||||
[
|
||||
String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`,
|
||||
" (sin x)/x-1\nlim ─────────── = 0\nx→0 (eˣ-1)/x-1",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`,
|
||||
"1+1/(1+1/x)\n───────────\n1-1/(1-1/x)",
|
||||
],
|
||||
];
|
||||
for (const [source, expected] of cases) {
|
||||
assert.strictEqual(renderLatex(source, { display: true }), expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps fractions linear in scripts and text-style fractions", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)");
|
||||
assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2");
|
||||
});
|
||||
|
||||
it("returns undefined for unsupported commands", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`x + \unknown{y}`), undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for malformed groups and environments", () => {
|
||||
const malformed = [String.raw`\frac{1}{x`, "x}", String.raw`\begin{matrix}1 & 2`, "x\\"];
|
||||
for (const source of malformed) {
|
||||
assert.strictEqual(renderLatex(source), undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
367
third_party/pi/packages/tui/test/layout.test.ts
vendored
Normal file
367
third_party/pi/packages/tui/test/layout.test.ts
vendored
Normal file
@@ -0,0 +1,367 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { HStack } from "../src/components/h-stack.ts";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import { renderLayoutFrame } from "../src/layout.ts";
|
||||
import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts";
|
||||
import { stripTerminalSequences } from "../src/utils.ts";
|
||||
|
||||
function visibleLines(lines: string[]): string[] {
|
||||
return lines.map((line) => stripTerminalSequences(line).trimEnd());
|
||||
}
|
||||
|
||||
describe("viewport layout", () => {
|
||||
it("allocates vertical grow space deterministically", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("top", 0, 0), basis: 1, shrink: 0 },
|
||||
{ component: new Text("body", 0, 0), basis: 0, grow: 1 },
|
||||
]),
|
||||
10,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
frame.root.children.map((child) => child.rect.height),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["top", "body", "", ""]);
|
||||
});
|
||||
|
||||
it("does not render fixed-basis scroll content during stack measurement", () => {
|
||||
let renderCount = 0;
|
||||
const transcript = new ScrollView({
|
||||
render: () => {
|
||||
renderCount += 1;
|
||||
return ["one", "two", "three"];
|
||||
},
|
||||
invalidate: () => {},
|
||||
});
|
||||
const root = new VStack([
|
||||
{ component: transcript, basis: 0, grow: 1 },
|
||||
{ component: new Text("dock", 0, 0), basis: "auto" },
|
||||
]);
|
||||
renderLayoutFrame(root, 10, 3, () => {});
|
||||
assert.strictEqual(renderCount, 1);
|
||||
});
|
||||
|
||||
it("paints only clipped rows from very large scroll content", () => {
|
||||
const lineCount = 1_000_000_000;
|
||||
const lines: string[] = [];
|
||||
lines.length = lineCount;
|
||||
lines[lineCount - 4] = "before";
|
||||
lines[lineCount - 3] = "visible 1";
|
||||
lines[lineCount - 2] = "visible 2";
|
||||
lines[lineCount - 1] = "visible 3";
|
||||
const transcript = new ScrollView(
|
||||
{
|
||||
render: () => lines,
|
||||
invalidate: () => {},
|
||||
},
|
||||
{ follow: "end" },
|
||||
);
|
||||
|
||||
const frame = renderLayoutFrame(transcript, 10, 3, () => {});
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["visible 1", "visible 2", "visible 3"]);
|
||||
});
|
||||
|
||||
it("shrinks entries to their minimum sizes", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("a1\na2\na3", 0, 0), shrink: 1, minSize: 1 },
|
||||
{ component: new Text("b1\nb2\nb3", 0, 0), shrink: 0 },
|
||||
]),
|
||||
10,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
frame.root.children.map((child) => child.rect.height),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]);
|
||||
});
|
||||
|
||||
it("includes nested minimum sizes in intrinsic stack measurement", () => {
|
||||
const dock = new VStack([
|
||||
new Text("top1\ntop2\ntop3", 0, 0),
|
||||
{ component: new Text("selector", 0, 0), minSize: 3 },
|
||||
new Text("below", 0, 0),
|
||||
{ component: new Text("footer", 0, 0), minSize: 1 },
|
||||
]);
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("body", 0, 0), basis: 0, grow: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", minSize: 1 },
|
||||
]),
|
||||
10,
|
||||
9,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), [
|
||||
"body",
|
||||
"top1",
|
||||
"top2",
|
||||
"top3",
|
||||
"selector",
|
||||
"",
|
||||
"",
|
||||
"below",
|
||||
"footer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits gaps around invisible entries", () => {
|
||||
const stack = new VStack(
|
||||
[new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)],
|
||||
{ gap: 1 },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
stack.render(10).map((line) => line.trimEnd()),
|
||||
["one", "", "two"],
|
||||
);
|
||||
});
|
||||
|
||||
it("crops Kitty images at a scroll view's lower boundary", () => {
|
||||
const imageId = 124;
|
||||
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
|
||||
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
|
||||
const transcript = new ScrollView({
|
||||
render: () => ["one", "two", imageLine, "", ""],
|
||||
invalidate: () => {},
|
||||
});
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([{ component: transcript, basis: 0, grow: 1 }, new Text("dock", 0, 0)]),
|
||||
20,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1"));
|
||||
});
|
||||
|
||||
it("composes horizontal children at allocated widths", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new HStack([
|
||||
{ component: new Text("left", 0, 0), basis: 6, shrink: 0 },
|
||||
{ component: new Text("right", 0, 0), basis: 6, shrink: 0 },
|
||||
]),
|
||||
12,
|
||||
1,
|
||||
() => {},
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["left right"]);
|
||||
});
|
||||
|
||||
it("does not paint zero-width horizontal children", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new HStack([
|
||||
{ component: new Text("hidden", 0, 0), basis: 0, shrink: 0 },
|
||||
{ component: new Text("shown", 0, 0), basis: 0, grow: 1 },
|
||||
]),
|
||||
5,
|
||||
1,
|
||||
() => {},
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["shown"]);
|
||||
});
|
||||
|
||||
it("tracks follow-end state and returns unused scroll delta", () => {
|
||||
const scrollView = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0), {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
});
|
||||
renderLayoutFrame(scrollView, 10, 3, () => {});
|
||||
assert.strictEqual(scrollView.scrollTop, 3);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, true);
|
||||
|
||||
assert.strictEqual(scrollView.scrollBy(-2), 0);
|
||||
assert.strictEqual(scrollView.scrollTop, 1);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, false);
|
||||
assert.strictEqual(scrollView.scrollBy(-3), -2);
|
||||
assert.strictEqual(scrollView.scrollTop, 0);
|
||||
assert.strictEqual(scrollView.scrollBy(10), 7);
|
||||
assert.strictEqual(scrollView.scrollTop, 3);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, true);
|
||||
});
|
||||
|
||||
it("renders a proportional glyph scrollbar with an expanded active thumb", async () => {
|
||||
const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"];
|
||||
const contentBackground = "\x1b[42m";
|
||||
const trackColor = "\x1b[38;5;2m";
|
||||
const thumbColor = "\x1b[38;5;1m";
|
||||
const scrollbarTrackStyle = (text: string) => `${trackColor}${text}\x1b[39m`;
|
||||
const scrollbarThumbStyle = (text: string) => `${thumbColor}${text}\x1b[39m`;
|
||||
const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`);
|
||||
const scrollView = new ScrollView(content, {
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
scrollbarHideDelayMs: 10,
|
||||
});
|
||||
const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines;
|
||||
const visible = (lines: string[]) => lines.map(stripTerminalSequences);
|
||||
|
||||
let lines = render();
|
||||
assert.deepStrictEqual(visible(lines), sourceLines.slice(0, 4));
|
||||
|
||||
scrollView.scrollBy(2);
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde┃", "abcde┃", "abcde│"]);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(trackColor)),
|
||||
[true, false, false, true],
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(thumbColor)),
|
||||
[false, true, true, false],
|
||||
);
|
||||
|
||||
scrollView.setScrollbarActive(true);
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde█", "abcde█", "abcde│"]);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(thumbColor)),
|
||||
[false, true, true, false],
|
||||
);
|
||||
assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(thumbColor));
|
||||
|
||||
scrollView.setScrollbarActive(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), sourceLines.slice(2, 6));
|
||||
|
||||
scrollView.scrollToEnd();
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde│", "abcde┃", "abcde┃"]);
|
||||
|
||||
scrollView.scrollToStart();
|
||||
lines = render();
|
||||
assert.strictEqual(visible(lines)[0], "abcd ┃");
|
||||
|
||||
const followedContent = new Text(sourceLines.join("\n"), 0, 0);
|
||||
const followed = new ScrollView(followedContent, {
|
||||
follow: "end",
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
renderLayoutFrame(followed, 6, 4, () => {});
|
||||
assert.strictEqual(followed.scrollTop, 4);
|
||||
followedContent.setText(`${sourceLines.join("\n")}\nabcde9`);
|
||||
const growthFrame = renderLayoutFrame(followed, 6, 4, () => {});
|
||||
assert.strictEqual(followed.scrollTop, 5);
|
||||
assert.ok(growthFrame.lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))));
|
||||
|
||||
const fittingContent = new Text("1\n2", 0, 0);
|
||||
const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarThumbStyle });
|
||||
renderLayoutFrame(automatic, 6, 4, () => {});
|
||||
automatic.scrollBy(1);
|
||||
assert.ok(
|
||||
renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))),
|
||||
);
|
||||
|
||||
const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarThumbStyle });
|
||||
const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {});
|
||||
assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5);
|
||||
assert.ok(visible(alwaysFittingFrame.lines).every((line) => line.endsWith("┃")));
|
||||
|
||||
const alwaysOverflowing = new ScrollView(content, {
|
||||
scrollbar: "always",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {});
|
||||
assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5);
|
||||
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("┃")).length, 2);
|
||||
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("│")).length, 2);
|
||||
for (const line of alwaysOverflowingFrame.lines) {
|
||||
const scrollbarStyleIndex = Math.max(line.lastIndexOf(trackColor), line.lastIndexOf(thumbColor));
|
||||
const reservedColumnResetIndex = line.lastIndexOf("\x1b[0m\x1b]8;;\x07", scrollbarStyleIndex);
|
||||
assert.ok(reservedColumnResetIndex > line.lastIndexOf(contentBackground));
|
||||
}
|
||||
|
||||
const thumbHeightFor = (contentHeight: number) => {
|
||||
const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), {
|
||||
scrollbar: "auto",
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
renderLayoutFrame(sized, 6, 20, () => {});
|
||||
sized.scrollBy(1);
|
||||
return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) =>
|
||||
stripTerminalSequences(line).endsWith("┃"),
|
||||
).length;
|
||||
};
|
||||
assert.strictEqual(thumbHeightFor(21), 19);
|
||||
assert.strictEqual(thumbHeightFor(40), 10);
|
||||
assert.strictEqual(thumbHeightFor(100), 4);
|
||||
assert.strictEqual(thumbHeightFor(400), 2);
|
||||
});
|
||||
|
||||
it("preserves only the underlying background beneath overlay scrollbar glyphs", () => {
|
||||
const background = "\x1b[42m";
|
||||
const borderForeground = "\x1b[31m";
|
||||
const content = {
|
||||
render: (width: number) =>
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
() => `${background}${"x".repeat(width - 1)}${borderForeground}│\x1b[39m\x1b[49m`,
|
||||
),
|
||||
invalidate: () => {},
|
||||
};
|
||||
const scrollView = new ScrollView(content, {
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle: (text) => text,
|
||||
scrollbarThumbStyle: (text) => text,
|
||||
});
|
||||
renderLayoutFrame(scrollView, 6, 4, () => {});
|
||||
scrollView.scrollBy(1);
|
||||
const frame = renderLayoutFrame(scrollView, 6, 4, () => {});
|
||||
|
||||
assert.deepStrictEqual(frame.lines.map(stripTerminalSequences), ["xxxxx│", "xxxxx┃", "xxxxx┃", "xxxxx│"]);
|
||||
for (const line of frame.lines) {
|
||||
assert.ok(line.includes(background));
|
||||
assert.ok(!line.includes(borderForeground));
|
||||
assert.ok(line.includes(`\x1b[0m\x1b]8;;\x07${background}`));
|
||||
}
|
||||
});
|
||||
|
||||
it("updates reserved scrollbar layout at runtime", () => {
|
||||
const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" });
|
||||
const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {});
|
||||
const always = render();
|
||||
assert.deepStrictEqual(visibleLines(always.lines), ["12345┃", "6 ┃"]);
|
||||
assert.strictEqual(always.root.children[0]?.rect.width, 6);
|
||||
assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5);
|
||||
|
||||
scrollView.setScrollbar("hidden");
|
||||
assert.strictEqual(render().root.children[0]?.children[0]?.rect.width, 6);
|
||||
assert.strictEqual(scrollView.isScrollbarVisible, false);
|
||||
});
|
||||
|
||||
it("measures nested scroll content from constrained child geometry", () => {
|
||||
const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0));
|
||||
const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)]));
|
||||
renderLayoutFrame(outer, 10, 2, () => {});
|
||||
|
||||
assert.strictEqual(inner.viewportHeight, 2);
|
||||
assert.strictEqual(outer.scrollBy(10), 9);
|
||||
assert.strictEqual(outer.scrollTop, 1);
|
||||
});
|
||||
|
||||
it("rebuilds geometry after content changes", () => {
|
||||
const text = new Text("one", 0, 0);
|
||||
const root = new VStack([text]);
|
||||
const first = renderLayoutFrame(root, 10, 4, () => {});
|
||||
text.setText("one\ntwo\nthree");
|
||||
const second = renderLayoutFrame(root, 10, 4, () => {});
|
||||
|
||||
assert.strictEqual(first.root.children[0]?.lines?.length, 1);
|
||||
assert.strictEqual(second.root.children[0]?.lines?.length, 3);
|
||||
});
|
||||
});
|
||||
1760
third_party/pi/packages/tui/test/markdown.test.ts
vendored
Normal file
1760
third_party/pi/packages/tui/test/markdown.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
249
third_party/pi/packages/tui/test/mouse-components.test.ts
vendored
Normal file
249
third_party/pi/packages/tui/test/mouse-components.test.ts
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor, type EditorTheme } from "../src/components/editor.ts";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import { SelectList, type SelectListTheme } from "../src/components/select-list.ts";
|
||||
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
||||
import { Container, type TuiMouseEvent, type TuiMouseEventType } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
function mouse(type: TuiMouseEventType, x: number, y: number, width = 80, height = 10): TuiMouseEvent {
|
||||
return {
|
||||
type,
|
||||
button: "left",
|
||||
x,
|
||||
y,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
width,
|
||||
height,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
...(type === "click" ? { clickCount: 1 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const selectTheme: SelectListTheme = {
|
||||
selectedPrefix: (text) => text,
|
||||
selectedText: (text) => text,
|
||||
description: (text) => text,
|
||||
scrollInfo: (text) => text,
|
||||
noMatch: (text) => text,
|
||||
};
|
||||
|
||||
const settingsTheme: SettingsListTheme = {
|
||||
label: (text) => text,
|
||||
value: (text) => text,
|
||||
description: (text) => text,
|
||||
cursor: "> ",
|
||||
hint: (text) => text,
|
||||
};
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => text,
|
||||
selectList: selectTheme,
|
||||
};
|
||||
|
||||
class InputOverlay extends Container {
|
||||
readonly input = new Input();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.input);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
describe("mouse-aware components", () => {
|
||||
it("positions a single-line input cursor on press", () => {
|
||||
const input = new Input();
|
||||
input.setValue("hello");
|
||||
input.render(20);
|
||||
|
||||
assert.strictEqual(input.handleMouse(mouse("press", 4, 0, 20, 1))?.handled, true);
|
||||
input.handleInput("X");
|
||||
assert.strictEqual(input.getValue(), "heXllo");
|
||||
});
|
||||
|
||||
it("selects and activates list rows", () => {
|
||||
const list = new SelectList(
|
||||
[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
{ value: "c", label: "C" },
|
||||
{ value: "d", label: "D" },
|
||||
{ value: "e", label: "E" },
|
||||
],
|
||||
3,
|
||||
selectTheme,
|
||||
);
|
||||
let selected: string | undefined;
|
||||
list.onSelect = (item) => {
|
||||
selected = item.value;
|
||||
};
|
||||
|
||||
assert.strictEqual(list.handleMouse(mouse("press", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "c");
|
||||
assert.strictEqual(list.handleMouse(mouse("click", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(selected, "c");
|
||||
});
|
||||
|
||||
it("activates settings rows", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
[
|
||||
{ id: "mode", label: "Mode", currentValue: "one", values: ["one", "two"] },
|
||||
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
|
||||
{ id: "third", label: "Third", currentValue: "low", values: ["low", "high"] },
|
||||
{ id: "fourth", label: "Fourth", currentValue: "x", values: ["x", "y"] },
|
||||
],
|
||||
3,
|
||||
settingsTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
);
|
||||
|
||||
list.handleMouse(mouse("press", 1, 2, 40, 5));
|
||||
list.handleMouse(mouse("click", 1, 2, 40, 5));
|
||||
assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]);
|
||||
});
|
||||
|
||||
for (const row of [0, 4]) {
|
||||
it(`ignores hover and clicks visible select-list row ${row} after scrolling`, () => {
|
||||
const list = new SelectList(
|
||||
Array.from({ length: 12 }, (_, i) => ({ value: `item-${i}`, label: `Item ${i}` })),
|
||||
5,
|
||||
selectTheme,
|
||||
);
|
||||
const changes: string[] = [];
|
||||
let selected: string | undefined;
|
||||
list.onSelectionChange = (item) => changes.push(item.value);
|
||||
list.onSelect = (item) => {
|
||||
selected = item.value;
|
||||
};
|
||||
list.setSelectedIndex(5);
|
||||
list.handleMouse({ ...mouse("wheel", 1, row), wheelDelta: 1 });
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
||||
assert.deepStrictEqual(changes, ["item-6"]);
|
||||
const before = list.render(80);
|
||||
assert.match(before[row], new RegExp(`Item ${4 + row}$`));
|
||||
|
||||
for (const y of [0, 1, 2, 3, 4, row]) {
|
||||
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y), button: "none" }), undefined);
|
||||
assert.deepStrictEqual(list.render(80), before);
|
||||
}
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
||||
assert.deepStrictEqual(changes, ["item-6"]);
|
||||
assert.strictEqual(selected, undefined);
|
||||
|
||||
list.handleMouse(mouse("press", 1, row));
|
||||
list.render(80);
|
||||
list.handleMouse(mouse("click", 1, row));
|
||||
assert.strictEqual(selected, `item-${4 + row}`);
|
||||
assert.deepStrictEqual(changes, ["item-6", `item-${4 + row}`]);
|
||||
});
|
||||
|
||||
it(`ignores hover and clicks visible settings row ${row} after scrolling`, () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `item-${i}`,
|
||||
label: `Item ${i}`,
|
||||
description: `Description ${i}`,
|
||||
currentValue: "off",
|
||||
values: ["off", "on"],
|
||||
})),
|
||||
5,
|
||||
settingsTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
list.selectItem("item-5");
|
||||
list.handleMouse({ ...mouse("wheel", 1, row + 2), wheelDelta: 1 });
|
||||
const before = list.render(80);
|
||||
assert.match(before[4], /^> Item 6/);
|
||||
assert.match(before[row + 2], new RegExp(`Item ${4 + row} `));
|
||||
|
||||
for (const y of [0, 1, 2, 3, 4, row]) {
|
||||
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y + 2), button: "none" }), undefined);
|
||||
assert.deepStrictEqual(list.render(80), before);
|
||||
}
|
||||
assert.deepStrictEqual(changes, []);
|
||||
|
||||
list.handleMouse(mouse("press", 1, row + 2));
|
||||
list.render(80);
|
||||
list.handleMouse(mouse("click", 1, row + 2));
|
||||
assert.deepStrictEqual(changes, [{ id: `item-${4 + row}`, value: "on" }]);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a delegating overlay focused when its nested input is clicked", async () => {
|
||||
const terminal = new VirtualTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const overlay = new InputOverlay();
|
||||
overlay.input.setValue("hi");
|
||||
tui.start();
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 20 });
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;5;1M");
|
||||
terminal.sendInput("\x1b[<0;5;1m");
|
||||
terminal.sendInput("!");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(overlay.input.getValue(), "hi!");
|
||||
assert.strictEqual(tui.getFocusedComponent(), overlay);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.setText("hello");
|
||||
tui.addChild(editor);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;3;2M");
|
||||
terminal.sendInput("\x1b[<0;3;2m");
|
||||
terminal.sendInput("X");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(editor.getText(), "heXllo");
|
||||
assert.strictEqual(tui.getFocusedComponent(), editor);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("selects and copies editor text on drag instead of moving the cursor", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const copied: string[] = [];
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
copySelection: async (text) => {
|
||||
copied.push(text);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.setText("hello world");
|
||||
tui.addChild(editor);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const cursorBefore = editor.getCursor();
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;2M");
|
||||
terminal.sendInput("\x1b[<32;5;2M");
|
||||
terminal.sendInput("\x1b[<0;5;2m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(copied, ["hello"]);
|
||||
assert.deepStrictEqual(editor.getCursor(), cursorBefore);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
45
third_party/pi/packages/tui/test/native-module-path.test.ts
vendored
Normal file
45
third_party/pi/packages/tui/test/native-module-path.test.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getNativeModuleCandidates } from "../src/native-module-path.ts";
|
||||
|
||||
describe("getNativeModuleCandidates", () => {
|
||||
it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => {
|
||||
const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui");
|
||||
const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js");
|
||||
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node");
|
||||
|
||||
const candidates = getNativeModuleCandidates(nativePath, {
|
||||
moduleUrl: pathToFileURL(bundledModule).href,
|
||||
execPath: resolve("virtual", "node", "node.exe"),
|
||||
resolvePackage: (specifier) => {
|
||||
assert.equal(specifier, "@earendil-works/pi-tui");
|
||||
return join(packageRoot, "dist", "index.js");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(candidates[0], join(packageRoot, nativePath));
|
||||
assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath)));
|
||||
});
|
||||
|
||||
it("keeps standalone binary fallbacks when the TUI package is unavailable", () => {
|
||||
const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js");
|
||||
const execPath = resolve("virtual", "pi", "pi.exe");
|
||||
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node");
|
||||
|
||||
const candidates = getNativeModuleCandidates(nativePath, {
|
||||
moduleUrl: pathToFileURL(bundledModule).href,
|
||||
execPath,
|
||||
resolvePackage: () => {
|
||||
throw new Error("not installed");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(candidates, [
|
||||
join(dirname(bundledModule), "..", nativePath),
|
||||
join(dirname(bundledModule), nativePath),
|
||||
join(dirname(execPath), nativePath),
|
||||
]);
|
||||
});
|
||||
});
|
||||
1203
third_party/pi/packages/tui/test/overlay-non-capturing.test.ts
vendored
Normal file
1203
third_party/pi/packages/tui/test/overlay-non-capturing.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
541
third_party/pi/packages/tui/test/overlay-options.test.ts
vendored
Normal file
541
third_party/pi/packages/tui/test/overlay-options.test.ts
vendored
Normal file
@@ -0,0 +1,541 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
private lines: string[];
|
||||
requestedWidth?: number;
|
||||
|
||||
constructor(lines: string[], requestedWidth?: number) {
|
||||
this.lines = lines;
|
||||
this.requestedWidth = requestedWidth;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Store the width we were asked to render at for verification
|
||||
this.requestedWidth = width;
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class EmptyContent implements Component {
|
||||
render(): string[] {
|
||||
return [];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
|
||||
tui.requestRender(true);
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await terminal.waitForRender();
|
||||
}
|
||||
|
||||
describe("TUI overlay options", () => {
|
||||
describe("width overflow protection", () => {
|
||||
it("should truncate overlay lines that exceed declared width", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// Overlay declares width 20 but renders lines much wider
|
||||
const overlay = new StaticOverlay(["X".repeat(100)]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash, and no line should exceed terminal width
|
||||
const viewport = terminal.getViewport();
|
||||
for (const line of viewport) {
|
||||
// visibleWidth not available here, but line length is a rough check
|
||||
// The important thing is it didn't crash
|
||||
assert.ok(line !== undefined);
|
||||
}
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay with complex ANSI sequences without crashing", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// Simulate complex ANSI content like the crash log showed
|
||||
const complexLine =
|
||||
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
|
||||
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
|
||||
" more content ".repeat(10);
|
||||
const overlay = new StaticOverlay([complexLine, complexLine, complexLine]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 60 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay composited on styled base content", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
// Base content with styling
|
||||
class StyledContent implements Component {
|
||||
render(width: number): string[] {
|
||||
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
|
||||
return [styledLine, styledLine, styledLine];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const overlay = new StaticOverlay(["OVERLAY"]);
|
||||
|
||||
tui.addChild(new StyledContent());
|
||||
tui.showOverlay(overlay, { width: 20, anchor: "center" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash and overlay should be visible
|
||||
const viewport = terminal.getViewport();
|
||||
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
|
||||
assert.ok(hasOverlay, "Overlay should be visible");
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle wide characters at overlay boundary", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// Wide chars (each takes 2 columns) at the edge of declared width
|
||||
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
|
||||
const overlay = new StaticOverlay([wideCharLine]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay positioned at terminal edge", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// Overlay positioned at right edge with content that exceeds declared width
|
||||
const overlay = new StaticOverlay(["X".repeat(50)]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Position at col 60 with width 20 - should fit exactly at right edge
|
||||
tui.showOverlay(overlay, { col: 60, width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay on base content with OSC sequences", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
// Base content with OSC 8 hyperlinks (like file paths in agent output)
|
||||
class HyperlinkContent implements Component {
|
||||
render(width: number): string[] {
|
||||
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
|
||||
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
|
||||
return [line, line, line];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
|
||||
|
||||
tui.addChild(new HyperlinkContent());
|
||||
tui.showOverlay(overlay, { anchor: "center", width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash - this was the original bug scenario
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("width percentage", () => {
|
||||
it("should render overlay at percentage of terminal width", async () => {
|
||||
const terminal = new VirtualTerminal(100, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(overlay.requestedWidth, 50);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect minWidth when widthPercent results in smaller width", async () => {
|
||||
const terminal = new VirtualTerminal(100, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(overlay.requestedWidth, 30);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor positioning", () => {
|
||||
it("should position overlay at top-left", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["TOP-LEFT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.startsWith("TOP-LEFT"), `Expected TOP-LEFT at start, got: ${viewport[0]}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should position overlay at bottom-right", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["BTM-RIGHT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on last row, ending at last column
|
||||
const lastRow = viewport[23];
|
||||
assert.ok(lastRow?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on last row, got: ${lastRow}`);
|
||||
assert.ok(lastRow?.trimEnd().endsWith("BTM-RIGHT"), `Expected BTM-RIGHT at end, got: ${lastRow}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should position overlay at top-center", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["CENTERED"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on first row, centered horizontally
|
||||
const firstRow = viewport[0];
|
||||
assert.ok(firstRow?.includes("CENTERED"), `Expected CENTERED on first row, got: ${firstRow}`);
|
||||
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
|
||||
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
|
||||
assert.ok(colIndex >= 30 && colIndex <= 40, `Expected centered, got col ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("margin", () => {
|
||||
it("should clamp negative margins to zero", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["NEG-MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Negative margins should be treated as 0
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "top-left",
|
||||
width: 12,
|
||||
margin: { top: -5, left: -10, right: 0, bottom: 0 },
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be at row 0, col 0 (negative margins clamped to 0)
|
||||
assert.ok(viewport[0]?.startsWith("NEG-MARGIN"), `Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect margin as number", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on row 5 (not 0) due to margin
|
||||
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
|
||||
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
|
||||
assert.ok(viewport[5]?.includes("MARGIN"), `Expected MARGIN on row 5, got: ${viewport[5]}`);
|
||||
// Should start at col 5 (not 0)
|
||||
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
|
||||
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect margin object", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "top-left",
|
||||
width: 10,
|
||||
margin: { top: 2, left: 3, right: 0, bottom: 0 },
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[2]?.includes("MARGIN"), `Expected MARGIN on row 2, got: ${viewport[2]}`);
|
||||
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
|
||||
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offset", () => {
|
||||
it("should apply offsetX and offsetY from anchor position", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["OFFSET"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 10, offsetX: 10, offsetY: 5 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[5]?.includes("OFFSET"), `Expected OFFSET on row 5, got: ${viewport[5]}`);
|
||||
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
|
||||
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentage positioning", () => {
|
||||
it("should position with rowPercent and colPercent", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["PCT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// 50% should center both ways
|
||||
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Find the row with PCT
|
||||
let foundRow = -1;
|
||||
for (let i = 0; i < viewport.length; i++) {
|
||||
if (viewport[i]?.includes("PCT")) {
|
||||
foundRow = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
|
||||
assert.ok(foundRow >= 10 && foundRow <= 13, `Expected centered row, got ${foundRow}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("rowPercent 0 should position at top", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["TOP"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, row: "0%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("TOP"), `Expected TOP on row 0, got: ${viewport[0]}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("rowPercent 100 should position at bottom", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["BOTTOM"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, row: "100%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[23]?.includes("BOTTOM"), `Expected BOTTOM on last row, got: ${viewport[23]}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxHeight", () => {
|
||||
it("should truncate overlay to maxHeight", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { maxHeight: 3 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const content = viewport.join("\n");
|
||||
assert.ok(content.includes("Line 1"), "Should include Line 1");
|
||||
assert.ok(content.includes("Line 2"), "Should include Line 2");
|
||||
assert.ok(content.includes("Line 3"), "Should include Line 3");
|
||||
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
|
||||
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should truncate overlay to maxHeightPercent", async () => {
|
||||
const terminal = new VirtualTerminal(80, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
|
||||
const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { maxHeight: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const content = viewport.join("\n");
|
||||
assert.ok(content.includes("L1"), "Should include L1");
|
||||
assert.ok(content.includes("L5"), "Should include L5");
|
||||
assert.ok(!content.includes("L6"), "Should NOT include L6");
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("absolute positioning", () => {
|
||||
it("row and col should override anchor", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const overlay = new StaticOverlay(["ABSOLUTE"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Even with bottom-right anchor, row/col should win
|
||||
tui.showOverlay(overlay, { anchor: "bottom-right", row: 3, col: 5, width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[3]?.includes("ABSOLUTE"), `Expected ABSOLUTE on row 3, got: ${viewport[3]}`);
|
||||
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
|
||||
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stacked overlays", () => {
|
||||
it("should render multiple overlays with later ones on top", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// First overlay at top-left
|
||||
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
|
||||
|
||||
// Second overlay at top-left (should cover part of first)
|
||||
const overlay2 = new StaticOverlay(["SECOND"]);
|
||||
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Second overlay should be visible (on top)
|
||||
assert.ok(viewport[0]?.includes("SECOND"), `Expected SECOND on row 0, got: ${viewport[0]}`);
|
||||
// Part of first overlay might still be visible after SECOND
|
||||
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlays at different positions without interference", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// Overlay at top-left
|
||||
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
|
||||
|
||||
// Overlay at bottom-right
|
||||
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
|
||||
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Both should be visible
|
||||
assert.ok(viewport[0]?.includes("TOP-LEFT"), `Expected TOP-LEFT on row 0, got: ${viewport[0]}`);
|
||||
assert.ok(viewport[23]?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on row 23, got: ${viewport[23]}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should properly hide overlays in stack order", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// Show two overlays
|
||||
const overlay1 = new StaticOverlay(["FIRST"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
|
||||
|
||||
const overlay2 = new StaticOverlay(["SECOND"]);
|
||||
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Second should be visible
|
||||
let viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("SECOND"), "SECOND should be visible initially");
|
||||
|
||||
// Hide top overlay
|
||||
tui.hideOverlay();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// First should now be visible
|
||||
viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("FIRST"), "FIRST should be visible after hiding SECOND");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
third_party/pi/packages/tui/test/overlay-short-content.test.ts
vendored
Normal file
62
third_party/pi/packages/tui/test/overlay-short-content.test.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class SimpleContent implements Component {
|
||||
private lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
class SimpleOverlay implements Component {
|
||||
render(): string[] {
|
||||
return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"];
|
||||
}
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
describe("TUI overlay with short content", () => {
|
||||
it("should render overlay when content is shorter than terminal height", async () => {
|
||||
// Terminal has 24 rows, but content only has 3 lines
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
|
||||
// Only 3 lines of content
|
||||
tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"]));
|
||||
|
||||
// Show overlay centered - should be around row 10 in a 24-row terminal
|
||||
const overlay = new SimpleOverlay();
|
||||
tui.showOverlay(overlay);
|
||||
|
||||
// Trigger render
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));
|
||||
|
||||
console.log("Terminal rows:", terminal.rows);
|
||||
console.log("Content lines: 3");
|
||||
console.log("Overlay visible:", hasOverlay);
|
||||
|
||||
if (!hasOverlay) {
|
||||
console.log("\nViewport contents:");
|
||||
for (let i = 0; i < viewport.length; i++) {
|
||||
console.log(` [${i}]: "${viewport[i]}"`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(hasOverlay, "Overlay should be visible when content is shorter than terminal");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
46
third_party/pi/packages/tui/test/regression-overlay-cjk-boundary.test.ts
vendored
Normal file
46
third_party/pi/packages/tui/test/regression-overlay-cjk-boundary.test.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { compositeTuiLine } from "../src/tui.ts";
|
||||
import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
describe("overlay CJK boundary regression", () => {
|
||||
it("excludes a wide grapheme from before when overlay starts inside it", () => {
|
||||
const segments = extractSegments("abcd让EFGH", 5, 9, 11, true);
|
||||
|
||||
assert.strictEqual(segments.before, "abcd");
|
||||
assert.strictEqual(segments.beforeWidth, 4);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
assert.strictEqual(segments.after, "H");
|
||||
assert.strictEqual(segments.afterWidth, 1);
|
||||
});
|
||||
|
||||
it("keeps ASCII before-segment behavior at the same boundary", () => {
|
||||
const segments = extractSegments("abcdG EFGH", 5, 9, 11, true);
|
||||
|
||||
assert.strictEqual(segments.before, "abcdG");
|
||||
assert.strictEqual(segments.beforeWidth, 5);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
});
|
||||
|
||||
it("composites an overlay at the requested column when it starts inside a wide grapheme", () => {
|
||||
const out = compositeTuiLine("abcd让EFGH", "│XX│", 5, 4, 20);
|
||||
const prefix = sliceByColumn(out, 0, 5, true);
|
||||
const overlay = sliceByColumn(out, 5, 4, true);
|
||||
|
||||
assert.strictEqual(out.includes("让"), false);
|
||||
assert.strictEqual(visibleWidth(out), 20);
|
||||
assert.strictEqual(visibleWidth(prefix), 5);
|
||||
assert.strictEqual(visibleWidth(overlay), 4);
|
||||
assert.strictEqual(overlay.includes("│XX│"), true);
|
||||
});
|
||||
|
||||
it("composites an overlay when it starts at a wide grapheme boundary", () => {
|
||||
const out = compositeTuiLine("abcd让EFGH", "│XX│", 4, 4, 20);
|
||||
const overlay = sliceByColumn(out, 4, 4, true);
|
||||
|
||||
assert.strictEqual(out.includes("让"), false);
|
||||
assert.strictEqual(visibleWidth(out), 20);
|
||||
assert.strictEqual(visibleWidth(overlay), 4);
|
||||
assert.strictEqual(overlay.includes("│XX│"), true);
|
||||
});
|
||||
});
|
||||
52
third_party/pi/packages/tui/test/regression-regional-indicator-width.test.ts
vendored
Normal file
52
third_party/pi/packages/tui/test/regression-regional-indicator-width.test.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts";
|
||||
|
||||
describe("regional indicator width regression", () => {
|
||||
it("treats partial flag grapheme as full-width to avoid streaming render drift", () => {
|
||||
// Repro context:
|
||||
// During streaming, "🇨🇳" often appears as an intermediate "🇨" first.
|
||||
// If "🇨" is measured as width 1 while terminal renders it as width 2,
|
||||
// differential rendering can drift and leave stale characters on screen.
|
||||
const partialFlag = "🇨";
|
||||
const listLine = " - 🇨";
|
||||
|
||||
assert.strictEqual(visibleWidth(partialFlag), 2);
|
||||
assert.strictEqual(visibleWidth(listLine), 10);
|
||||
});
|
||||
|
||||
it("wraps intermediate partial-flag list line before overflow", () => {
|
||||
// Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10).
|
||||
// This must wrap to avoid terminal auto-wrap mismatch.
|
||||
const wrapped = wrapTextWithAnsi(" - 🇨", 9);
|
||||
|
||||
assert.strictEqual(wrapped.length, 2);
|
||||
assert.strictEqual(visibleWidth(wrapped[0] || ""), 7);
|
||||
assert.strictEqual(visibleWidth(wrapped[1] || ""), 2);
|
||||
});
|
||||
|
||||
it("treats all regional-indicator singleton graphemes as width 2", () => {
|
||||
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
|
||||
const regionalIndicator = String.fromCodePoint(cp);
|
||||
assert.strictEqual(
|
||||
visibleWidth(regionalIndicator),
|
||||
2,
|
||||
`Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps full flag pairs at width 2", () => {
|
||||
const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"];
|
||||
for (const flag of samples) {
|
||||
assert.strictEqual(visibleWidth(flag), 2, `Expected ${flag} to be width 2`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps common streaming emoji intermediates at stable width", () => {
|
||||
const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨💻", "🏳️🌈"];
|
||||
for (const sample of samples) {
|
||||
assert.strictEqual(visibleWidth(sample), 2, `Expected ${sample} to be width 2`);
|
||||
}
|
||||
});
|
||||
});
|
||||
62
third_party/pi/packages/tui/test/regression-sigwinch-kill-eacces.test.ts
vendored
Normal file
62
third_party/pi/packages/tui/test/regression-sigwinch-kill-eacces.test.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { refreshTerminalDimensions } from "../src/terminal.ts";
|
||||
|
||||
describe("refreshTerminalDimensions", () => {
|
||||
const originalKill = process.kill;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
it("does not throw when kill(2) returns EACCES for self-signal", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return (pid, _signal) => {
|
||||
if (pid === process.pid) {
|
||||
const err = new Error("kill EACCES") as NodeJS.ErrnoException;
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
}
|
||||
return originalKill.call(process, pid, _signal);
|
||||
};
|
||||
})();
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
refreshTerminalDimensions();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call kill on win32", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
let killCalled = false;
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return () => {
|
||||
killCalled = true;
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
|
||||
refreshTerminalDimensions();
|
||||
|
||||
assert.strictEqual(killCalled, false, "kill should not be called on win32");
|
||||
});
|
||||
|
||||
it("preserves other error codes", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return () => {
|
||||
const err = new Error("kill EPERM") as NodeJS.ErrnoException;
|
||||
err.code = "EPERM";
|
||||
throw err;
|
||||
};
|
||||
})();
|
||||
|
||||
// EPERM is also ignored - the refresh is best-effort
|
||||
assert.doesNotThrow(() => {
|
||||
refreshTerminalDimensions();
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.kill = originalKill;
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
});
|
||||
});
|
||||
203
third_party/pi/packages/tui/test/render-churn-bench.ts
vendored
Normal file
203
third_party/pi/packages/tui/test/render-churn-bench.ts
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Alt-screen render churn benchmark.
|
||||
*
|
||||
* Measures cumulative JS allocation and wall time for repeated TuiAltScreen
|
||||
* frames on a layout mirroring pi's fullscreen interactive mode:
|
||||
* VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ].
|
||||
*
|
||||
* Two scenarios:
|
||||
* - static: nothing changes between frames (pure recomposite churn)
|
||||
* - editor: one character appended to the editor per frame (doc scenario
|
||||
* "30 editor updates")
|
||||
*
|
||||
* Allocation is estimated with the V8 sampling heap profiler including
|
||||
* objects collected by minor/major GC, i.e. it measures churn, not retention.
|
||||
*
|
||||
* Run from packages/tui: node test/render-churn-bench.ts
|
||||
*/
|
||||
|
||||
import { Session } from "node:inspector/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
|
||||
const COLUMNS = 100;
|
||||
const ROWS = 30;
|
||||
const WARMUP_FRAMES = 20;
|
||||
const FRAMES = 300;
|
||||
const SAMPLING_INTERVAL = 4096;
|
||||
|
||||
/** Terminal that discards output; keeps xterm parsing out of the measurement. */
|
||||
class NullTerminal implements Terminal {
|
||||
bytesWritten = 0;
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.bytesWritten += data.length;
|
||||
}
|
||||
get columns(): number {
|
||||
return COLUMNS;
|
||||
}
|
||||
get rows(): number {
|
||||
return ROWS;
|
||||
}
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */
|
||||
class EditorSim implements Component {
|
||||
private text = "";
|
||||
private cachedText?: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
append(char: string): void {
|
||||
this.text += char;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`;
|
||||
const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border];
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTranscript(): Container {
|
||||
const container = new Container();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const styled =
|
||||
i % 3 === 0
|
||||
? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding`
|
||||
: `assistant ${i} plain response line with enough text to be representative of a transcript row`;
|
||||
container.addChild(new Text(styled, 1, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
interface SamplingNode {
|
||||
selfSize: number;
|
||||
children: SamplingNode[];
|
||||
}
|
||||
|
||||
function sumProfile(node: SamplingNode): number {
|
||||
let total = node.selfSize;
|
||||
for (const child of node.children) total += sumProfile(child);
|
||||
return total;
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
allocatedBytes: number;
|
||||
elapsedMs: number;
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
session: Session,
|
||||
terminal: NullTerminal,
|
||||
tui: TuiAltScreen,
|
||||
frame: (index: number) => void,
|
||||
): Promise<ScenarioResult> {
|
||||
const writtenBefore = terminal.bytesWritten;
|
||||
await session.post("HeapProfiler.startSampling", {
|
||||
samplingInterval: SAMPLING_INTERVAL,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < FRAMES; i++) {
|
||||
frame(i);
|
||||
tui.renderNow();
|
||||
}
|
||||
const elapsedMs = performance.now() - start;
|
||||
const { profile } = await session.post("HeapProfiler.stopSampling");
|
||||
return {
|
||||
allocatedBytes: sumProfile(profile.head as SamplingNode),
|
||||
elapsedMs,
|
||||
bytesWritten: terminal.bytesWritten - writtenBefore,
|
||||
};
|
||||
}
|
||||
|
||||
function report(name: string, result: ScenarioResult): void {
|
||||
const perFrameKiB = result.allocatedBytes / FRAMES / 1024;
|
||||
const totalMiB = result.allocatedBytes / 1024 / 1024;
|
||||
const msPerFrame = result.elapsedMs / FRAMES;
|
||||
console.log(
|
||||
`${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` +
|
||||
`${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` +
|
||||
`${msPerFrame.toFixed(3).padStart(7)} ms/frame ` +
|
||||
`${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const terminal = new NullTerminal();
|
||||
const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench");
|
||||
|
||||
const transcript = buildTranscript();
|
||||
const editor = new EditorSim();
|
||||
const scrollView = new ScrollView(transcript, {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
overscroll: "chain",
|
||||
scrollbar: "auto",
|
||||
});
|
||||
const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0);
|
||||
const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0);
|
||||
const dock = new VStack([
|
||||
{ component: status, shrink: 1, minSize: 0 },
|
||||
{ component: editor, shrink: 1, minSize: 3 },
|
||||
{ component: footer, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
const root = new VStack([
|
||||
{ component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
tui.setLayoutRoot(root);
|
||||
tui.start();
|
||||
|
||||
for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow();
|
||||
|
||||
const session = new Session();
|
||||
session.connect();
|
||||
|
||||
const staticResult = await runScenario(session, terminal, tui, () => {});
|
||||
const editorResult = await runScenario(session, terminal, tui, (i) => {
|
||||
editor.append(String.fromCharCode(97 + (i % 26)));
|
||||
});
|
||||
|
||||
session.disconnect();
|
||||
tui.stop();
|
||||
|
||||
console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`);
|
||||
report("static", staticResult);
|
||||
report("editor", editorResult);
|
||||
}
|
||||
|
||||
await main();
|
||||
116
third_party/pi/packages/tui/test/select-list.test.ts
vendored
Normal file
116
third_party/pi/packages/tui/test/select-list.test.ts
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { SelectList } from "../src/components/select-list.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
const testTheme = {
|
||||
selectedPrefix: (text: string) => text,
|
||||
selectedText: (text: string) => text,
|
||||
description: (text: string) => text,
|
||||
scrollInfo: (text: string) => text,
|
||||
noMatch: (text: string) => text,
|
||||
};
|
||||
|
||||
const visibleIndexOf = (line: string, text: string): number => {
|
||||
const index = line.indexOf(text);
|
||||
assert.notEqual(index, -1);
|
||||
return visibleWidth(line.slice(0, index));
|
||||
};
|
||||
|
||||
describe("SelectList", () => {
|
||||
it("normalizes multiline descriptions to single line", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "test",
|
||||
label: "test",
|
||||
description: "Line one\nLine two\nLine three",
|
||||
},
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme);
|
||||
const rendered = list.render(100);
|
||||
|
||||
assert.ok(rendered.length > 0);
|
||||
assert.ok(!rendered[0].includes("\n"));
|
||||
assert.ok(rendered[0].includes("Line one Line two Line three"));
|
||||
});
|
||||
|
||||
it("keeps descriptions aligned when the primary text is truncated", () => {
|
||||
const items = [
|
||||
{ value: "short", label: "short", description: "short description" },
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "long description",
|
||||
},
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme);
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(visibleIndexOf(rendered[0], "short description"), visibleIndexOf(rendered[1], "long description"));
|
||||
});
|
||||
|
||||
it("uses the configured minimum primary column width", () => {
|
||||
const items = [
|
||||
{ value: "a", label: "a", description: "first" },
|
||||
{ value: "bb", label: "bb", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 20,
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(rendered[0].indexOf("first"), 14);
|
||||
assert.equal(rendered[1].indexOf("second"), 14);
|
||||
});
|
||||
|
||||
it("uses the configured maximum primary column width", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "first",
|
||||
},
|
||||
{ value: "short", label: "short", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 20,
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(visibleIndexOf(rendered[0], "first"), 22);
|
||||
assert.equal(visibleIndexOf(rendered[1], "second"), 22);
|
||||
});
|
||||
|
||||
it("allows overriding primary truncation while preserving description alignment", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "first",
|
||||
},
|
||||
{ value: "short", label: "short", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 12,
|
||||
truncatePrimary: ({ text, maxWidth }) => {
|
||||
if (text.length <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, maxWidth - 1))}…`;
|
||||
},
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.ok(rendered[0].includes("…"));
|
||||
assert.equal(visibleIndexOf(rendered[0], "first"), visibleIndexOf(rendered[1], "second"));
|
||||
});
|
||||
});
|
||||
58
third_party/pi/packages/tui/test/settings-list.test.ts
vendored
Normal file
58
third_party/pi/packages/tui/test/settings-list.test.ts
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
||||
|
||||
const testTheme: SettingsListTheme = {
|
||||
label: (text) => text,
|
||||
value: (text) => text,
|
||||
description: (text) => text,
|
||||
cursor: "> ",
|
||||
hint: (text) => text,
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "tui-mode",
|
||||
label: "TUI mode",
|
||||
currentValue: "regular",
|
||||
values: ["regular", "fullscreen"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("SettingsList", () => {
|
||||
it("includes spaces in an active search instead of changing the selected setting", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
items.map((item) => ({ ...item })),
|
||||
10,
|
||||
testTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
|
||||
for (const character of "TUI mode") list.handleInput(character);
|
||||
|
||||
assert.deepStrictEqual(changes, []);
|
||||
assert.match(list.render(80)[0] ?? "", /TUI mode/);
|
||||
|
||||
list.handleInput("\r");
|
||||
assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]);
|
||||
});
|
||||
|
||||
it("keeps Space as a change shortcut before a search query is entered", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
items.map((item) => ({ ...item })),
|
||||
10,
|
||||
testTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
|
||||
list.handleInput(" ");
|
||||
|
||||
assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]);
|
||||
});
|
||||
});
|
||||
526
third_party/pi/packages/tui/test/stdin-buffer.test.ts
vendored
Normal file
526
third_party/pi/packages/tui/test/stdin-buffer.test.ts
vendored
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Tests for StdinBuffer
|
||||
*
|
||||
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
||||
* MIT License - Copyright (c) 2025 opentui
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
import { matchesKey } from "../src/keys.ts";
|
||||
import { StdinBuffer } from "../src/stdin-buffer.ts";
|
||||
|
||||
describe("StdinBuffer", () => {
|
||||
let buffer: StdinBuffer;
|
||||
let emittedSequences: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = new StdinBuffer({ timeout: 10 });
|
||||
|
||||
// Collect emitted sequences
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to process data through the buffer
|
||||
function processInput(data: string | Buffer): void {
|
||||
buffer.process(data);
|
||||
}
|
||||
|
||||
// Helper to wait for async operations
|
||||
async function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("Regular Characters", () => {
|
||||
it("should pass through regular characters immediately", () => {
|
||||
processInput("a");
|
||||
assert.deepStrictEqual(emittedSequences, ["a"]);
|
||||
});
|
||||
|
||||
it("should pass through multiple regular characters", () => {
|
||||
processInput("abc");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle unicode characters", () => {
|
||||
processInput("hello 世界");
|
||||
assert.deepStrictEqual(emittedSequences, ["h", "e", "l", "l", "o", " ", "世", "界"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete Escape Sequences", () => {
|
||||
it("should pass through complete mouse SGR sequences", () => {
|
||||
const mouseSeq = "\x1b[<35;20;5m";
|
||||
processInput(mouseSeq);
|
||||
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
|
||||
});
|
||||
|
||||
it("should pass through complete arrow key sequences", () => {
|
||||
const upArrow = "\x1b[A";
|
||||
processInput(upArrow);
|
||||
assert.deepStrictEqual(emittedSequences, [upArrow]);
|
||||
});
|
||||
|
||||
it("should pass through complete function key sequences", () => {
|
||||
const f1 = "\x1b[11~";
|
||||
processInput(f1);
|
||||
assert.deepStrictEqual(emittedSequences, [f1]);
|
||||
});
|
||||
|
||||
it("should pass through meta key sequences", () => {
|
||||
const metaA = "\x1ba";
|
||||
processInput(metaA);
|
||||
assert.deepStrictEqual(emittedSequences, [metaA]);
|
||||
});
|
||||
|
||||
it("should pass through SS3 sequences", () => {
|
||||
const ss3 = "\x1bOA";
|
||||
processInput(ss3);
|
||||
assert.deepStrictEqual(emittedSequences, [ss3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Partial Escape Sequences", () => {
|
||||
it("should buffer incomplete mouse SGR sequence", async () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b");
|
||||
|
||||
processInput("[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
processInput(";20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should buffer incomplete CSI sequence", () => {
|
||||
processInput("\x1b[");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
processInput("1;");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
processInput("5H");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
|
||||
});
|
||||
|
||||
it("should buffer split across many chunks", () => {
|
||||
processInput("\x1b");
|
||||
processInput("[");
|
||||
processInput("<");
|
||||
processInput("3");
|
||||
processInput("5");
|
||||
processInput(";");
|
||||
processInput("2");
|
||||
processInput("0");
|
||||
processInput(";");
|
||||
processInput("5");
|
||||
processInput("m");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
});
|
||||
|
||||
it("should flush incomplete sequence after timeout", async () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// Wait for timeout
|
||||
await wait(15);
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
||||
});
|
||||
|
||||
it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => {
|
||||
// Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits
|
||||
// the bytes further apart than the timeout, ESC is flushed alone and the
|
||||
// host sees Escape (interrupt) instead of Alt+Enter. This locks in the
|
||||
// behavior so the configurable timeout in ProcessTerminal stays honest.
|
||||
processInput("\x1b");
|
||||
await wait(20); // buffer timeout is 10ms in beforeEach
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true);
|
||||
});
|
||||
|
||||
it("should merge ESC + CR split across chunks within a larger timeout", async () => {
|
||||
buffer = new StdinBuffer({ escapeTimeout: 100 });
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
processInput("\x1b");
|
||||
await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true);
|
||||
});
|
||||
|
||||
it("does not apply the sequence timeout to a lone ESC", async () => {
|
||||
buffer = new StdinBuffer({ timeout: 100 });
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
processInput("\x1b");
|
||||
await wait(20);
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true);
|
||||
});
|
||||
|
||||
it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => {
|
||||
const delayedBuffer = new StdinBuffer();
|
||||
const delayedSequences: string[] = [];
|
||||
delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence));
|
||||
|
||||
delayedBuffer.process("\x1b[");
|
||||
await wait(20);
|
||||
assert.deepStrictEqual(delayedSequences, []);
|
||||
delayedBuffer.process("<65;48;39M");
|
||||
assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]);
|
||||
delayedBuffer.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed Content", () => {
|
||||
it("should handle characters followed by escape sequence", () => {
|
||||
processInput("abc\x1b[A");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
|
||||
});
|
||||
|
||||
it("should handle escape sequence followed by characters", () => {
|
||||
processInput("\x1b[Aabc");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle multiple complete sequences", () => {
|
||||
processInput("\x1b[A\x1b[B\x1b[C");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
|
||||
});
|
||||
|
||||
it("should handle partial sequence with preceding characters", () => {
|
||||
processInput("abc\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
processInput(";20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[<35;20;5m"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty Keyboard Protocol", () => {
|
||||
it("should handle Kitty CSI u press events", () => {
|
||||
// Press 'a' in Kitty protocol
|
||||
processInput("\x1b[97u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty CSI u release events", () => {
|
||||
// Release 'a' in Kitty protocol
|
||||
processInput("\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle batched Kitty press and release", () => {
|
||||
// Press 'a', release 'a' batched together (common over SSH)
|
||||
processInput("\x1b[97u\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle multiple batched Kitty events", () => {
|
||||
// Press 'a', release 'a', press 'b', release 'b'
|
||||
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty arrow keys with event type", () => {
|
||||
// Up arrow press with event type
|
||||
processInput("\x1b[1;1:1A");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty functional keys with event type", () => {
|
||||
// Delete key release
|
||||
processInput("\x1b[3;1:3~");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
|
||||
});
|
||||
|
||||
it("should split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => {
|
||||
// WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b
|
||||
// and the release as a full Kitty CSI-u sequence, concatenated.
|
||||
// The buffer must not treat \x1b\x1b as a complete meta-key when the
|
||||
// following byte starts a new escape sequence.
|
||||
processInput("\x1b\x1b[27;129:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]);
|
||||
});
|
||||
|
||||
it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => {
|
||||
processInput("\x1b\x1b[27;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]);
|
||||
});
|
||||
|
||||
it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => {
|
||||
// \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[
|
||||
processInput("\x1b\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]);
|
||||
});
|
||||
|
||||
it("should handle plain characters mixed with Kitty sequences", () => {
|
||||
// Plain 'a' followed by Kitty release
|
||||
processInput("a\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should drop raw duplicate character after matching Kitty printable sequence", () => {
|
||||
processInput("\x1b[224uà");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]);
|
||||
});
|
||||
|
||||
it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => {
|
||||
processInput("\x1b[64u");
|
||||
processInput("@");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]);
|
||||
});
|
||||
|
||||
it("should keep non-matching plain character after Kitty printable sequence", () => {
|
||||
processInput("\x1b[97ub");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]);
|
||||
});
|
||||
|
||||
it("should keep raw character after modified Kitty printable sequence", () => {
|
||||
processInput("\x1b[64;3u@");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]);
|
||||
});
|
||||
|
||||
it("should handle rapid typing simulation with Kitty protocol", () => {
|
||||
// Simulates typing "hi" quickly with releases interleaved
|
||||
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mouse Events", () => {
|
||||
it("should handle mouse press event", () => {
|
||||
processInput("\x1b[<0;10;5M");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
|
||||
});
|
||||
|
||||
it("should handle mouse release event", () => {
|
||||
processInput("\x1b[<0;10;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
|
||||
});
|
||||
|
||||
it("should handle mouse move event", () => {
|
||||
processInput("\x1b[<35;20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
});
|
||||
|
||||
it("should handle split mouse events", () => {
|
||||
processInput("\x1b[<3");
|
||||
processInput("5;1");
|
||||
processInput("5;");
|
||||
processInput("10m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
|
||||
});
|
||||
|
||||
it("should handle multiple mouse events", () => {
|
||||
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]);
|
||||
});
|
||||
|
||||
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
|
||||
processInput("\x1b[M abc");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
|
||||
});
|
||||
|
||||
it("should buffer incomplete old-style mouse sequence", () => {
|
||||
processInput("\x1b[M");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
|
||||
|
||||
processInput(" a");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
|
||||
|
||||
processInput("b");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty input", () => {
|
||||
processInput("");
|
||||
// Empty string emits an empty data event
|
||||
assert.deepStrictEqual(emittedSequences, [""]);
|
||||
});
|
||||
|
||||
it("should handle lone escape character with timeout", async () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// After timeout, should emit
|
||||
await wait(15);
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
|
||||
});
|
||||
|
||||
it("flushes a lone escape promptly with the longer default sequence timeout", async () => {
|
||||
const defaultBuffer = new StdinBuffer();
|
||||
const defaultSequences: string[] = [];
|
||||
defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence));
|
||||
|
||||
defaultBuffer.process("\x1b");
|
||||
await wait(20);
|
||||
assert.deepStrictEqual(defaultSequences, ["\x1b"]);
|
||||
defaultBuffer.destroy();
|
||||
});
|
||||
|
||||
it("should handle lone escape character with explicit flush", () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, ["\x1b"]);
|
||||
});
|
||||
|
||||
it("should handle buffer input", () => {
|
||||
processInput(Buffer.from("\x1b[A"));
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
|
||||
});
|
||||
|
||||
it("should handle very long sequences", () => {
|
||||
const longSeq = `\x1b[${"1;".repeat(50)}H`;
|
||||
processInput(longSeq);
|
||||
assert.deepStrictEqual(emittedSequences, [longSeq]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flush", () => {
|
||||
it("should flush incomplete sequences", () => {
|
||||
processInput("\x1b[<35");
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should return empty array if nothing to flush", () => {
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, []);
|
||||
});
|
||||
|
||||
it("should emit flushed data via timeout", async () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// Wait for timeout to flush
|
||||
await wait(15);
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clear", () => {
|
||||
it("should clear buffered content without emitting", () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
buffer.clear();
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracketed Paste", () => {
|
||||
let emittedPaste: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = new StdinBuffer({ timeout: 10 });
|
||||
|
||||
// Collect emitted sequences
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
// Collect paste events
|
||||
emittedPaste = [];
|
||||
buffer.on("paste", (data) => {
|
||||
emittedPaste.push(data);
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit paste event for complete bracketed paste", () => {
|
||||
const pasteStart = "\x1b[200~";
|
||||
const pasteEnd = "\x1b[201~";
|
||||
const content = "hello world";
|
||||
|
||||
processInput(pasteStart + content + pasteEnd);
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
||||
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
|
||||
});
|
||||
|
||||
it("should handle paste arriving in chunks", () => {
|
||||
processInput("\x1b[200~");
|
||||
assert.deepStrictEqual(emittedPaste, []);
|
||||
|
||||
processInput("hello ");
|
||||
assert.deepStrictEqual(emittedPaste, []);
|
||||
|
||||
processInput("world\x1b[201~");
|
||||
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
|
||||
it("should handle paste with input before and after", () => {
|
||||
processInput("a");
|
||||
processInput("\x1b[200~pasted\x1b[201~");
|
||||
processInput("b");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
|
||||
assert.deepStrictEqual(emittedPaste, ["pasted"]);
|
||||
});
|
||||
|
||||
it("should handle paste with newlines", () => {
|
||||
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
|
||||
it("should handle paste with unicode", () => {
|
||||
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Destroy", () => {
|
||||
it("should clear buffer on destroy", () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
buffer.destroy();
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should clear pending timeouts on destroy", async () => {
|
||||
processInput("\x1b[<35");
|
||||
buffer.destroy();
|
||||
|
||||
// Wait longer than timeout
|
||||
await wait(15);
|
||||
|
||||
// Should not have emitted anything
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
third_party/pi/packages/tui/test/tab-width.test.ts
vendored
Normal file
88
third_party/pi/packages/tui/test/tab-width.test.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class FullViewportContent implements Component {
|
||||
render(width: number): string[] {
|
||||
return ["base 0", "base 1", "base 2"].map((line) => line.padEnd(width));
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class CapturingVirtualTerminal extends VirtualTerminal {
|
||||
private output = "";
|
||||
|
||||
override write(data: string): void {
|
||||
this.output += data;
|
||||
super.write(data);
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
return this.output;
|
||||
}
|
||||
}
|
||||
|
||||
class TabStatusOverlay implements Component {
|
||||
render(): string[] {
|
||||
return ["\tX"];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
describe("tab width accounting", () => {
|
||||
it("keeps slice helper widths consistent with visible width", () => {
|
||||
const text = "out 192M\t.pi/skill-tests/results-ha";
|
||||
const slice = sliceWithWidth(text, 0, 10, true);
|
||||
|
||||
assert.strictEqual(slice.text, "out 192M");
|
||||
assert.strictEqual(slice.width, 8);
|
||||
assert.strictEqual(visibleWidth(slice.text), slice.width);
|
||||
});
|
||||
|
||||
it("keeps overlay segment widths consistent with visible width", () => {
|
||||
const text = "out 192M\t.pi/skill-tests/results-ha";
|
||||
const segments = extractSegments(text, 10, 13, 10, true);
|
||||
|
||||
assert.strictEqual(segments.before, "out 192M");
|
||||
assert.strictEqual(segments.beforeWidth, 8);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
|
||||
const tabFits = extractSegments(text, 11, 13, 10, true);
|
||||
assert.strictEqual(tabFits.before, "out 192M\t");
|
||||
assert.strictEqual(tabFits.beforeWidth, 11);
|
||||
assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth);
|
||||
});
|
||||
|
||||
it("keeps tabs inside terminal control sequences byte-identical", () => {
|
||||
const controlSequences = [
|
||||
"\x1b]8;;https://example.test/a\tb\x07",
|
||||
"\x1b]0;window\ttitle\x1b\\",
|
||||
"\x1b_payload\tdata\x1b\\",
|
||||
];
|
||||
|
||||
for (const controlSequence of controlSequences) {
|
||||
assert.strictEqual(normalizeTerminalOutput(`${controlSequence}label\ttext`), `${controlSequence}label text`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps tab-containing overlays on one physical terminal row", async () => {
|
||||
const terminal = new CapturingVirtualTerminal(16, 3);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.addChild(new FullViewportContent());
|
||||
tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 });
|
||||
tui.start();
|
||||
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["base 0 ", "base X ", "base 2 "]);
|
||||
assert.ok(!terminal.getOutput().includes("\t"));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
252
third_party/pi/packages/tui/test/terminal-colors.test.ts
vendored
Normal file
252
third_party/pi/packages/tui/test/terminal-colors.test.ts
vendored
Normal file
@@ -0,0 +1,252 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
type Component,
|
||||
parseOsc11BackgroundColor,
|
||||
parseTerminalColorSchemeReport,
|
||||
type Terminal,
|
||||
type TUI,
|
||||
TuiMainScreen,
|
||||
} from "../src/index.ts";
|
||||
|
||||
class TestTerminal implements Terminal {
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private readonly columnCount: number;
|
||||
private readonly rowCount: number;
|
||||
readonly writes: string[] = [];
|
||||
|
||||
constructor(columnCount = 80, rowCount = 24) {
|
||||
this.columnCount = columnCount;
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.inputHandler = undefined;
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
|
||||
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.columnCount;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.rowCount;
|
||||
}
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
moveBy(_lines: number): void {}
|
||||
|
||||
hideCursor(): void {}
|
||||
|
||||
showCursor(): void {}
|
||||
|
||||
clearLine(): void {}
|
||||
|
||||
clearFromCursor(): void {}
|
||||
|
||||
clearScreen(): void {}
|
||||
|
||||
setTitle(_title: string): void {}
|
||||
|
||||
setProgress(_active: boolean): void {}
|
||||
|
||||
sendInput(data: string): void {
|
||||
this.inputHandler?.(data);
|
||||
}
|
||||
|
||||
sendResize(): void {
|
||||
this.resizeHandler?.();
|
||||
}
|
||||
}
|
||||
|
||||
class InputRecorder implements Component {
|
||||
readonly inputs: string[] = [];
|
||||
|
||||
render(_width: number): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputs.push(data);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe("parseOsc11BackgroundColor", () => {
|
||||
it("parses 16-bit OSC 11 rgb responses", () => {
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), {
|
||||
r: 0,
|
||||
g: 128,
|
||||
b: 255,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses OSC 11 hex responses", () => {
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 });
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it("rejects non-strict OSC 11 responses", () => {
|
||||
assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined);
|
||||
assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined);
|
||||
assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTerminalColorSchemeReport", () => {
|
||||
it("parses color scheme reports", () => {
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n\x1b[?997;1n\x1b[?997;1n"), "dark");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n\x1b[?997;2n\x1b[?997;2n"), "light");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined);
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined);
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI.queryTerminalBackgroundColor", () => {
|
||||
it("writes OSC 11 query and resolves with the parsed RGB reply", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
assert.ok(terminal.writes.includes("\x1b]11;?\x07"));
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
|
||||
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
|
||||
terminal.sendInput("\x1b]11;#000000\x07");
|
||||
|
||||
assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 });
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
|
||||
terminal.sendInput("\x1b]11;not-a-color\x07");
|
||||
|
||||
assert.strictEqual(await query, undefined);
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
let settled = false;
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => {
|
||||
settled = true;
|
||||
return rgb;
|
||||
});
|
||||
|
||||
terminal.sendInput("x");
|
||||
await Promise.resolve();
|
||||
|
||||
assert.strictEqual(settled, false);
|
||||
assert.deepStrictEqual(listenerInputs, ["x"]);
|
||||
assert.deepStrictEqual(component.inputs, ["x"]);
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps consuming a late OSC 11 reply after timeout", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 });
|
||||
await wait(5);
|
||||
|
||||
assert.strictEqual(await query, undefined);
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
700
third_party/pi/packages/tui/test/terminal-image.test.ts
vendored
Normal file
700
third_party/pi/packages/tui/test/terminal-image.test.ts
vendored
Normal file
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* Tests for terminal image detection and line handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import {
|
||||
cropKittyImageLine,
|
||||
deleteAllKittyImages,
|
||||
deleteAllKittyPlacements,
|
||||
deleteKittyImage,
|
||||
detectCapabilities,
|
||||
encodeITerm2,
|
||||
encodeKitty,
|
||||
getCapabilities,
|
||||
getKittyImageMetadata,
|
||||
getKittyImagePlacement,
|
||||
hyperlink,
|
||||
imageFallback,
|
||||
isImageLine,
|
||||
registerKittyImageMetadata,
|
||||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"TERMINAL_EMULATOR",
|
||||
"COLORTERM",
|
||||
"TMUX",
|
||||
"KITTY_WINDOW_ID",
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
"WEZTERM_PANE",
|
||||
"ITERM_SESSION_ID",
|
||||
"WT_SESSION",
|
||||
"CMUX_WORKSPACE_ID",
|
||||
"WARP_SESSION_ID",
|
||||
"WARP_TERMINAL_SESSION_UUID",
|
||||
"PI_HYPERLINKS",
|
||||
"PI_IMAGE_PROTOCOL",
|
||||
"PI_TRUE_COLOR",
|
||||
] as const;
|
||||
|
||||
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("isImageLine", () => {
|
||||
describe("iTerm2 image protocol", () => {
|
||||
it("should detect iTerm2 image escape sequence at start of line", () => {
|
||||
// iTerm2 image escape sequence: ESC ]1337;File=...
|
||||
const iterm2ImageLine = "\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07";
|
||||
assert.strictEqual(isImageLine(iterm2ImageLine), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence with text before it", () => {
|
||||
// Simulating a line that has text then image data (bug scenario)
|
||||
const lineWithTextAndImage = "Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text";
|
||||
assert.strictEqual(isImageLine(lineWithTextAndImage), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence in middle of long line", () => {
|
||||
// Simulate a very long line with image data in the middle
|
||||
const longLineWithImage =
|
||||
"Text before image..." + "\x1b]1337;File=inline=1:verylongbase64data==" + "...text after";
|
||||
assert.strictEqual(isImageLine(longLineWithImage), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence at end of line", () => {
|
||||
const lineWithImageAtEnd = "Regular text ending with \x1b]1337;File=inline=1:base64data==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithImageAtEnd), true);
|
||||
});
|
||||
|
||||
it("should detect minimal iTerm2 image escape sequence", () => {
|
||||
const minimalImageLine = "\x1b]1337;File=:\x07";
|
||||
assert.strictEqual(isImageLine(minimalImageLine), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty image protocol", () => {
|
||||
it("should detect Kitty image escape sequence at start of line", () => {
|
||||
// Kitty image escape sequence: ESC _G
|
||||
const kittyImageLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
assert.strictEqual(isImageLine(kittyImageLine), true);
|
||||
});
|
||||
|
||||
it("should detect Kitty image escape sequence with text before it", () => {
|
||||
// Bug scenario: text + image data in same line
|
||||
const lineWithTextAndKittyImage = "Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true);
|
||||
});
|
||||
|
||||
it("should detect Kitty image escape sequence with padding", () => {
|
||||
// Kitty protocol adds padding to escape sequences
|
||||
const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ ";
|
||||
assert.strictEqual(isImageLine(kittyWithPadding), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug regression tests", () => {
|
||||
it("should detect image sequences in very long lines (304k+ chars)", () => {
|
||||
// This simulates the crash scenario: a line with 304,401 chars
|
||||
// containing image escape sequences somewhere
|
||||
const base64Char = "A".repeat(100); // 100 chars of base64-like data
|
||||
const imageSequence = "\x1b]1337;File=size=800,600;inline=1:";
|
||||
|
||||
// Build a long line with image sequence
|
||||
const longLine =
|
||||
"Text prefix " +
|
||||
imageSequence +
|
||||
base64Char.repeat(3000) + // ~300,000 chars
|
||||
" suffix";
|
||||
|
||||
assert.strictEqual(longLine.length > 300000, true);
|
||||
assert.strictEqual(isImageLine(longLine), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences when terminal doesn't support images", () => {
|
||||
// The bug occurred when getImageEscapePrefix() returned null
|
||||
// isImageLine should still detect image sequences regardless
|
||||
const lineWithImage = "Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithImage), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences with ANSI codes before them", () => {
|
||||
// Text might have ANSI styling before image data
|
||||
const lineWithAnsiAndImage = "\x1b[31mError output \x1b]1337;File=inline=1:image==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithAnsiAndImage), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences with ANSI codes after them", () => {
|
||||
const lineWithImageAndAnsi = "\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset";
|
||||
assert.strictEqual(isImageLine(lineWithImageAndAnsi), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative cases - lines without images", () => {
|
||||
it("should not detect images in plain text lines", () => {
|
||||
const plainText = "This is just a regular text line without any escape sequences";
|
||||
assert.strictEqual(isImageLine(plainText), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with only ANSI codes", () => {
|
||||
const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
|
||||
assert.strictEqual(isImageLine(ansiText), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with cursor movement codes", () => {
|
||||
const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up";
|
||||
assert.strictEqual(isImageLine(cursorCodes), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with partial iTerm2 sequences", () => {
|
||||
// Similar prefix but missing the complete sequence
|
||||
const partialSequence = "Some text with ]1337;File but missing ESC at start";
|
||||
assert.strictEqual(isImageLine(partialSequence), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with partial Kitty sequences", () => {
|
||||
// Similar prefix but missing the complete sequence
|
||||
const partialSequence = "Some text with _G but missing ESC at start";
|
||||
assert.strictEqual(isImageLine(partialSequence), false);
|
||||
});
|
||||
|
||||
it("should not detect images in empty lines", () => {
|
||||
assert.strictEqual(isImageLine(""), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with newlines only", () => {
|
||||
assert.strictEqual(isImageLine("\n"), false);
|
||||
assert.strictEqual(isImageLine("\n\n"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed content scenarios", () => {
|
||||
it("should detect images when line has both Kitty and iTerm2 sequences", () => {
|
||||
const mixedLine = "Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07";
|
||||
assert.strictEqual(isImageLine(mixedLine), true);
|
||||
});
|
||||
|
||||
it("should detect image in line with multiple text and image segments", () => {
|
||||
const complexLine = "Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end";
|
||||
assert.strictEqual(isImageLine(complexLine), true);
|
||||
});
|
||||
|
||||
it("should not falsely detect image in line with file path containing keywords", () => {
|
||||
// File path might contain "1337" or "File" but without escape sequences
|
||||
const filePathLine = "/path/to/File_1337_backup/image.jpg";
|
||||
assert.strictEqual(isImageLine(filePathLine), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectCapabilities", () => {
|
||||
it("defaults to hyperlinks: false for unknown terminals", () => {
|
||||
withEnv({}, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => detectCapabilities()),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ TERM_PROGRAM: "iterm.app", PI_HYPERLINKS: "0", PI_IMAGE_PROTOCOL: "none", PI_TRUE_COLOR: "0" }, () =>
|
||||
detectCapabilities(),
|
||||
),
|
||||
{ images: null, trueColor: false, hyperlinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves auto-detection for auto environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv(
|
||||
{
|
||||
TERM_PROGRAM: "ghostty",
|
||||
PI_HYPERLINKS: "auto",
|
||||
PI_IMAGE_PROTOCOL: "auto",
|
||||
PI_TRUE_COLOR: "auto",
|
||||
},
|
||||
() => detectCapabilities(),
|
||||
),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies and clears programmatic overrides", () => {
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => {
|
||||
setCapabilityOverrides({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
assert.deepStrictEqual(getCapabilities(), { images: null, trueColor: false, hyperlinks: false });
|
||||
setCapabilityOverrides({});
|
||||
assert.deepStrictEqual(getCapabilities(), { images: "kitty", trueColor: true, hyperlinks: true });
|
||||
} finally {
|
||||
setCapabilityOverrides({});
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses the tmux probe when hyperlinks are overridden", () => {
|
||||
let probed = false;
|
||||
const caps = withEnv(
|
||||
{ TMUX: "/tmp/tmux-1000/default,1234,0", PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty" },
|
||||
() =>
|
||||
detectCapabilities(() => {
|
||||
probed = true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(probed, false);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
});
|
||||
|
||||
it("enables hyperlinks under tmux when the client forwards them", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables hyperlinks under tmux when the client does not forward them", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("checks tmux capability when TERM starts with 'tmux'", () => {
|
||||
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
|
||||
const caps2 = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps2.hyperlinks, false);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces hyperlinks: false when TERM starts with 'screen'", () => {
|
||||
withEnv({ TERM: "screen-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Ghostty", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not disable Ghostty images solely because cmux is present", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty", CMUX_WORKSPACE_ID: "workspace" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Kitty", () => {
|
||||
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for WezTerm", () => {
|
||||
withEnv({ WEZTERM_PANE: "0" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via TERM_PROGRAM", () => {
|
||||
withEnv({ TERM_PROGRAM: "WarpTerminal" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via WARP_SESSION_ID", () => {
|
||||
withEnv({ WARP_SESSION_ID: "some-session-id" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via WARP_TERMINAL_SESSION_UUID", () => {
|
||||
withEnv({ WARP_TERMINAL_SESSION_UUID: "d0e1a2e5-7ca7-44cd-9037-ac7222011161" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables images for Warp inside tmux", () => {
|
||||
withEnv(
|
||||
{
|
||||
TERM_PROGRAM: "WarpTerminal",
|
||||
TMUX: "/tmp/tmux-1000/default,1234,0",
|
||||
TERM: "tmux-256color",
|
||||
},
|
||||
() => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("enables hyperlinks for iTerm2", () => {
|
||||
withEnv({ TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for VSCode", () => {
|
||||
withEnv({ TERM_PROGRAM: "vscode" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Alacritty capabilities for Zed", () => {
|
||||
withEnv({ TERM_PROGRAM: "zed" }, () => {
|
||||
assert.deepStrictEqual(detectCapabilities(), { images: null, trueColor: true, hyperlinks: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("enables truecolor and hyperlinks for Windows Terminal outside multiplexers", () => {
|
||||
withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables truecolor without hyperlinks for JetBrains terminal", () => {
|
||||
withEnv({ TERMINAL_EMULATOR: "JetBrains-JediTerm", TERM: "xterm-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit Windows Terminal truecolor through tmux", () => {
|
||||
withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.trueColor, false);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts explicit truecolor hints through tmux", () => {
|
||||
withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("iTerm2 image encoding", () => {
|
||||
it("includes the decoded payload size in OSC 1337 metadata", () => {
|
||||
const sequence = encodeITerm2("AAAA", { width: 2, height: "auto" });
|
||||
assert.strictEqual(sequence, "\x1b]1337;File=inline=1;size=3;width=2;height=auto:AAAA\x07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty image cursor movement", () => {
|
||||
it("can request no terminal-side cursor movement", () => {
|
||||
const sequence = encodeKitty("AAAA", { columns: 2, rows: 2, moveCursor: false });
|
||||
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
|
||||
});
|
||||
|
||||
it("suppresses Kitty replies for delete commands", () => {
|
||||
assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\");
|
||||
assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\");
|
||||
assert.strictEqual(deleteAllKittyPlacements(), "\x1b_Ga=d,d=a,q=2\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves renderImage's default terminal-side cursor movement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2 });
|
||||
assert.ok(result);
|
||||
assert.ok(!result.sequence.includes(",C=1,"));
|
||||
assert.strictEqual(result.rows, 2);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("can opt renderImage into no terminal-side cursor movement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2, moveCursor: false });
|
||||
assert.ok(result);
|
||||
assert.ok(result.sequence.includes(",C=1,"));
|
||||
assert.strictEqual(result.rows, 2);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("registers metadata and crops a partially visible placement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage(
|
||||
"AAAA",
|
||||
{ widthPx: 100, heightPx: 100 },
|
||||
{ maxWidthCells: 3, imageId: 42, moveCursor: false },
|
||||
);
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(getKittyImageMetadata(result.sequence), {
|
||||
imageId: 42,
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
widthPx: 100,
|
||||
heightPx: 100,
|
||||
});
|
||||
assert.ok(cropKittyImageLine(result.sequence, 2, 1).includes("y=66,h=34,r=1"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates placement-only commands for uploaded and cropped images", () => {
|
||||
registerKittyImageMetadata({ imageId: 42, columns: 3, rows: 3, widthPx: 100, heightPx: 100 });
|
||||
const transmission = encodeKitty("A".repeat(8192), {
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
imageId: 42,
|
||||
moveCursor: false,
|
||||
});
|
||||
const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`;
|
||||
const placement = getKittyImagePlacement(line);
|
||||
assert.ok(placement);
|
||||
assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length);
|
||||
assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4);
|
||||
assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\");
|
||||
assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`);
|
||||
assert.ok(!placement.replacementLine.includes("AAAA"));
|
||||
});
|
||||
|
||||
it("honors maxHeightCells by reducing rendered width", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 });
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.rows, 5);
|
||||
assert.ok(result.sequence.includes(",c=1,r=5"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("caps Image component height to a square pixel box by default", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 20 });
|
||||
try {
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 10 },
|
||||
{ widthPx: 10, heightPx: 100 },
|
||||
);
|
||||
const lines = image.render(12);
|
||||
assert.strictEqual(lines.length, 5);
|
||||
assert.ok(lines[0].includes(",c=1,r=5"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("places image sequence on first line with empty padding rows", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 2 },
|
||||
{ widthPx: 20, heightPx: 20 },
|
||||
);
|
||||
const lines = image.render(4);
|
||||
const imageId = image.getImageId();
|
||||
assert.strictEqual(typeof imageId, "number");
|
||||
assert.ok(lines[0].startsWith("\x1b_G"));
|
||||
assert.ok(lines[0].includes(",C=1,"));
|
||||
assert.ok(lines[0].includes(`,i=${imageId}`));
|
||||
assert.ok(lines[0].endsWith("\x1b\\"));
|
||||
assert.deepStrictEqual(lines.slice(1, lines.length), [""]);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long image fallback lines to render width", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
const longPath = join(
|
||||
homedir(),
|
||||
"images",
|
||||
`${"generated-image-with-a-very-long-absolute-path".repeat(4)}.png`,
|
||||
);
|
||||
const width = 40;
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => `\x1b[33m${value}\x1b[0m` },
|
||||
{ filename: longPath },
|
||||
{ widthPx: 1280, heightPx: 720 },
|
||||
);
|
||||
const lines = image.render(width);
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.ok(
|
||||
visibleWidth(lines[0]) <= width,
|
||||
`fallback line wider than ${width}: visible=${visibleWidth(lines[0])} raw=${JSON.stringify(lines[0])}`,
|
||||
);
|
||||
assert.ok(lines[0].includes("..."), "expected ellipsis when truncating long fallback path");
|
||||
assert.ok(lines[0].includes("~"), "expected home-shortened path in fallback");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageFallback", () => {
|
||||
it("shortens home-prefixed absolute paths without hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
const abs = join(homedir(), ".pi", "agent", "shot.png");
|
||||
const result = imageFallback("image/png", { widthPx: 1280, heightPx: 720 }, abs);
|
||||
assert.strictEqual(result, "[Image: ~/.pi/agent/shot.png [image/png] 1280x720]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps shortened absolute paths in OSC 8 file links when hyperlinks are enabled", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
try {
|
||||
const abs = join(homedir(), ".pi", "agent", "shot.png");
|
||||
const result = imageFallback("image/png", { widthPx: 10, heightPx: 10 }, abs);
|
||||
assert.ok(result.includes("\x1b]8;;file://"), "expected OSC 8 file link");
|
||||
assert.ok(
|
||||
result.includes(abs.replaceAll("\\", "/")) || result.includes(abs),
|
||||
"file URL should target absolute path",
|
||||
);
|
||||
// Visible text must use ~/... not the expanded home path.
|
||||
const visible = result.replace(/\x1b\]8;;.*?\x1b\\/g, "");
|
||||
assert.strictEqual(visible, "[Image: ~/.pi/agent/shot.png [image/png] 10x10]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves bare basenames unchanged and does not hyperlink them", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
try {
|
||||
const result = imageFallback("image/png", { widthPx: 1, heightPx: 1 }, "clankolas.png");
|
||||
assert.strictEqual(result, "[Image: clankolas.png [image/png] 1x1]");
|
||||
assert.ok(!result.includes("\x1b]8;"), "basename must not be hyperlinked");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits filename segment when not provided", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
assert.strictEqual(imageFallback("image/png", { widthPx: 8, heightPx: 6 }), "[Image: [image/png] 8x6]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperlink", () => {
|
||||
it("wraps text in OSC 8 open and close sequences", () => {
|
||||
const result = hyperlink("click me", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves ANSI styling inside the hyperlink", () => {
|
||||
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
|
||||
const result = hyperlink(styled, "https://example.com");
|
||||
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
|
||||
assert.ok(result.includes(styled));
|
||||
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
|
||||
});
|
||||
|
||||
it("works with empty text", () => {
|
||||
const result = hyperlink("", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("works with file:// URIs", () => {
|
||||
const result = hyperlink("README.md", "file:///home/user/README.md");
|
||||
assert.ok(result.includes("file:///home/user/README.md"));
|
||||
assert.ok(result.includes("README.md"));
|
||||
});
|
||||
});
|
||||
300
third_party/pi/packages/tui/test/terminal.test.ts
vendored
Normal file
300
third_party/pi/packages/tui/test/terminal.test.ts
vendored
Normal file
@@ -0,0 +1,300 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import { setKittyProtocolActive } from "../src/keys.ts";
|
||||
import {
|
||||
normalizeAppleTerminalInput,
|
||||
normalizeNativeShiftEnterInput,
|
||||
ProcessTerminal,
|
||||
resolveEscapeTimeoutMs,
|
||||
} from "../src/terminal.ts";
|
||||
|
||||
describe("resolveEscapeTimeoutMs", () => {
|
||||
it("uses PI_TUI_ESC_TIMEOUT when configured", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80);
|
||||
});
|
||||
|
||||
it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10);
|
||||
});
|
||||
|
||||
it("defaults to 100ms over SSH", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100);
|
||||
assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100);
|
||||
});
|
||||
|
||||
it("defaults to 10ms otherwise", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({}), 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeNativeShiftEnterInput", () => {
|
||||
it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", true, true), "\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("leaves Return unchanged when native Shift detection is disabled", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", false, true), "\r");
|
||||
});
|
||||
|
||||
it("leaves Return unchanged when Shift is not pressed", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", true, false), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Return input unchanged", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\x1b[13;2u", true, true), "\x1b[13;2u");
|
||||
assert.equal(normalizeNativeShiftEnterInput("a", true, true), "a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAppleTerminalInput", () => {
|
||||
it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Return input unchanged", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u");
|
||||
assert.equal(normalizeAppleTerminalInput("a", true, true), "a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
|
||||
type NegotiationHarness = {
|
||||
terminal: ProcessTerminal;
|
||||
writes: string[];
|
||||
send(data: string): void;
|
||||
getInput(): string | undefined;
|
||||
cleanup(): void;
|
||||
};
|
||||
|
||||
function setupNegotiation(): NegotiationHarness {
|
||||
const terminal = new ProcessTerminal();
|
||||
const writes: string[] = [];
|
||||
let input: string | undefined;
|
||||
let dataHandler: ((data: string) => void) | undefined;
|
||||
let cleaned = false;
|
||||
const previousWrite = process.stdout.write;
|
||||
const previousOn = process.stdin.on;
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
if (event === "data") dataHandler = listener as (data: string) => void;
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.on;
|
||||
|
||||
(
|
||||
terminal as unknown as {
|
||||
inputHandler?: (data: string) => void;
|
||||
queryAndEnableKittyProtocol(): void;
|
||||
}
|
||||
).inputHandler = (data) => {
|
||||
input = data;
|
||||
};
|
||||
(terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol();
|
||||
|
||||
return {
|
||||
terminal,
|
||||
writes,
|
||||
send(data: string): void {
|
||||
dataHandler?.(data);
|
||||
},
|
||||
getInput(): string | undefined {
|
||||
return input;
|
||||
},
|
||||
cleanup(): void {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
try {
|
||||
terminal.stop();
|
||||
} finally {
|
||||
process.stdout.write = previousWrite;
|
||||
process.stdin.on = previousOn;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("queries Kitty mode before enabling modifyOtherKeys fallback", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("activates Kitty mode for non-zero negotiated flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7u");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[<u").length, 1);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for zero Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?0u");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?62;4;52c");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards normal input while waiting for Kitty response", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("a");
|
||||
|
||||
assert.equal(harness.getInput(), "a");
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks split Kitty confirmation", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7");
|
||||
mock.timers.tick(10);
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
|
||||
harness.send("u");
|
||||
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("replays buffered CSI-prefix input when it is not a Kitty response", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[");
|
||||
mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
|
||||
mock.timers.tick(150);
|
||||
|
||||
assert.equal(harness.getInput(), "\x1b[");
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal progress", () => {
|
||||
it("writes a valid OSC 9;4 clear sequence", () => {
|
||||
const terminal = new ProcessTerminal();
|
||||
const writes: string[] = [];
|
||||
const previousWrite = process.stdout.write;
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
terminal.setProgress(false);
|
||||
assert.deepEqual(writes, ["\x1b]9;4;0\x07"]);
|
||||
} finally {
|
||||
process.stdout.write = previousWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal dimensions", () => {
|
||||
it("falls back to COLUMNS and LINES before default dimensions", () => {
|
||||
const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
||||
const previousRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
||||
const previousColumns = process.env.COLUMNS;
|
||||
const previousLines = process.env.LINES;
|
||||
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "columns", { value: undefined, configurable: true });
|
||||
Object.defineProperty(process.stdout, "rows", { value: undefined, configurable: true });
|
||||
process.env.COLUMNS = "123";
|
||||
process.env.LINES = "45";
|
||||
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
assert.equal(terminal.columns, 123);
|
||||
assert.equal(terminal.rows, 45);
|
||||
} finally {
|
||||
if (previousColumnsDescriptor) {
|
||||
Object.defineProperty(process.stdout, "columns", previousColumnsDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "columns");
|
||||
}
|
||||
if (previousRowsDescriptor) {
|
||||
Object.defineProperty(process.stdout, "rows", previousRowsDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "rows");
|
||||
}
|
||||
if (previousColumns === undefined) {
|
||||
delete process.env.COLUMNS;
|
||||
} else {
|
||||
process.env.COLUMNS = previousColumns;
|
||||
}
|
||||
if (previousLines === undefined) {
|
||||
delete process.env.LINES;
|
||||
} else {
|
||||
process.env.LINES = previousLines;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
38
third_party/pi/packages/tui/test/test-themes.ts
vendored
Normal file
38
third_party/pi/packages/tui/test/test-themes.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Default themes for TUI tests using chalk
|
||||
*/
|
||||
|
||||
import { Chalk } from "chalk";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.ts";
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
export const defaultSelectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text: string) => chalk.blue(text),
|
||||
selectedText: (text: string) => chalk.bold(text),
|
||||
description: (text: string) => chalk.dim(text),
|
||||
scrollInfo: (text: string) => chalk.dim(text),
|
||||
noMatch: (text: string) => chalk.dim(text),
|
||||
};
|
||||
|
||||
export const defaultMarkdownTheme: MarkdownTheme = {
|
||||
heading: (text: string) => chalk.bold.cyan(text),
|
||||
link: (text: string) => chalk.blue(text),
|
||||
linkUrl: (text: string) => chalk.dim(text),
|
||||
code: (text: string) => chalk.yellow(text),
|
||||
codeBlock: (text: string) => chalk.green(text),
|
||||
codeBlockBorder: (text: string) => chalk.dim(text),
|
||||
quote: (text: string) => chalk.italic(text),
|
||||
quoteBorder: (text: string) => chalk.dim(text),
|
||||
hr: (text: string) => chalk.dim(text),
|
||||
listBullet: (text: string) => chalk.cyan(text),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
underline: (text: string) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const defaultEditorTheme: EditorTheme = {
|
||||
borderColor: (text: string) => chalk.dim(text),
|
||||
selectList: defaultSelectListTheme,
|
||||
};
|
||||
127
third_party/pi/packages/tui/test/truncate-to-width.test.ts
vendored
Normal file
127
third_party/pi/packages/tui/test/truncate-to-width.test.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
describe("truncateToWidth", () => {
|
||||
it("keeps output within width for very large unicode input", () => {
|
||||
const text = "🙂界".repeat(100_000);
|
||||
const truncated = truncateToWidth(text, 40, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 40);
|
||||
assert.strictEqual(truncated.endsWith("…\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("preserves ANSI styling for kept text and resets before and after ellipsis", () => {
|
||||
const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`;
|
||||
const truncated = truncateToWidth(text, 20, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 20);
|
||||
assert.strictEqual(truncated.includes("\x1b[31m"), true);
|
||||
assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("closes a BEL-terminated OSC 8 link when truncating its label", () => {
|
||||
const open = "\x1b]8;;https://example.com\x07";
|
||||
const close = "\x1b]8;;\x07";
|
||||
const text = `${open}some-longer-label-here${close}`;
|
||||
|
||||
assert.strictEqual(truncateToWidth(text, 15), `${open}some-longer-${close}\x1b[0m...\x1b[0m`);
|
||||
});
|
||||
|
||||
it("handles malformed ANSI escape prefixes without hanging", () => {
|
||||
const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`;
|
||||
const truncated = truncateToWidth(text, 20, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 20);
|
||||
});
|
||||
|
||||
it("clips wide ellipsis safely and brackets it with resets", () => {
|
||||
assert.strictEqual(truncateToWidth("abcdef", 1, "🙂"), "");
|
||||
assert.strictEqual(truncateToWidth("abcdef", 2, "🙂"), "\x1b[0m🙂\x1b[0m");
|
||||
assert.ok(visibleWidth(truncateToWidth("abcdef", 2, "🙂")) <= 2);
|
||||
});
|
||||
|
||||
it("returns the original text when it already fits even if ellipsis is too wide", () => {
|
||||
assert.strictEqual(truncateToWidth("a", 2, "🙂"), "a");
|
||||
assert.strictEqual(truncateToWidth("界", 2, "🙂"), "界");
|
||||
});
|
||||
|
||||
it("pads truncated output to requested width", () => {
|
||||
const truncated = truncateToWidth("🙂界🙂界🙂界", 8, "…", true);
|
||||
assert.strictEqual(visibleWidth(truncated), 8);
|
||||
});
|
||||
|
||||
it("adds a trailing reset when truncating without an ellipsis", () => {
|
||||
const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, "");
|
||||
assert.ok(visibleWidth(truncated) <= 10);
|
||||
assert.strictEqual(truncated.endsWith("\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("keeps a contiguous prefix instead of skipping a wide grapheme and resuming later", () => {
|
||||
const truncated = truncateToWidth("🙂\t界 \x1b_abc\x07", 7, "…", true);
|
||||
assert.strictEqual(truncated, "🙂\t\x1b[0m…\x1b[0m ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleWidth", () => {
|
||||
it("counts tabs inline and skips ANSI inline", () => {
|
||||
assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5);
|
||||
});
|
||||
|
||||
it("counts Indic conjunct spacing code points within grapheme clusters", () => {
|
||||
assert.strictEqual(visibleWidth("र्क"), 2);
|
||||
assert.strictEqual(visibleWidth("नेटवर्क"), 5);
|
||||
assert.strictEqual(visibleWidth("सर्वाधिकार सुरक्षित। ऑर्डर पर क्लिक करें"), 33);
|
||||
assert.strictEqual(visibleWidth("র্ক"), 2);
|
||||
assert.strictEqual(visibleWidth("ર્ક"), 2);
|
||||
assert.strictEqual(visibleWidth("ର୍କ"), 2);
|
||||
assert.strictEqual(visibleWidth("ర్క"), 2);
|
||||
assert.strictEqual(visibleWidth("ര്ക"), 2);
|
||||
});
|
||||
|
||||
it("keeps ordinary combining marks zero-width", () => {
|
||||
assert.strictEqual(visibleWidth("e\u0301"), 1);
|
||||
assert.strictEqual(visibleWidth("čřžůú"), 5);
|
||||
assert.strictEqual(visibleWidth("שָׁ"), 1);
|
||||
assert.strictEqual(visibleWidth("بّ"), 1);
|
||||
assert.strictEqual(visibleWidth("རྐ"), 1);
|
||||
assert.strictEqual(visibleWidth("ᜠ᜴"), 1);
|
||||
assert.strictEqual(visibleWidth("가〮"), 2);
|
||||
assert.strictEqual(visibleWidth("가〯"), 2);
|
||||
});
|
||||
|
||||
it("keeps CJK and Japanese width accounting unchanged", () => {
|
||||
assert.strictEqual(visibleWidth("网络"), 4);
|
||||
assert.strictEqual(visibleWidth("ネットワーク"), 12);
|
||||
assert.strictEqual(visibleWidth("が"), 2);
|
||||
assert.strictEqual(visibleWidth("か\u3099"), 2);
|
||||
});
|
||||
|
||||
it("counts Myanmar marks that terminals allocate cells for", () => {
|
||||
assert.strictEqual(visibleWidth("ကာ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကေ"), 2);
|
||||
assert.strictEqual(visibleWidth("က်"), 2);
|
||||
assert.strictEqual(visibleWidth("ကျ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကြ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဳ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဴ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဵ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကး"), 2);
|
||||
assert.strictEqual(visibleWidth("ကို"), 1);
|
||||
assert.strictEqual(visibleWidth("က္"), 1);
|
||||
});
|
||||
|
||||
it("keeps Thai and Lao AM clusters at their normal cell width", () => {
|
||||
assert.strictEqual(visibleWidth("ำ"), 1);
|
||||
assert.strictEqual(visibleWidth("ຳ"), 1);
|
||||
assert.strictEqual(visibleWidth("กำ"), 2);
|
||||
assert.strictEqual(visibleWidth("ກຳ"), 2);
|
||||
});
|
||||
|
||||
it("normalizes Thai and Lao AM vowels only for terminal output", () => {
|
||||
assert.strictEqual(normalizeTerminalOutput("ำ"), "ํา");
|
||||
assert.strictEqual(normalizeTerminalOutput("ຳ"), "ໍາ");
|
||||
assert.strictEqual(visibleWidth(normalizeTerminalOutput("ำabc")), visibleWidth("ำabc"));
|
||||
assert.strictEqual(visibleWidth(normalizeTerminalOutput("ຳabc")), visibleWidth("ຳabc"));
|
||||
});
|
||||
});
|
||||
129
third_party/pi/packages/tui/test/truncated-text.test.ts
vendored
Normal file
129
third_party/pi/packages/tui/test/truncated-text.test.ts
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Chalk } from "chalk";
|
||||
import { TruncatedText } from "../src/components/truncated-text.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
// Force full color in CI so ANSI assertions are deterministic
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
describe("TruncatedText component", () => {
|
||||
it("pads output lines to exactly match width", () => {
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(50);
|
||||
|
||||
// Should have exactly one content line (no vertical padding)
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Line should be exactly 50 visible characters
|
||||
const visibleLen = visibleWidth(lines[0]);
|
||||
assert.strictEqual(visibleLen, 50);
|
||||
});
|
||||
|
||||
it("pads output with vertical padding lines to width", () => {
|
||||
const text = new TruncatedText("Hello", 0, 2);
|
||||
const lines = text.render(40);
|
||||
|
||||
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
|
||||
assert.strictEqual(lines.length, 5);
|
||||
|
||||
// All lines should be exactly 40 characters
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(visibleWidth(line), 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long text and pads to width", () => {
|
||||
const longText = "This is a very long piece of text that will definitely exceed the available width";
|
||||
const text = new TruncatedText(longText, 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 30 characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("preserves ANSI codes in output and pads correctly", () => {
|
||||
const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`;
|
||||
const text = new TruncatedText(styledText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 40 visible characters (ANSI codes don't count)
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should preserve the color codes
|
||||
assert.ok(lines[0].includes("\x1b["));
|
||||
});
|
||||
|
||||
it("truncates styled text and adds reset code before ellipsis", () => {
|
||||
const longStyledText = chalk.red("This is a very long red text that will be truncated");
|
||||
const text = new TruncatedText(longStyledText, 1, 0);
|
||||
const lines = text.render(20);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 20 visible characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 20);
|
||||
|
||||
// Should contain reset code before ellipsis
|
||||
assert.ok(lines[0].includes("\x1b[0m..."));
|
||||
});
|
||||
|
||||
it("handles text that fits exactly", () => {
|
||||
// With paddingX=1, available width is 30-2=28
|
||||
// "Hello world" is 11 chars, fits comfortably
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should NOT contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(!stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const text = new TruncatedText("", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
});
|
||||
|
||||
it("stops at newline and only shows first line", () => {
|
||||
const multilineText = "First line\nSecond line\nThird line";
|
||||
const text = new TruncatedText(multilineText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should only contain "First line"
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
|
||||
assert.ok(stripped.includes("First line"));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
assert.ok(!stripped.includes("Third line"));
|
||||
});
|
||||
|
||||
it("truncates first line even with newlines in text", () => {
|
||||
const longMultilineText = "This is a very long first line that needs truncation\nSecond line";
|
||||
const text = new TruncatedText(longMultilineText, 1, 0);
|
||||
const lines = text.render(25);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 25);
|
||||
|
||||
// Should contain ellipsis and not second line
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
});
|
||||
});
|
||||
1908
third_party/pi/packages/tui/test/tui-alt-screen.test.ts
vendored
Normal file
1908
third_party/pi/packages/tui/test/tui-alt-screen.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
82
third_party/pi/packages/tui/test/tui-cell-size-input.test.ts
vendored
Normal file
82
third_party/pi/packages/tui/test/tui-cell-size-input.test.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class InputRecorder implements Component {
|
||||
readonly inputs: string[] = [];
|
||||
|
||||
render(): string[] {
|
||||
return [""];
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputs.push(data);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function withImageTerminal<T>(fn: () => T): T {
|
||||
const prevTermProgram = process.env.TERM_PROGRAM;
|
||||
const prevTerm = process.env.TERM;
|
||||
const prevGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR;
|
||||
|
||||
process.env.TERM_PROGRAM = "ghostty";
|
||||
delete process.env.TERM;
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR;
|
||||
resetCapabilitiesCache();
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prevTermProgram === undefined) delete process.env.TERM_PROGRAM;
|
||||
else process.env.TERM_PROGRAM = prevTermProgram;
|
||||
if (prevTerm === undefined) delete process.env.TERM;
|
||||
else process.env.TERM = prevTerm;
|
||||
if (prevGhosttyResourcesDir === undefined) delete process.env.GHOSTTY_RESOURCES_DIR;
|
||||
else process.env.GHOSTTY_RESOURCES_DIR = prevGhosttyResourcesDir;
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
}
|
||||
|
||||
describe("TUI cell size responses", () => {
|
||||
it("forwards bare escape even when a cell size query was sent at startup", () => {
|
||||
withImageTerminal(() => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const recorder = new InputRecorder();
|
||||
|
||||
tui.setFocus(recorder);
|
||||
tui.start();
|
||||
|
||||
terminal.sendInput("\x1b");
|
||||
|
||||
assert.deepStrictEqual(recorder.inputs, ["\x1b"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes cell size responses and still forwards later user input", () => {
|
||||
withImageTerminal(() => {
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const recorder = new InputRecorder();
|
||||
|
||||
tui.setFocus(recorder);
|
||||
tui.start();
|
||||
|
||||
terminal.sendInput("\x1b[6;20;10t");
|
||||
assert.deepStrictEqual(recorder.inputs, []);
|
||||
assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 });
|
||||
|
||||
terminal.sendInput("q");
|
||||
assert.deepStrictEqual(recorder.inputs, ["q"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
third_party/pi/packages/tui/test/tui-overlay-style-leak.test.ts
vendored
Normal file
81
third_party/pi/packages/tui/test/tui-overlay-style-leak.test.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class StaticLines implements Component {
|
||||
private readonly lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
private readonly line: string;
|
||||
|
||||
constructor(line: string) {
|
||||
this.line = line;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return [this.line];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
|
||||
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
||||
const buffer = xterm.buffer.active;
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
assert.ok(line, `Missing buffer line at row ${row}`);
|
||||
const cell = line.getCell(col);
|
||||
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
||||
return cell.isItalic();
|
||||
}
|
||||
|
||||
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
|
||||
tui.requestRender(true);
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await terminal.waitForRender();
|
||||
}
|
||||
|
||||
describe("TUI overlay compositing", () => {
|
||||
it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => {
|
||||
const width = 20;
|
||||
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
|
||||
|
||||
const terminal = new VirtualTerminal(width, 6);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.addChild(new StaticLines([baseLine, "INPUT"]));
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should not leak styles when overlay slicing drops trailing SGR resets", async () => {
|
||||
const width = 20;
|
||||
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
|
||||
|
||||
const terminal = new VirtualTerminal(width, 6);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.addChild(new StaticLines([baseLine, "INPUT"]));
|
||||
|
||||
tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
952
third_party/pi/packages/tui/test/tui-render.test.ts
vendored
Normal file
952
third_party/pi/packages/tui/test/tui-render.test.ts
vendored
Normal file
@@ -0,0 +1,952 @@
|
||||
import assert from "node:assert";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import {
|
||||
deleteKittyImage,
|
||||
encodeKitty,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class TestComponent implements Component {
|
||||
lines: string[] = [];
|
||||
render(_width: number): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class InputComponent extends TestComponent {
|
||||
renderCount = 0;
|
||||
|
||||
override render(width: number): string[] {
|
||||
this.renderCount += 1;
|
||||
return super.render(width);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.lines = [data];
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
||||
|
||||
class BoundedWriteTerminal implements Terminal {
|
||||
readonly writes: string[] = [];
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
readonly kittyProtocolActive = false;
|
||||
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
class LoggingVirtualTerminal extends VirtualTerminal {
|
||||
private writes: string[] = [];
|
||||
|
||||
override write(data: string): void {
|
||||
this.writes.push(data);
|
||||
super.write(data);
|
||||
}
|
||||
|
||||
getWrites(): string {
|
||||
return this.writes.join("");
|
||||
}
|
||||
|
||||
clearWrites(): void {
|
||||
this.writes = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function withEnv<T>(updates: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
|
||||
const previousValues = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
previousValues.set(key, process.env[key]);
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
for (const [key, value] of previousValues) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
|
||||
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
||||
const buffer = xterm.buffer.active;
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
assert.ok(line, `Missing buffer line at row ${row}`);
|
||||
const cell = line.getCell(col);
|
||||
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
||||
return cell.isItalic();
|
||||
}
|
||||
|
||||
describe("TUI render scheduling", () => {
|
||||
it("renders keyboard input without waiting for a throttled frame", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputComponent();
|
||||
component.lines = ["initial"];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.start();
|
||||
tui.renderNow();
|
||||
const renderCountBeforeInput = component.renderCount;
|
||||
|
||||
// Queue a normal throttled render first. Keyboard input should preempt it.
|
||||
component.lines = ["pending"];
|
||||
tui.requestRender();
|
||||
terminal.sendInput("first");
|
||||
terminal.sendInput("second");
|
||||
terminal.sendInput("typed");
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
|
||||
assert.strictEqual(component.renderCount, renderCountBeforeInput + 1);
|
||||
assert.deepStrictEqual(component.lines, ["typed"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI debug logging", () => {
|
||||
it("writes redraw logs to the provided directory", async () => {
|
||||
const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-"));
|
||||
try {
|
||||
await withEnv({ PI_TUI_DEBUG_REDRAW: "1" }, async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal, undefined, logDir);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["test"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.match(readFileSync(join(logDir, "pi-tui-debug.log"), "utf-8"), /fullRender: first render/);
|
||||
tui.stop();
|
||||
});
|
||||
} finally {
|
||||
rmSync(logDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI bounded render output", () => {
|
||||
it("splits a large full render without changing its output", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = [kittyLine, kittyLine];
|
||||
tui.addChild(component);
|
||||
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(
|
||||
terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS),
|
||||
"each terminal write should stay below the configured limit",
|
||||
);
|
||||
assert.strictEqual(
|
||||
terminal.writes.join(""),
|
||||
`\x1b[?2026h${kittyLine}\r\n${kittyLine}\x1b[?2026l`,
|
||||
"chunking must preserve the synchronized render output",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits large differential updates without a full redraw", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["before"];
|
||||
tui.renderNow();
|
||||
terminal.writes.length = 0;
|
||||
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = ["before", kittyLine, kittyLine];
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS));
|
||||
const output = terminal.writes.join("");
|
||||
assert.ok(output.startsWith("\x1b[?2026h"));
|
||||
assert.ok(output.endsWith("\x1b[?2026l"));
|
||||
assert.ok(!output.includes("\x1b[2J"), "the update should stay on the differential render path");
|
||||
});
|
||||
});
|
||||
|
||||
/** Set each environment variable to `value`, returning a function that restores the previous state. */
|
||||
function overrideEnv(names: readonly string[], value: string): () => void {
|
||||
const previousValues = names.map((name) => [name, process.env[name]] as const);
|
||||
for (const name of names) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
for (const [name, previousValue] of previousValues) {
|
||||
if (previousValue === undefined) delete process.env[name];
|
||||
else process.env[name] = previousValue;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("TUI crash dump without configured log directory", () => {
|
||||
it("writes the crash dump to the OS temp directory instead of a home-directory default", async () => {
|
||||
// The TUI falls back to os.tmpdir() when no log directory is configured, so
|
||||
// isolate the test by pointing the temp directory at a fresh directory rather
|
||||
// than sharing the real one with concurrent test runs. os.tmpdir() reads
|
||||
// TMPDIR on POSIX and TEMP/TMP on Windows, so override all three.
|
||||
const crashDir = mkdtempSync(join(tmpdir(), "pi-tui-crash-"));
|
||||
const crashLogPath = join(crashDir, "pi-tui-crash.log");
|
||||
const restoreTmpdirEnv = overrideEnv(["TMPDIR", "TEMP", "TMP"], crashDir);
|
||||
try {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["ok"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Width overflow is detected in the differential render path
|
||||
component.lines = ["ok", "x".repeat(60)];
|
||||
assert.throws(
|
||||
() => tui.renderNow(),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error.message.includes(crashLogPath), `error message should reference ${crashLogPath}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.match(readFileSync(crashLogPath, "utf-8"), /Terminal width: 40/);
|
||||
} finally {
|
||||
restoreTmpdirEnv();
|
||||
rmSync(crashDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI Kitty image cleanup", () => {
|
||||
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 2 },
|
||||
{ widthPx: 20, heightPx: 20 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
component.lines = ["before", ...imageLines, "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(
|
||||
writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`),
|
||||
"reserved rows should be cleared before the image placement is drawn",
|
||||
);
|
||||
assert.ok(
|
||||
!writes.includes(`${imageSequence}\r\n\x1b[2K`),
|
||||
"reserved row clears must not run after the image placement is drawn",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to full redraw when Kitty image pre-clear would scroll", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 2);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const redrawsBeforeImage = tui.fullRedraws;
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 3 },
|
||||
{ widthPx: 30, heightPx: 30 },
|
||||
);
|
||||
component.lines = ["before", ...image.render(40), "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw");
|
||||
assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw");
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves Kitty image rows before drawing during full redraw fallbacks", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["l0", "l1", "l2", "l3", "l4"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const redrawsBeforeImage = tui.fullRedraws;
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 3 },
|
||||
{ widthPx: 30, heightPx: 30 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
component.lines = ["l0", "l1", "l2", "l3", "l4", ...imageLines, "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeImage, "scrolling image append should force a full redraw");
|
||||
assert.ok(
|
||||
writes.includes(`\r\n\r\n\x1b[2A${imageSequence}\x1b[2B`),
|
||||
"full redraw should reserve visible image rows before drawing the placement",
|
||||
);
|
||||
assert.ok(
|
||||
!writes.includes(`${imageSequence}\r\n\x1b[0m`),
|
||||
"full redraw must not write reserved padding rows after drawing the placement",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use cursor-up placement for Kitty images taller than the viewport", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 6 },
|
||||
{ widthPx: 60, heightPx: 60 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
assert.ok(imageLines.length > terminal.rows, "test image should exceed the viewport height");
|
||||
|
||||
component.lines = ["before", ...imageLines, "after"];
|
||||
tui.requestRender(true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(writes.includes(imageSequence), "image placement should be drawn");
|
||||
assert.ok(
|
||||
!writes.includes(`\x1b[${imageLines.length - 1}A${imageSequence}`),
|
||||
"taller-than-viewport images must keep the #4461 first-row placement path",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes changed image ids before drawing moved placements", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
const oldImage = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 42, moveCursor: false });
|
||||
component.lines = ["top", oldImage];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const newImage = encodeKitty("BBBB", { columns: 2, rows: 1, imageId: 42, moveCursor: false });
|
||||
component.lines = [newImage, ""];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(42));
|
||||
const drawIndex = writes.indexOf(newImage);
|
||||
assert.ok(deleteIndex >= 0, "changed old image should be deleted");
|
||||
assert.ok(drawIndex >= 0, "new image should be drawn");
|
||||
assert.ok(deleteIndex < drawIndex, "old image must be deleted before the new placement is drawn");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("redraws image lines when an earlier reserved image row changes", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
const image = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 88, moveCursor: false });
|
||||
component.lines = ["", image];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
component.lines = ["covered", image];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(88));
|
||||
const drawIndex = writes.indexOf(image);
|
||||
assert.ok(deleteIndex >= 0, "image should be deleted when a reserved row changes");
|
||||
assert.ok(drawIndex >= 0, "unchanged image line should be redrawn after deleting the placement");
|
||||
assert.ok(deleteIndex < drawIndex, "old placement must be deleted before the image line is redrawn");
|
||||
assert.ok(!writes.includes("\x1b[2J"), "reserved row changes should not force a full redraw");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("deletes previously rendered image ids during full redraws", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = [encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 77, moveCursor: false })];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
component.lines = ["plain text"];
|
||||
tui.requestRender(true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(77));
|
||||
const clearIndex = writes.indexOf("\x1b[2J");
|
||||
assert.ok(deleteIndex >= 0, "previous image should be deleted during full redraw");
|
||||
assert.ok(clearIndex >= 0, "full redraw should clear the screen");
|
||||
assert.ok(deleteIndex < clearIndex, "old image should be deleted before the screen is cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI resize handling", () => {
|
||||
it("triggers full re-render when terminal height changes", async () => {
|
||||
await withEnv({ TERMUX_VERSION: undefined }, async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize height
|
||||
terminal.resize(40, 15);
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Should have triggered a full redraw
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips full re-render on height changes in Termux", async () => {
|
||||
await withEnv({ TERMUX_VERSION: "1" }, async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
for (const height of [15, 8, 14, 11]) {
|
||||
terminal.resize(40, height);
|
||||
await terminal.waitForRender();
|
||||
}
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw");
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Height change should not clear the screen");
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Height change should not clear scrollback");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.join("\n").includes("Line 19"), "Latest content remains visible after resize");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers full re-render when terminal width changes", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize width
|
||||
terminal.resize(60, 10);
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Should have triggered a full redraw
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger full redraw");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI content shrinkage", () => {
|
||||
it("clears empty rows when content shrinks significantly", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Start with many lines
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Shrink to fewer lines
|
||||
component.lines = ["Line 0", "Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Should have triggered a full redraw to clear empty rows
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
|
||||
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
|
||||
// Lines below should be empty (cleared)
|
||||
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
|
||||
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles shrink to single line", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Shrink to single line
|
||||
component.lines = ["Only line"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
|
||||
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles shrink to empty", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Shrink to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// All lines should be empty
|
||||
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
|
||||
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI differential rendering", () => {
|
||||
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Initial render: 5 identical lines
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// cursorRow should be 2 (last line of new content)
|
||||
// Verify by doing another render with a change on line 1
|
||||
component.lines = ["Line 0", "CHANGED", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Line 1 should show "CHANGED", proving cursor tracking was correct
|
||||
assert.ok(viewport[1]?.includes("CHANGED"), `Expected "CHANGED" on line 1, got: ${viewport[1]}`);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when only a middle line changes (spinner case)", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Initial render
|
||||
component.lines = ["Header", "Working...", "Footer"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Simulate spinner animation - only middle line changes
|
||||
const spinnerFrames = ["|", "/", "-", "\\"];
|
||||
for (const frame of spinnerFrames) {
|
||||
component.lines = ["Header", `Working ${frame}`, "Footer"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`);
|
||||
assert.ok(viewport[1]?.includes(`Working ${frame}`), `Spinner updated: ${viewport[1]}`);
|
||||
assert.ok(viewport[2]?.includes("Footer"), `Footer preserved: ${viewport[2]}`);
|
||||
}
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("resets styles after each rendered line", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["\x1b[3mItalic", "Plain"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when first line changes but rest stays same", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Change only first line
|
||||
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`);
|
||||
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
|
||||
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
||||
assert.ok(viewport[3]?.includes("Line 3"), `Line 3 preserved: ${viewport[3]}`);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when last line changes but rest stays same", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Change only last line
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
|
||||
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
|
||||
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
||||
assert.ok(viewport[3]?.includes("CHANGED"), `Last line changed: ${viewport[3]}`);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when multiple non-adjacent lines change", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Change lines 1 and 3, keep 0, 2, 4 the same
|
||||
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
|
||||
assert.ok(viewport[1]?.includes("CHANGED 1"), `Line 1 changed: ${viewport[1]}`);
|
||||
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
||||
assert.ok(viewport[3]?.includes("CHANGED 3"), `Line 3 changed: ${viewport[3]}`);
|
||||
assert.ok(viewport[4]?.includes("Line 4"), `Line 4 preserved: ${viewport[4]}`);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles transition from content to empty and back to content", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Start with content
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
let viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
|
||||
|
||||
// Clear to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Add content back - this should work correctly even after empty state
|
||||
component.lines = ["New Line 0", "New Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`);
|
||||
assert.ok(viewport[1]?.includes("New Line 1"), `New content line 1: ${viewport[1]}`);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("full re-renders when deleted lines move the viewport upward", async () => {
|
||||
const terminal = new VirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("appends after a shrink without another full redraw once the viewport is reset", async () => {
|
||||
const terminal = new VirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
component.lines = ["Line 0", "Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
|
||||
const redrawsAfterShrink = tui.fullRedraws;
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("clears stale content when maxLinesRendered was inflated by a transient component", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const chat = new TestComponent();
|
||||
const editor = new TestComponent();
|
||||
tui.addChild(chat);
|
||||
tui.addChild(editor);
|
||||
|
||||
const longChat = Array.from({ length: 15 }, (_, i) => `Chat ${i}`);
|
||||
const shortChat = Array.from({ length: 12 }, (_, i) => `Chat ${i}`);
|
||||
const editorLines = ["Editor 0", "Editor 1", "Editor 2"];
|
||||
const selectorLines = Array.from({ length: 8 }, (_, i) => `Selector ${i}`);
|
||||
|
||||
chat.lines = longChat;
|
||||
editor.lines = editorLines;
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
editor.lines = selectorLines;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
editor.lines = editorLines;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const redrawsBeforeSwitch = tui.fullRedraws;
|
||||
chat.lines = shortChat;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeSwitch, "Branch switch should trigger a full redraw");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const line = viewport[i] ?? "";
|
||||
assert.ok(!line.includes("Chat 12"), `Stale "Chat 12" at viewport row ${i}`);
|
||||
assert.ok(!line.includes("Chat 13"), `Stale "Chat 13" at viewport row ${i}`);
|
||||
assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(viewport, [
|
||||
"Chat 5",
|
||||
"Chat 6",
|
||||
"Chat 7",
|
||||
"Chat 8",
|
||||
"Chat 9",
|
||||
"Chat 10",
|
||||
"Chat 11",
|
||||
"Editor 0",
|
||||
"Editor 1",
|
||||
"Editor 2",
|
||||
]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
45
third_party/pi/packages/tui/test/tui-shrink.test.ts
vendored
Normal file
45
third_party/pi/packages/tui/test/tui-shrink.test.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class Lines implements Component {
|
||||
private lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
describe("TUI shrinking content", () => {
|
||||
it("clears all rendered lines when content shrinks to zero", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const content = new Lines(["first", "second", "third"]);
|
||||
tui.addChild(content);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("first")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("second")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("third")));
|
||||
|
||||
tui.clear();
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
108
third_party/pi/packages/tui/test/viewport-overwrite-repro.ts
vendored
Normal file
108
third_party/pi/packages/tui/test/viewport-overwrite-repro.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TUI viewport overwrite repro
|
||||
*
|
||||
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
|
||||
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
|
||||
*
|
||||
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
|
||||
* tmux new-session -d -s tui-bug -x 80 -y 12
|
||||
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
|
||||
* tmux attach -t tui-bug
|
||||
*
|
||||
* Expected behavior:
|
||||
* - PRE-TOOL lines remain visible above tool output.
|
||||
* - POST-TOOL lines append after tool output without overwriting earlier content.
|
||||
*
|
||||
* Actual behavior (bug):
|
||||
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
|
||||
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
|
||||
*/
|
||||
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
class Lines implements Component {
|
||||
private lines: string[] = [];
|
||||
|
||||
set(lines: string[]): void {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
append(lines: string[]): void {
|
||||
this.lines.push(...lines);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.lines.map((line) => {
|
||||
if (line.length > width) return line.slice(0, width);
|
||||
return line.padEnd(width, " ");
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
async function streamLines(buffer: Lines, label: string, count: number, delayMs: number, ui: TUI): Promise<void> {
|
||||
for (let i = 1; i <= count; i += 1) {
|
||||
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
|
||||
ui.requestRender();
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ui: TUI = new TuiMainScreen(new ProcessTerminal());
|
||||
const buffer = new Lines();
|
||||
ui.addChild(buffer);
|
||||
ui.start();
|
||||
|
||||
const height = ui.terminal.rows;
|
||||
const preCount = height + 8; // Ensure content exceeds viewport
|
||||
const toolCount = height + 12; // Tool output pushes further into scrollback
|
||||
const postCount = 6;
|
||||
|
||||
buffer.set([
|
||||
"TUI viewport overwrite repro",
|
||||
`Viewport rows detected: ${height}`,
|
||||
"(Resize to ~8-12 rows for best repro)",
|
||||
"",
|
||||
"=== PRE-TOOL STREAM ===",
|
||||
]);
|
||||
ui.requestRender();
|
||||
await sleep(300);
|
||||
|
||||
// Phase 1: Stream pre-tool text until viewport is exceeded.
|
||||
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
|
||||
|
||||
// Phase 2: Simulate tool call pause and tool output.
|
||||
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
|
||||
ui.requestRender();
|
||||
await sleep(700);
|
||||
|
||||
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
|
||||
|
||||
// Phase 3: Post-tool streaming. This is where overwrite often appears.
|
||||
buffer.append(["", "=== POST-TOOL STREAM ==="]);
|
||||
ui.requestRender();
|
||||
await sleep(300);
|
||||
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
|
||||
|
||||
// Leave the output visible briefly, then restore terminal state.
|
||||
await sleep(1500);
|
||||
ui.stop();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Ensure terminal is restored if something goes wrong.
|
||||
try {
|
||||
const ui: TUI = new TuiMainScreen(new ProcessTerminal());
|
||||
ui.stop();
|
||||
} catch {
|
||||
// Ignore restore errors.
|
||||
}
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
218
third_party/pi/packages/tui/test/virtual-terminal.ts
vendored
Normal file
218
third_party/pi/packages/tui/test/virtual-terminal.ts
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import xterm from "@xterm/headless";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
|
||||
// Extract Terminal class from the module
|
||||
const XtermTerminal = xterm.Terminal;
|
||||
|
||||
/**
|
||||
* Virtual terminal for testing using xterm.js for accurate terminal emulation
|
||||
*/
|
||||
export class VirtualTerminal implements Terminal {
|
||||
private xterm: XtermTerminalType;
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private _columns: number;
|
||||
private _rows: number;
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
|
||||
// Create xterm instance with specified dimensions
|
||||
this.xterm = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows: rows,
|
||||
// Disable all interactive features for testing
|
||||
disableStdin: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
// Enable bracketed paste mode for consistency with ProcessTerminal
|
||||
this.xterm.write("\x1b[?2004h");
|
||||
}
|
||||
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
|
||||
// No-op for virtual terminal - no stdin to drain
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Disable bracketed paste mode
|
||||
this.xterm.write("\x1b[?2004l");
|
||||
this.inputHandler = undefined;
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.xterm.write(data);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this._columns;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
// Virtual terminal always reports Kitty protocol as active for testing
|
||||
return true;
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) {
|
||||
// Move down
|
||||
this.xterm.write(`\x1b[${lines}B`);
|
||||
} else if (lines < 0) {
|
||||
// Move up
|
||||
this.xterm.write(`\x1b[${-lines}A`);
|
||||
}
|
||||
// lines === 0: no movement
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.xterm.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.xterm.write("\x1b[?25h");
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.xterm.write("\x1b[K");
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.xterm.write("\x1b[J");
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
// OSC 0;title BEL - set terminal window title
|
||||
this.xterm.write(`\x1b]0;${title}\x07`);
|
||||
}
|
||||
|
||||
setProgress(_active: boolean): void {}
|
||||
|
||||
// Test-specific methods not in Terminal interface
|
||||
|
||||
/**
|
||||
* Simulate keyboard input
|
||||
*/
|
||||
sendInput(data: string): void {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the terminal
|
||||
*/
|
||||
resize(columns: number, rows: number): void {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
this.xterm.resize(columns, rows);
|
||||
if (this.resizeHandler) {
|
||||
this.resizeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
// Write an empty string to ensure all previous writes are flushed
|
||||
return new Promise<void>((resolve) => {
|
||||
this.xterm.write("", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and get viewport - convenience method for tests
|
||||
*/
|
||||
async flushAndGetViewport(): Promise<string[]> {
|
||||
await this.flush();
|
||||
return this.getViewport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible viewport (what's currently on screen)
|
||||
* Note: You should use getViewportAfterWrite() for testing after writing data
|
||||
*/
|
||||
getViewport(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get only the visible lines (viewport)
|
||||
for (let i = 0; i < this.xterm.rows; i++) {
|
||||
const line = buffer.getLine(buffer.viewportY + i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entire scroll buffer
|
||||
*/
|
||||
getScrollBuffer(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get all lines in the buffer (including scrollback)
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal viewport
|
||||
*/
|
||||
clear(): void {
|
||||
this.xterm.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the terminal completely
|
||||
*/
|
||||
reset(): void {
|
||||
this.xterm.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position
|
||||
*/
|
||||
getCursorPosition(): { x: number; y: number } {
|
||||
const buffer = this.xterm.buffer.active;
|
||||
return {
|
||||
x: buffer.cursorX,
|
||||
y: buffer.cursorY,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for TUI's throttled render pipeline to settle. */
|
||||
async waitForRender(): Promise<void> {
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
191
third_party/pi/packages/tui/test/word-navigation.test.ts
vendored
Normal file
191
third_party/pi/packages/tui/test/word-navigation.test.ts
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { findWordBackward, findWordForward } from "../src/word-navigation.ts";
|
||||
|
||||
describe("findWordBackward", () => {
|
||||
it("basic words: hello world", () => {
|
||||
const text = "hello world";
|
||||
assert.strictEqual(findWordBackward(text, 11), 6);
|
||||
assert.strictEqual(findWordBackward(text, 6), 0);
|
||||
});
|
||||
|
||||
it("dotted: foo.bar", () => {
|
||||
const text = "foo.bar";
|
||||
assert.strictEqual(findWordBackward(text, 7), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("colon: foo:bar", () => {
|
||||
const text = "foo:bar";
|
||||
assert.strictEqual(findWordBackward(text, 7), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("path: path/to/file", () => {
|
||||
const text = "path/to/file";
|
||||
assert.strictEqual(findWordBackward(text, 12), 8);
|
||||
assert.strictEqual(findWordBackward(text, 8), 7);
|
||||
// "/to" is one word-like segment with "/" as punctuation boundary
|
||||
assert.strictEqual(findWordBackward(text, 7), 5);
|
||||
assert.strictEqual(findWordBackward(text, 5), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 0);
|
||||
});
|
||||
|
||||
it("CJK mixed", () => {
|
||||
const text = "你好世界 test";
|
||||
assert.strictEqual(findWordBackward(text, text.length), 5);
|
||||
// Intl.Segmenter treats each CJK char as a separate word-like segment
|
||||
assert.strictEqual(findWordBackward(text, 5), 2);
|
||||
assert.strictEqual(findWordBackward(text, 2), 0);
|
||||
});
|
||||
|
||||
it("whitespace at boundaries", () => {
|
||||
const text = " hello ";
|
||||
assert.strictEqual(findWordBackward(text, 9), 2);
|
||||
assert.strictEqual(findWordBackward(text, 2), 0);
|
||||
});
|
||||
|
||||
it("punctuation run: foo...bar", () => {
|
||||
const text = "foo...bar";
|
||||
assert.strictEqual(findWordBackward(text, 9), 6);
|
||||
assert.strictEqual(findWordBackward(text, 6), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("cursor at 0 returns 0", () => {
|
||||
assert.strictEqual(findWordBackward("hello", 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWordForward", () => {
|
||||
it("basic words: hello world", () => {
|
||||
const text = "hello world";
|
||||
assert.strictEqual(findWordForward(text, 0), 5);
|
||||
assert.strictEqual(findWordForward(text, 5), 11);
|
||||
});
|
||||
|
||||
it("dotted: foo.bar", () => {
|
||||
const text = "foo.bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 7);
|
||||
});
|
||||
|
||||
it("colon: foo:bar", () => {
|
||||
const text = "foo:bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 7);
|
||||
});
|
||||
|
||||
it("path: path/to/file", () => {
|
||||
const text = "path/to/file";
|
||||
assert.strictEqual(findWordForward(text, 0), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 5);
|
||||
assert.strictEqual(findWordForward(text, 5), 7);
|
||||
assert.strictEqual(findWordForward(text, 7), 8);
|
||||
assert.strictEqual(findWordForward(text, 8), 12);
|
||||
});
|
||||
|
||||
it("CJK mixed", () => {
|
||||
const text = "你好世界 test";
|
||||
const firstEnd = findWordForward(text, 0);
|
||||
assert.ok(firstEnd > 0);
|
||||
assert.ok(firstEnd <= 4);
|
||||
// Walk to end
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const next = findWordForward(text, pos);
|
||||
if (next === pos) break;
|
||||
pos = next;
|
||||
}
|
||||
assert.strictEqual(pos, text.length);
|
||||
});
|
||||
|
||||
it("whitespace at boundaries", () => {
|
||||
const text = " hello ";
|
||||
assert.strictEqual(findWordForward(text, 0), 7);
|
||||
assert.strictEqual(findWordForward(text, 7), 9);
|
||||
});
|
||||
|
||||
it("punctuation run: foo...bar", () => {
|
||||
const text = "foo...bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 6);
|
||||
assert.strictEqual(findWordForward(text, 6), 9);
|
||||
});
|
||||
|
||||
it("cursor at end returns end", () => {
|
||||
assert.strictEqual(findWordForward("hello", 5), 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomic segments", () => {
|
||||
const marker = "[paste #1 +5 lines]";
|
||||
const text = `hello ${marker} world`;
|
||||
const isAtomic = (s: string) => s === marker;
|
||||
|
||||
// The functions slice text before calling segment(), so we map each expected
|
||||
// substring to its pre-split segments.
|
||||
const segmentMap = new Map<string, Intl.SegmentData[]>([
|
||||
[
|
||||
text, // full text (not used but for clarity)
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 26, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
[
|
||||
// backward from end: slice(0, 31) = full text
|
||||
text.slice(0, text.length),
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 26, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
[
|
||||
// backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] "
|
||||
text.slice(0, 26),
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
],
|
||||
],
|
||||
[
|
||||
// forward from 6: slice(6) = "[paste #1 +5 lines] world"
|
||||
text.slice(6),
|
||||
[
|
||||
{ segment: marker, index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 19, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 20, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const opts = {
|
||||
segment: (input: string) => segmentMap.get(input) ?? [],
|
||||
isAtomicSegment: isAtomic,
|
||||
};
|
||||
|
||||
it("backward skips word then stops before atomic marker", () => {
|
||||
assert.strictEqual(findWordBackward(text, text.length, opts), 26);
|
||||
});
|
||||
|
||||
it("backward skips whitespace then atomic marker as one unit", () => {
|
||||
assert.strictEqual(findWordBackward(text, 26, opts), 6);
|
||||
});
|
||||
|
||||
it("forward skips atomic marker as one unit", () => {
|
||||
assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length);
|
||||
});
|
||||
});
|
||||
266
third_party/pi/packages/tui/test/wrap-ansi.test.ts
vendored
Normal file
266
third_party/pi/packages/tui/test/wrap-ansi.test.ts
vendored
Normal file
@@ -0,0 +1,266 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts";
|
||||
|
||||
describe("wrapTextWithAnsi", () => {
|
||||
describe("underline styling", () => {
|
||||
it("should not apply underline style before the styled text", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const url = "https://example.com/very/long/path/that/will/wrap";
|
||||
const text = `read this thread ${underlineOn}${url}${underlineOff}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
// First line should NOT contain underline code - it's just "read this thread"
|
||||
assert.strictEqual(wrapped[0], "read this thread");
|
||||
|
||||
// Second line should start with underline, have URL content
|
||||
assert.strictEqual(wrapped[1].startsWith(underlineOn), true);
|
||||
assert.ok(wrapped[1].includes("https://"));
|
||||
});
|
||||
|
||||
it("should not have whitespace before underline reset code", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
|
||||
|
||||
assert.ok(!wrapped[0].includes(` ${underlineOff}`));
|
||||
});
|
||||
|
||||
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const url = "https://example.com/very/long/path/that/will/definitely/wrap";
|
||||
const text = `prefix ${underlineOn}${url}${underlineOff} suffix`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 30);
|
||||
|
||||
// Middle lines (with underlined content) should end with underline-off, not full reset
|
||||
// Line 1 and 2 contain underlined URL parts
|
||||
for (let i = 1; i < wrapped.length - 1; i++) {
|
||||
const line = wrapped[i];
|
||||
if (line.includes(underlineOn)) {
|
||||
// Should end with underline off, NOT full reset
|
||||
assert.strictEqual(line.endsWith(underlineOff), true);
|
||||
assert.strictEqual(line.endsWith("\x1b[0m"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("background color preservation", () => {
|
||||
it("should preserve background color across wrapped lines without full reset", () => {
|
||||
const bgBlue = "\x1b[44m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${bgBlue}hello world this is blue background text${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 15);
|
||||
|
||||
// Each line should have background color
|
||||
for (const line of wrapped) {
|
||||
assert.ok(line.includes(bgBlue));
|
||||
}
|
||||
|
||||
// Middle lines should NOT end with full reset (kills background for padding)
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 20);
|
||||
|
||||
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
|
||||
for (const line of wrapped) {
|
||||
const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;");
|
||||
assert.ok(hasBgColor);
|
||||
}
|
||||
|
||||
// Lines with underlined content should use underline-off at end, not full reset
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
const line = wrapped[i];
|
||||
// If this line has underline on, it should end with underline off (not full reset)
|
||||
if (
|
||||
(line.includes("[4m") || line.includes("[4;") || line.includes(";4m")) &&
|
||||
!line.includes(underlineOff)
|
||||
) {
|
||||
assert.strictEqual(line.endsWith(underlineOff), true);
|
||||
assert.strictEqual(line.endsWith("\x1b[0m"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic wrapping", () => {
|
||||
it("should handle LF, CRLF, and CR line endings", () => {
|
||||
assert.deepStrictEqual(wrapTextWithAnsi("first\nsecond\r\nthird\rfourth", 80), [
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
"fourth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve ANSI state across CRLF and CR line endings", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
assert.deepStrictEqual(wrapTextWithAnsi(`${red}first\r\nsecond\rthird${reset}`, 80), [
|
||||
`${red}first`,
|
||||
`${red}second`,
|
||||
`${red}third${reset}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should wrap plain text correctly", () => {
|
||||
const text = "hello world this is a test";
|
||||
const wrapped = wrapTextWithAnsi(text, 10);
|
||||
|
||||
assert.ok(wrapped.length > 1);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 10);
|
||||
}
|
||||
});
|
||||
|
||||
it("should break CJK runs at grapheme boundaries after Latin text", () => {
|
||||
const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容.";
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve color codes when wrapping CJK runs", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`;
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
assert.strictEqual(wrapped.length, 2);
|
||||
assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`);
|
||||
assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("should ignore OSC 133 semantic markers in visible width", () => {
|
||||
const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
|
||||
assert.strictEqual(visibleWidth(text), 5);
|
||||
});
|
||||
|
||||
it("should ignore OSC sequences terminated with ST in visible width", () => {
|
||||
const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\";
|
||||
assert.strictEqual(visibleWidth(text), 5);
|
||||
});
|
||||
|
||||
it("should treat isolated regional indicators as width 2", () => {
|
||||
assert.strictEqual(visibleWidth("🇨"), 2);
|
||||
assert.strictEqual(visibleWidth("🇨🇳"), 2);
|
||||
});
|
||||
|
||||
it("should truncate trailing whitespace that exceeds width", () => {
|
||||
const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1);
|
||||
assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);
|
||||
});
|
||||
|
||||
it("should preserve color codes across wraps", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${red}hello world this is red${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 10);
|
||||
|
||||
// Each continuation line should start with red code
|
||||
for (let i = 1; i < wrapped.length; i++) {
|
||||
assert.strictEqual(wrapped[i].startsWith(red), true);
|
||||
}
|
||||
|
||||
// Middle lines should not end with full reset
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
|
||||
it("re-emits OSC 8 open at the start of continuation lines", () => {
|
||||
// A hyperlink whose text is long enough to wrap
|
||||
const url = "https://example.com";
|
||||
// OSC 8 open + text that is 10 visible chars + OSC 8 close
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
// Every line that contains visible text from inside the hyperlink
|
||||
// should start with the OSC 8 open sequence (or be preceded by it).
|
||||
for (const line of lines) {
|
||||
// If the line has visible content it must begin with the OSC 8 re-open
|
||||
// OR it is the line where the close appeared with no following content.
|
||||
const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
if (stripped.trim().length > 0) {
|
||||
assert.ok(
|
||||
line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`),
|
||||
`Line "${line}" has visible text but no OSC 8 re-open`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("closes OSC 8 before each line break", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
// Every non-final line that is inside a hyperlink should end with the close
|
||||
if (line.includes(`\x1b]8;;${url}\x1b\\`)) {
|
||||
assert.ok(
|
||||
line.endsWith("\x1b]8;;\x1b\\"),
|
||||
`Non-final line "${line}" is inside a hyperlink but does not close it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => {
|
||||
const url = `https://example.com/oauth/${"a".repeat(32)}`;
|
||||
const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
|
||||
const lines = wrapTextWithAnsi(input, 20);
|
||||
|
||||
assert.ok(lines.length > 1);
|
||||
for (const line of lines) {
|
||||
assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`);
|
||||
assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`);
|
||||
}
|
||||
for (const line of lines.slice(0, -1)) {
|
||||
assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
||||
const lines = wrapTextWithAnsi(input, 80);
|
||||
|
||||
// With width 80 everything fits on one line; there should be exactly one
|
||||
// OSC 8 open and one OSC 8 close.
|
||||
assert.strictEqual(lines.length, 1);
|
||||
const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length;
|
||||
const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length;
|
||||
assert.strictEqual(openCount, 1);
|
||||
assert.strictEqual(closeCount, 1);
|
||||
});
|
||||
});
|
||||
9
third_party/pi/packages/tui/tsconfig.build.json
vendored
Normal file
9
third_party/pi/packages/tui/tsconfig.build.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user