Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
src/*.js
|
||||
docs/
|
||||
@@ -0,0 +1,492 @@
|
||||
# Hermes TUI
|
||||
|
||||
React + Ink terminal UI for Hermes. TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic.
|
||||
|
||||
```bash
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
## What runs
|
||||
|
||||
The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY, starts `GatewayClient`, then renders `App`.
|
||||
|
||||
`GatewayClient` spawns:
|
||||
|
||||
```text
|
||||
python -m tui_gateway.entry
|
||||
```
|
||||
|
||||
Interpreter resolution order is: `HERMES_PYTHON` → `PYTHON` → `$VIRTUAL_ENV/bin/python` → `./.venv/bin/python` → `./venv/bin/python` → `python3` (or `python` on Windows).
|
||||
|
||||
The transport is newline-delimited JSON-RPC over stdio:
|
||||
|
||||
```text
|
||||
ui-tui/src tui_gateway/
|
||||
----------- -------------
|
||||
entry.tsx entry.py
|
||||
-> GatewayClient -> request loop
|
||||
-> App -> server.py RPC handlers
|
||||
|
||||
stdin/stdout: JSON-RPC requests, responses, events
|
||||
stderr: captured into an in-memory log ring
|
||||
```
|
||||
|
||||
Malformed stdout lines are treated as protocol noise and surfaced as `gateway.protocol_error`. Stderr lines become `gateway.stderr`. Neither writes directly into the terminal.
|
||||
|
||||
## Running it
|
||||
|
||||
From the repo root, the normal path is:
|
||||
|
||||
```bash
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`.
|
||||
|
||||
```bash
|
||||
cd ui-tui
|
||||
npm install
|
||||
```
|
||||
|
||||
Local package commands:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm start
|
||||
npm run build
|
||||
npm run lint
|
||||
npm run fmt
|
||||
npm run fix
|
||||
```
|
||||
|
||||
Tests use vitest:
|
||||
|
||||
```bash
|
||||
npm test # single run
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## App model
|
||||
|
||||
`src/app.tsx` is the center of the UI. Heavy logic is split into `src/app/`:
|
||||
|
||||
- `src/app/createGatewayEventHandler.ts` — maps gateway events to state updates
|
||||
- `src/app/createSlashHandler.ts` — local slash command dispatch
|
||||
- `src/app/useComposerState.ts` — draft, multiline buffer, queue editing
|
||||
- `src/app/useInputHandlers.ts` — keypress routing
|
||||
- `src/app/useMainApp.ts` — top-level composition hook: wires all sub-hooks, manages transcript history, session polling, and exposes props consumed by `app.tsx`
|
||||
- `src/app/useSessionLifecycle.ts` — session create / resume / activate / close and visible-history reset
|
||||
- `src/app/useSubmission.ts` — message send, shell exec (`!cmd`), inline interpolation (`{!cmd}`), and busy-input-mode dispatch (queue / steer / interrupt)
|
||||
- `src/app/turnController.ts` — stateful class that drives the turn lifecycle: buffers streaming deltas, manages tool/reasoning state, handles interrupt and message-complete transitions
|
||||
- `src/app/turnStore.ts` — nanostore for turn state (streaming text, tools, reasoning, subagents, todos, activity trail)
|
||||
- `src/app/useConfigSync.ts` — fetches `config.get full` on session start and polls config mtime every 5 s; applies display settings and triggers MCP reload on change
|
||||
- `src/app/useLongRunToolCharms.ts` — fires ambient activity messages for tools running longer than 8 s
|
||||
- `src/app/overlayStore.ts` / `src/app/uiStore.ts` — nanostores for overlay and UI state
|
||||
- `src/app/delegationStore.ts` — nanostore for subagent spawning caps and overlay accordion state
|
||||
- `src/app/spawnHistoryStore.ts` — in-memory ring (last 10) of finished subagent fan-out snapshots; populated at turn end for `/replay`
|
||||
- `src/app/inputSelectionStore.ts` — nanostore exposing the active text-input selection handle
|
||||
- `src/app/gatewayContext.tsx` — React context for the gateway client
|
||||
- `src/app/gatewayRecovery.ts` — pure function that decides whether to respawn and resume after a gateway crash, with a 3-attempt / 60 s budget
|
||||
- `src/app/setupHandoff.ts` — launches external `hermes setup`, suspends Ink while it runs, opens a new session on success
|
||||
- `src/app/scroll.ts` — scrolls the viewport while keeping the text selection anchor in sync
|
||||
- `src/app/interfaces.ts` — internal interfaces (ComposerActions, GatewayRpc, etc.)
|
||||
|
||||
### Slash command subsystem (`src/app/slash/`)
|
||||
|
||||
- `types.ts` — `SlashCommand` interface and `SlashRunCtx` execution context (gateway rpc, transcript helpers, session refs, stale-guard)
|
||||
- `registry.ts` — assembles `SLASH_COMMANDS` from all command files in registration order (core → billing → credits → session → ops → setup → debug) and exposes `findSlashCommand(name)` for case-insensitive lookup
|
||||
- `commands/core.ts` — general TUI commands
|
||||
- `commands/billing.ts` — `/billing`: manage Nous remote spending — buy credits, auto-reload, limits
|
||||
- `commands/credits.ts` — `/credits`
|
||||
- `commands/session.ts` — session and agent commands
|
||||
- `commands/ops.ts` — operations commands
|
||||
- `commands/setup.ts` — `/setup`
|
||||
- `commands/debug.ts` — `/heapdump`, `/mem`
|
||||
|
||||
The top-level `app.tsx` composes these into the Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
|
||||
|
||||
State managed at the top level includes:
|
||||
|
||||
- transcript and streaming state
|
||||
- queued messages and input history
|
||||
- session lifecycle
|
||||
- tool progress and reasoning text
|
||||
- prompt flows for approval, clarify, sudo, and secret input
|
||||
- slash command routing
|
||||
- tab completion and path completion
|
||||
- theme state from gateway skin data
|
||||
|
||||
The UI renders as a normal Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
|
||||
|
||||
The intro panel is driven by `session.info` and rendered through `branding.tsx`.
|
||||
|
||||
## Hotkeys and interactions
|
||||
|
||||
Current input behavior is split across `app.tsx`, `components/textInput.tsx`, and the prompt/picker components.
|
||||
|
||||
### Main chat input
|
||||
|
||||
| Key | Behavior |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Enter` | Submit the current draft |
|
||||
| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message |
|
||||
| `Shift+Enter` / `Alt+Enter` | Insert a newline in the current draft |
|
||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Cmd/Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind the primary keystroke to Find Next) |
|
||||
| `Ctrl+L` | New session (same as `/clear`) |
|
||||
| `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable |
|
||||
| `Tab` | Apply the active completion |
|
||||
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
|
||||
| `Left/Right` | Move the cursor |
|
||||
| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key |
|
||||
| `Home` / `Ctrl+A` | Start of line |
|
||||
| `End` / `Ctrl+E` | End of line |
|
||||
| `Backspace` | Delete the character to the left of the cursor |
|
||||
| `Delete` | Delete the character to the right of the cursor |
|
||||
| modified `Backspace` | Delete the previous word |
|
||||
| modified `Delete` | Delete the next word |
|
||||
| `Ctrl+W` | Delete the previous word |
|
||||
| `Ctrl+U` | Delete from the cursor back to the start of the line |
|
||||
| `Ctrl+K` | Delete from the cursor to the end of the line |
|
||||
| `Meta+B` / `Meta+F` | Move by word |
|
||||
| `!cmd` | Run a shell command through the gateway |
|
||||
| `{!cmd}` | Inline shell interpolation before send; queued drafts keep the raw text until they are sent |
|
||||
|
||||
Notes:
|
||||
|
||||
- `Tab` only applies completions when completions are present and you are not in multiline mode.
|
||||
- Queue/history navigation only applies when you are not in multiline mode.
|
||||
- `PgUp` / `PgDn` are left to the terminal emulator; the TUI does not handle them.
|
||||
|
||||
### Prompt and picker modes
|
||||
|
||||
| Context | Keys | Behavior |
|
||||
| --------------------------- | ------------------- | ------------------------------------------------- |
|
||||
| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice |
|
||||
| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` |
|
||||
| approval prompt | `Esc`, `Ctrl+C` | Deny |
|
||||
| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice |
|
||||
| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice |
|
||||
| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry |
|
||||
| clarify free-text mode | `Enter` | Submit typed answer |
|
||||
| sudo / secret prompt | `Enter` | Submit typed value |
|
||||
| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response |
|
||||
| resume picker | `Up/Down`, `Enter` | Move and resume the selected session |
|
||||
| resume picker | `1-9` | Quick-pick one of the first nine visible sessions |
|
||||
| resume picker | `Esc`, `Ctrl+C` | Close the picker |
|
||||
|
||||
Notes:
|
||||
|
||||
- Clarify free-text mode and masked prompts use `ink-text-input`, so text editing there follows the library's default bindings rather than `components/textInput.tsx`.
|
||||
- When a blocking prompt is open, the main chat input hotkeys are suspended.
|
||||
- Clarify mode has no dedicated cancel shortcut in the current client. Sudo and secret prompts only expose `Ctrl+C` cancellation from the app-level blocked handler.
|
||||
|
||||
### Interaction rules
|
||||
|
||||
- Plain text entered while the agent is busy is queued instead of sent immediately.
|
||||
- Slash commands and `!cmd` do not queue; they execute immediately even while a run is active.
|
||||
- Queue auto-drains after each assistant response, unless a queued item is currently being edited.
|
||||
- `Up/Down` prioritizes queued-message editing over history. History only activates when there is no queue to edit.
|
||||
- Queued drafts keep their original `!cmd` and `{!cmd}` text while you edit them. Shell commands and interpolation run when the queued item is actually sent.
|
||||
- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
|
||||
- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`.
|
||||
- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
|
||||
- `Cmd/Ctrl+G` (or `Alt+G` in VSCode/Cursor, which intercept the primary keystroke for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
||||
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
||||
|
||||
## Rendering
|
||||
|
||||
Assistant output is rendered in one of two ways:
|
||||
|
||||
- if the payload already contains ANSI, `messageLine.tsx` prints it directly
|
||||
- otherwise `components/markdown.tsx` renders a small Markdown subset into Ink components
|
||||
|
||||
The Markdown renderer handles headings, lists, block quotes, tables, fenced code blocks, diff coloring, inline code, emphasis, links, and plain URLs.
|
||||
|
||||
Tool/status activity is shown in a live activity lane. Transcript rows stay focused on user/assistant turns.
|
||||
|
||||
## Prompt flows
|
||||
|
||||
The Python gateway can pause the main loop and request structured input:
|
||||
|
||||
- `approval.request`: allow once, allow for session, allow always, or deny
|
||||
- `clarify.request`: pick from choices or type a custom answer
|
||||
- `sudo.request`: masked password entry
|
||||
- `secret.request`: masked value entry for a named env var
|
||||
- `session.list`: used by `SessionPicker` for `/resume`
|
||||
|
||||
These are stateful UI branches in `app.tsx`, not separate screens.
|
||||
|
||||
## Commands
|
||||
|
||||
The following commands are handled directly by the TUI client. Unrecognized commands fall through to the Python gateway via `slash.exec` and `command.dispatch`.
|
||||
|
||||
### Core (`core.ts`)
|
||||
`/help`, `/quit` (alias `/exit`), `/update`, `/clear` (alias `/new`),
|
||||
`/density`, `/copy`, `/paste`, `/details` (alias `/detail`),
|
||||
`/statusbar` (alias `/sb`), `/queue` (alias `/q`), `/logs`, `/history`,
|
||||
`/save`, `/undo`, `/retry`, `/steer`, `/mouse` (alias `/scroll`),
|
||||
`/status`, `/title`, `/fortune`, `/redraw`, `/terminal-setup`
|
||||
|
||||
### Billing (`billing.ts`)
|
||||
`/billing` — manage Nous remote spending — buy credits, auto-reload, limits
|
||||
|
||||
### Session (`session.ts`)
|
||||
`/model`, `/sessions` (aliases `/switch`, `/session`, `/resume`),
|
||||
`/bg`, `/btw`, `/image`, `/personality`,
|
||||
`/compress`, `/branch` (alias `/fork`), `/voice`, `/skin`,
|
||||
`/indicator`, `/yolo`, `/reasoning`, `/fast`, `/busy`, `/verbose`, `/usage`
|
||||
|
||||
### Ops (`ops.ts`)
|
||||
`/stop`, `/reload-mcp` (alias `/reload_mcp`), `/reload`, `/browser`,
|
||||
`/rollback`, `/agents` (alias `/tasks`), `/replay`, `/replay-diff`,
|
||||
`/skills`, `/reload-skills` (alias `/reload_skills`), `/plugins`, `/tools`
|
||||
|
||||
### Credits (`credits.ts`)
|
||||
`/credits` — Nous credit balance and browser top-up
|
||||
|
||||
### Setup (`setup.ts`)
|
||||
`/setup` — launches external `hermes setup` wizard, suspends Ink while it runs
|
||||
|
||||
### Debug (`debug.ts`)
|
||||
`/heapdump`, `/mem` — V8 memory diagnostics
|
||||
|
||||
---
|
||||
|
||||
Anything not matched above falls through to:
|
||||
|
||||
1. `slash.exec`
|
||||
2. `command.dispatch`
|
||||
|
||||
That lets Python own aliases, plugins, skills, and registry-backed commands without duplicating the logic in the TUI.
|
||||
|
||||
## Event surface
|
||||
|
||||
Primary event types the client handles today:
|
||||
|
||||
| Event | Payload |
|
||||
| -------------------------- | --------------------------------------------------------------------------- |
|
||||
| `gateway.ready` | `{ skin? }` |
|
||||
| `skin.changed` | `{ skin }` |
|
||||
| `session.info` | session metadata for banner + tool/skill panels |
|
||||
| `message.start` | start assistant streaming |
|
||||
| `message.delta` | `{ text, rendered? }` |
|
||||
| `message.complete` | `{ text, rendered?, usage, status }` |
|
||||
| `thinking.delta` | `{ text }` |
|
||||
| `reasoning.delta` | `{ text, verbose? }` |
|
||||
| `reasoning.available` | `{ text, verbose? }` |
|
||||
| `status.update` | `{ kind, text }` |
|
||||
| `notification.show` | `{ id, key, kind, level, text, ttl_ms? }` |
|
||||
| `notification.clear` | `{ key }` |
|
||||
| `tool.start` | `{ tool_id, name, context?, args_text? }` |
|
||||
| `tool.generating` | `{ name }` |
|
||||
| `tool.progress` | `{ name, preview }` |
|
||||
| `tool.complete` | `{ tool_id, name, error?, summary?, duration_s?, inline_diff?, todos? }` |
|
||||
| `clarify.request` | `{ question, choices?, request_id }` |
|
||||
| `approval.request` | `{ command, description, allow_permanent? }` |
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `secret.expire` | `{ request_id }` clears a timed-out secret prompt |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `billing.step_up.verification` | `{ verification_url, user_code }` |
|
||||
| `review.summary` | `{ text }` |
|
||||
| `browser.progress` | `{ message }` |
|
||||
| `voice.status` | `{ state }` |
|
||||
| `voice.transcript` | `{ text, no_speech_limit? }` |
|
||||
| `subagent.spawn_requested` | `{ subagent_id?, task_index, goal?, depth?, parent_id? }` |
|
||||
| `subagent.start` | `{ subagent_id?, task_index, goal?, depth?, parent_id? }` |
|
||||
| `subagent.thinking` | `{ text }` |
|
||||
| `subagent.tool` | `{ tool_name?, tool_preview?, text? }` |
|
||||
| `subagent.progress` | `{ text }` |
|
||||
| `subagent.complete` | `{ status, summary?, text?, duration_seconds? }` |
|
||||
| `error` | `{ message }` |
|
||||
| `gateway.stderr` | synthesized from child stderr |
|
||||
| `gateway.protocol_error` | synthesized from malformed stdout |
|
||||
| `gateway.start_timeout` | `{ cwd?, python?, stderr_tail? }` |
|
||||
|
||||
## Theme model
|
||||
|
||||
The client starts with `DEFAULT_THEME` from `theme.ts`, then merges in gateway skin data from `gateway.ready`.
|
||||
|
||||
Current branding overrides:
|
||||
|
||||
- agent name
|
||||
- prompt symbol
|
||||
- welcome text
|
||||
- goodbye text
|
||||
|
||||
Current color overrides:
|
||||
|
||||
- banner title, accent, border, body, dim
|
||||
- label, ok, error, warn
|
||||
|
||||
`branding.tsx` uses those values for the logo, session panel, and update notice.
|
||||
|
||||
## File map
|
||||
|
||||
```text
|
||||
ui-tui/
|
||||
packages/hermes-ink/ forked Ink renderer (local dep)
|
||||
src/
|
||||
entry.tsx TTY gate + render()
|
||||
app.tsx top-level Ink tree, composes src/app/*
|
||||
gatewayClient.ts child process + JSON-RPC bridge
|
||||
gatewayTypes.ts gateway event and RPC response type definitions
|
||||
theme.ts theme colors and skin merge
|
||||
banner.ts ASCII art renderer (parses Rich color tags)
|
||||
types.ts shared client-side types (ActiveTool, Msg, etc.)
|
||||
|
||||
app/
|
||||
createGatewayEventHandler.ts event → state mapping
|
||||
createSlashHandler.ts local slash dispatch
|
||||
delegationStore.ts nanostore for subagent spawning caps and overlay accordion state
|
||||
gatewayContext.tsx React context for gateway client
|
||||
gatewayRecovery.ts crash-recovery budget: respawn+resume capped to 3 attempts / 60 s
|
||||
inputSelectionStore.ts nanostore exposing the active text-input selection handle
|
||||
interfaces.ts internal interfaces (ComposerActions, GatewayRpc, etc.)
|
||||
overlayStore.ts nanostores for overlay state
|
||||
scroll.ts viewport scroll with text-selection anchor sync
|
||||
setupHandoff.ts launches external hermes setup, suspends Ink while it runs
|
||||
spawnHistoryStore.ts ring buffer of finished subagent fan-out snapshots
|
||||
turnController.ts stateful turn lifecycle driver (streaming, tools, reasoning)
|
||||
turnStore.ts nanostore for turn state (streaming, tools, reasoning, subagents)
|
||||
uiStore.ts nanostores for UI flags (busy, sid, mouseTracking, etc.)
|
||||
useComposerState.ts draft + multiline buffer + queue editing
|
||||
useConfigSync.ts config polling and MCP reload on mtime change
|
||||
useInputHandlers.ts keypress routing
|
||||
useLongRunToolCharms.ts ambient activity messages for tools running longer than 8 s
|
||||
useMainApp.ts top-level composition hook
|
||||
useSessionLifecycle.ts session create / resume / activate / close
|
||||
useSubmission.ts message send, shell exec, interpolation, busy-input-mode dispatch
|
||||
|
||||
slash/
|
||||
types.ts SlashCommand interface and SlashRunCtx execution context
|
||||
registry.ts SLASH_COMMANDS assembly and findSlashCommand lookup
|
||||
commands/
|
||||
billing.ts /billing — manage Nous remote spending
|
||||
core.ts general TUI commands
|
||||
credits.ts /credits
|
||||
debug.ts /heapdump, /mem
|
||||
ops.ts operations commands
|
||||
session.ts session and agent commands
|
||||
setup.ts /setup wizard
|
||||
|
||||
components/
|
||||
activeSessionSwitcher.tsx active session switch overlay
|
||||
agentsOverlay.tsx subagent delegation overlay
|
||||
appChrome.tsx status bar, input row, completions
|
||||
appLayout.tsx top-level layout composition
|
||||
appOverlays.tsx overlay routing (pickers, prompts)
|
||||
billingOverlay.tsx billing overlay
|
||||
branding.tsx banner + session summary
|
||||
fpsOverlay.tsx FPS debug overlay
|
||||
helpHint.tsx contextual help hint
|
||||
markdown.tsx Markdown-to-Ink renderer
|
||||
maskedPrompt.tsx masked input for sudo / secrets
|
||||
messageLine.tsx transcript rows
|
||||
modelPicker.tsx model switch picker
|
||||
overlayControls.tsx shared overlay control buttons
|
||||
pluginsHub.tsx plugins hub overlay
|
||||
prompts.tsx approval + clarify flows
|
||||
queuedMessages.tsx queued input preview
|
||||
skillsHub.tsx skills hub overlay
|
||||
streamingAssistant.tsx live streaming assistant row
|
||||
streamingMarkdown.tsx streaming Markdown renderer
|
||||
textInput.tsx custom line editor
|
||||
themed.tsx theme-aware wrapper
|
||||
thinking.tsx spinner, reasoning, tool activity
|
||||
todoPanel.tsx todo list panel
|
||||
|
||||
config/
|
||||
env.ts environment variable resolution and Termux/mouse defaults
|
||||
limits.ts paste size, live-render and history limits
|
||||
timing.ts streaming batch and debounce timing constants
|
||||
|
||||
content/
|
||||
charms.ts ambient activity strings for long-running tools
|
||||
faces.ts agent face / kaomoji pool
|
||||
fortunes.ts /fortune quote pool
|
||||
hotkeys.ts platform-aware hotkey display strings
|
||||
placeholders.ts rotating input placeholder strings
|
||||
setup.ts setup-required panel content
|
||||
verbs.ts tool activity verb map (browser → browsing, etc.)
|
||||
|
||||
domain/
|
||||
blockLayout.ts block layout and lead-gap helpers
|
||||
details.ts details visibility mode resolution (hidden/collapsed/expanded)
|
||||
messages.ts message formatting and transcript helpers
|
||||
paths.ts cwd shortening and path display helpers
|
||||
providers.ts provider display name helpers
|
||||
roles.ts message role color and label helpers
|
||||
slash.ts slash command parsing and TUI session model flag
|
||||
usage.ts token usage zero value and helpers
|
||||
viewport.ts viewport height estimation helpers
|
||||
|
||||
hooks/
|
||||
useCompletion.ts tab completion (slash + path)
|
||||
useGitBranch.ts current git branch via child_process execFile
|
||||
useInputHistory.ts persistent history navigation
|
||||
useQueue.ts queued message management
|
||||
useVirtualHistory.ts virtual list scroll and height tracking
|
||||
|
||||
lib/
|
||||
circularBuffer.ts fixed-size generic ring buffer
|
||||
clipboard.ts clipboard read / write via child_process
|
||||
editor.ts $EDITOR launch, PATH resolution, and Ink suspend
|
||||
emoji.ts emoji and variation selector width helpers
|
||||
externalCli.ts external CLI subprocess launcher
|
||||
externalLink.ts open URLs in the system browser
|
||||
forceTruecolor.ts 24-bit truecolor override before chalk imports
|
||||
fpsStore.ts Ink frame FPS tracker nanostore
|
||||
fuzzy.ts lightweight fuzzy subsequence scorer
|
||||
gracefulExit.ts clean shutdown with failsafe timeout
|
||||
history.ts persistent input history (read/append to disk)
|
||||
inputMetrics.ts input width and wrap metrics
|
||||
liveProgress.ts todo helpers and tool-shelf message assembly
|
||||
mathUnicode.ts best-effort LaTeX → Unicode for inline math
|
||||
memory.ts V8 heap snapshot and diagnostics helpers
|
||||
memoryMonitor.ts automatic heap-dump trigger on high usage
|
||||
messages.ts transcript message append helpers
|
||||
openExternalUrl.ts platform-aware URL opener (macOS/Linux/Windows)
|
||||
osc52.ts OSC 52 terminal clipboard copy sequence
|
||||
parentLog.ts append-only log to ~/.hermes/tui-parent.log
|
||||
perfPane.tsx FPS / render perf overlay pane
|
||||
platform.ts platform-aware keybinding and SSH detection helpers
|
||||
precisionWheel.ts high-precision scroll wheel with sticky-frame budget
|
||||
prompt.ts composer prompt text helpers (Termux-safe)
|
||||
reasoning.ts reasoning tag detection and split helpers
|
||||
rpc.ts JSON-RPC result and command dispatch helpers
|
||||
subagentTree.ts subagent tree flattening and aggregate helpers
|
||||
syntax.ts syntax token types and theme-aware highlighting
|
||||
terminalModes.ts terminal mode reset sequences (kitty, mouse, etc.)
|
||||
terminalParity.ts VSCode-like terminal detection and hint helpers
|
||||
terminalSetup.ts IDE keybinding config file install helpers
|
||||
termux.ts Termux platform detection helpers
|
||||
text.ts text helpers, ANSI detection, tool trail builders
|
||||
todo.ts todo item tone and display helpers
|
||||
viewportStore.ts viewport height nanostore via ScrollBoxHandle
|
||||
virtualHeights.ts virtual list row height estimation
|
||||
wheelAccel.ts scroll wheel acceleration state machine
|
||||
|
||||
protocol/
|
||||
interpolation.ts {!cmd} inline shell interpolation regex and helpers
|
||||
paste.ts bracketed paste snippet token regex
|
||||
|
||||
types/
|
||||
hermes-ink.d.ts type declarations for @hermes/ink
|
||||
|
||||
__tests__/ vitest suite
|
||||
```
|
||||
|
||||
Related Python side:
|
||||
|
||||
```text
|
||||
tui_gateway/
|
||||
entry.py stdio entrypoint
|
||||
server.py RPC handlers and session logic
|
||||
render.py optional rich/ANSI bridge
|
||||
slash_worker.py persistent HermesCLI subprocess for slash commands
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
import shared from '../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared,
|
||||
{
|
||||
files: ['packages/hermes-ink/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'no-constant-condition': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "hermes-tui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build:ink && tsx --watch src/entry.tsx",
|
||||
"start": "tsx src/entry.tsx",
|
||||
"build": "node scripts/build.mjs",
|
||||
"build:ink": "npm run build --prefix packages/hermes-ink",
|
||||
"visual": "node scripts/visual/run.mjs",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src/ packages/",
|
||||
"lint:fix": "eslint src/ packages/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"check": "npm run build:ink && npm run typecheck && npm run test && npm run lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
"@hermes/shared": "file:../apps/shared",
|
||||
"@nanostores/react": "1.1.0",
|
||||
"ink-text-input": "6.0.0",
|
||||
"nanostores": "1.4.2",
|
||||
"react": "19.2.7",
|
||||
"undici": "6.28.0",
|
||||
"unicode-animations": "1.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"ink-text-input": {
|
||||
"ink": "npm:@hermes/ink@0.0.1"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.20.1",
|
||||
"@types/react": "19.2.17",
|
||||
"esbuild": "0.28.1",
|
||||
"prettier": "3.9.5",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
declare module 'react/compiler-runtime' {
|
||||
export function c(size: number): any[]
|
||||
}
|
||||
|
||||
declare module 'bidi-js' {
|
||||
const bidiFactory: () => Record<string, any>
|
||||
export default bidiFactory
|
||||
}
|
||||
|
||||
declare module 'stack-utils' {
|
||||
class StackUtils {
|
||||
static nodeInternals(): RegExp[]
|
||||
constructor(opts?: { cwd?: string; internals?: RegExp[] })
|
||||
clean(stack: string | undefined): string | undefined
|
||||
parseLine(line: string): { file?: string; line?: number; column?: number; function?: string } | undefined
|
||||
}
|
||||
export default StackUtils
|
||||
}
|
||||
|
||||
declare module 'react-reconciler' {
|
||||
export type FiberRoot = unknown
|
||||
const createReconciler: any
|
||||
export default createReconciler
|
||||
}
|
||||
|
||||
declare module 'react-reconciler/constants.js' {
|
||||
export const ConcurrentRoot: number
|
||||
export const LegacyRoot: number
|
||||
export const DiscreteEventPriority: symbol | number
|
||||
export const ContinuousEventPriority: symbol | number
|
||||
export const DefaultEventPriority: symbol | number
|
||||
export const NoEventPriority: symbol | number
|
||||
}
|
||||
|
||||
declare module 'lodash-es/noop.js' {
|
||||
const noop: (...args: unknown[]) => void
|
||||
export default noop
|
||||
}
|
||||
|
||||
declare module 'lodash-es/throttle.js' {
|
||||
function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
wait?: number,
|
||||
opts?: { leading?: boolean; trailing?: boolean }
|
||||
): T & { cancel(): void; flush(): void }
|
||||
export default throttle
|
||||
}
|
||||
|
||||
declare module 'semver' {
|
||||
export function coerce(version: string | number | null | undefined): { version: string } | null
|
||||
export function gt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function gte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function satisfies(version: string, range: string, opts?: { loose?: boolean }): boolean
|
||||
export function compare(a: string, b: string, opts?: { loose?: boolean }): number
|
||||
}
|
||||
|
||||
interface BunSemver {
|
||||
order(a: string, b: string): -1 | 0 | 1
|
||||
satisfies(version: string, range: string): boolean
|
||||
}
|
||||
|
||||
interface BunRuntime {
|
||||
stringWidth(s: string, opts?: { ambiguousIsNarrow?: boolean }): number
|
||||
semver: BunSemver
|
||||
wrapAnsi?(input: string, columns: number, options?: { hard?: boolean; wordWrap?: boolean; trim?: boolean }): string
|
||||
}
|
||||
|
||||
declare var Bun: BunRuntime | undefined
|
||||
|
||||
declare namespace React {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'ink-box': Record<string, unknown>
|
||||
'ink-text': Record<string, unknown>
|
||||
'ink-link': Record<string, unknown>
|
||||
'ink-raw-ansi': Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/// <reference path="./ambient.d.ts" />
|
||||
export { default as useStderr } from './src/hooks/use-stderr.ts'
|
||||
export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
||||
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||
export { Ansi } from './src/ink/Ansi.tsx'
|
||||
export { evictInkCaches } from './src/ink/cache-eviction.ts'
|
||||
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||
export { colorize } from './src/ink/colorize.ts'
|
||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||
export { default as Box } from './src/ink/components/Box.tsx'
|
||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||
export { default as Link } from './src/ink/components/Link.tsx'
|
||||
export { default as Newline } from './src/ink/components/Newline.tsx'
|
||||
export { NoSelect } from './src/ink/components/NoSelect.tsx'
|
||||
export { RawAnsi } from './src/ink/components/RawAnsi.tsx'
|
||||
export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx'
|
||||
export type { ScrollBoxHandle, ScrollBoxProps } from './src/ink/components/ScrollBox.tsx'
|
||||
export { default as Spacer } from './src/ink/components/Spacer.tsx'
|
||||
export type { Props as StdinProps } from './src/ink/components/StdinContext.ts'
|
||||
export { default as Text } from './src/ink/components/Text.tsx'
|
||||
export type { Props as TextProps } from './src/ink/components/Text.tsx'
|
||||
export type { Key } from './src/ink/events/input-event.ts'
|
||||
export { default as useApp } from './src/ink/hooks/use-app.ts'
|
||||
export { useCursorAdvance } from './src/ink/hooks/use-cursor-advance.ts'
|
||||
export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts'
|
||||
export { default as useInput } from './src/ink/hooks/use-input.ts'
|
||||
export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts'
|
||||
export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
||||
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
|
||||
export { wrapAnsi } from './src/ink/wrapAnsi.ts'
|
||||
// 'ink-text-input' types deliberately not re-exported here; see
|
||||
// src/entry-exports.ts for the full rationale (#31227). Use the
|
||||
// '@hermes/ink/text-input' subpath when the upstream widget is needed.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dist/entry-exports.js'
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@hermes/ink",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist",
|
||||
"check": "npm run typecheck",
|
||||
"typecheck": "tsc -b . --noEmit",
|
||||
"lint": "echo 'ok!'",
|
||||
"fix": "echo 'nothing to fix'"
|
||||
},
|
||||
"sideEffects": true,
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./text-input": {
|
||||
"types": "./text-input.d.ts",
|
||||
"import": "./text-input.js",
|
||||
"default": "./text-input.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink-text-input": "6.0.0",
|
||||
"react": "19.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "0.1.3",
|
||||
"auto-bind": "5.0.1",
|
||||
"bidi-js": "1.0.3",
|
||||
"chalk": "5.6.2",
|
||||
"cli-boxes": "3.0.0",
|
||||
"code-excerpt": "4.0.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"indent-string": "5.0.0",
|
||||
"lodash-es": "4.18.1",
|
||||
"react": "19.2.7",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "7.8.5",
|
||||
"signal-exit": "4.1.0",
|
||||
"stack-utils": "2.0.6",
|
||||
"strip-ansi": "7.2.0",
|
||||
"supports-hyperlinks": "3.2.0",
|
||||
"type-fest": "4.41.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"wrap-ansi": "9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function flushInteractionTime(): void {}
|
||||
|
||||
export function updateLastInteractionTime(): void {}
|
||||
|
||||
export function markScrollActivity(): void {}
|
||||
|
||||
export function getIsInteractive(): boolean {
|
||||
return !!process.stdin.isTTY && !!process.stdout.isTTY
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export { default as useStderr } from './hooks/use-stderr.js'
|
||||
export { default as useStdout } from './hooks/use-stdout.js'
|
||||
export { Ansi } from './ink/Ansi.js'
|
||||
export { evictInkCaches, type EvictLevel, type InkCacheSizes } from './ink/cache-eviction.js'
|
||||
export { colorize } from './ink/colorize.js'
|
||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||
export { default as Box } from './ink/components/Box.js'
|
||||
export { default as Link } from './ink/components/Link.js'
|
||||
export { default as Newline } from './ink/components/Newline.js'
|
||||
export { NoSelect } from './ink/components/NoSelect.js'
|
||||
export { RawAnsi } from './ink/components/RawAnsi.js'
|
||||
export { default as ScrollBox } from './ink/components/ScrollBox.js'
|
||||
export { default as Spacer } from './ink/components/Spacer.js'
|
||||
export { setDimFallbackColor, default as Text } from './ink/components/Text.js'
|
||||
export { default as useApp } from './ink/hooks/use-app.js'
|
||||
export { useCursorAdvance } from './ink/hooks/use-cursor-advance.js'
|
||||
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'
|
||||
export { type RunExternalProcess, useExternalProcess, withInkSuspended } from './ink/hooks/use-external-process.js'
|
||||
export { default as useInput } from './ink/hooks/use-input.js'
|
||||
export { useHasSelection, useSelection } from './ink/hooks/use-selection.js'
|
||||
export { default as useStdin } from './ink/hooks/use-stdin.js'
|
||||
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export {
|
||||
isXtermJs,
|
||||
onTerminalBackground,
|
||||
onTerminalForeground,
|
||||
parseOscColor,
|
||||
terminalBackgroundHex,
|
||||
terminalForegroundHex
|
||||
} from './ink/terminal.js'
|
||||
export type { MouseTrackingMode } from './ink/termio/dec.js'
|
||||
export { wrapAnsi } from './ink/wrapAnsi.js'
|
||||
|
||||
// NOTE: Do not re-export from 'ink-text-input' here.
|
||||
//
|
||||
// 'ink-text-input' depends on the npm 'ink' package; pulling it in from
|
||||
// this re-export drags an entire second copy of ink (and its async
|
||||
// top-level init chain) into any caller that bundles `@hermes/ink` from
|
||||
// source. esbuild's `__esm` helper then deadlocks on the circular
|
||||
// async init between the two ink graphs — the dashboard TUI bundle
|
||||
// stalls at startup with only 141 bytes of ANSI reset output, blank
|
||||
// screen forever (#31227).
|
||||
//
|
||||
// Consumers that actually want the upstream ink-text-input widget must
|
||||
// import it via the dedicated subpath:
|
||||
//
|
||||
// import TextInput from '@hermes/ink/text-input'
|
||||
//
|
||||
// which still resolves through this package's `./text-input` export,
|
||||
// just outside the entry-exports surface that gets inlined by callers.
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react'
|
||||
export type StderrHandle = {
|
||||
stderr: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStderr(): StderrHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stderr: process.stderr,
|
||||
write: data => process.stderr.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react'
|
||||
export type StdoutHandle = {
|
||||
stdout: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStdout(): StdoutHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stdout: process.stdout,
|
||||
write: data => process.stdout.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Box from './components/Box.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const paint = (node: React.ReactElement) => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.render(node)
|
||||
ink.onRender()
|
||||
const frame = stdout.chunks.join('')
|
||||
ink.unmount()
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
// The composer's floating panels (session switcher, model picker, …) are
|
||||
// absolute `bottom: 100%` children of a relative Box whose only OTHER children
|
||||
// — the input rows — unmount while an overlay is open. That leaves the host box
|
||||
// at height 0 with a sibling on the same row, which is exactly the shape the
|
||||
// same-row ghost guard skips. The guard must not take the escaping absolute
|
||||
// child with it: it paints outside the host's bounds and can never ghost.
|
||||
describe('absolute children of a zero-height box', () => {
|
||||
it('paints an absolute bottom:100% panel when its host box collapses to h=0', () => {
|
||||
const frame = paint(
|
||||
<Box flexDirection="column">
|
||||
<Text>transcript</Text>
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
<Box bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
<Text>PANEL-CONTENT</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text>footer</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
expect(frame).toContain('PANEL-CONTENT')
|
||||
})
|
||||
|
||||
// NOT covered here: that the guard still SUPPRESSES the same-row ghost it
|
||||
// exists for (a squeezed box and its sibling both writing one row, leaving
|
||||
// the longer content's tail behind). That path has no test upstream either,
|
||||
// and the obvious candidates are vacuous — they pass with the guard deleted
|
||||
// outright, which would silently reintroduce the ghost. Asserting it needs a
|
||||
// tree where Yoga actually squeezes a node to h=0 onto a sibling's row (the
|
||||
// HelpV2 shortcuts column is the known real case); worth adding separately.
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transitionAnsiCodes } from './ansi-transition.js'
|
||||
import { StylePool } from './screen.js'
|
||||
|
||||
const ESC = '\u001b'
|
||||
const BOLD = { type: 'ansi' as const, code: `${ESC}[1m`, endCode: `${ESC}[22m` }
|
||||
const DIM = { type: 'ansi' as const, code: `${ESC}[2m`, endCode: `${ESC}[22m` }
|
||||
const FG_PINK = { type: 'ansi' as const, code: `${ESC}[38;5;204m`, endCode: `${ESC}[39m` }
|
||||
const FG_GRAY = { type: 'ansi' as const, code: `${ESC}[38;5;245m`, endCode: `${ESC}[39m` }
|
||||
|
||||
const codes = (result: ReturnType<typeof transitionAnsiCodes>) => result.map(c => c.code)
|
||||
|
||||
/**
|
||||
* SGR 1 (bold) and SGR 2 (dim) are independent attributes sharing one reset
|
||||
* (SGR 22). A transition that swaps one for the other MUST pass through 22 —
|
||||
* otherwise the terminal accumulates both and every later transition is
|
||||
* computed from phantom state (the "random dimness/opacity" drift).
|
||||
*/
|
||||
describe('transitionAnsiCodes weight family', () => {
|
||||
it('bold → dim resets the weight family before applying dim', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD], [DIM]))).toEqual([`${ESC}[22m`, `${ESC}[2m`])
|
||||
})
|
||||
|
||||
it('dim → bold resets the weight family before applying bold', () => {
|
||||
expect(codes(transitionAnsiCodes([DIM], [BOLD]))).toEqual([`${ESC}[22m`, `${ESC}[1m`])
|
||||
})
|
||||
|
||||
it('carries color changes through a weight swap', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD, FG_PINK], [DIM, FG_GRAY]))).toEqual([
|
||||
`${ESC}[22m`,
|
||||
`${ESC}[38;5;245m`,
|
||||
`${ESC}[2m`
|
||||
])
|
||||
})
|
||||
|
||||
it('weight removal to plain still emits the reset', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD, FG_PINK], [FG_GRAY]))).toEqual([`${ESC}[22m`, `${ESC}[38;5;245m`])
|
||||
expect(codes(transitionAnsiCodes([DIM], []))).toEqual([`${ESC}[22m`])
|
||||
})
|
||||
|
||||
it('pure additions stay minimal (no gratuitous resets)', () => {
|
||||
expect(codes(transitionAnsiCodes([], [BOLD]))).toEqual([`${ESC}[1m`])
|
||||
expect(codes(transitionAnsiCodes([FG_PINK], [FG_PINK, DIM]))).toEqual([`${ESC}[2m`])
|
||||
expect(codes(transitionAnsiCodes([BOLD], [BOLD, FG_PINK]))).toEqual([`${ESC}[38;5;204m`])
|
||||
})
|
||||
|
||||
it('unchanged styles emit nothing', () => {
|
||||
expect(transitionAnsiCodes([BOLD, FG_PINK], [BOLD, FG_PINK])).toEqual([])
|
||||
})
|
||||
|
||||
// Real tool output (ls/grep) ships COMPOUND sequences like `[1;31m` whose
|
||||
// endCode is `[0m` — weight detection must parse params, not endCodes.
|
||||
const compound = (params: string) => ({ type: 'ansi' as const, code: `${ESC}[${params}m`, endCode: `${ESC}[0m` })
|
||||
|
||||
it('compound bold → compound dim resets the weight family', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('2;37')]))).toEqual([`${ESC}[22m`, `${ESC}[2;37m`])
|
||||
})
|
||||
|
||||
it('compound bold → compound bold (color change) stays minimal', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('1;32')]))).toEqual([`${ESC}[1;32m`])
|
||||
})
|
||||
|
||||
it('compound weight removal to plain emits the reset', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [FG_GRAY]))).toEqual([`${ESC}[22m`, `${ESC}[38;5;245m`])
|
||||
})
|
||||
|
||||
it('extended-color arguments are not read as weight atoms', () => {
|
||||
// `38;2;r;g;b` / `38;5;N` carry literal 2/5 sub-params: not SGR atoms.
|
||||
const tc = { type: 'ansi' as const, code: `${ESC}[38;2;120;87;109m`, endCode: `${ESC}[39m` }
|
||||
|
||||
expect(codes(transitionAnsiCodes([tc], [FG_GRAY]))).toEqual([`${ESC}[38;5;245m`])
|
||||
expect(codes(transitionAnsiCodes([FG_PINK], [tc]))).toEqual([`${ESC}[38;2;120;87;109m`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StylePool.transition weight correctness', () => {
|
||||
// Minimal SGR interpreter: the ground truth a real terminal applies.
|
||||
const applySgr = (state: { bold: boolean; dim: boolean; fg: string }, str: string) => {
|
||||
const re = new RegExp(`${ESC}\\[([0-9;]*)m`, 'g')
|
||||
let match: null | RegExpExecArray
|
||||
|
||||
while ((match = re.exec(str)) !== null) {
|
||||
const parts = (match[1] || '0').split(';')
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]!
|
||||
|
||||
if (p === '0' || p === '') {
|
||||
state.bold = false
|
||||
state.dim = false
|
||||
state.fg = 'default'
|
||||
} else if (p === '1') {
|
||||
state.bold = true
|
||||
} else if (p === '2') {
|
||||
state.dim = true
|
||||
} else if (p === '22') {
|
||||
state.bold = false
|
||||
state.dim = false
|
||||
} else if (p === '39') {
|
||||
state.fg = 'default'
|
||||
} else if (p === '38' && parts[i + 1] === '5') {
|
||||
state.fg = `ansi${parts[i + 2]}`
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('every pairwise transition lands the terminal in exactly the target state', () => {
|
||||
const pool = new StylePool()
|
||||
|
||||
const ids = [
|
||||
pool.none,
|
||||
pool.intern([BOLD]),
|
||||
pool.intern([DIM]),
|
||||
pool.intern([FG_PINK]),
|
||||
pool.intern([BOLD, FG_PINK]),
|
||||
pool.intern([DIM, FG_GRAY]),
|
||||
pool.intern([DIM, FG_PINK]),
|
||||
pool.intern([BOLD, FG_GRAY])
|
||||
]
|
||||
|
||||
const expected = (id: number) => {
|
||||
const styles = pool.get(id)
|
||||
const fgCode = styles.find(s => s.endCode === `${ESC}[39m`)?.code
|
||||
|
||||
return {
|
||||
bold: styles.some(s => s.code === `${ESC}[1m`),
|
||||
dim: styles.some(s => s.code === `${ESC}[2m`),
|
||||
fg: fgCode ? `ansi${fgCode.slice(7, -1)}` : 'default'
|
||||
}
|
||||
}
|
||||
|
||||
for (const from of ids) {
|
||||
for (const to of ids) {
|
||||
const state = expected(from)
|
||||
applySgr(state, pool.transition(from, to))
|
||||
expect(state, `transition ${from} → ${to}`).toEqual(expected(to))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type AnsiCode, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
/**
|
||||
* Bold (SGR 1) and dim (SGR 2) are INDEPENDENT terminal attributes that share
|
||||
* one reset code (SGR 22). `diffAnsiCodes` models "same endCode" as "same
|
||||
* slot, new start code overwrites" — true for fg (39) / bg (49), false here:
|
||||
* emitting `[2m` over a bold cell yields bold+dim, not dim.
|
||||
*
|
||||
* Every transition the diff renderer emits from that assumption leaves the
|
||||
* terminal's real attributes diverged from the StylePool's tracked state, and
|
||||
* because later transitions are computed FROM that tracked state, the
|
||||
* corruption compounds and sticks — visible as random spans of wrong
|
||||
* weight/brightness ("random dimness/opacity changes") that depend on which
|
||||
* cells happened to change in which order.
|
||||
*
|
||||
* Weight flags hide in two shapes: standalone `[1m`/`[2m` (endCode `[22m`),
|
||||
* and compound sequences from real tool output — `[1;31m` ls/grep style —
|
||||
* whose endCode is `[0m`, dodging any endCode-based check. Both are detected
|
||||
* by parsing the params (skipping 38/48 extended-color arguments, whose
|
||||
* literal `2`/`5` sub-params are not SGR atoms).
|
||||
*/
|
||||
const WEIGHT_END = '\u001b[22m'
|
||||
|
||||
const WEIGHT_RESET: AnsiCode = {
|
||||
type: 'ansi',
|
||||
code: WEIGHT_END,
|
||||
endCode: WEIGHT_END
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- SGR CSI matcher; ESC is intentional
|
||||
const SGR_PARAMS_RE = /^\u001b\[([0-9;]*)m$/
|
||||
|
||||
/** The bold/dim atoms ('1' / '2') a single SGR sequence turns on. */
|
||||
function weightAtoms(code: AnsiCode): string[] {
|
||||
const match = SGR_PARAMS_RE.exec(code.code)
|
||||
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parts = (match[1] || '0').split(';')
|
||||
const atoms: string[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i] || '0'
|
||||
|
||||
if ((p === '38' || p === '48') && i + 1 < parts.length) {
|
||||
// Extended color: consume the argument sub-params so their literal
|
||||
// 2/5 aren't read as weight atoms.
|
||||
i += parts[i + 1] === '5' ? 2 : parts[i + 1] === '2' ? 4 : 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (p === '1' || p === '2') {
|
||||
atoms.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
return atoms
|
||||
}
|
||||
|
||||
const carriesWeight = (code: AnsiCode): boolean => weightAtoms(code).length > 0
|
||||
|
||||
/**
|
||||
* Like `diffAnsiCodes`, but correct for the shared-reset weight family:
|
||||
* when the bold/dim set changes in a way that removes a flag, emit SGR 22
|
||||
* first, then re-apply every weight-carrying sequence the target style has.
|
||||
*/
|
||||
export function transitionAnsiCodes(from: AnsiCode[], to: AnsiCode[]): AnsiCode[] {
|
||||
const fromAtoms = new Set(from.flatMap(weightAtoms))
|
||||
|
||||
if (fromAtoms.size === 0) {
|
||||
// Nothing to un-set; the library's "add what's missing" pass is correct.
|
||||
return diffAnsiCodes(from, to)
|
||||
}
|
||||
|
||||
const toAtoms = new Set(to.flatMap(weightAtoms))
|
||||
let removesWeight = false
|
||||
|
||||
for (const atom of fromAtoms) {
|
||||
if (!toAtoms.has(atom)) {
|
||||
removesWeight = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!removesWeight) {
|
||||
// from's weights ⊆ to's weights — additions only, library handles it.
|
||||
return diffAnsiCodes(from, to)
|
||||
}
|
||||
|
||||
// A weight flag must be dropped: SGR 22 is the only way (it clears BOTH),
|
||||
// so reset the family and re-apply the target's weight-carrying sequences
|
||||
// in full (a compound re-asserts its color too — redundant bytes, never
|
||||
// wrong). The rest of the style diffs normally with the weight carriers
|
||||
// stripped from both sides.
|
||||
const rest = diffAnsiCodes(
|
||||
from.filter(code => !carriesWeight(code)),
|
||||
to.filter(code => !carriesWeight(code))
|
||||
)
|
||||
|
||||
return [WEIGHT_RESET, ...rest, ...to.filter(carriesWeight)]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import StdinContext from './components/StdinContext.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import instances from './instances.js'
|
||||
import { csi } from './termio/csi.js'
|
||||
import { DEC, DISABLE_MOUSE_TRACKING, enableMouseTrackingFor } from './termio/dec.js'
|
||||
|
||||
// DECRQM request for mode 1000 (what the watchdog writes).
|
||||
const DECRQM_1000 = csi(`?${DEC.MOUSE_NORMAL}$p`)
|
||||
// DA1 sentinel (what querier.flush() writes).
|
||||
const DA1_REQUEST = csi('c')
|
||||
// DECRPM replies (what the terminal answers).
|
||||
const DECRPM_1000_SET = csi(`?${DEC.MOUSE_NORMAL};1$y`)
|
||||
const DECRPM_1000_RESET = csi(`?${DEC.MOUSE_NORMAL};2$y`)
|
||||
const DA1_REPLY = csi('?62c')
|
||||
|
||||
// Watchdog cadence (mirrors MOUSE_WATCHDOG_INTERVAL_MS in App.tsx).
|
||||
const TICK_MS = 2000
|
||||
|
||||
class FakeStdout extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 80
|
||||
rows = 24
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Stdin fake with a real readable-style buffer so tests can feed terminal
|
||||
// responses (DECRPM / DA1) the way a live pty would deliver them.
|
||||
class FakeStdin extends EventEmitter {
|
||||
isTTY = true
|
||||
isRaw = false
|
||||
private buffer: string[] = []
|
||||
|
||||
get readableLength(): number {
|
||||
return this.buffer.reduce((n, c) => n + c.length, 0)
|
||||
}
|
||||
|
||||
ref(): void {}
|
||||
unref(): void {}
|
||||
setEncoding(): this {
|
||||
return this
|
||||
}
|
||||
setRawMode(mode: boolean): this {
|
||||
this.isRaw = mode
|
||||
|
||||
return this
|
||||
}
|
||||
read(): string | null {
|
||||
return this.buffer.shift() ?? null
|
||||
}
|
||||
feed(data: string): void {
|
||||
this.buffer.push(data)
|
||||
this.emit('readable')
|
||||
}
|
||||
}
|
||||
|
||||
function RawModeConsumer() {
|
||||
const { setRawMode, isRawModeSupported } = useContext(StdinContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRawModeSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => setRawMode(false)
|
||||
}, [isRawModeSupported, setRawMode])
|
||||
|
||||
return React.createElement(Text, null, 'x')
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
ink: Ink
|
||||
stdout: FakeStdout
|
||||
stdin: FakeStdin
|
||||
/** Advance one watchdog tick and let the probe write settle. */
|
||||
tickWatchdog: () => Promise<void>
|
||||
/** Feed a terminal response and let promise resolution settle. */
|
||||
answer: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
// Real setImmediate turns: lets React flush effects (raw-mode enable),
|
||||
// the deferred init writes fire, and querier promise chains settle.
|
||||
// Two rounds cover promise → setImmediate interleave.
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function mount(mouseTracking: 'all' | 'off' = 'all'): Promise<Harness> {
|
||||
const stdout = new FakeStdout()
|
||||
const stdin = new FakeStdin()
|
||||
const stderr = new FakeStdout()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
// Production instances are registered by render.ts; direct construction
|
||||
// skips that. The watchdog (like the raw-mode re-assert) resolves its
|
||||
// Ink through this map, so mirror production here.
|
||||
instances.set(stdout as unknown as NodeJS.WriteStream, ink)
|
||||
|
||||
ink.setAltScreenActive(true, mouseTracking)
|
||||
ink.render(React.createElement(RawModeConsumer))
|
||||
ink.onRender()
|
||||
await flushMicrotasks()
|
||||
|
||||
// The XTVERSION probe from raw-mode entry has its own DA1 sentinel
|
||||
// pending. Answer it so the querier queue is empty before tests start.
|
||||
stdin.feed(DA1_REPLY)
|
||||
await flushMicrotasks()
|
||||
|
||||
stdout.chunks = []
|
||||
|
||||
return {
|
||||
ink,
|
||||
stdout,
|
||||
stdin,
|
||||
tickWatchdog: async () => {
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS)
|
||||
},
|
||||
answer: async (data: string) => {
|
||||
stdin.feed(data)
|
||||
await flushMicrotasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('App mouse-mode watchdog', () => {
|
||||
beforeEach(() => {
|
||||
// Fake only the interval + Date clock. setImmediate/setTimeout stay
|
||||
// real so React effect flushing and Ink's internal scheduling work.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('probes DECRQM on the interval and re-asserts tracking when the terminal reports RESET', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
|
||||
// Probe went out: DECRQM for mode 1000 + DA1 sentinel.
|
||||
const probed = h.stdout.chunks.join('')
|
||||
|
||||
expect(probed).toContain(DECRQM_1000)
|
||||
expect(probed).toContain(DA1_REQUEST)
|
||||
|
||||
h.stdout.chunks = []
|
||||
|
||||
// Terminal says mode 1000 is RESET (someone cleared our modes), then
|
||||
// answers the sentinel.
|
||||
await h.answer(DECRPM_1000_RESET + DA1_REPLY)
|
||||
|
||||
const out = h.stdout.chunks.join('')
|
||||
|
||||
// reassertTerminalModes: DISABLE first, then the full 'all' preset.
|
||||
expect(out).toContain(DISABLE_MOUSE_TRACKING)
|
||||
expect(out).toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('does nothing when the terminal reports the mode still SET', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
h.stdout.chunks = []
|
||||
|
||||
await h.answer(DECRPM_1000_SET + DA1_REPLY)
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('disables itself permanently when the terminal ignores DECRQM', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
expect(h.stdout.chunks.join('')).toContain(DECRQM_1000)
|
||||
h.stdout.chunks = []
|
||||
|
||||
// Terminal answers only the DA1 sentinel — DECRQM unsupported.
|
||||
await h.answer(DA1_REPLY)
|
||||
|
||||
// No re-assert...
|
||||
expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
// ...and no further probes on subsequent ticks.
|
||||
await h.tickWatchdog()
|
||||
await h.tickWatchdog()
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('does not probe when mouse tracking is off', async () => {
|
||||
const h = await mount('off')
|
||||
|
||||
await h.tickWatchdog()
|
||||
await h.tickWatchdog()
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('skips the probe when a mouse event arrived within the interval', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
// Half a tick in, a live SGR mouse event (wheel-up at 10;5) proves
|
||||
// tracking works; the interval fires half a tick later → gap < interval.
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS / 2)
|
||||
await h.answer(csi('<64;10;5M'))
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS / 2)
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { handleMouseEvent } from './components/App.js'
|
||||
import { createSelectionState, hasSelection, startSelection, updateSelection } from './selection.js'
|
||||
|
||||
const makeApp = () => {
|
||||
const selection = createSelectionState()
|
||||
|
||||
return {
|
||||
clickCount: 1,
|
||||
lastHoverCol: -1,
|
||||
lastHoverRow: -1,
|
||||
mouseCaptureTarget: undefined,
|
||||
props: {
|
||||
getSelectedText: vi.fn(() => 'selected text'),
|
||||
onCopySelectionNoClear: vi.fn(async () => 'selected text'),
|
||||
onHoverAt: vi.fn(),
|
||||
onMouseDownAt: vi.fn(),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onSelectionChange: vi.fn(),
|
||||
selection
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('handleMouseEvent right-click selection behavior', () => {
|
||||
it('copies an active selection instead of dispatching right-click paste handlers', async () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
expect(app.clickCount).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the highlight after a successful right-click copy', async () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Deliberate copy clears the selection (visual confirmation + a follow-up
|
||||
// right-click on empty space pastes rather than re-copying a stale range).
|
||||
expect(hasSelection(app.props.selection)).toBe(false)
|
||||
expect(app.props.onSelectionChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the highlight when right-click copy fails (no clipboard path)', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Copy didn't land, so the highlight must survive (and we fall back to the
|
||||
// right-click paste handler instead).
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
|
||||
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
|
||||
})
|
||||
|
||||
it('does not paste when highlighted selection text is empty', async () => {
|
||||
const app = makeApp()
|
||||
app.props.getSelectedText.mockReturnValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not repeatedly copy or paste during right-button motion events over a selection', () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 0x20 | 2, col: 3, kind: 'mouse', row: 1 })
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still dispatches right-click handlers when no text is selected', () => {
|
||||
const app = makeApp()
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import StdinContext from './components/StdinContext.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { DISABLE_MOUSE_TRACKING } from './termio/dec.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 80
|
||||
rows = 24
|
||||
isTTY = true
|
||||
isRaw = false
|
||||
|
||||
ref(): void {}
|
||||
unref(): void {}
|
||||
read(): null {
|
||||
return null
|
||||
}
|
||||
setEncoding(): this {
|
||||
return this
|
||||
}
|
||||
setRawMode(mode: boolean): this {
|
||||
this.isRaw = mode
|
||||
|
||||
return this
|
||||
}
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
// A child that grabs the last useInput consumer's raw-mode toggle. Mounting
|
||||
// enables raw mode (count 0→1); unmounting disables it (count 1→0), which is
|
||||
// the teardown path that must DISABLE_MOUSE_TRACKING so DEC 1003 hover can't
|
||||
// leak as cooked-mode `35;col;row M` text over the prompt.
|
||||
function RawModeConsumer({ active }: { active: boolean }) {
|
||||
const { setRawMode, isRawModeSupported } = useContext(StdinContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !isRawModeSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => setRawMode(false)
|
||||
}, [active, isRawModeSupported, setRawMode])
|
||||
|
||||
return React.createElement(Text, null, 'x')
|
||||
}
|
||||
|
||||
describe('App raw-mode teardown', () => {
|
||||
it('disables mouse tracking when the last raw-mode consumer detaches', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
// Mouse tracking is asserted on the alt screen; the teardown path lives in
|
||||
// App, independent of who enabled tracking.
|
||||
ink.setAltScreenActive(true, 'all')
|
||||
ink.render(React.createElement(RawModeConsumer, { active: true }))
|
||||
ink.onRender()
|
||||
await tick()
|
||||
expect(stdin.isRaw).toBe(true)
|
||||
|
||||
stdout.chunks = []
|
||||
|
||||
// Drop the consumer → raw-mode count hits 0 → teardown runs.
|
||||
ink.render(React.createElement(RawModeConsumer, { active: false }))
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
expect(stdin.isRaw).toBe(false)
|
||||
expect(stdout.chunks.join('')).toContain(DISABLE_MOUSE_TRACKING)
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import App from './components/App.js'
|
||||
|
||||
// Regression for issue #31486: when processInput throws inside the
|
||||
// handleReadable read loop, any bytes still buffered in stdin are stranded
|
||||
// because Node only emits 'readable' on buffer transitions, not for data
|
||||
// the consumer has already been notified about. Without a re-pump, the
|
||||
// TUI freezes; stdin appears wedged while the agent loop keeps running.
|
||||
|
||||
const makeFakeStdin = (initialChunks: Array<string | null>) => {
|
||||
const queue: Array<string | null> = [...initialChunks]
|
||||
const readableListeners: Array<() => void> = []
|
||||
|
||||
return {
|
||||
addListener: vi.fn((event: string, fn: () => void) => {
|
||||
if (event === 'readable') {
|
||||
readableListeners.push(fn)
|
||||
}
|
||||
}),
|
||||
listeners: vi.fn((event: string) => (event === 'readable' ? [...readableListeners] : [])),
|
||||
read: vi.fn(() => (queue.length > 0 ? queue.shift()! : null)),
|
||||
get readableLength() {
|
||||
return queue.filter(c => c !== null).reduce((n, c) => n + (c as string).length, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noopStream = { isTTY: false, write: () => true } as unknown as NodeJS.WriteStream
|
||||
|
||||
const makeApp = (stdin: ReturnType<typeof makeFakeStdin>) => {
|
||||
// Construct a real App instance with minimal props. PureComponent only
|
||||
// stores `props`; class-field arrows (including handleReadable) bind to
|
||||
// the instance during construction.
|
||||
const app = new App({
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: noopStream,
|
||||
stderr: noopStream,
|
||||
exitOnCtrlC: false,
|
||||
onExit: vi.fn(),
|
||||
terminalColumns: 80,
|
||||
terminalRows: 24,
|
||||
selection: undefined as any,
|
||||
onSelectionChange: vi.fn(),
|
||||
onClickAt: vi.fn(() => false),
|
||||
onMouseDownAt: vi.fn(() => undefined),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onHoverAt: vi.fn(),
|
||||
onCopySelectionNoClear: vi.fn(async () => ''),
|
||||
getSelectedText: vi.fn(() => ''),
|
||||
getHyperlinkAt: vi.fn(() => undefined),
|
||||
onOpenHyperlink: vi.fn(),
|
||||
onMultiClick: vi.fn(),
|
||||
onSelectionDrag: vi.fn(),
|
||||
onStdinResume: vi.fn(),
|
||||
dispatchKeyboardEvent: vi.fn(),
|
||||
children: null as any
|
||||
} as any)
|
||||
|
||||
;(app as any).rawModeEnabledCount = 1
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
describe('App.handleReadable error recovery (issue #31486)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-pumps the readable handler when bytes remain buffered after a throw', () => {
|
||||
const stdin = makeFakeStdin(['boom', 'queued-keystroke', null])
|
||||
const app = makeApp(stdin)
|
||||
|
||||
let calls = 0
|
||||
|
||||
;(app as any).processInput = vi.fn((chunk: string) => {
|
||||
calls++
|
||||
|
||||
if (calls === 1) {
|
||||
throw new Error('synthetic processInput failure')
|
||||
}
|
||||
|
||||
void chunk
|
||||
})
|
||||
;(app as any).handleReadable()
|
||||
|
||||
// First handler run threw mid-loop. The remaining chunk is still in
|
||||
// the fake stdin buffer; without the re-pump, Node would never call
|
||||
// the listener again because no new bytes arrive.
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(2)
|
||||
expect((app as any).processInput).toHaveBeenLastCalledWith('queued-keystroke')
|
||||
})
|
||||
|
||||
it('does not re-pump when raw mode has been fully disabled during recovery', () => {
|
||||
const stdin = makeFakeStdin(['boom', 'stranded', null])
|
||||
|
||||
const app = makeApp(stdin)
|
||||
|
||||
;(app as any).processInput = vi.fn(() => {
|
||||
// Simulate a useInput handler that disabled raw mode and threw.
|
||||
;(app as any).rawModeEnabledCount = 0
|
||||
throw new Error('synthetic')
|
||||
})
|
||||
;(app as any).handleReadable()
|
||||
vi.runAllTimers()
|
||||
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Bidirectional text reordering for terminal rendering.
|
||||
*
|
||||
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
|
||||
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
|
||||
* applies the bidi algorithm to reorder ClusteredChar arrays from
|
||||
* logical order to visual order before Ink's LTR cell placement loop.
|
||||
*
|
||||
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
|
||||
* Windows Terminal (including WSL) does not implement bidi
|
||||
* (https://github.com/microsoft/terminal/issues/538).
|
||||
*
|
||||
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
|
||||
* also lacks bidi. We enable bidi reordering when running on Windows or
|
||||
* inside Windows Terminal (covers WSL).
|
||||
*/
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type ClusteredChar = {
|
||||
value: string
|
||||
width: number
|
||||
styleId: number
|
||||
hyperlink: string | undefined
|
||||
}
|
||||
|
||||
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||
let needsSoftwareBidi: boolean | undefined
|
||||
|
||||
function needsBidi(): boolean {
|
||||
if (needsSoftwareBidi === undefined) {
|
||||
needsSoftwareBidi =
|
||||
process.platform === 'win32' ||
|
||||
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
|
||||
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
|
||||
}
|
||||
|
||||
return needsSoftwareBidi
|
||||
}
|
||||
|
||||
function getBidi() {
|
||||
if (!bidiInstance) {
|
||||
bidiInstance = bidiFactory()
|
||||
}
|
||||
|
||||
return bidiInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder an array of ClusteredChars from logical order to visual order
|
||||
* using the Unicode Bidi Algorithm. Active on terminals that lack native
|
||||
* bidi support (Windows Terminal, conhost, WSL).
|
||||
*
|
||||
* Returns the same array on bidi-capable terminals (no-op).
|
||||
*/
|
||||
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
|
||||
if (!needsBidi() || characters.length === 0) {
|
||||
return characters
|
||||
}
|
||||
|
||||
// Build a plain string from the clustered chars to run through bidi
|
||||
const plainText = characters.map(c => c.value).join('')
|
||||
|
||||
// Check if there are any RTL characters — skip bidi if pure LTR
|
||||
if (!hasRTLCharacters(plainText)) {
|
||||
return characters
|
||||
}
|
||||
|
||||
const bidi = getBidi()
|
||||
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
|
||||
|
||||
// Map bidi levels back to ClusteredChar indices.
|
||||
// Each ClusteredChar may be multiple code units in the joined string.
|
||||
const charLevels: number[] = []
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < characters.length; i++) {
|
||||
charLevels.push(levels[offset]!)
|
||||
offset += characters[i]!.value.length
|
||||
}
|
||||
|
||||
// Get reorder segments from bidi-js, but we need to work at the
|
||||
// ClusteredChar level, not the string level. We'll implement the
|
||||
// standard bidi reordering: find the max level, then for each level
|
||||
// from max down to 1, reverse all contiguous runs >= that level.
|
||||
const reordered = [...characters]
|
||||
const maxLevel = Math.max(...charLevels)
|
||||
|
||||
for (let level = maxLevel; level >= 1; level--) {
|
||||
let i = 0
|
||||
|
||||
while (i < reordered.length) {
|
||||
if (charLevels[i]! >= level) {
|
||||
// Find the end of this run
|
||||
let j = i + 1
|
||||
|
||||
while (j < reordered.length && charLevels[j]! >= level) {
|
||||
j++
|
||||
}
|
||||
|
||||
// Reverse the run in both arrays
|
||||
reverseRange(reordered, i, j - 1)
|
||||
reverseRangeNumbers(charLevels, i, j - 1)
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
function reverseRange<T>(arr: T[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check for RTL characters (Hebrew, Arabic, and related scripts).
|
||||
* Avoids running the full bidi algorithm on pure-LTR text.
|
||||
*/
|
||||
function hasRTLCharacters(text: string): boolean {
|
||||
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
|
||||
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
|
||||
// Thaana: U+0780-U+07BF
|
||||
// Syriac: U+0700-U+074F
|
||||
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
|
||||
text
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Unified cache eviction for the four hot Ink module-level caches:
|
||||
// - widthCache (stringWidth.ts)
|
||||
// - wrapCache (wrap-text.ts)
|
||||
// - sliceCache (sliceAnsi.ts)
|
||||
// - lineWidthCache (line-width-cache.ts)
|
||||
//
|
||||
// Used by the host (TUI) under memory pressure or on session swap to drop
|
||||
// content-keyed entries that won't recur. All caches are content-keyed
|
||||
// (not session-keyed), so cross-session sharing is normally beneficial —
|
||||
// only evict when memory tightens or when the user explicitly resets.
|
||||
|
||||
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
||||
|
||||
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
||||
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
||||
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
||||
|
||||
export interface InkCacheSizes {
|
||||
lineWidth: number
|
||||
slice: number
|
||||
width: number
|
||||
wrap: number
|
||||
}
|
||||
|
||||
function inkCacheSizes(): InkCacheSizes {
|
||||
return {
|
||||
lineWidth: lineWidthCacheSize(),
|
||||
slice: sliceCacheSize(),
|
||||
width: widthCacheSize(),
|
||||
wrap: wrapCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
export type EvictLevel = 'all' | 'half'
|
||||
|
||||
export function evictInkCaches(level: EvictLevel = 'half'): InkCacheSizes {
|
||||
const keep = level === 'half' ? 0.5 : 0
|
||||
|
||||
evictWidthCache(keep)
|
||||
evictWrapCache(keep)
|
||||
evictSliceCache(keep)
|
||||
evictLineWidthCache(keep)
|
||||
|
||||
return inkCacheSizes()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Cross-platform terminal clearing with scrollback support.
|
||||
* Detects modern terminals that support ESC[3J for clearing scrollback.
|
||||
*/
|
||||
|
||||
import { csi, CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||
|
||||
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
|
||||
const CURSOR_HOME_WINDOWS = csi(0, 'f')
|
||||
|
||||
function isWindowsTerminal(): boolean {
|
||||
return process.platform === 'win32' && !!process.env.WT_SESSION
|
||||
}
|
||||
|
||||
function isMintty(): boolean {
|
||||
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
|
||||
if (process.env.TERM_PROGRAM === 'mintty') {
|
||||
return true
|
||||
}
|
||||
|
||||
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
|
||||
if (process.platform === 'win32' && process.env.MSYSTEM) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isModernWindowsTerminal(): boolean {
|
||||
// Windows Terminal sets WT_SESSION environment variable
|
||||
if (isWindowsTerminal()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// VS Code integrated terminal on Windows with ConPTY support
|
||||
if (process.platform === 'win32' && process.env.TERM_PROGRAM === 'vscode' && process.env.TERM_PROGRAM_VERSION) {
|
||||
return true
|
||||
}
|
||||
|
||||
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
|
||||
if (isMintty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ANSI escape sequence to clear the terminal including scrollback.
|
||||
* Automatically detects terminal capabilities.
|
||||
*/
|
||||
export function getClearTerminalSequence(): string {
|
||||
if (process.platform === 'win32') {
|
||||
if (isModernWindowsTerminal()) {
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
} else {
|
||||
// Legacy Windows console - can't clear scrollback
|
||||
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
|
||||
}
|
||||
}
|
||||
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen. On supported terminals, also clears scrollback.
|
||||
*/
|
||||
export const clearTerminal = getClearTerminalSequence()
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE,
|
||||
richEightBitColorNumber,
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal
|
||||
} from './colorize.js'
|
||||
|
||||
describe('shouldUseRichEightBitDowngradeForLegacyAppleTerminal', () => {
|
||||
it('memoizes the current process decision for render hot paths', () => {
|
||||
expect(typeof CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE).toBe('boolean')
|
||||
})
|
||||
|
||||
it('uses Rich-compatible 256-color downgrade on legacy Apple Terminal', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv, 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not rewrite when Apple Terminal advertises truecolor', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
3
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not override explicit color environment choices', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ FORCE_COLOR: '2', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
2
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ HERMES_TUI_TRUECOLOR: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
3
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('richEightBitColorNumber', () => {
|
||||
it('matches Rich downgrade output for default Hermes skin colors', () => {
|
||||
expect(richEightBitColorNumber(0xff, 0xd7, 0x00)).toBe(220)
|
||||
expect(richEightBitColorNumber(0xff, 0xbf, 0x00)).toBe(214)
|
||||
expect(richEightBitColorNumber(0xcd, 0x7f, 0x32)).toBe(173)
|
||||
expect(richEightBitColorNumber(0xb8, 0x86, 0x0b)).toBe(136)
|
||||
expect(richEightBitColorNumber(0xff, 0xf8, 0xdc)).toBe(230)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
import type { Color, TextStyles } from './styles.js'
|
||||
|
||||
/**
|
||||
* xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
|
||||
* since 2017, but code-server/Coder containers often don't set
|
||||
* COLORTERM=truecolor. chalk's supports-color doesn't recognize
|
||||
* TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
|
||||
* through to the -256color regex → level 2. At level 2, chalk.rgb()
|
||||
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) → idx 174
|
||||
* rgb(215,135,135) — washed-out salmon.
|
||||
*
|
||||
* Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 —
|
||||
* those yield level 0 and are an explicit "no colors" request. Desktop VS
|
||||
* Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
|
||||
*
|
||||
* Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
|
||||
* terminal, tmux's passthrough limitation wins and we want level 2.
|
||||
*/
|
||||
function boostChalkLevelForXtermJs(): boolean {
|
||||
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
|
||||
chalk.level = 3
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
level = chalk.level
|
||||
): boolean {
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
const truecolorOverride = /^(?:1|true|yes|on)$/i.test((env.HERMES_TUI_TRUECOLOR ?? '').trim())
|
||||
const advertisesTruecolor = /^(?:truecolor|24bit)$/i.test((env.COLORTERM ?? '').trim())
|
||||
|
||||
return (
|
||||
termProgram === 'Apple_Terminal' &&
|
||||
!truecolorOverride &&
|
||||
!advertisesTruecolor &&
|
||||
!('FORCE_COLOR' in env) &&
|
||||
level === 2
|
||||
)
|
||||
}
|
||||
|
||||
export function richEightBitColorNumber(red: number, green: number, blue: number): number {
|
||||
const rn = red / 255
|
||||
const gn = green / 255
|
||||
const bn = blue / 255
|
||||
const max = Math.max(rn, gn, bn)
|
||||
const min = Math.min(rn, gn, bn)
|
||||
const lightness = (max + min) / 2
|
||||
const saturation = max === min ? 0 : lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min)
|
||||
|
||||
if (saturation < 0.15) {
|
||||
const gray = Math.round(lightness * 25)
|
||||
|
||||
return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray
|
||||
}
|
||||
|
||||
const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40
|
||||
const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40
|
||||
const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40
|
||||
|
||||
return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue)
|
||||
}
|
||||
|
||||
/**
|
||||
* tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
|
||||
* but its client-side emitter only re-emits truecolor to the outer terminal if
|
||||
* the outer terminal advertises Tc/RGB capability (via terminal-overrides).
|
||||
* Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
|
||||
* WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
|
||||
* dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
|
||||
* which tmux passes through cleanly. grey93 (255) is visually identical to
|
||||
* rgb(240,240,240).
|
||||
*
|
||||
* Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
|
||||
* downgrade, but the visual difference is imperceptible. Querying
|
||||
* `tmux show -gv terminal-overrides` to detect this would add a subprocess on
|
||||
* startup — not worth it.
|
||||
*
|
||||
* $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
|
||||
* globalSettings.env, so reading it here is correct. chalk is a singleton, so
|
||||
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
|
||||
*/
|
||||
function clampChalkLevelForTmux(): boolean {
|
||||
if (process.env.TMUX && chalk.level > 2) {
|
||||
chalk.level = 2
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Computed once at module load — terminal/tmux environment doesn't change mid-session.
|
||||
// Order matters: boost first; then tmux can still clamp RGB to 256.
|
||||
// Exported for debugging — tree-shaken if unused.
|
||||
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
|
||||
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
|
||||
export const CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE = shouldUseRichEightBitDowngradeForLegacyAppleTerminal()
|
||||
|
||||
export type ColorType = 'foreground' | 'background'
|
||||
|
||||
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
|
||||
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
|
||||
const HEX_REGEX = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
export const colorize = (str: string, color: string | undefined, type: ColorType): string => {
|
||||
if (!color) {
|
||||
return str
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi:')) {
|
||||
const value = color.substring('ansi:'.length)
|
||||
|
||||
switch (value) {
|
||||
case 'black':
|
||||
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
|
||||
|
||||
case 'red':
|
||||
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
|
||||
|
||||
case 'green':
|
||||
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
|
||||
|
||||
case 'yellow':
|
||||
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
|
||||
|
||||
case 'blue':
|
||||
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
|
||||
|
||||
case 'magenta':
|
||||
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
|
||||
|
||||
case 'cyan':
|
||||
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
|
||||
|
||||
case 'white':
|
||||
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
|
||||
|
||||
case 'blackBright':
|
||||
return type === 'foreground' ? chalk.blackBright(str) : chalk.bgBlackBright(str)
|
||||
|
||||
case 'redBright':
|
||||
return type === 'foreground' ? chalk.redBright(str) : chalk.bgRedBright(str)
|
||||
|
||||
case 'greenBright':
|
||||
return type === 'foreground' ? chalk.greenBright(str) : chalk.bgGreenBright(str)
|
||||
|
||||
case 'yellowBright':
|
||||
return type === 'foreground' ? chalk.yellowBright(str) : chalk.bgYellowBright(str)
|
||||
|
||||
case 'blueBright':
|
||||
return type === 'foreground' ? chalk.blueBright(str) : chalk.bgBlueBright(str)
|
||||
|
||||
case 'magentaBright':
|
||||
return type === 'foreground' ? chalk.magentaBright(str) : chalk.bgMagentaBright(str)
|
||||
|
||||
case 'cyanBright':
|
||||
return type === 'foreground' ? chalk.cyanBright(str) : chalk.bgCyanBright(str)
|
||||
|
||||
case 'whiteBright':
|
||||
return type === 'foreground' ? chalk.whiteBright(str) : chalk.bgWhiteBright(str)
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith('#')) {
|
||||
if (HEX_REGEX.test(color) && CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) {
|
||||
const value = Number.parseInt(color.slice(1), 16)
|
||||
const red = (value >> 16) & 0xff
|
||||
const green = (value >> 8) & 0xff
|
||||
const blue = value & 0xff
|
||||
const ansi = richEightBitColorNumber(red, green, blue)
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str)
|
||||
}
|
||||
|
||||
return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi256')) {
|
||||
const matches = ANSI_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const value = Number(matches[1])
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('rgb')) {
|
||||
const matches = RGB_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const firstValue = Number(matches[1])
|
||||
const secondValue = Number(matches[2])
|
||||
const thirdValue = Number(matches[3])
|
||||
|
||||
if (CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) {
|
||||
const ansi = richEightBitColorNumber(firstValue, secondValue, thirdValue)
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str)
|
||||
}
|
||||
|
||||
return type === 'foreground'
|
||||
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
|
||||
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply TextStyles to a string using chalk.
|
||||
* This is the inverse of parsing ANSI codes - we generate them from structured styles.
|
||||
* Theme resolution happens at component layer, not here.
|
||||
*/
|
||||
export function applyTextStyles(text: string, styles: TextStyles): string {
|
||||
let result = text
|
||||
|
||||
// Apply styles in reverse order of desired nesting.
|
||||
// chalk wraps text so later calls become outer wrappers.
|
||||
// Desired order (outermost to innermost):
|
||||
// background > foreground > text modifiers
|
||||
// So we apply: text modifiers first, then foreground, then background last.
|
||||
|
||||
if (styles.inverse) {
|
||||
result = chalk.inverse(result)
|
||||
}
|
||||
|
||||
if (styles.strikethrough) {
|
||||
result = chalk.strikethrough(result)
|
||||
}
|
||||
|
||||
if (styles.underline) {
|
||||
result = chalk.underline(result)
|
||||
}
|
||||
|
||||
if (styles.italic) {
|
||||
result = chalk.italic(result)
|
||||
}
|
||||
|
||||
if (styles.bold) {
|
||||
result = chalk.bold(result)
|
||||
}
|
||||
|
||||
if (styles.dim) {
|
||||
result = chalk.dim(result)
|
||||
}
|
||||
|
||||
if (styles.color) {
|
||||
// Color is now always a raw color value (theme resolution happens at component layer)
|
||||
result = colorize(result, styles.color, 'foreground')
|
||||
}
|
||||
|
||||
if (styles.backgroundColor) {
|
||||
// backgroundColor is now always a raw color value
|
||||
result = colorize(result, styles.backgroundColor, 'background')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw color value to text.
|
||||
* Theme resolution should happen at component layer, not here.
|
||||
*/
|
||||
export function applyColor(text: string, color: Color | undefined): string {
|
||||
if (!color) {
|
||||
return text
|
||||
}
|
||||
|
||||
return colorize(text, color, 'foreground')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import instances from '../instances.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js'
|
||||
import {
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
enableMouseTrackingFor,
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
type MouseTrackingMode
|
||||
} from '../termio/dec.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
import Box from './Box.js'
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
||||
type Props = PropsWithChildren<{
|
||||
/**
|
||||
* Which SGR mouse-tracking preset to enable. Default `'all'` — wheel +
|
||||
* click + drag + hover (1000 + 1002 + 1003 + 1006). Set to `'wheel'`
|
||||
* (1000 + 1006) to silence the noisy hover events that tmux turns into
|
||||
* "No image in clipboard" spam over the prompt row, while keeping
|
||||
* scroll-wheel scrolling. `'off'` disables tracking entirely.
|
||||
*/
|
||||
mouseTracking?: MouseTrackingMode
|
||||
}>
|
||||
|
||||
/**
|
||||
* Run children in the terminal's alternate screen buffer, constrained to
|
||||
* the viewport height. While mounted:
|
||||
*
|
||||
* - Enters the alt screen (DEC 1049), clears it, homes the cursor
|
||||
* - Constrains its own height to the terminal row count, so overflow must
|
||||
* be handled via `overflow: scroll` / flexbox (no native scrollback)
|
||||
* - Optionally enables a subset of SGR mouse tracking (wheel-only,
|
||||
* wheel+drag, or wheel+drag+hover) — events surface as `ParsedKey`
|
||||
* (wheel) and update the Ink instance's selection state (click/drag).
|
||||
* See `MouseTrackingMode` for the available presets.
|
||||
*
|
||||
* On unmount, disables mouse tracking and exits the alt screen, restoring
|
||||
* the main screen's content. Safe for use in ctrl-o transcript overlays
|
||||
* and similar temporary fullscreen views — the main screen is preserved.
|
||||
*
|
||||
* Notifies the Ink instance via `setAltScreenActive()` so the renderer
|
||||
* keeps the cursor inside the viewport (preventing the cursor-restore LF
|
||||
* from scrolling content) and so signal-exit cleanup can exit the alt
|
||||
* screen if the component's own unmount doesn't run.
|
||||
*/
|
||||
export function AlternateScreen(t0: Props) {
|
||||
const $ = _c(7)
|
||||
|
||||
const { children, mouseTracking: t1 } = t0
|
||||
|
||||
const mouseTracking: MouseTrackingMode = t1 === undefined ? 'all' : t1
|
||||
const size = useContext(TerminalSizeContext)
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
let t2
|
||||
let t3
|
||||
|
||||
if ($[0] !== mouseTracking || $[1] !== writeRaw) {
|
||||
t2 = () => {
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
if (!writeRaw) {
|
||||
return
|
||||
}
|
||||
|
||||
const enableMouse = enableMouseTrackingFor(mouseTracking)
|
||||
|
||||
// Always reset every mouse mode before enabling the requested preset
|
||||
// so the terminal lands in an exact state. If a previous instance
|
||||
// (crash, another app, lingering DECSET from a debugger) left DEC
|
||||
// 1003 hover events asserted, picking 'wheel' or 'buttons' without
|
||||
// an unconditional DISABLE would silently leave hover on and defeat
|
||||
// the point of the preset.
|
||||
writeRaw(ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + DISABLE_MOUSE_TRACKING + enableMouse)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
// setAltScreenActive(true, mouseTracking) above stores the mode for
|
||||
// SIGCONT/resize/stdin-gap re-assertion. We don't also call
|
||||
// setAltScreenMouseTracking(mouseTracking) here: it would early-return
|
||||
// in the happy mode-change path (active flipped false→true with the
|
||||
// new mode), and on any path where setAltScreenActive saw active was
|
||||
// already true (so it didn't store mode), the writeRaw above has
|
||||
// already DISABLE'd + enabled the new mode. A second
|
||||
// setAltScreenMouseTracking would just duplicate the same DEC bytes.
|
||||
|
||||
return () => {
|
||||
ink?.setAltScreenActive(false)
|
||||
ink?.clearTextSelection()
|
||||
// DISABLE_MOUSE_TRACKING is safe to send even when we never enabled
|
||||
// tracking (it unconditionally resets all four modes). Sending it
|
||||
// on every teardown means a crash mid-mount can't leak DEC modes
|
||||
// back to the host shell.
|
||||
writeRaw(DISABLE_MOUSE_TRACKING + EXIT_ALT_SCREEN)
|
||||
}
|
||||
}
|
||||
|
||||
t3 = [writeRaw, mouseTracking]
|
||||
$[0] = mouseTracking
|
||||
$[1] = writeRaw
|
||||
$[2] = t2
|
||||
$[3] = t3
|
||||
} else {
|
||||
t2 = $[2]
|
||||
t3 = $[3]
|
||||
}
|
||||
|
||||
useInsertionEffect(t2, t3)
|
||||
const t4 = size?.rows ?? 24
|
||||
let t5
|
||||
|
||||
if ($[4] !== children || $[5] !== t4) {
|
||||
t5 = (
|
||||
<Box flexDirection="column" flexShrink={0} height={t4} width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
$[4] = children
|
||||
$[5] = t4
|
||||
$[6] = t5
|
||||
} else {
|
||||
t5 = $[6]
|
||||
}
|
||||
|
||||
return t5
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createSelectionState } from '../selection.js'
|
||||
import { getTerminalFocusState, resetTerminalFocusState } from '../terminal-focus-state.js'
|
||||
import { FOCUS_IN, FOCUS_OUT } from '../termio/csi.js'
|
||||
|
||||
import App from './App.js'
|
||||
|
||||
function makeApp(onTerminalFocusChange = vi.fn()) {
|
||||
const stdin = {
|
||||
isTTY: true,
|
||||
readableLength: 0,
|
||||
read: vi.fn(),
|
||||
ref: vi.fn(),
|
||||
unref: vi.fn(),
|
||||
setEncoding: vi.fn(),
|
||||
setRawMode: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
} as unknown as NodeJS.ReadStream
|
||||
|
||||
const stdout = {
|
||||
isTTY: true,
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
write: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
} as unknown as NodeJS.WriteStream
|
||||
|
||||
return new App({
|
||||
children: null,
|
||||
dispatchKeyboardEvent: vi.fn(),
|
||||
exitOnCtrlC: false,
|
||||
getHyperlinkAt: vi.fn(),
|
||||
onClickAt: vi.fn(() => false),
|
||||
onCursorDeclaration: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
onHoverAt: vi.fn(),
|
||||
onMouseDownAt: vi.fn(() => undefined),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onMultiClick: vi.fn(),
|
||||
onOpenHyperlink: vi.fn(),
|
||||
onSelectionChange: vi.fn(),
|
||||
onSelectionDrag: vi.fn(),
|
||||
onTerminalFocusChange,
|
||||
selection: createSelectionState(),
|
||||
stderr: stdout,
|
||||
stdin,
|
||||
stdout,
|
||||
terminalColumns: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
}
|
||||
|
||||
describe('App terminal focus events', () => {
|
||||
beforeEach(() => {
|
||||
resetTerminalFocusState()
|
||||
})
|
||||
|
||||
it('notifies the renderer on DECSET 1004 focus transitions', () => {
|
||||
const onTerminalFocusChange = vi.fn()
|
||||
const app = makeApp(onTerminalFocusChange)
|
||||
|
||||
app.processInput(FOCUS_OUT)
|
||||
expect(getTerminalFocusState()).toBe('blurred')
|
||||
expect(onTerminalFocusChange).toHaveBeenLastCalledWith(false)
|
||||
|
||||
app.processInput(FOCUS_IN)
|
||||
expect(getTerminalFocusState()).toBe('focused')
|
||||
expect(onTerminalFocusChange).toHaveBeenLastCalledWith(true)
|
||||
expect(onTerminalFocusChange).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
* Exit (unmount) the whole Ink app.
|
||||
*/
|
||||
readonly exit: (error?: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `AppContext` is a React context, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
|
||||
const AppContext = createContext<Props>({
|
||||
exit() {}
|
||||
})
|
||||
|
||||
AppContext.displayName = 'InternalAppContext'
|
||||
|
||||
export default AppContext
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
/**
|
||||
* Notify Ink that the physical terminal cursor was advanced by an
|
||||
* out-of-band stdout.write (e.g. the TextInput fast-echo path).
|
||||
*
|
||||
* This is a two-part notification — calling it updates both:
|
||||
*
|
||||
* 1. Ink's cached `displayCursor` (the basis log-update uses to
|
||||
* compute relative cursor moves for the next frame's preamble).
|
||||
* Without this, the next frame's preamble starts from a stale
|
||||
* parked position and the diff is rendered N cells offset.
|
||||
* This half is SKIPPED on alt-screen — every alt-screen frame
|
||||
* begins with CSI H which absolutely repositions the cursor, so
|
||||
* the relative-move basis is reset for free.
|
||||
*
|
||||
* 2. Ink's active `cursorDeclaration` (the target the cursor parks
|
||||
* at after every frame, set by `useDeclaredCursor`). Without
|
||||
* this, an unrelated component re-rendering before the deferred
|
||||
* React state catches up would publish a stale declaration and
|
||||
* visually undo the fast-echo's advance. This half applies to
|
||||
* BOTH main-screen and alt-screen — on alt-screen the cursor-
|
||||
* park branch in onRender emits an absolute CUP to
|
||||
* `rect.x + decl.relativeX`, so a stale declaration there is
|
||||
* still wrong even though displayCursor is skipped.
|
||||
*
|
||||
* `dx`/`dy` are deltas in terminal cells (positive = right/down,
|
||||
* negative = left/up). The caller is responsible for ensuring the
|
||||
* physical cursor really did move by that amount.
|
||||
*/
|
||||
export type CursorAdvanceNotifier = (dx: number, dy?: number) => void
|
||||
|
||||
const CursorAdvanceContext = createContext<CursorAdvanceNotifier>(() => {})
|
||||
|
||||
export default CursorAdvanceContext
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
export type CursorDeclaration = {
|
||||
/** Display column (terminal cell width) within the declared node */
|
||||
readonly relativeX: number
|
||||
/** Line number within the declared node */
|
||||
readonly relativeY: number
|
||||
/** The ink-box DOMElement whose yoga layout provides the absolute origin */
|
||||
readonly node: DOMElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the declared cursor position.
|
||||
*
|
||||
* The optional second argument makes `null` a conditional clear: the
|
||||
* declaration is only cleared if the currently-declared node matches
|
||||
* `clearIfNode`. This makes the hook safe for sibling components
|
||||
* (e.g. list items) that transfer focus among themselves — without the
|
||||
* node check, a newly-unfocused item's clear could clobber a
|
||||
* newly-focused sibling's set depending on layout-effect order.
|
||||
*/
|
||||
export type CursorDeclarationSetter = (declaration: CursorDeclaration | null, clearIfNode?: DOMElement | null) => void
|
||||
|
||||
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(() => {})
|
||||
|
||||
export default CursorDeclarationContext
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import Text from './Text.js'
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
// Kept for backwards-compat: prior versions rendered `fallback` instead of
|
||||
// the linked content on terminals where supportsHyperlinks() was false. We
|
||||
// now always emit the hyperlink metadata so the in-process click/hover
|
||||
// dispatcher can act on it regardless of the terminal's own OSC 8 support
|
||||
// (see comment in the function body), so `fallback` is no longer wired up.
|
||||
// Leaving the prop on the interface keeps existing call sites compiling.
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function Link({ children, url }: Props): React.ReactNode {
|
||||
// Always emit <ink-link>: the renderer stores `hyperlink` per cell in the
|
||||
// screen buffer, which the click dispatcher (Ink.getHyperlinkAt →
|
||||
// onHyperlinkClick) reads on mouseup to open URLs externally. Gating this
|
||||
// on supportsHyperlinks() broke clicks in Apple Terminal / any terminal
|
||||
// not on the OSC 8 allowlist — the cell's hyperlink field stayed empty,
|
||||
// so the click pipeline had nothing to open.
|
||||
//
|
||||
// The OSC 8 escape itself is emitted unconditionally by the renderer
|
||||
// (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts).
|
||||
// Terminals that don't understand OSC 8 silently strip it — including
|
||||
// Apple Terminal, which is why hover/click affordance has to come from
|
||||
// the in-process overlay (applyHyperlinkHoverHighlight) and not from the
|
||||
// terminal's own link rendering.
|
||||
const content = children ?? url
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
export type Props = {
|
||||
/**
|
||||
* Number of newlines to insert.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
readonly count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||
*/
|
||||
export default function Newline(t0: Props) {
|
||||
const $ = _c(4)
|
||||
|
||||
const { count: t1 } = t0
|
||||
|
||||
const count = t1 === undefined ? 1 : t1
|
||||
let t2
|
||||
|
||||
if ($[0] !== count) {
|
||||
t2 = '\n'.repeat(count)
|
||||
$[0] = count
|
||||
$[1] = t2
|
||||
} else {
|
||||
t2 = $[1]
|
||||
}
|
||||
|
||||
let t3
|
||||
|
||||
if ($[2] !== t2) {
|
||||
t3 = <ink-text>{t2}</ink-text>
|
||||
$[2] = t2
|
||||
$[3] = t3
|
||||
} else {
|
||||
t3 = $[3]
|
||||
}
|
||||
|
||||
return t3
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box, { type Props as BoxProps } from './Box.js'
|
||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
/**
|
||||
* Extend the exclusion zone from column 0 to this box's right edge,
|
||||
* for every row this box occupies. Use for gutters rendered inside a
|
||||
* wider indented container (e.g. a diff inside a tool message row):
|
||||
* without this, a multi-row drag picks up the container's leading
|
||||
* indent on rows below the prefix.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
fromLeftEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks its contents as non-selectable in fullscreen text selection.
|
||||
* Cells inside this box are skipped by both the selection highlight and
|
||||
* the copied text — the gutter stays visually unchanged while the user
|
||||
* drags, making it clear what will be copied.
|
||||
*
|
||||
* Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
|
||||
* so click-drag over rendered code yields clean pasteable content:
|
||||
*
|
||||
* <Box flexDirection="row">
|
||||
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
|
||||
* <Text>const x = 1</Text>
|
||||
* </Box>
|
||||
*
|
||||
* Only affects alt-screen text selection (<AlternateScreen> with mouse
|
||||
* tracking). No-op in the main-screen scrollback render where the
|
||||
* terminal's native selection is used instead.
|
||||
*/
|
||||
export function NoSelect(t0: Props) {
|
||||
const $ = _c(8)
|
||||
let boxProps
|
||||
let children
|
||||
let fromLeftEdge
|
||||
|
||||
if ($[0] !== t0) {
|
||||
;({ children, fromLeftEdge, ...boxProps } = t0)
|
||||
$[0] = t0
|
||||
$[1] = boxProps
|
||||
$[2] = children
|
||||
$[3] = fromLeftEdge
|
||||
} else {
|
||||
boxProps = $[1]
|
||||
children = $[2]
|
||||
fromLeftEdge = $[3]
|
||||
}
|
||||
|
||||
const t1 = fromLeftEdge ? 'from-left-edge' : true
|
||||
let t2
|
||||
|
||||
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
|
||||
t2 = (
|
||||
<Box {...boxProps} noSelect={t1}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
$[4] = boxProps
|
||||
$[5] = children
|
||||
$[6] = t1
|
||||
$[7] = t2
|
||||
} else {
|
||||
t2 = $[7]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiQm94IiwiUHJvcHMiLCJCb3hQcm9wcyIsIk9taXQiLCJmcm9tTGVmdEVkZ2UiLCJOb1NlbGVjdCIsInQwIiwiJCIsIl9jIiwiYm94UHJvcHMiLCJjaGlsZHJlbiIsInQxIiwidDIiXSwic291cmNlcyI6WyJOb1NlbGVjdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4gfSBmcm9tICdyZWFjdCdcbmltcG9ydCBCb3gsIHsgdHlwZSBQcm9wcyBhcyBCb3hQcm9wcyB9IGZyb20gJy4vQm94LmpzJ1xuXG50eXBlIFByb3BzID0gT21pdDxCb3hQcm9wcywgJ25vU2VsZWN0Jz4gJiB7XG4gIC8qKlxuICAgKiBFeHRlbmQgdGhlIGV4Y2x1c2lvbiB6b25lIGZyb20gY29sdW1uIDAgdG8gdGhpcyBib3gncyByaWdodCBlZGdlLFxuICAgKiBmb3IgZXZlcnkgcm93IHRoaXMgYm94IG9jY3VwaWVzLiBVc2UgZm9yIGd1dHRlcnMgcmVuZGVyZWQgaW5zaWRlIGFcbiAgICogd2lkZXIgaW5kZW50ZWQgY29udGFpbmVyIChlLmcuIGEgZGlmZiBpbnNpZGUgYSB0b29sIG1lc3NhZ2Ugcm93KTpcbiAgICogd2l0aG91dCB0aGlzLCBhIG11bHRpLXJvdyBkcmFnIHBpY2tzIHVwIHRoZSBjb250YWluZXIncyBsZWFkaW5nXG4gICAqIGluZGVudCBvbiByb3dzIGJlbG93IHRoZSBwcmVmaXguXG4gICAqXG4gICAqIEBkZWZhdWx0IGZhbHNlXG4gICAqL1xuICBmcm9tTGVmdEVkZ2U/OiBib29sZWFuXG59XG5cbi8qKlxuICogTWFya3MgaXRzIGNvbnRlbnRzIGFzIG5vbi1zZWxlY3RhYmxlIGluIGZ1bGxzY3JlZW4gdGV4dCBzZWxlY3Rpb24uXG4gKiBDZWxscyBpbnNpZGUgdGhpcyBib3ggYXJlIHNraXBwZWQgYnkgYm90aCB0aGUgc2VsZWN0aW9uIGhpZ2hsaWdodCBhbmRcbiAqIHRoZSBjb3BpZWQgdGV4dCDigJQgdGhlIGd1dHRlciBzdGF5cyB2aXN1YWxseSB1bmNoYW5nZWQgd2hpbGUgdGhlIHVzZXJcbiAqIGRyYWdzLCBtYWtpbmcgaXQgY2xlYXIgd2hhdCB3aWxsIGJlIGNvcGllZC5cbiAqXG4gKiBVc2UgdG8gZmVuY2Ugb2ZmIGd1dHRlcnMgKGxpbmUgbnVtYmVycywgZGlmZiArLy0gc2lnaWxzLCBsaXN0IGJ1bGxldHMpXG4gKiBzbyBjbGljay1kcmFnIG92ZXIgcmVuZGVyZWQgY29kZSB5aWVsZHMgY2xlYW4gcGFzdGVhYmxlIGNvbnRlbnQ6XG4gKlxuICogICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIj5cbiAqICAgICA8Tm9TZWxlY3QgZnJvbUxlZnRFZGdlPjxUZXh0IGRpbUNvbG9yPiA0MiArPC9UZXh0PjwvTm9TZWxlY3Q+XG4gKiAgICAgPFRleHQ+Y29uc3QgeCA9IDE8L1RleHQ+XG4gKiAgIDwvQm94PlxuICpcbiAqIE9ubHkgYWZmZWN0cyBhbHQtc2NyZWVuIHRleHQgc2VsZWN0aW9uICg8QWx0ZXJuYXRlU2NyZWVuPiB3aXRoIG1vdXNlXG4gKiB0cmFja2luZykuIE5vLW9wIGluIHRoZSBtYWluLXNjcmVlbiBzY3JvbGxiYWNrIHJlbmRlciB3aGVyZSB0aGVcbiAqIHRlcm1pbmFsJ3MgbmF0aXZlIHNlbGVjdGlvbiBpcyB1c2VkIGluc3RlYWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBOb1NlbGVjdCh7XG4gIGNoaWxkcmVuLFxuICBmcm9tTGVmdEVkZ2UsXG4gIC4uLmJveFByb3BzXG59OiBQcm9wc1dpdGhDaGlsZHJlbjxQcm9wcz4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLmJveFByb3BzfSBub1NlbGVjdD17ZnJvbUxlZnRFZGdlID8gJ2Zyb20tbGVmdC1lZGdlJyA6IHRydWV9PlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLFFBQVEsT0FBTztBQUNyRCxPQUFPQyxHQUFHLElBQUksS0FBS0MsS0FBSyxJQUFJQyxRQUFRLFFBQVEsVUFBVTtBQUV0RCxLQUFLRCxLQUFLLEdBQUdFLElBQUksQ0FBQ0QsUUFBUSxFQUFFLFVBQVUsQ0FBQyxHQUFHO0VBQ3hDO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFRSxZQUFZLENBQUMsRUFBRSxPQUFPO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxTQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBTixZQUFBO0VBQUEsSUFBQUcsQ0FBQSxRQUFBRCxFQUFBO0lBQWtCO01BQUFJLFFBQUE7TUFBQU4sWUFBQTtNQUFBLEdBQUFLO0lBQUEsSUFBQUgsRUFJRTtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsUUFBQTtJQUFBSCxDQUFBLE1BQUFILFlBQUE7RUFBQTtJQUFBSyxRQUFBLEdBQUFGLENBQUE7SUFBQUcsUUFBQSxHQUFBSCxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtFQUFBO0VBRU0sTUFBQUksRUFBQSxHQUFBUCxZQUFZLEdBQVosZ0JBQXNDLEdBQXRDLElBQXNDO0VBQUEsSUFBQVEsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLFFBQUEsSUFBQUgsQ0FBQSxRQUFBSSxFQUFBO0lBQW5FQyxFQUFBLElBQUMsR0FBRyxLQUFLSCxRQUFRLEVBQVksUUFBc0MsQ0FBdEMsQ0FBQUUsRUFBcUMsQ0FBQyxDQUNoRUQsU0FBTyxDQUNWLEVBRkMsR0FBRyxDQUVFO0lBQUFILENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFHLFFBQUE7SUFBQUgsQ0FBQSxNQUFBSSxFQUFBO0lBQUFKLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FGTkssRUFFTTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
type Props = {
|
||||
/**
|
||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||
*/
|
||||
lines: string[]
|
||||
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||
width: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||
* content that is already terminal-ready.
|
||||
*
|
||||
* Use this when an external renderer (e.g. the ColorDiff NAPI module) has
|
||||
* already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
|
||||
* reparses that output into one React <Text> per style span, lays out each
|
||||
* span as a Yoga flex child, then walks the tree to re-emit the same escape
|
||||
* codes it was given. For a long transcript full of syntax-highlighted diffs
|
||||
* that roundtrip is the dominant cost of the render.
|
||||
*
|
||||
* This component emits a single Yoga leaf with a constant-time measure func
|
||||
* (width × lines.length) and hands the joined string straight to output.write(),
|
||||
* which already splits on '\n' and parses ANSI into the screen buffer.
|
||||
*/
|
||||
export function RawAnsi(t0: Props) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { lines, width } = t0
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let t1
|
||||
|
||||
if ($[0] !== lines) {
|
||||
t1 = lines.join('\n')
|
||||
$[0] = lines
|
||||
$[1] = t1
|
||||
} else {
|
||||
t1 = $[1]
|
||||
}
|
||||
|
||||
let t2
|
||||
|
||||
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
|
||||
t2 = <ink-raw-ansi rawHeight={lines.length} rawText={t1} rawWidth={width} />
|
||||
$[2] = lines.length
|
||||
$[3] = t1
|
||||
$[4] = width
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box from './Box.js'
|
||||
|
||||
/**
|
||||
* A flexible space that expands along the major axis of its containing layout.
|
||||
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||
*/
|
||||
export default function Spacer() {
|
||||
const $ = _c(1)
|
||||
let t0
|
||||
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Box flexGrow={1} />
|
||||
$[0] = t0
|
||||
} else {
|
||||
t0 = $[0]
|
||||
}
|
||||
|
||||
return t0
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import { EventEmitter } from '../events/emitter.js'
|
||||
import type { TerminalQuerier } from '../terminal-querier.js'
|
||||
|
||||
export type Props = {
|
||||
readonly stdin: NodeJS.ReadStream
|
||||
readonly setRawMode: (value: boolean) => void
|
||||
readonly isRawModeSupported: boolean
|
||||
readonly exitOnCtrlC: boolean
|
||||
readonly inputEmitter: EventEmitter
|
||||
readonly querier: TerminalQuerier | null
|
||||
}
|
||||
|
||||
const StdinContext = createContext<Props>({
|
||||
stdin: process.stdin,
|
||||
inputEmitter: new EventEmitter(),
|
||||
setRawMode() {},
|
||||
isRawModeSupported: false,
|
||||
exitOnCtrlC: true,
|
||||
querier: null
|
||||
})
|
||||
|
||||
StdinContext.displayName = 'StdinContext'
|
||||
export default StdinContext
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { createContext, type ReactNode, useSyncExternalStore } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import {
|
||||
getTerminalFocused,
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState
|
||||
} from '../terminal-focus-state.js'
|
||||
export type { TerminalFocusState }
|
||||
export type TerminalFocusContextProps = {
|
||||
readonly isTerminalFocused: boolean
|
||||
readonly terminalFocusState: TerminalFocusState
|
||||
}
|
||||
|
||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||
isTerminalFocused: true,
|
||||
terminalFocusState: 'unknown'
|
||||
})
|
||||
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||
|
||||
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||
// Children are a stable prop reference, so they don't re-render either —
|
||||
// only components that consume the context will re-render.
|
||||
export function TerminalFocusProvider(t0: { readonly children: ReactNode }) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { children } = t0
|
||||
|
||||
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused)
|
||||
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState)
|
||||
let t1
|
||||
|
||||
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
|
||||
t1 = {
|
||||
isTerminalFocused,
|
||||
terminalFocusState
|
||||
}
|
||||
$[0] = isTerminalFocused
|
||||
$[1] = terminalFocusState
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
const value = t1
|
||||
let t2
|
||||
|
||||
if ($[3] !== children || $[4] !== value) {
|
||||
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>
|
||||
$[3] = children
|
||||
$[4] = value
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
|
||||
export default TerminalFocusContext
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react'
|
||||
export type TerminalSize = {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { dimColorFallback, setDimFallbackColor, shouldUseAnsiDim } from './Text.js'
|
||||
|
||||
describe('shouldUseAnsiDim', () => {
|
||||
it('disables ANSI dim on VTE terminals by default', () => {
|
||||
expect(shouldUseAnsiDim({ VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('disables ANSI dim on Apple Terminal by default', () => {
|
||||
expect(shouldUseAnsiDim({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ANSI dim enabled elsewhere by default', () => {
|
||||
expect(shouldUseAnsiDim({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('honors explicit env override', () => {
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dimColorFallback', () => {
|
||||
afterEach(() => {
|
||||
setDimFallbackColor(undefined)
|
||||
})
|
||||
|
||||
it('renders Apple Terminal dim as muted gray by default', () => {
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', () => {
|
||||
expect(dimColorFallback({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('does not apply when dim is explicitly configured', () => {
|
||||
expect(
|
||||
dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the theme tone once one is supplied, so dim stays in-palette', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#936e06')
|
||||
})
|
||||
|
||||
it('falls back to the boot default when the theme tone is cleared', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
setDimFallbackColor(undefined)
|
||||
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('stays inert on terminals that honor SGR 2, whatever the theme tone', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
|
||||
expect(dimColorFallback({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
// Shared frame interval for render throttling and animations (~60fps).
|
||||
export const FRAME_INTERVAL_MS = 16
|
||||
|
||||
// Keep clock-driven animations at full speed when terminal focus changes.
|
||||
// We still pause entirely when there are no keepAlive subscribers.
|
||||
export const BLURRED_FRAME_INTERVAL_MS = FRAME_INTERVAL_MS
|
||||
|
||||
// Issue #31486 (stdout-backpressure strand): when the previous frame's
|
||||
// stdout.write has NOT drained yet (terminal parser overwhelmed by a wide
|
||||
// CR+LF burst — CJK + ANSI tool output on a high-context session), piling
|
||||
// another write on the backed-up pipe both wastes the frame and keeps the
|
||||
// macrotask queue churning, starving the stdin 'readable' callback. We
|
||||
// instead COALESCE: skip the frame and retry on the drain tick. This ceiling
|
||||
// caps how many consecutive frames we'll coalesce before forcing a write
|
||||
// through, so a terminal whose drain callback never fires (e.g. EIO on
|
||||
// flush) can't wedge the renderer permanently — it self-heals once the pipe
|
||||
// recovers. ~10 frames at the drain-tick cadence is a few hundred ms of
|
||||
// breathing room, well under any human-perceptible render stall.
|
||||
export const MAX_COALESCED_BACKPRESSURE_FRAMES = 10
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Cursor = {
|
||||
x: number
|
||||
y: number
|
||||
visible: boolean
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Optional react-devtools hook; package may be absent. */
|
||||
export {}
|
||||
@@ -0,0 +1,494 @@
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
import { LayoutMeasureMode } from './layout/node.js'
|
||||
import measureText from './measure-text.js'
|
||||
import { addPendingClear, nodeCache } from './node-cache.js'
|
||||
import squashTextNodes from './squash-text-nodes.js'
|
||||
import type { Styles, TextStyles } from './styles.js'
|
||||
import { expandTabs } from './tabstops.js'
|
||||
import wrapText from './wrap-text.js'
|
||||
|
||||
type InkNode = {
|
||||
parentNode: DOMElement | undefined
|
||||
yogaNode?: LayoutNode
|
||||
style: Styles
|
||||
}
|
||||
|
||||
export type TextName = '#text'
|
||||
export type ElementNames =
|
||||
'ink-root' | 'ink-box' | 'ink-text' | 'ink-virtual-text' | 'ink-link' | 'ink-progress' | 'ink-raw-ansi'
|
||||
|
||||
export type NodeNames = ElementNames | TextName
|
||||
|
||||
export type DOMElement = {
|
||||
nodeName: ElementNames
|
||||
attributes: Record<string, DOMNodeAttribute>
|
||||
childNodes: DOMNode[]
|
||||
textStyles?: TextStyles
|
||||
|
||||
// Internal properties
|
||||
onComputeLayout?: () => void
|
||||
onRender?: () => void
|
||||
onImmediateRender?: () => void
|
||||
// Used to skip empty renders during React 19's effect double-invoke in test mode
|
||||
hasRenderedContent?: boolean
|
||||
|
||||
// When true, this node needs re-rendering
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
// are computed at render time and stored for imperative access. stickyScroll
|
||||
// auto-pins scrollTop to the bottom when content grows.
|
||||
scrollTop?: number
|
||||
// Accumulated scroll delta not yet applied to scrollTop. The renderer
|
||||
// drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
|
||||
// intermediate frames instead of one big jump. Direction reversal
|
||||
// naturally cancels (pure accumulator, no target tracking).
|
||||
pendingScrollDelta?: number
|
||||
// One-render record of additive scrollTop changes made to preserve the
|
||||
// visual anchor after content above the viewport changes height. The
|
||||
// renderer subtracts this when evaluating positional bottom-follow and
|
||||
// defers pending input for that paint, then clears the record.
|
||||
scrollTopCompensation?: number
|
||||
// Render-time clamp bounds for virtual scroll. useVirtualScroll writes
|
||||
// the currently-mounted children's coverage span; render-node-to-output
|
||||
// clamps scrollTop to stay within it. Prevents blank screen when
|
||||
// scrollTo's direct write races past React's async re-render — instead
|
||||
// of painting spacer (blank), the renderer holds at the edge of mounted
|
||||
// content until React catches up (next commit updates these bounds and
|
||||
// the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
|
||||
scrollClampMin?: number
|
||||
scrollClampMax?: number
|
||||
scrollHeight?: number
|
||||
scrollViewportHeight?: number
|
||||
scrollViewportTop?: number
|
||||
stickyScroll?: boolean
|
||||
notifyScrollChange?: () => void
|
||||
// Set by ScrollBox.scrollToElement; render-node-to-output reads
|
||||
// el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
|
||||
// and sets scrollTop = top + offset, then clears this. Unlike an
|
||||
// imperative scrollTo(N) which bakes in a number that's stale by the
|
||||
// time the throttled render fires, the element ref defers the position
|
||||
// read to paint time. One-shot.
|
||||
scrollAnchor?: { el: DOMElement; offset: number }
|
||||
// Only set on ink-root. The document owns focus — any node can
|
||||
// reach it by walking parentNode, like browser getRootNode().
|
||||
focusManager?: FocusManager
|
||||
// Measurement cache for ink-text nodes: avoids re-squashing and re-wrapping
|
||||
// text when yoga calls measureFunc multiple times per frame with different
|
||||
// widths during flex re-pass. Keyed by `${width}|${widthMode}`.
|
||||
_textMeasureCache?: { gen: number; entries: Map<string, { _gen: number; result: { width: number; height: number } }> }
|
||||
} & InkNode
|
||||
|
||||
export type TextNode = {
|
||||
nodeName: TextName
|
||||
nodeValue: string
|
||||
} & InkNode
|
||||
|
||||
export type DOMNode<T = { nodeName: NodeNames }> = T extends {
|
||||
nodeName: infer U
|
||||
}
|
||||
? U extends '#text'
|
||||
? TextNode
|
||||
: DOMElement
|
||||
: never
|
||||
|
||||
export type DOMNodeAttribute = boolean | string | number
|
||||
|
||||
export const createNode = (nodeName: ElementNames): DOMElement => {
|
||||
const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress'
|
||||
|
||||
const node: DOMElement = {
|
||||
nodeName,
|
||||
style: {},
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: undefined,
|
||||
yogaNode: needsYogaNode ? createLayoutNode() : undefined,
|
||||
dirty: false
|
||||
}
|
||||
|
||||
if (nodeName === 'ink-text') {
|
||||
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
|
||||
} else if (nodeName === 'ink-raw-ansi') {
|
||||
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => {
|
||||
if (childNode.parentNode) {
|
||||
removeChildNode(childNode.parentNode, childNode)
|
||||
}
|
||||
|
||||
childNode.parentNode = node
|
||||
node.childNodes.push(childNode)
|
||||
|
||||
if (childNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const insertBeforeNode = (node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode): void => {
|
||||
if (newChildNode.parentNode) {
|
||||
removeChildNode(newChildNode.parentNode, newChildNode)
|
||||
}
|
||||
|
||||
newChildNode.parentNode = node
|
||||
|
||||
const index = node.childNodes.indexOf(beforeChildNode)
|
||||
|
||||
if (index >= 0) {
|
||||
// Calculate yoga index BEFORE modifying childNodes.
|
||||
// We can't use DOM index directly because some children (like ink-progress,
|
||||
// ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
|
||||
// match yoga indices.
|
||||
let yogaIndex = 0
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (node.childNodes[i]?.yogaNode) {
|
||||
yogaIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.childNodes.splice(index, 0, newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
node.childNodes.push(newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => {
|
||||
if (removeNode.yogaNode) {
|
||||
removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
|
||||
}
|
||||
|
||||
// Collect cached rects from the removed subtree so they can be cleared
|
||||
collectRemovedRects(node, removeNode)
|
||||
|
||||
removeNode.parentNode = undefined
|
||||
|
||||
const index = node.childNodes.indexOf(removeNode)
|
||||
|
||||
if (index >= 0) {
|
||||
node.childNodes.splice(index, 1)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void {
|
||||
if (removed.nodeName === '#text') {
|
||||
return
|
||||
}
|
||||
|
||||
const elem = removed as DOMElement
|
||||
// If this node or any ancestor in the removed subtree was absolute,
|
||||
// its painted pixels may overlap non-siblings — flag for global blit
|
||||
// disable. Normal-flow removals only affect direct siblings, which
|
||||
// hasRemovedChild already handles.
|
||||
const isAbsolute = underAbsolute || elem.style.position === 'absolute'
|
||||
const cached = nodeCache.get(elem)
|
||||
|
||||
if (cached) {
|
||||
addPendingClear(parent, cached, isAbsolute)
|
||||
nodeCache.delete(elem)
|
||||
}
|
||||
|
||||
for (const child of elem.childNodes) {
|
||||
collectRemovedRects(parent, child, isAbsolute)
|
||||
}
|
||||
}
|
||||
|
||||
export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => {
|
||||
// Skip 'children' - React handles children via appendChild/removeChild,
|
||||
// not attributes. React always passes a new children reference, so
|
||||
// tracking it as an attribute would mark everything dirty every render.
|
||||
if (key === 'children') {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.attributes[key] === value) {
|
||||
return
|
||||
}
|
||||
|
||||
node.attributes[key] = value
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setStyle = (node: DOMNode, style: Styles): void => {
|
||||
// Compare style properties to avoid marking dirty unnecessarily.
|
||||
// React creates new style objects on every render even when unchanged.
|
||||
if (stylesEqual(node.style, style)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.style = style
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => {
|
||||
// Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
|
||||
// allocate a new textStyles object on every render even when values are
|
||||
// unchanged, so compare by value to avoid markDirty -> yoga re-measurement
|
||||
// on every Text re-render.
|
||||
if (shallowEqual(node.textStyles, textStyles)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.textStyles = textStyles
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function stylesEqual(a: Styles, b: Styles): boolean {
|
||||
return shallowEqual(a, b)
|
||||
}
|
||||
|
||||
function shallowEqual<T extends object>(a: T | undefined, b: T | undefined): boolean {
|
||||
// Fast path: same object reference (or both undefined)
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get all keys from both objects
|
||||
const aKeys = Object.keys(a) as (keyof T)[]
|
||||
const bKeys = Object.keys(b) as (keyof T)[]
|
||||
|
||||
// Different number of properties
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare each property
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const createTextNode = (text: string): TextNode => {
|
||||
const node: TextNode = {
|
||||
nodeName: '#text',
|
||||
nodeValue: text,
|
||||
yogaNode: undefined,
|
||||
parentNode: undefined,
|
||||
style: {}
|
||||
}
|
||||
|
||||
setTextNodeValue(node, text)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
const MEASURE_CACHE_CAP = 16
|
||||
|
||||
const measureTextNode = function (
|
||||
node: DOMNode,
|
||||
width: number,
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode
|
||||
|
||||
if (elem && elem.nodeName === 'ink-text') {
|
||||
let cache = elem._textMeasureCache
|
||||
|
||||
if (!cache) {
|
||||
cache = { gen: 0, entries: new Map() }
|
||||
elem._textMeasureCache = cache
|
||||
}
|
||||
|
||||
const key = `${width}|${widthMode}`
|
||||
const hit = cache.entries.get(key)
|
||||
|
||||
if (hit && hit._gen === cache.gen) {
|
||||
return hit.result
|
||||
}
|
||||
|
||||
const result = computeTextMeasure(node, width, widthMode)
|
||||
|
||||
// Enforce cap with FIFO eviction to avoid unbounded growth during
|
||||
// pathological frames where yoga probes many widths.
|
||||
if (cache.entries.size >= MEASURE_CACHE_CAP) {
|
||||
const firstKey = cache.entries.keys().next().value
|
||||
|
||||
if (firstKey !== undefined) {
|
||||
cache.entries.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
cache.entries.set(key, { _gen: cache.gen, result })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return computeTextMeasure(node, width, widthMode)
|
||||
}
|
||||
|
||||
const computeTextMeasure = function (
|
||||
node: DOMNode,
|
||||
width: number,
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
|
||||
|
||||
// Expand tabs for measurement (worst case: 8 spaces each).
|
||||
// Actual tab expansion happens in output.ts based on screen position.
|
||||
const text = expandTabs(rawText)
|
||||
|
||||
const dimensions = measureText(text, width)
|
||||
|
||||
// Text fits into container, no need to wrap
|
||||
if (dimensions.width <= width) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// This is happening when <Box> is shrinking child nodes and layout asks
|
||||
// if we can fit this text node in a <1px space, so we just say "no"
|
||||
if (dimensions.width >= 1 && width > 0 && width < 1) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// For text with embedded newlines (pre-wrapped content), avoid re-wrapping
|
||||
// at measurement width when layout is asking for intrinsic size (Undefined mode).
|
||||
// This prevents height inflation during min/max size checks.
|
||||
//
|
||||
// However, when layout provides an actual constraint (Exactly or AtMost mode),
|
||||
// we must respect it and measure at that width. Otherwise, if the actual
|
||||
// rendering width is smaller than the natural width, the text will wrap to
|
||||
// more lines than layout expects, causing content to be truncated.
|
||||
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
|
||||
const effectiveWidth = Math.max(width, dimensions.width)
|
||||
|
||||
return measureText(text, effectiveWidth)
|
||||
}
|
||||
|
||||
const textWrap = node.style?.textWrap ?? 'wrap'
|
||||
const wrappedText = wrapText(text, width, textWrap)
|
||||
|
||||
return measureText(wrappedText, width)
|
||||
}
|
||||
|
||||
// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
|
||||
// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
|
||||
// already wrapped to the target width and each line is exactly one terminal row.
|
||||
const measureRawAnsiNode = function (node: DOMElement): {
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
return {
|
||||
width: node.attributes['rawWidth'] as number,
|
||||
height: node.attributes['rawHeight'] as number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node and all its ancestors as dirty for re-rendering.
|
||||
* Also marks yoga dirty for text remeasurement if this is a text node.
|
||||
*/
|
||||
export const markDirty = (node?: DOMNode): void => {
|
||||
let current: DOMNode | undefined = node
|
||||
let markedYoga = false
|
||||
|
||||
while (current) {
|
||||
if (current.nodeName !== '#text') {
|
||||
const elem = current as DOMElement
|
||||
elem.dirty = true
|
||||
|
||||
// Only mark yoga dirty on leaf nodes that have measure functions
|
||||
if (!markedYoga && (elem.nodeName === 'ink-text' || elem.nodeName === 'ink-raw-ansi') && elem.yogaNode) {
|
||||
elem.yogaNode.markDirty()
|
||||
markedYoga = true
|
||||
}
|
||||
|
||||
// Invalidate text measurement cache — child text or style changed.
|
||||
if (elem._textMeasureCache) {
|
||||
elem._textMeasureCache.gen++
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
// Walk to root and call its onRender (the throttled scheduleRender). Use for
|
||||
// DOM-level mutations (scrollTop changes) that should trigger an Ink frame
|
||||
// without going through React's reconciler. Pair with markDirty() so the
|
||||
// renderer knows which subtree to re-evaluate.
|
||||
export const scheduleRenderFrom = (node?: DOMNode): void => {
|
||||
let cur: DOMNode | undefined = node
|
||||
|
||||
while (cur?.parentNode) {
|
||||
cur = cur.parentNode
|
||||
}
|
||||
|
||||
if (cur && cur.nodeName !== '#text') {
|
||||
;(cur as DOMElement).onRender?.()
|
||||
}
|
||||
}
|
||||
|
||||
export const setTextNodeValue = (node: TextNode, text: string): void => {
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text)
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.nodeValue === text) {
|
||||
return
|
||||
}
|
||||
|
||||
node.nodeValue = text
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
|
||||
return node.nodeName !== '#text'
|
||||
}
|
||||
|
||||
// Clear yogaNode references recursively before freeing.
|
||||
// freeRecursive() frees the node and ALL its children, so we must clear
|
||||
// all yogaNode references to prevent dangling pointers.
|
||||
export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||
if ('childNodes' in node) {
|
||||
for (const child of node.childNodes) {
|
||||
clearYogaNodeReferences(child)
|
||||
}
|
||||
|
||||
node._textMeasureCache = undefined
|
||||
}
|
||||
|
||||
node.yogaNode = undefined
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
/**
|
||||
* Mouse click event. Fired on left-button release without drag, only when
|
||||
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
|
||||
*
|
||||
* Bubbles from the deepest hit node up through parentNode. Call
|
||||
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
|
||||
*/
|
||||
export class ClickEvent extends Event {
|
||||
/** 0-indexed screen column of the click */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row of the click */
|
||||
readonly row: number
|
||||
/**
|
||||
* Click column relative to the current handler's Box (col - box.x).
|
||||
* Recomputed by dispatchClick before each handler fires, so an onClick
|
||||
* on a container sees coords relative to that container, not to any
|
||||
* child the click landed on.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Click row relative to the current handler's Box (row - box.y). */
|
||||
localRow = 0
|
||||
/**
|
||||
* True if the clicked cell has no visible content (unwritten in the
|
||||
* screen buffer — both packed words are 0). Handlers can check this to
|
||||
* ignore clicks on blank space to the right of text, so accidental
|
||||
* clicks on empty terminal space don't toggle state.
|
||||
*/
|
||||
readonly cellIsBlank: boolean
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseMultipleKeypresses } from '../parse-keypress.js'
|
||||
|
||||
import { InputEvent } from './input-event.js'
|
||||
|
||||
function parseOne(sequence: string) {
|
||||
const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence)
|
||||
expect(keys).toHaveLength(1)
|
||||
|
||||
return keys[0]!
|
||||
}
|
||||
|
||||
describe('enhanced keyboard modifier parsing', () => {
|
||||
it('detects modified Enter sequences for multiline composer shortcuts', () => {
|
||||
const shiftEnter = new InputEvent(parseOne('\u001b[13;2u'))
|
||||
const ctrlEnter = new InputEvent(parseOne('\u001b[13;5u'))
|
||||
const modifyOtherShiftEnter = new InputEvent(parseOne('\u001b[27;2;13~'))
|
||||
|
||||
expect(shiftEnter.key.return).toBe(true)
|
||||
expect(shiftEnter.key.shift).toBe(true)
|
||||
expect(shiftEnter.input).toBe('')
|
||||
|
||||
expect(ctrlEnter.key.return).toBe(true)
|
||||
expect(ctrlEnter.key.ctrl).toBe(true)
|
||||
expect(ctrlEnter.input).toBe('')
|
||||
|
||||
expect(modifyOtherShiftEnter.key.return).toBe(true)
|
||||
expect(modifyOtherShiftEnter.key.shift).toBe(true)
|
||||
expect(modifyOtherShiftEnter.input).toBe('')
|
||||
})
|
||||
|
||||
it('preserves Cmd as super for kitty keyboard CSI-u sequences', () => {
|
||||
const parsed = parseOne('\u001b[99;9u')
|
||||
const event = new InputEvent(parsed)
|
||||
|
||||
expect(parsed.name).toBe('c')
|
||||
expect(event.key.meta).toBe(false)
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => {
|
||||
const parsed = parseOne('\u001b[99;13u')
|
||||
const event = new InputEvent(parsed)
|
||||
|
||||
expect(parsed.name).toBe('c')
|
||||
expect(event.key.ctrl).toBe(true)
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves Cmd on word-delete and word-navigation sequences', () => {
|
||||
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
|
||||
const left = new InputEvent(parseOne('\u001b[1;9D'))
|
||||
const right = new InputEvent(parseOne('\u001b[1;9C'))
|
||||
|
||||
expect(backspace.key.backspace).toBe(true)
|
||||
expect(backspace.key.super).toBe(true)
|
||||
|
||||
expect(left.key.leftArrow).toBe(true)
|
||||
expect(left.key.super).toBe(true)
|
||||
|
||||
expect(right.key.rightArrow).toBe(true)
|
||||
expect(right.key.super).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
ContinuousEventPriority,
|
||||
DefaultEventPriority,
|
||||
DiscreteEventPriority,
|
||||
NoEventPriority
|
||||
} from 'react-reconciler/constants.js'
|
||||
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
import { HANDLER_FOR_EVENT } from './event-handlers.js'
|
||||
import type { EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
// --
|
||||
|
||||
type DispatchListener = {
|
||||
node: EventTarget
|
||||
handler: (event: TerminalEvent) => void
|
||||
phase: 'capturing' | 'at_target' | 'bubbling'
|
||||
}
|
||||
|
||||
function getHandler(
|
||||
node: EventTarget,
|
||||
eventType: string,
|
||||
capture: boolean
|
||||
): ((event: TerminalEvent) => void) | undefined {
|
||||
const handlers = node._eventHandlers
|
||||
|
||||
if (!handlers) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mapping = HANDLER_FOR_EVENT[eventType]
|
||||
|
||||
if (!mapping) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const propName = capture ? mapping.capture : mapping.bubble
|
||||
|
||||
if (!propName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all listeners for an event in dispatch order.
|
||||
*
|
||||
* Uses react-dom's two-phase accumulation pattern:
|
||||
* - Walk from target to root
|
||||
* - Capture handlers are prepended (unshift) → root-first
|
||||
* - Bubble handlers are appended (push) → target-first
|
||||
*
|
||||
* Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
|
||||
*/
|
||||
function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] {
|
||||
const listeners: DispatchListener[] = []
|
||||
|
||||
let node: EventTarget | undefined = target
|
||||
|
||||
while (node) {
|
||||
const isTarget = node === target
|
||||
|
||||
const captureHandler = getHandler(node, event.type, true)
|
||||
const bubbleHandler = getHandler(node, event.type, false)
|
||||
|
||||
if (captureHandler) {
|
||||
listeners.unshift({
|
||||
node,
|
||||
handler: captureHandler,
|
||||
phase: isTarget ? 'at_target' : 'capturing'
|
||||
})
|
||||
}
|
||||
|
||||
if (bubbleHandler && (event.bubbles || isTarget)) {
|
||||
listeners.push({
|
||||
node,
|
||||
handler: bubbleHandler,
|
||||
phase: isTarget ? 'at_target' : 'bubbling'
|
||||
})
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
return listeners
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute collected listeners with propagation control.
|
||||
*
|
||||
* Before each handler, calls event._prepareForTarget(node) so event
|
||||
* subclasses can do per-node setup.
|
||||
*/
|
||||
function processDispatchQueue(listeners: DispatchListener[], event: TerminalEvent): void {
|
||||
let previousNode: EventTarget | undefined
|
||||
|
||||
for (const { node, handler, phase } of listeners) {
|
||||
if (event._isImmediatePropagationStopped()) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event._isPropagationStopped() && node !== previousNode) {
|
||||
break
|
||||
}
|
||||
|
||||
event._setEventPhase(phase)
|
||||
event._setCurrentTarget(node)
|
||||
event._prepareForTarget(node)
|
||||
|
||||
try {
|
||||
handler(event)
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
|
||||
previousNode = node
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
/**
|
||||
* Map terminal event types to React scheduling priorities.
|
||||
* Mirrors react-dom's getEventPriority() switch.
|
||||
*/
|
||||
function getEventPriority(eventType: string): number {
|
||||
switch (eventType) {
|
||||
case 'keydown':
|
||||
|
||||
case 'keyup':
|
||||
|
||||
case 'click':
|
||||
|
||||
case 'focus':
|
||||
|
||||
case 'blur':
|
||||
|
||||
case 'paste':
|
||||
return DiscreteEventPriority as number
|
||||
|
||||
case 'resize':
|
||||
|
||||
case 'scroll':
|
||||
|
||||
case 'mousemove':
|
||||
return ContinuousEventPriority as number
|
||||
|
||||
default:
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
type DiscreteUpdates = <A, B>(fn: (a: A, b: B) => boolean, a: A, b: B, c: undefined, d: undefined) => boolean
|
||||
|
||||
/**
|
||||
* Owns event dispatch state and the capture/bubble dispatch loop.
|
||||
*
|
||||
* The reconciler host config reads currentEvent and currentUpdatePriority
|
||||
* to implement resolveUpdatePriority, resolveEventType, and
|
||||
* resolveEventTimeStamp — mirroring how react-dom's host config reads
|
||||
* ReactDOMSharedInternals and window.event.
|
||||
*
|
||||
* discreteUpdates is injected after construction (by InkReconciler)
|
||||
* to break the import cycle.
|
||||
*/
|
||||
export class Dispatcher {
|
||||
currentEvent: TerminalEvent | null = null
|
||||
currentUpdatePriority: number = DefaultEventPriority as number
|
||||
discreteUpdates: DiscreteUpdates | null = null
|
||||
|
||||
/**
|
||||
* Infer event priority from the currently-dispatching event.
|
||||
* Called by the reconciler host config's resolveUpdatePriority
|
||||
* when no explicit priority has been set.
|
||||
*/
|
||||
resolveEventPriority(): number {
|
||||
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
|
||||
return this.currentUpdatePriority
|
||||
}
|
||||
|
||||
if (this.currentEvent) {
|
||||
return getEventPriority(this.currentEvent.type)
|
||||
}
|
||||
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event through capture and bubble phases.
|
||||
* Returns true if preventDefault() was NOT called.
|
||||
*/
|
||||
dispatch(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousEvent = this.currentEvent
|
||||
this.currentEvent = event
|
||||
|
||||
try {
|
||||
event._setTarget(target)
|
||||
|
||||
const listeners = collectListeners(target, event)
|
||||
processDispatchQueue(listeners, event)
|
||||
|
||||
event._setEventPhase('none')
|
||||
event._setCurrentTarget(null)
|
||||
|
||||
return !event.defaultPrevented
|
||||
} finally {
|
||||
this.currentEvent = previousEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with discrete (sync) priority.
|
||||
* For user-initiated events: keyboard, click, focus, paste.
|
||||
*/
|
||||
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
|
||||
if (!this.discreteUpdates) {
|
||||
return this.dispatch(target, event)
|
||||
}
|
||||
|
||||
return this.discreteUpdates((t, e) => this.dispatch(t, e), target, event, undefined, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with continuous priority.
|
||||
* For high-frequency events: resize, scroll, mouse move.
|
||||
*/
|
||||
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousPriority = this.currentUpdatePriority
|
||||
|
||||
try {
|
||||
this.currentUpdatePriority = ContinuousEventPriority as number
|
||||
|
||||
return this.dispatch(target, event)
|
||||
} finally {
|
||||
this.currentUpdatePriority = previousPriority
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { EventEmitter as NodeEventEmitter } from 'events'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
|
||||
// class, and so `emit` respects `stopImmediatePropagation()`.
|
||||
export class EventEmitter extends NodeEventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
// Disable the default maxListeners warning. In React, many components
|
||||
// can legitimately listen to the same event (e.g., useInput hooks).
|
||||
// The default limit of 10 causes spurious warnings.
|
||||
this.setMaxListeners(0)
|
||||
}
|
||||
|
||||
override emit(type: string | symbol, ...args: unknown[]): boolean {
|
||||
// Delegate to node for `error`, since it's not treated like a normal event
|
||||
if (type === 'error') {
|
||||
return super.emit(type, ...args)
|
||||
}
|
||||
|
||||
const listeners = this.rawListeners(type)
|
||||
|
||||
if (listeners.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ccEvent = args[0] instanceof Event ? args[0] : null
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener.apply(this, args)
|
||||
|
||||
if (ccEvent?.didStopImmediatePropagation()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ClickEvent } from './click-event.js'
|
||||
import type { FocusEvent } from './focus-event.js'
|
||||
import type { KeyboardEvent } from './keyboard-event.js'
|
||||
import type { MouseEvent } from './mouse-event.js'
|
||||
import type { PasteEvent } from './paste-event.js'
|
||||
import type { ResizeEvent } from './resize-event.js'
|
||||
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void
|
||||
type FocusEventHandler = (event: FocusEvent) => void
|
||||
type PasteEventHandler = (event: PasteEvent) => void
|
||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||
type ClickEventHandler = (event: ClickEvent) => void
|
||||
type MouseEventHandler = (event: MouseEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
* Props for event handlers on Box and other host components.
|
||||
*
|
||||
* Follows the React/DOM naming convention:
|
||||
* - onEventName: handler for bubble phase
|
||||
* - onEventNameCapture: handler for capture phase
|
||||
*/
|
||||
export type EventHandlerProps = {
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
onKeyDownCapture?: KeyboardEventHandler
|
||||
|
||||
onFocus?: FocusEventHandler
|
||||
onFocusCapture?: FocusEventHandler
|
||||
onBlur?: FocusEventHandler
|
||||
onBlurCapture?: FocusEventHandler
|
||||
|
||||
onPaste?: PasteEventHandler
|
||||
onPasteCapture?: PasteEventHandler
|
||||
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseDown?: MouseEventHandler
|
||||
onMouseUp?: MouseEventHandler
|
||||
onMouseDrag?: MouseEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: event type string → handler prop names.
|
||||
* Used by the dispatcher for O(1) handler lookup per node.
|
||||
*/
|
||||
export const HANDLER_FOR_EVENT: Record<
|
||||
string,
|
||||
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
|
||||
> = {
|
||||
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
|
||||
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
|
||||
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
click: { bubble: 'onClick' },
|
||||
mousedown: { bubble: 'onMouseDown' },
|
||||
mouseup: { bubble: 'onMouseUp' },
|
||||
mousedrag: { bubble: 'onMouseDrag' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all event handler prop names, for the reconciler to detect
|
||||
* event props and store them in _eventHandlers instead of attributes.
|
||||
*/
|
||||
export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||
'onKeyDown',
|
||||
'onKeyDownCapture',
|
||||
'onFocus',
|
||||
'onFocusCapture',
|
||||
'onBlur',
|
||||
'onBlurCapture',
|
||||
'onPaste',
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseDown',
|
||||
'onMouseUp',
|
||||
'onMouseDrag',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave'
|
||||
])
|
||||
@@ -0,0 +1,11 @@
|
||||
export class Event {
|
||||
private _didStopImmediatePropagation = false
|
||||
|
||||
didStopImmediatePropagation(): boolean {
|
||||
return this._didStopImmediatePropagation
|
||||
}
|
||||
|
||||
stopImmediatePropagation(): void {
|
||||
this._didStopImmediatePropagation = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Focus event for component focus changes.
|
||||
*
|
||||
* Dispatched when focus moves between elements. 'focus' fires on the
|
||||
* newly focused element, 'blur' fires on the previously focused one.
|
||||
* Both bubble, matching react-dom's use of focusin/focusout semantics
|
||||
* so parent components can observe descendant focus changes.
|
||||
*/
|
||||
export class FocusEvent extends TerminalEvent {
|
||||
readonly relatedTarget: EventTarget | null
|
||||
|
||||
constructor(type: 'focus' | 'blur', relatedTarget: EventTarget | null = null) {
|
||||
super(type, { bubbles: true, cancelable: false })
|
||||
this.relatedTarget = relatedTarget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
const inputForSpecialSequence = (name: string): string =>
|
||||
name === 'space' ? ' ' : name === 'return' || name === 'escape' ? '' : name
|
||||
|
||||
export type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean
|
||||
wheelDown: boolean
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean
|
||||
super: boolean
|
||||
}
|
||||
|
||||
function parseKey(keypress: ParsedKey): [Key, string] {
|
||||
const key: Key = {
|
||||
upArrow: keypress.name === 'up',
|
||||
downArrow: keypress.name === 'down',
|
||||
leftArrow: keypress.name === 'left',
|
||||
rightArrow: keypress.name === 'right',
|
||||
pageDown: keypress.name === 'pagedown',
|
||||
pageUp: keypress.name === 'pageup',
|
||||
wheelUp: keypress.name === 'wheelup',
|
||||
wheelDown: keypress.name === 'wheeldown',
|
||||
home: keypress.name === 'home',
|
||||
end: keypress.name === 'end',
|
||||
return: keypress.name === 'return',
|
||||
escape: keypress.name === 'escape',
|
||||
fn: keypress.fn,
|
||||
ctrl: keypress.ctrl,
|
||||
shift: keypress.shift,
|
||||
tab: keypress.name === 'tab',
|
||||
backspace: keypress.name === 'backspace',
|
||||
delete: keypress.name === 'delete',
|
||||
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
||||
// but with option = true, so we need to take this into account here
|
||||
// to avoid breaking changes in Ink.
|
||||
// TODO(vadimdemedes): consider removing this in the next major version.
|
||||
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
||||
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
||||
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
||||
// bindings like cmd+c can be expressed separately from opt+c.
|
||||
super: keypress.super
|
||||
}
|
||||
|
||||
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
||||
|
||||
// Handle undefined input case
|
||||
if (input === undefined) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// When ctrl is set, keypress.name for space is the literal word "space".
|
||||
// Convert to actual space character for consistency with the CSI u branch
|
||||
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
||||
// word "space" into text input.
|
||||
if (keypress.ctrl && input === 'space') {
|
||||
input = ' '
|
||||
}
|
||||
|
||||
// Suppress unrecognized escape sequences that were parsed as function keys
|
||||
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
||||
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
||||
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
||||
// "[25~") leaks into the input as literal text.
|
||||
if (keypress.code && !keypress.name) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// (SGR mouse-report fragments used to be scrubbed here. They no longer reach
|
||||
// this layer: the tokenizer keeps an incomplete CSI buffered across a
|
||||
// watchdog flush and reassembles it on the next feed instead of force-
|
||||
// emitting the partial as input. See termio/tokenize.ts.)
|
||||
|
||||
// Strip meta if it's still remaining after `parseKeypress`
|
||||
// TODO(vadimdemedes): remove this in the next major version.
|
||||
if (input.startsWith('\u001B')) {
|
||||
input = input.slice(1)
|
||||
}
|
||||
|
||||
// Track whether we've already processed this as a special sequence
|
||||
// that converted input to the key name (CSI u or application keypad mode).
|
||||
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
||||
let processedAsSpecialSequence = false
|
||||
|
||||
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
||||
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
||||
// Use the parsed key name instead for input handling. Require a digit
|
||||
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
||||
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
||||
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
||||
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
||||
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
||||
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
||||
input = ''
|
||||
} else {
|
||||
input = inputForSpecialSequence(keypress.name)
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
||||
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
||||
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
||||
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
||||
if (input.startsWith('[27;') && input.endsWith('~')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
||||
// the CSI u handler above. Practically untriggerable today (xterm
|
||||
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
||||
// guards against future terminal behavior.
|
||||
input = ''
|
||||
} else {
|
||||
input = inputForSpecialSequence(keypress.name)
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle application keypad mode sequences: after stripping ESC,
|
||||
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
||||
// Use the parsed key name (the digit character) for input handling.
|
||||
if (input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1) {
|
||||
input = keypress.name
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
||||
// Skip this for CSI u and application keypad mode sequences since
|
||||
// those were already converted to their proper input characters.
|
||||
if (!processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name)) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Set shift=true for uppercase letters (A-Z)
|
||||
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
||||
if (input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z') {
|
||||
key.shift = true
|
||||
}
|
||||
|
||||
return [key, input]
|
||||
}
|
||||
|
||||
export class InputEvent extends Event {
|
||||
readonly keypress: ParsedKey
|
||||
readonly key: Key
|
||||
readonly input: string
|
||||
|
||||
constructor(keypress: ParsedKey) {
|
||||
super()
|
||||
const [key, input] = parseKey(keypress)
|
||||
|
||||
this.keypress = keypress
|
||||
this.key = key
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Keyboard event dispatched through the DOM tree via capture/bubble.
|
||||
*
|
||||
* Follows browser KeyboardEvent semantics: `key` is the literal character
|
||||
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
|
||||
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
|
||||
* printable-char check is `e.key.length === 1`.
|
||||
*/
|
||||
export class KeyboardEvent extends TerminalEvent {
|
||||
readonly key: string
|
||||
readonly ctrl: boolean
|
||||
readonly shift: boolean
|
||||
readonly meta: boolean
|
||||
readonly superKey: boolean
|
||||
readonly fn: boolean
|
||||
|
||||
constructor(parsedKey: ParsedKey) {
|
||||
super('keydown', { bubbles: true, cancelable: true })
|
||||
|
||||
this.key = keyFromParsed(parsedKey)
|
||||
this.ctrl = parsedKey.ctrl
|
||||
this.shift = parsedKey.shift
|
||||
this.meta = parsedKey.meta || parsedKey.option
|
||||
this.superKey = parsedKey.super
|
||||
this.fn = parsedKey.fn
|
||||
}
|
||||
}
|
||||
|
||||
function keyFromParsed(parsed: ParsedKey): string {
|
||||
const seq = parsed.sequence ?? ''
|
||||
const name = parsed.name ?? ''
|
||||
|
||||
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
|
||||
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
|
||||
if (parsed.ctrl) {
|
||||
return name
|
||||
}
|
||||
|
||||
// Single printable char (space through ~, plus anything above ASCII):
|
||||
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
|
||||
if (seq.length === 1) {
|
||||
const code = seq.charCodeAt(0)
|
||||
|
||||
if (code >= 0x20 && code !== 0x7f) {
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
|
||||
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
|
||||
// the parsed name. Browsers report e.key === 'ArrowDown'.
|
||||
return name || seq
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
export class MouseEvent extends Event {
|
||||
readonly col: number
|
||||
readonly row: number
|
||||
localCol = 0
|
||||
localRow = 0
|
||||
readonly cellIsBlank: boolean
|
||||
readonly button: number
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean, button: number) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
this.button = button
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class PasteEvent extends TerminalEvent {
|
||||
readonly text: string
|
||||
|
||||
constructor(text: string) {
|
||||
super('paste', { bubbles: true, cancelable: true })
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class ResizeEvent extends TerminalEvent {
|
||||
readonly columns: number
|
||||
readonly rows: number
|
||||
|
||||
constructor(columns: number, rows: number) {
|
||||
super('resize', { bubbles: true, cancelable: true })
|
||||
this.columns = columns
|
||||
this.rows = rows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
|
||||
|
||||
type TerminalEventInit = {
|
||||
bubbles?: boolean
|
||||
cancelable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all terminal events with DOM-style propagation.
|
||||
*
|
||||
* Extends Event so existing event types (ClickEvent, InputEvent,
|
||||
* TerminalFocusEvent) share a common ancestor and can migrate later.
|
||||
*
|
||||
* Mirrors the browser's Event API: target, currentTarget, eventPhase,
|
||||
* stopPropagation(), preventDefault(), timeStamp.
|
||||
*/
|
||||
export class TerminalEvent extends Event {
|
||||
readonly type: string
|
||||
readonly timeStamp: number
|
||||
readonly bubbles: boolean
|
||||
readonly cancelable: boolean
|
||||
|
||||
private _target: EventTarget | null = null
|
||||
private _currentTarget: EventTarget | null = null
|
||||
private _eventPhase: EventPhase = 'none'
|
||||
private _propagationStopped = false
|
||||
private _defaultPrevented = false
|
||||
|
||||
constructor(type: string, init?: TerminalEventInit) {
|
||||
super()
|
||||
this.type = type
|
||||
this.timeStamp = performance.now()
|
||||
this.bubbles = init?.bubbles ?? true
|
||||
this.cancelable = init?.cancelable ?? true
|
||||
}
|
||||
|
||||
get target(): EventTarget | null {
|
||||
return this._target
|
||||
}
|
||||
|
||||
get currentTarget(): EventTarget | null {
|
||||
return this._currentTarget
|
||||
}
|
||||
|
||||
get eventPhase(): EventPhase {
|
||||
return this._eventPhase
|
||||
}
|
||||
|
||||
get defaultPrevented(): boolean {
|
||||
return this._defaultPrevented
|
||||
}
|
||||
|
||||
stopPropagation(): void {
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
override stopImmediatePropagation(): void {
|
||||
super.stopImmediatePropagation()
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
preventDefault(): void {
|
||||
if (this.cancelable) {
|
||||
this._defaultPrevented = true
|
||||
}
|
||||
}
|
||||
|
||||
// -- Internal setters used by the Dispatcher
|
||||
|
||||
/** @internal */
|
||||
_setTarget(target: EventTarget): void {
|
||||
this._target = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setCurrentTarget(target: EventTarget | null): void {
|
||||
this._currentTarget = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setEventPhase(phase: EventPhase): void {
|
||||
this._eventPhase = phase
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isPropagationStopped(): boolean {
|
||||
return this._propagationStopped
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isImmediatePropagationStopped(): boolean {
|
||||
return this.didStopImmediatePropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses to do per-node setup before each handler fires.
|
||||
* Default is a no-op.
|
||||
*/
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
|
||||
|
||||
/**
|
||||
* Event fired when the terminal window gains or loses focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends:
|
||||
* - CSI I (\x1b[I) when the terminal gains focus
|
||||
* - CSI O (\x1b[O) when the terminal loses focus
|
||||
*/
|
||||
export class TerminalFocusEvent extends Event {
|
||||
readonly type: TerminalFocusEventType
|
||||
|
||||
constructor(type: TerminalFocusEventType) {
|
||||
super()
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { FocusEvent } from './events/focus-event.js'
|
||||
|
||||
const MAX_FOCUS_STACK = 32
|
||||
|
||||
/**
|
||||
* DOM-like focus manager for the Ink terminal UI.
|
||||
*
|
||||
* Pure state — tracks activeElement and a focus stack. Has no reference
|
||||
* to the tree; callers pass the root when tree walks are needed.
|
||||
*
|
||||
* Stored on the root DOMElement so any node can reach it by walking
|
||||
* parentNode (like browser's `node.ownerDocument`).
|
||||
*/
|
||||
export class FocusManager {
|
||||
activeElement: DOMElement | null = null
|
||||
private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean
|
||||
private enabled = true
|
||||
private focusStack: DOMElement[] = []
|
||||
|
||||
constructor(dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean) {
|
||||
this.dispatchFocusEvent = dispatchFocusEvent
|
||||
}
|
||||
|
||||
focus(node: DOMElement): void {
|
||||
if (node === this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.activeElement
|
||||
|
||||
if (previous) {
|
||||
// Deduplicate before pushing to prevent unbounded growth from Tab cycling
|
||||
const idx = this.focusStack.indexOf(previous)
|
||||
|
||||
if (idx !== -1) {
|
||||
this.focusStack.splice(idx, 1)
|
||||
}
|
||||
|
||||
this.focusStack.push(previous)
|
||||
|
||||
if (this.focusStack.length > MAX_FOCUS_STACK) {
|
||||
this.focusStack.shift()
|
||||
}
|
||||
|
||||
this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
|
||||
}
|
||||
|
||||
this.activeElement = node
|
||||
this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
if (!this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.activeElement
|
||||
this.activeElement = null
|
||||
this.dispatchFocusEvent(previous, new FocusEvent('blur', null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the reconciler when a node is removed from the tree.
|
||||
* Handles both the exact node and any focused descendant within
|
||||
* the removed subtree. Dispatches blur and restores focus from stack.
|
||||
*/
|
||||
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
|
||||
// Remove the node and any descendants from the stack
|
||||
this.focusStack = this.focusStack.filter(n => n !== node && isInTree(n, root))
|
||||
|
||||
// Check if activeElement is the removed node OR a descendant
|
||||
if (!this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.activeElement !== node && isInTree(this.activeElement, root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const removed = this.activeElement
|
||||
this.activeElement = null
|
||||
this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
|
||||
|
||||
// Restore focus to the most recent still-mounted element
|
||||
while (this.focusStack.length > 0) {
|
||||
const candidate = this.focusStack.pop()!
|
||||
|
||||
if (isInTree(candidate, root)) {
|
||||
this.activeElement = candidate
|
||||
this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAutoFocus(node: DOMElement): void {
|
||||
this.focus(node)
|
||||
}
|
||||
|
||||
handleClickFocus(node: DOMElement): void {
|
||||
const tabIndex = node.attributes['tabIndex']
|
||||
|
||||
if (typeof tabIndex !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
this.focus(node)
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
focusNext(root: DOMElement): void {
|
||||
this.moveFocus(1, root)
|
||||
}
|
||||
|
||||
focusPrevious(root: DOMElement): void {
|
||||
this.moveFocus(-1, root)
|
||||
}
|
||||
|
||||
private moveFocus(direction: 1 | -1, root: DOMElement): void {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const tabbable = collectTabbable(root)
|
||||
|
||||
if (tabbable.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1
|
||||
|
||||
const nextIndex =
|
||||
currentIndex === -1
|
||||
? direction === 1
|
||||
? 0
|
||||
: tabbable.length - 1
|
||||
: (currentIndex + direction + tabbable.length) % tabbable.length
|
||||
|
||||
const next = tabbable[nextIndex]
|
||||
|
||||
if (next) {
|
||||
this.focus(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectTabbable(root: DOMElement): DOMElement[] {
|
||||
const result: DOMElement[] = []
|
||||
walkTree(root, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function walkTree(node: DOMElement, result: DOMElement[]): void {
|
||||
const tabIndex = node.attributes['tabIndex']
|
||||
|
||||
if (typeof tabIndex === 'number' && tabIndex >= 0) {
|
||||
result.push(node)
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName !== '#text') {
|
||||
walkTree(child, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isInTree(node: DOMElement, root: DOMElement): boolean {
|
||||
let current: DOMElement | undefined = node
|
||||
|
||||
while (current) {
|
||||
if (current === root) {
|
||||
return true
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up to root and return it. The root is the node that holds
|
||||
* the FocusManager — like browser's `node.getRootNode()`.
|
||||
*/
|
||||
export function getRootNode(node: DOMElement): DOMElement {
|
||||
let current: DOMElement | undefined = node
|
||||
|
||||
while (current) {
|
||||
if (current.focusManager) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
|
||||
throw new Error('Node is not in a tree with a FocusManager')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up to root and return its FocusManager.
|
||||
* Like browser's `node.ownerDocument` — focus belongs to the root.
|
||||
*/
|
||||
export function getFocusManager(node: DOMElement): FocusManager {
|
||||
return getRootNode(node).focusManager!
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Cursor } from './cursor.js'
|
||||
import type { Size } from './layout/geometry.js'
|
||||
import type { ScrollHint } from './render-node-to-output.js'
|
||||
import { type CharPool, createScreen, type HyperlinkPool, type Screen, type StylePool } from './screen.js'
|
||||
|
||||
export type Frame = {
|
||||
readonly screen: Screen
|
||||
readonly viewport: Size
|
||||
readonly cursor: Cursor
|
||||
/** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */
|
||||
readonly scrollHint?: ScrollHint | null
|
||||
/** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
|
||||
readonly scrollDrainPending?: boolean
|
||||
/** Absolute overlay moved/resized — schedule corrective frame without prevScreen. */
|
||||
readonly absoluteOverlayMoved?: boolean
|
||||
}
|
||||
|
||||
export function emptyFrame(
|
||||
rows: number,
|
||||
columns: number,
|
||||
stylePool: StylePool,
|
||||
charPool: CharPool,
|
||||
hyperlinkPool: HyperlinkPool
|
||||
): Frame {
|
||||
return {
|
||||
screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
|
||||
viewport: { width: columns, height: rows },
|
||||
cursor: { x: 0, y: 0, visible: true }
|
||||
}
|
||||
}
|
||||
|
||||
export type FlickerReason = 'resize' | 'offscreen' | 'clear'
|
||||
|
||||
export type FrameEvent = {
|
||||
durationMs: number
|
||||
/** Phase breakdown in ms + patch count. Populated when the ink instance
|
||||
* has frame-timing instrumentation enabled (via onFrame wiring). */
|
||||
phases?: {
|
||||
/** createRenderer output: DOM → yoga layout → screen buffer */
|
||||
renderer: number
|
||||
/** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */
|
||||
diff: number
|
||||
/** optimize(): patch merge/dedupe */
|
||||
optimize: number
|
||||
/** writeDiffToTerminal(): serialize patches → ANSI → stdout */
|
||||
write: number
|
||||
/** Pre-optimize patch count (proxy for how much changed this frame) */
|
||||
patches: number
|
||||
/** Post-optimize patch count. */
|
||||
optimizedPatches: number
|
||||
/** Bytes written to stdout this frame. */
|
||||
writeBytes: number
|
||||
/** Whether stdout.write returned false. */
|
||||
backpressure: boolean
|
||||
/** Previous stdout.write callback latency; 0 if drained before next frame. */
|
||||
prevFrameDrainMs: number
|
||||
/** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */
|
||||
yoga: number
|
||||
/** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */
|
||||
commit: number
|
||||
/** layoutNode() calls this frame (recursive, includes cache-hit returns) */
|
||||
yogaVisited: number
|
||||
/** measureFunc (text wrap/width) calls — the expensive part */
|
||||
yogaMeasured: number
|
||||
/** early returns via _hasL single-slot cache */
|
||||
yogaCacheHits: number
|
||||
/** total yoga Node instances alive (create - free). Growth = leak. */
|
||||
yogaLive: number
|
||||
}
|
||||
flickers: Array<{
|
||||
desiredHeight: number
|
||||
availableHeight: number
|
||||
reason: FlickerReason
|
||||
}>
|
||||
}
|
||||
|
||||
export type Patch =
|
||||
| { type: 'stdout'; content: string }
|
||||
| { type: 'clear'; count: number }
|
||||
| {
|
||||
type: 'clearTerminal'
|
||||
reason: FlickerReason
|
||||
// Populated by log-update when a scrollback diff triggers the reset.
|
||||
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||
}
|
||||
| { type: 'cursorHide' }
|
||||
| { type: 'cursorShow' }
|
||||
| { type: 'cursorMove'; x: number; y: number }
|
||||
| { type: 'cursorTo'; col: number }
|
||||
| { type: 'carriageReturn' }
|
||||
| { type: 'hyperlink'; uri: string }
|
||||
// Pre-serialized style transition string from StylePool.transition() —
|
||||
// cached by (fromId, toId), zero allocations after warmup.
|
||||
| { type: 'styleStr'; str: string }
|
||||
|
||||
export type Diff = Patch[]
|
||||
|
||||
/**
|
||||
* Determines whether the screen should be cleared based on the current and previous frame.
|
||||
* Returns the reason for clearing, or undefined if no clear is needed.
|
||||
*
|
||||
* Screen clearing is triggered when:
|
||||
* 1. Terminal has been resized (viewport dimensions changed) → 'resize'
|
||||
* 2. Current frame screen height exceeds available terminal rows → 'offscreen'
|
||||
* 3. Previous frame screen height exceeded available terminal rows → 'offscreen'
|
||||
*/
|
||||
export function shouldClearScreen(prevFrame: Frame, frame: Frame): FlickerReason | undefined {
|
||||
const didResize =
|
||||
frame.viewport.height !== prevFrame.viewport.height || frame.viewport.width !== prevFrame.viewport.width
|
||||
|
||||
if (didResize) {
|
||||
return 'resize'
|
||||
}
|
||||
|
||||
const currentFrameOverflows = frame.screen.height >= frame.viewport.height
|
||||
|
||||
const previousFrameOverflowed = prevFrame.screen.height >= prevFrame.viewport.height
|
||||
|
||||
if (currentFrameOverflows || previousFrameOverflowed) {
|
||||
return 'offscreen'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LayoutEdge, type LayoutNode } from './layout/node.js'
|
||||
|
||||
/**
|
||||
* Returns the yoga node's content width (computed width minus padding and
|
||||
* border).
|
||||
*
|
||||
* Warning: can return a value WIDER than the parent container. In a
|
||||
* column-direction flex parent, width is the cross axis — align-items:
|
||||
* stretch never shrinks children below their intrinsic size, so the text
|
||||
* node overflows (standard CSS behavior). Yoga measures leaf nodes in two
|
||||
* passes: the AtMost pass determines width, the Exactly pass determines
|
||||
* height. getComputedWidth() reflects the wider AtMost result while
|
||||
* getComputedHeight() reflects the narrower Exactly result. Callers that
|
||||
* use this for wrapping should clamp to actual available screen space so
|
||||
* the rendered line count stays consistent with the layout height.
|
||||
*/
|
||||
const getMaxWidth = (yogaNode: LayoutNode): number => {
|
||||
return (
|
||||
yogaNode.getComputedWidth() -
|
||||
yogaNode.getComputedPadding(LayoutEdge.Left) -
|
||||
yogaNode.getComputedPadding(LayoutEdge.Right) -
|
||||
yogaNode.getComputedBorder(LayoutEdge.Left) -
|
||||
yogaNode.getComputedBorder(LayoutEdge.Right)
|
||||
)
|
||||
}
|
||||
|
||||
export default getMaxWidth
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { appendChildNode, createNode } from './dom.js'
|
||||
import { dispatchClick, hitTest } from './hit-test.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
const rect = (node: ReturnType<typeof createNode>, x: number, y: number, width: number, height: number) => {
|
||||
nodeCache.set(node, { x, y, width, height })
|
||||
}
|
||||
|
||||
describe('hit-test', () => {
|
||||
it('hits absolutely positioned children that paint outside their parent rect', () => {
|
||||
const root = createNode('ink-root')
|
||||
const parent = createNode('ink-box')
|
||||
const wrapper = createNode('ink-box')
|
||||
const overlay = createNode('ink-box')
|
||||
const row = createNode('ink-box')
|
||||
const seen: string[] = []
|
||||
|
||||
appendChildNode(root, parent)
|
||||
appendChildNode(parent, wrapper)
|
||||
appendChildNode(wrapper, overlay)
|
||||
appendChildNode(overlay, row)
|
||||
|
||||
overlay.style.position = 'absolute'
|
||||
row._eventHandlers = { onClick: () => seen.push('row') }
|
||||
|
||||
rect(root, 0, 0, 120, 40)
|
||||
rect(parent, 0, 30, 120, 1)
|
||||
rect(wrapper, 0, 30, 120, 1)
|
||||
rect(overlay, 0, 20, 96, 6)
|
||||
rect(row, 1, 22, 80, 1)
|
||||
|
||||
expect(hitTest(root, 2, 22)).toBe(row)
|
||||
expect(dispatchClick(root, 2, 22)).toBe(true)
|
||||
expect(seen).toEqual(['row'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { ClickEvent } from './events/click-event.js'
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import { MouseEvent } from './events/mouse-event.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
function hitTestAbsoluteDescendants(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = node.childNodes[i]!
|
||||
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!nodeCache.get(child)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.style.position === 'absolute') {
|
||||
const hit = hitTest(child, col, row)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
const nestedHit = hitTestAbsoluteDescendants(child, col, row)
|
||||
|
||||
if (nestedHit) {
|
||||
return nestedHit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the deepest DOM element whose rendered rect contains (col, row).
|
||||
*
|
||||
* Uses the nodeCache populated by renderNodeToOutput — rects are in screen
|
||||
* coordinates with all offsets (including scrollTop translation) already
|
||||
* applied. Children are traversed in reverse so later siblings (painted on
|
||||
* top) win. Nodes not in nodeCache (not rendered this frame, or lacking a
|
||||
* yogaNode) are skipped along with their subtrees.
|
||||
*
|
||||
* Returns the hit node even if it has no onClick — dispatchClick walks up
|
||||
* via parentNode to find handlers.
|
||||
*/
|
||||
export function hitTest(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inside = col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
|
||||
|
||||
if (!inside) {
|
||||
return hitTestAbsoluteDescendants(node, col, row)
|
||||
}
|
||||
|
||||
// Later siblings paint on top; reversed traversal returns topmost hit.
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = node.childNodes[i]!
|
||||
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = hitTest(child, col, row)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit-test the root at (col, row) and bubble a ClickEvent from the deepest
|
||||
* containing node up through parentNode. Only nodes with an onClick handler
|
||||
* fire. Stops when a handler calls stopImmediatePropagation(). Returns
|
||||
* true if at least one onClick handler fired.
|
||||
*/
|
||||
export function dispatchClick(root: DOMElement, col: number, row: number, cellIsBlank = false): boolean {
|
||||
let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Click-to-focus: find the closest focusable ancestor and focus it.
|
||||
// root is always ink-root, which owns the FocusManager.
|
||||
if (root.focusManager) {
|
||||
let focusTarget: DOMElement | undefined = target
|
||||
|
||||
while (focusTarget) {
|
||||
if (typeof focusTarget.attributes['tabIndex'] === 'number') {
|
||||
root.focusManager.handleClickFocus(focusTarget)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
focusTarget = focusTarget.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
const event = new ClickEvent(col, row, cellIsBlank)
|
||||
let handled = false
|
||||
|
||||
while (target) {
|
||||
const handler = target._eventHandlers?.onClick as ((event: ClickEvent) => void) | undefined
|
||||
|
||||
if (handler) {
|
||||
handled = true
|
||||
const rect = nodeCache.get(target)
|
||||
|
||||
if (rect) {
|
||||
event.localCol = col - rect.x
|
||||
event.localRow = row - rect.y
|
||||
}
|
||||
|
||||
handler(event)
|
||||
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
target = target.parentNode
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
type MouseHandler = 'onMouseDown' | 'onMouseUp' | 'onMouseDrag'
|
||||
|
||||
export function dispatchMouse(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number,
|
||||
handlerName: MouseHandler,
|
||||
button: number,
|
||||
cellIsBlank = false,
|
||||
target?: DOMElement
|
||||
): DOMElement | undefined {
|
||||
let node: DOMElement | undefined = target ?? hitTest(root, col, row) ?? undefined
|
||||
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const event = new MouseEvent(col, row, cellIsBlank, button)
|
||||
let handled: DOMElement | undefined
|
||||
|
||||
while (node) {
|
||||
const handler = node._eventHandlers?.[handlerName] as ((event: MouseEvent) => void) | undefined
|
||||
|
||||
if (handler) {
|
||||
handled ??= node
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (rect) {
|
||||
event.localCol = col - rect.x
|
||||
event.localRow = row - rect.y
|
||||
}
|
||||
|
||||
handler(event)
|
||||
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return handled
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
|
||||
* mouseenter/mouseleave: does NOT bubble — moving between children does
|
||||
* not re-fire on the parent. Walks up from the hit node collecting every
|
||||
* ancestor with a hover handler; diffs against the previous hovered set;
|
||||
* fires leave on the nodes exited, enter on the nodes entered.
|
||||
*
|
||||
* Mutates `hovered` in place so the caller (App instance) can hold it
|
||||
* across calls. Clears the set when the hit is null (cursor moved into a
|
||||
* non-rendered gap or off the root rect).
|
||||
*/
|
||||
export function dispatchHover(root: DOMElement, col: number, row: number, hovered: Set<DOMElement>): void {
|
||||
const next = new Set<DOMElement>()
|
||||
let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||
|
||||
while (node) {
|
||||
const h = node._eventHandlers as EventHandlerProps | undefined
|
||||
|
||||
if (h?.onMouseEnter || h?.onMouseLeave) {
|
||||
next.add(node)
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
for (const old of hovered) {
|
||||
if (!next.has(old)) {
|
||||
hovered.delete(old)
|
||||
|
||||
// Skip handlers on detached nodes (removed between mouse events)
|
||||
if (old.parentNode) {
|
||||
;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of next) {
|
||||
if (!hovered.has(n)) {
|
||||
hovered.add(n)
|
||||
;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
import { useTerminalViewport } from './use-terminal-viewport.js'
|
||||
|
||||
/**
|
||||
* Hook for synchronized animations that pause when offscreen.
|
||||
*
|
||||
* Returns a ref to attach to the animated element and the current animation time.
|
||||
* All instances share the same clock, so animations stay in sync.
|
||||
* The clock only runs when at least one keepAlive subscriber exists.
|
||||
*
|
||||
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
|
||||
* Time freezes at the last value and resumes from the current clock time
|
||||
* when a number is passed again.
|
||||
*
|
||||
* @param intervalMs - How often to update, or null to pause
|
||||
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
|
||||
*
|
||||
* @example
|
||||
* function Spinner() {
|
||||
* const [ref, time] = useAnimationFrame(120)
|
||||
* const frame = Math.floor(time / 120) % FRAMES.length
|
||||
* return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||
* }
|
||||
*
|
||||
* The clock automatically slows when the terminal is blurred,
|
||||
* so consumers don't need to handle focus state.
|
||||
*/
|
||||
export function useAnimationFrame(
|
||||
intervalMs: number | null = 16
|
||||
): [ref: (element: DOMElement | null) => void, time: number] {
|
||||
const clock = useContext(ClockContext)
|
||||
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
const active = isVisible && intervalMs !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || !active) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs!) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive: true — visible animations drive the clock
|
||||
return clock.subscribe(onChange, true)
|
||||
}, [clock, intervalMs, active])
|
||||
|
||||
return [viewportRef, time]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import AppContext from '../components/AppContext.js'
|
||||
|
||||
/**
|
||||
* `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
const useApp = () => useContext(AppContext)
|
||||
export default useApp
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import CursorAdvanceContext, { type CursorAdvanceNotifier } from '../components/CursorAdvanceContext.js'
|
||||
|
||||
/**
|
||||
* Returns a function that notifies Ink the physical terminal cursor was
|
||||
* advanced out-of-band (e.g. by a direct stdout.write from the
|
||||
* TextInput fast-echo bypass).
|
||||
*
|
||||
* Calling the returned function updates two pieces of Ink state:
|
||||
*
|
||||
* - `displayCursor` — the cached parked-cursor position log-update
|
||||
* uses as the relative-move basis for the next frame. Skipped on
|
||||
* alt-screen, where every frame's CSI H resets the cursor anyway.
|
||||
*
|
||||
* - The active `cursorDeclaration` — the target the cursor parks at
|
||||
* after every frame. Bumped on BOTH main- and alt-screen, because
|
||||
* onRender's alt-screen park branch emits an absolute CUP from
|
||||
* this value and a stale declaration there is still visibly wrong.
|
||||
* The next React commit that publishes a fresh declaration
|
||||
* supersedes the bump.
|
||||
*
|
||||
* The caller is responsible for the stdout write itself; this hook
|
||||
* only reports the resulting cursor delta. Pass `dx` and optional
|
||||
* `dy` in terminal cells (positive = moved right/down, negative =
|
||||
* moved left/up).
|
||||
*
|
||||
* If the host isn't an Ink render root (test stubs, non-Ink renderer)
|
||||
* the returned callback is a safe no-op.
|
||||
*/
|
||||
export function useCursorAdvance(): CursorAdvanceNotifier {
|
||||
return useContext(CursorAdvanceContext)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
/**
|
||||
* Declares where the terminal cursor should be parked after each frame.
|
||||
*
|
||||
* Terminal emulators render IME preedit text at the physical cursor
|
||||
* position, and screen readers / screen magnifiers track the native
|
||||
* cursor — so parking it at the text input's caret makes CJK input
|
||||
* appear inline and lets accessibility tools follow the input.
|
||||
*
|
||||
* Returns a ref callback to attach to the Box that contains the input.
|
||||
* The declared (line, column) is interpreted relative to that Box's
|
||||
* nodeCache rect (populated by renderNodeToOutput).
|
||||
*
|
||||
* Timing: Both ref attach and useLayoutEffect fire in React's layout
|
||||
* phase — after resetAfterCommit calls scheduleRender. scheduleRender
|
||||
* defers onRender via queueMicrotask, so onRender runs AFTER layout
|
||||
* effects commit and reads the fresh declaration on the first frame
|
||||
* (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
|
||||
* no microtask), so tests compensate by calling ink.onRender()
|
||||
* explicitly after render.
|
||||
*/
|
||||
export function useDeclaredCursor({
|
||||
line,
|
||||
column,
|
||||
active
|
||||
}: {
|
||||
line: number
|
||||
column: number
|
||||
active: boolean
|
||||
}): (element: DOMElement | null) => void {
|
||||
const setCursorDeclaration = useContext(CursorDeclarationContext)
|
||||
const nodeRef = useRef<DOMElement | null>(null)
|
||||
|
||||
const setNode = useCallback((node: DOMElement | null) => {
|
||||
nodeRef.current = node
|
||||
}, [])
|
||||
|
||||
// When active, set unconditionally. When inactive, clear conditionally
|
||||
// (only if the currently-declared node is ours). The node-identity check
|
||||
// handles two hazards:
|
||||
// 1. A memo()ized active instance elsewhere (e.g. the search input in
|
||||
// a memo'd Footer) doesn't re-render this commit — an inactive
|
||||
// instance re-rendering here must not clobber it.
|
||||
// 2. Sibling handoff (menu focus moving between list items) — when
|
||||
// focus moves opposite to sibling order, the newly-inactive item's
|
||||
// effect runs AFTER the newly-active item's set. Without the node
|
||||
// check it would clobber.
|
||||
// No dep array: must re-declare every commit so the active instance
|
||||
// re-claims the declaration after another instance's unmount-cleanup or
|
||||
// sibling handoff nulls it.
|
||||
useLayoutEffect(() => {
|
||||
const node = nodeRef.current
|
||||
|
||||
if (active && node) {
|
||||
setCursorDeclaration({ relativeX: column, relativeY: line, node })
|
||||
} else {
|
||||
setCursorDeclaration(null, node)
|
||||
}
|
||||
})
|
||||
|
||||
// Clear on unmount (conditionally — another instance may own by then).
|
||||
// Separate effect with empty deps so cleanup only fires once — not on
|
||||
// every line/column change, which would transiently null between commits.
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
setCursorDeclaration(null, nodeRef.current)
|
||||
}
|
||||
}, [setCursorDeclaration])
|
||||
|
||||
return setNode
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import instances from '../instances.js'
|
||||
|
||||
export type RunExternalProcess = () => Promise<void>
|
||||
|
||||
export async function withInkSuspended(run: RunExternalProcess): Promise<void> {
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
if (!ink) {
|
||||
await run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ink.enterAlternateScreen()
|
||||
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
ink.exitAlternateScreen()
|
||||
}
|
||||
}
|
||||
|
||||
export function useExternalProcess(): (run: RunExternalProcess) => Promise<void> {
|
||||
return useCallback((run: RunExternalProcess) => withInkSuspended(run), [])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useLayoutEffect } from 'react'
|
||||
import { useEventCallback } from 'usehooks-ts'
|
||||
|
||||
import type { InputEvent, Key } from '../events/input-event.js'
|
||||
|
||||
import useStdin from './use-stdin.js'
|
||||
|
||||
type Handler = (input: string, key: Key, event: InputEvent) => void
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Enable or disable capturing of user input.
|
||||
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used for handling user input.
|
||||
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
|
||||
* The callback you pass to `useInput` is called for each character when user enters any input.
|
||||
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
|
||||
*
|
||||
* ```
|
||||
* import {useInput} from 'ink';
|
||||
*
|
||||
* const UserInput = () => {
|
||||
* useInput((input, key) => {
|
||||
* if (input === 'q') {
|
||||
* // Exit program
|
||||
* }
|
||||
*
|
||||
* if (key.leftArrow) {
|
||||
* // Left arrow key pressed
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return …
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
const useInput = (inputHandler: Handler, options: Options = {}) => {
|
||||
const { setRawMode, exitOnCtrlC, inputEmitter } = useStdin()
|
||||
|
||||
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
|
||||
// during React's commit phase, before render() returns. With useEffect, raw
|
||||
// mode setup is deferred to the next event loop tick via React's scheduler,
|
||||
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
|
||||
// visible until the effect fires.
|
||||
useLayoutEffect(() => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => {
|
||||
setRawMode(false)
|
||||
}
|
||||
}, [options.isActive, setRawMode])
|
||||
|
||||
// Register the listener once on mount so its slot in the EventEmitter's
|
||||
// listener array is stable. If isActive were in the effect's deps, the
|
||||
// listener would re-append on false→true, moving it behind listeners
|
||||
// that registered while it was inactive — breaking
|
||||
// stopImmediatePropagation() ordering. useEventCallback keeps the
|
||||
// reference stable while reading latest isActive/inputHandler from
|
||||
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
|
||||
const handleData = useEventCallback((event: InputEvent) => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const { input, key } = event
|
||||
|
||||
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
|
||||
// Note: discreteUpdates is called at the App level when emitting events,
|
||||
// so all listeners are already within a high-priority update context.
|
||||
if (!(input === 'c' && key.ctrl) || !exitOnCtrlC) {
|
||||
inputHandler(input, key, event)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
inputEmitter?.on('input', handleData)
|
||||
|
||||
return () => {
|
||||
inputEmitter?.removeListener('input', handleData)
|
||||
}
|
||||
}, [inputEmitter, handleData])
|
||||
}
|
||||
|
||||
export default useInput
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
|
||||
/**
|
||||
* Returns the clock time, updating at the given interval.
|
||||
* Subscribes as non-keepAlive — won't keep the clock alive on its own,
|
||||
* but updates whenever a keepAlive subscriber (e.g. the spinner)
|
||||
* is driving the clock.
|
||||
*
|
||||
* Use this to drive pure time-based computations (shimmer position,
|
||||
* frame index) from the shared clock.
|
||||
*/
|
||||
export function useAnimationTimer(intervalMs: number): number {
|
||||
const clock = useContext(ClockContext)
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval hook backed by the shared Clock.
|
||||
*
|
||||
* Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
|
||||
* this piggybacks on the single shared clock so all timers consolidate into
|
||||
* one wake-up. Pass `null` for intervalMs to pause.
|
||||
*/
|
||||
export function useInterval(callback: () => void, intervalMs: number | null): void {
|
||||
const callbackRef = useRef(callback)
|
||||
callbackRef.current = callback
|
||||
|
||||
const clock = useContext(ClockContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || intervalMs === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
callbackRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useContext, useMemo } from 'react'
|
||||
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
import instances from '../instances.js'
|
||||
import type { MatchPosition } from '../render-to-screen.js'
|
||||
|
||||
/**
|
||||
* Set the search highlight query on the Ink instance. Non-empty → all
|
||||
* visible occurrences are inverted on the next frame (SGR 7, screen-buffer
|
||||
* overlay, same damage machinery as selection). Empty → clears.
|
||||
*
|
||||
* This is a screen-space highlight — it matches the RENDERED text, not the
|
||||
* source message text. Works for anything visible (bash output, file paths,
|
||||
* error messages) regardless of where it came from in the message tree. A
|
||||
* query that matched in source but got truncated/ellipsized in rendering
|
||||
* won't highlight; that's acceptable — we highlight what you see.
|
||||
*/
|
||||
export function useSearchHighlight(): {
|
||||
setQuery: (query: string) => void
|
||||
/** Paint an existing DOM subtree (from the MAIN tree) to a fresh
|
||||
* Screen at its natural height, scan. Element-relative positions
|
||||
* (row 0 = element top). Zero context duplication — the element
|
||||
* IS the one built with all real providers. */
|
||||
scanElement: (el: DOMElement) => MatchPosition[]
|
||||
/** Position-based CURRENT highlight. Every frame writes yellow at
|
||||
* positions[currentIdx] + rowOffset. The scan-highlight (inverse on
|
||||
* all matches) still runs — this overlays on top. rowOffset tracks
|
||||
* scroll; positions stay stable (message-relative). null clears. */
|
||||
setPositions: (
|
||||
state: {
|
||||
positions: MatchPosition[]
|
||||
rowOffset: number
|
||||
currentIdx: number
|
||||
} | null
|
||||
) => void
|
||||
} {
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
setQuery: () => {},
|
||||
scanElement: () => [],
|
||||
setPositions: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setQuery: (query: string) => ink.setSearchHighlight(query),
|
||||
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
|
||||
setPositions: state => ink.setSearchPositions(state)
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useContext, useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
import instances from '../instances.js'
|
||||
import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.js'
|
||||
|
||||
/**
|
||||
* Access to text selection operations on the Ink instance (fullscreen only).
|
||||
* Returns no-op functions when fullscreen mode is disabled.
|
||||
*/
|
||||
export function useSelection(): {
|
||||
copySelection: () => Promise<string>
|
||||
/** Copy without clearing the highlight (for copy-on-select). */
|
||||
copySelectionNoClear: () => Promise<string>
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
/** Read the raw mutable selection state (for drag-to-scroll). */
|
||||
getState: () => SelectionState | null
|
||||
/** Subscribe to selection mutations (start/update/finish/clear). */
|
||||
subscribe: (cb: () => void) => () => void
|
||||
/** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Shift anchor AND focus by dRow (keyboard scroll: whole selection
|
||||
* tracks content). Clamped points get col reset to the full-width edge
|
||||
* since their content was captured by captureScrolledRows. Reads
|
||||
* screen.width from the ink instance for the col-reset boundary. */
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
|
||||
* Left/right wrap across rows; up/down clamp at viewport edges. */
|
||||
moveFocus: (move: FocusMove) => void
|
||||
/** Capture text from rows about to scroll out of the viewport (call
|
||||
* BEFORE scrollBy so the screen buffer still has the outgoing rows). */
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
/** Set the selection highlight bg color (theme-piping; solid bg
|
||||
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||
* under selection). Call once on mount + whenever theme changes. */
|
||||
setSelectionBgColor: (color: string) => void
|
||||
/** Monotonic counter incremented on every selection mutation. */
|
||||
version: () => number
|
||||
} {
|
||||
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||
// StdinContext is available (it's always provided), and the Ink instance
|
||||
// is keyed by stdout which we can get from process.stdout since there's
|
||||
// only one Ink instance per process in practice.
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
// Memoize so callers can safely use the return value in dependency arrays.
|
||||
// ink is a singleton per stdout — stable across renders.
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
copySelection: async () => '',
|
||||
copySelectionNoClear: async () => '',
|
||||
clearSelection: () => {},
|
||||
hasSelection: () => false,
|
||||
getState: () => null,
|
||||
subscribe: () => () => {},
|
||||
shiftAnchor: () => {},
|
||||
shiftSelection: () => {},
|
||||
moveFocus: () => {},
|
||||
captureScrolledRows: () => {},
|
||||
setSelectionBgColor: () => {},
|
||||
version: () => 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
copySelection: () => ink.copySelection(),
|
||||
copySelectionNoClear: () => ink.copySelectionNoClear(),
|
||||
clearSelection: () => ink.clearTextSelection(),
|
||||
hasSelection: () => ink.hasTextSelection(),
|
||||
getState: () => ink.selection,
|
||||
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => shiftAnchor(ink.selection, dRow, minRow, maxRow),
|
||||
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
|
||||
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
|
||||
version: () => ink.getSelectionVersion()
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
|
||||
const NO_SUBSCRIBE = () => () => {}
|
||||
const ALWAYS_FALSE = () => false
|
||||
|
||||
/**
|
||||
* Reactive selection-exists state. Re-renders the caller when a text
|
||||
* selection is created or cleared. Always returns false outside
|
||||
* fullscreen mode (selection is only available in alt-screen).
|
||||
*/
|
||||
export function useHasSelection(): boolean {
|
||||
useContext(StdinContext)
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
return useSyncExternalStore(
|
||||
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
|
||||
ink ? ink.hasTextSelection : ALWAYS_FALSE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
|
||||
/**
|
||||
* `useStdin` is a React hook, which exposes stdin stream.
|
||||
*/
|
||||
const useStdin = () => useContext(StdinContext)
|
||||
export default useStdin
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
|
||||
import { CLEAR_TAB_STATUS, supportsTabStatus, tabStatus, wrapForMultiplexer } from '../termio/osc.js'
|
||||
import type { Color } from '../termio/types.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
|
||||
const rgb = (r: number, g: number, b: number): Color => ({
|
||||
type: 'rgb',
|
||||
r,
|
||||
g,
|
||||
b
|
||||
})
|
||||
|
||||
// Per the OSC 21337 usage guide's suggested mapping.
|
||||
const TAB_STATUS_PRESETS: Record<TabStatusKind, { indicator: Color; status: string; statusColor: Color }> = {
|
||||
idle: {
|
||||
indicator: rgb(0, 215, 95),
|
||||
status: 'Idle',
|
||||
statusColor: rgb(136, 136, 136)
|
||||
},
|
||||
busy: {
|
||||
indicator: rgb(255, 149, 0),
|
||||
status: 'Working…',
|
||||
statusColor: rgb(255, 149, 0)
|
||||
},
|
||||
waiting: {
|
||||
indicator: rgb(95, 135, 255),
|
||||
status: 'Waiting',
|
||||
statusColor: rgb(95, 135, 255)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaratively set the tab-status indicator (OSC 21337).
|
||||
*
|
||||
* Emits a colored dot + short status text to the tab sidebar. Terminals
|
||||
* that don't support OSC 21337 discard the sequence silently, so this is
|
||||
* safe to call unconditionally. Wrapped for tmux/screen passthrough.
|
||||
*
|
||||
* Pass `null` to opt out. If a status was previously set, transitioning to
|
||||
* `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
|
||||
* a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
|
||||
*/
|
||||
export function useTabStatus(kind: TabStatusKind | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
const prevKindRef = useRef<TabStatusKind | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// When kind transitions from non-null to null (e.g. user toggles off
|
||||
// showStatusInTerminalTab mid-session), clear the stale dot.
|
||||
if (kind === null) {
|
||||
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
|
||||
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
|
||||
}
|
||||
|
||||
prevKindRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prevKindRef.current = kind
|
||||
|
||||
if (!writeRaw || !supportsTabStatus()) {
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
|
||||
}, [kind, writeRaw])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import TerminalFocusContext from '../components/TerminalFocusContext.js'
|
||||
|
||||
/**
|
||||
* Hook to check if the terminal has focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends escape sequences
|
||||
* when it gains or loses focus. These are handled automatically
|
||||
* by Ink and filtered from useInput.
|
||||
*
|
||||
* @returns true if the terminal is focused (or focus state is unknown)
|
||||
*/
|
||||
export function useTerminalFocus(): boolean {
|
||||
const { isTerminalFocused } = useContext(TerminalFocusContext)
|
||||
|
||||
return isTerminalFocused
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useContext, useEffect } from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { OSC, osc } from '../termio/osc.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
/**
|
||||
* Declaratively set the terminal tab/window title.
|
||||
*
|
||||
* Pass a single string to set both the tab and window title (OSC 0).
|
||||
* Pass `{ tab, window }` to set them independently: the short `tab` string
|
||||
* goes to OSC 1 (icon/tab label) and the longer `window` string goes to
|
||||
* OSC 2 (window title bar). This matters for terminals like Apple
|
||||
* Terminal.app whose narrow background tabs truncate the title from the
|
||||
* left — a single long OSC 0 string leaves only the tail visible, while a
|
||||
* separate short OSC 1 keeps the session name readable.
|
||||
*
|
||||
* Pass `null` to opt out — the hook becomes a no-op and leaves the
|
||||
* terminal title untouched.
|
||||
*
|
||||
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
|
||||
*/
|
||||
export function useTerminalTitle(title: string | TerminalTitlePair | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (title === null || !writeRaw) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? ''))
|
||||
process.title = clean
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof title === 'string') {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars
|
||||
// show the short session name instead of a truncated tail.
|
||||
const tab = stripAnsi(title.tab ?? '')
|
||||
const window = stripAnsi(title.window ?? '')
|
||||
|
||||
if (tab && window) {
|
||||
writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window))
|
||||
} else if (window) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, window))
|
||||
} else if (tab) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab))
|
||||
}
|
||||
}, [title, writeRaw])
|
||||
}
|
||||
|
||||
export interface TerminalTitlePair {
|
||||
/** Short title for the tab/icon label (OSC 1). */
|
||||
tab?: string
|
||||
/** Full title for the window title bar (OSC 2). */
|
||||
window?: string
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
type ViewportEntry = {
|
||||
/**
|
||||
* Whether the element is currently within the terminal viewport
|
||||
*/
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if a component is within the terminal viewport.
|
||||
*
|
||||
* Returns a callback ref and a viewport entry object.
|
||||
* Attach the ref to the component you want to track.
|
||||
*
|
||||
* The entry is updated during the layout phase (useLayoutEffect) so callers
|
||||
* always read fresh values during render. Visibility changes do NOT trigger
|
||||
* re-renders on their own — callers that re-render for other reasons (e.g.
|
||||
* animation ticks, state changes) will pick up the latest value naturally.
|
||||
* This avoids infinite update loops when combined with other layout effects
|
||||
* that also call setState.
|
||||
*
|
||||
* @example
|
||||
* const [ref, entry] = useTerminalViewport()
|
||||
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
|
||||
*/
|
||||
export function useTerminalViewport(): [ref: (element: DOMElement | null) => void, entry: ViewportEntry] {
|
||||
const terminalSize = useContext(TerminalSizeContext)
|
||||
const elementRef = useRef<DOMElement | null>(null)
|
||||
const entryRef = useRef<ViewportEntry>({ isVisible: true })
|
||||
|
||||
const setElement = useCallback((el: DOMElement | null) => {
|
||||
elementRef.current = el
|
||||
}, [])
|
||||
|
||||
// Runs on every render because yoga layout values can change
|
||||
// without React being aware. Only updates the ref — no setState
|
||||
// to avoid cascading re-renders during the commit phase.
|
||||
// Walks the DOM ancestor chain fresh each time to avoid holding stale
|
||||
// references after yoga tree rebuilds.
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current
|
||||
|
||||
if (!element?.yogaNode || !terminalSize) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = element.yogaNode.getComputedHeight()
|
||||
const rows = terminalSize.rows
|
||||
|
||||
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
|
||||
// scroll containers and subtract their scrollTop. Yoga computes layout
|
||||
// positions without scroll offset — scrollTop is applied at render time.
|
||||
// Without this, an element inside a ScrollBox whose yoga position exceeds
|
||||
// terminalRows would be considered offscreen even when scrolled into view
|
||||
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
|
||||
let absoluteTop = element.yogaNode.getComputedTop()
|
||||
let parent: DOMElement | undefined = element.parentNode
|
||||
let root = element.yogaNode
|
||||
|
||||
while (parent) {
|
||||
if (parent.yogaNode) {
|
||||
absoluteTop += parent.yogaNode.getComputedTop()
|
||||
root = parent.yogaNode
|
||||
}
|
||||
|
||||
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
|
||||
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
|
||||
if (parent.scrollTop) {
|
||||
absoluteTop -= parent.scrollTop
|
||||
}
|
||||
|
||||
parent = parent.parentNode
|
||||
}
|
||||
|
||||
// Only the root's height matters
|
||||
const screenHeight = root.getComputedHeight()
|
||||
|
||||
const bottom = absoluteTop + height
|
||||
// When content overflows the viewport (screenHeight > rows), the
|
||||
// cursor-restore at frame end scrolls one extra row into scrollback.
|
||||
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
|
||||
// We must match, otherwise an element at the boundary is considered
|
||||
// "visible" here (animation keeps ticking) but its row is treated as
|
||||
// scrollback by log-update (content change → full reset → flicker).
|
||||
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
|
||||
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
|
||||
const viewportBottom = viewportY + rows
|
||||
const visible = bottom > viewportY && absoluteTop < viewportBottom
|
||||
|
||||
if (visible !== entryRef.current.isVisible) {
|
||||
entryRef.current = { isVisible: visible }
|
||||
}
|
||||
})
|
||||
|
||||
return [setElement, entryRef.current]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js'
|
||||
|
||||
/**
|
||||
* Highlight every cell whose OSC 8 hyperlink matches `hoveredUrl` by inverting
|
||||
* its style. This is the cursor-hover affordance for clickable links: terminal
|
||||
* applications can't change the system mouse cursor, so we light up the link
|
||||
* itself when the pointer is over it. Same overlay machinery as
|
||||
* applySearchHighlight — post-layout, pure SGR, picked up by the diff.
|
||||
*
|
||||
* Returns true if any cell was highlighted. The caller decides whether to
|
||||
* promote that into a full-frame damage request — for hover specifically,
|
||||
* full damage is only useful on enter/leave/change transitions (so the
|
||||
* previous frame's inverted cells get re-emitted), not on every steady-state
|
||||
* frame the pointer sits on the link.
|
||||
*/
|
||||
export function applyHyperlinkHoverHighlight(
|
||||
screen: Screen,
|
||||
hoveredUrl: string | undefined,
|
||||
stylePool: StylePool
|
||||
): boolean {
|
||||
if (!hoveredUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const w = screen.width
|
||||
const height = screen.height
|
||||
let applied = false
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
const rowOff = row * w
|
||||
|
||||
for (let col = 0; col < w; col++) {
|
||||
const cell = cellAtIndex(screen, rowOff + col)
|
||||
|
||||
// Skip SpacerTail — the head cell at col-1 owns the hyperlink, and
|
||||
// setCellStyleId on the tail would split the styling of a wide-char
|
||||
// glyph mid-cell. The head's restyle covers both halves.
|
||||
if (cell.width === CellWidth.SpacerTail) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.hyperlink !== hoveredUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
applied = true
|
||||
setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
|
||||
}
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import { MAX_COALESCED_BACKPRESSURE_FRAMES } from './constants.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
// Regression for issue #31486 (stdout-backpressure strand): when the
|
||||
// previous frame's stdout.write has not drained (the terminal parser is
|
||||
// overwhelmed — a wide CR+LF burst on a high-context session), the renderer
|
||||
// must COALESCE rather than pile another write on the backed-up pipe. Piling
|
||||
// writes keeps the macrotask queue hot and starves the stdin 'readable'
|
||||
// callback, which is the observed freeze. The coalesce must be bounded: after
|
||||
// MAX_COALESCED_BACKPRESSURE_FRAMES skipped frames it forces a write through
|
||||
// so a terminal whose drain callback never fires can't wedge the renderer.
|
||||
|
||||
/**
|
||||
* A TTY whose write() reports backpressure (returns false) and WITHHOLDS the
|
||||
* drain callback until fireDrain() is called — simulating a wedged terminal
|
||||
* parser. Each write records its drain callback so the test controls timing.
|
||||
*/
|
||||
class WedgedTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
private pendingDrains: Array<(err?: Error | null) => void> = []
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
|
||||
if (cb) {
|
||||
// Hold the callback — do NOT fire it. This leaves the renderer's
|
||||
// pendingWriteStart non-null, the backpressure signal it coalesces on.
|
||||
this.pendingDrains.push(cb)
|
||||
}
|
||||
|
||||
// Report backpressure.
|
||||
return false
|
||||
}
|
||||
|
||||
/** Fire all withheld drain callbacks, simulating the pipe recovering. */
|
||||
fireDrain(): void {
|
||||
const drains = this.pendingDrains
|
||||
this.pendingDrains = []
|
||||
|
||||
for (const cb of drains) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
get pendingDrainCount(): number {
|
||||
return this.pendingDrains.length
|
||||
}
|
||||
}
|
||||
|
||||
/** A normal fast TTY: write succeeds and drains synchronously. */
|
||||
class FastTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const makeInk = (stdout: WedgedTty | FastTty) => {
|
||||
const stdin = new EventEmitter() as unknown as NodeJS.ReadStream
|
||||
const stderr = new FastTty()
|
||||
|
||||
return new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
}
|
||||
|
||||
describe('Ink stdout backpressure coalescing (issue #31486)', () => {
|
||||
it('coalesces frames while the previous write has not drained', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
|
||||
// First frame wrote (and reported backpressure; drain withheld).
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
expect(writesAfterFirst).toBeGreaterThan(0)
|
||||
expect(stdout.pendingDrainCount).toBe(1)
|
||||
|
||||
// Subsequent renders while the write is still pending must coalesce —
|
||||
// no new bytes written, a retry timer scheduled instead.
|
||||
ink.render(React.createElement(Text, null, 'world'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes writing once the wedged pipe drains', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
|
||||
// Backed up: this render coalesces.
|
||||
ink.render(React.createElement(Text, null, 'changed'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
// Pipe recovers — drain callback fires, clearing pendingWriteStart.
|
||||
stdout.fireDrain()
|
||||
|
||||
// The retry tick now finds the pipe drained and writes the pending frame.
|
||||
vi.runAllTimers()
|
||||
expect(stdout.chunks.length).toBeGreaterThan(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('forces a write through after the coalesce ceiling so it never wedges forever', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
|
||||
// Mark content dirty and drive renders. The drain callback NEVER fires
|
||||
// (pendingDrainCount stays > 0). After MAX_COALESCED_BACKPRESSURE_FRAMES
|
||||
// coalesced retries, the renderer must force a write through.
|
||||
ink.render(React.createElement(Text, null, 'forced'))
|
||||
|
||||
// Drive enough retry ticks to exceed the ceiling.
|
||||
for (let i = 0; i <= MAX_COALESCED_BACKPRESSURE_FRAMES + 2; i++) {
|
||||
vi.advanceTimersByTime(4)
|
||||
}
|
||||
|
||||
// A write was forced through despite the never-firing drain callback.
|
||||
expect(stdout.chunks.length).toBeGreaterThan(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never coalesces on a fast terminal that drains synchronously', () => {
|
||||
const stdout = new FastTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'a'))
|
||||
ink.onRender()
|
||||
const afterA = stdout.chunks.length
|
||||
expect(afterA).toBeGreaterThan(0)
|
||||
|
||||
// Each changed render writes immediately — synchronous drain clears the
|
||||
// backpressure signal before the next frame, so nothing is coalesced.
|
||||
ink.render(React.createElement(Text, null, 'b'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBeGreaterThan(afterA)
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function makeInk() {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
return { ink, stdout, stdin, stderr }
|
||||
}
|
||||
|
||||
// Cast helper instead of exposing __get*ForTest methods on production Ink —
|
||||
// these are internal frame/cursor caches we only inspect from tests.
|
||||
type InkPrivate = {
|
||||
displayCursor: { x: number; y: number } | null
|
||||
cursorDeclaration: { node: unknown; relativeX: number; relativeY: number } | null
|
||||
frontFrame: { cursor: { x: number; y: number } }
|
||||
}
|
||||
const peek = (ink: Ink): InkPrivate => ink as unknown as InkPrivate
|
||||
|
||||
// Closes the cursor-drift bug: when TextInput's fast-echo path writes a
|
||||
// printable character directly to stdout, the hardware cursor advances by
|
||||
// one cell BUT Ink's `displayCursor` cache (used as the basis for the
|
||||
// next frame's relative cursor preamble) wasn't being updated. On long
|
||||
// sessions an unrelated re-render (status bar timer, streaming
|
||||
// reasoning, etc.) would then park the hardware cursor N cells offset
|
||||
// from the actual caret — visible as "extra whitespace between my last
|
||||
// typed character and the cursor block".
|
||||
describe('Ink.noteExternalCursorAdvance', () => {
|
||||
it('bumps an already-tracked displayCursor by the given delta', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
// Seed a known parked position directly. In production this is set by
|
||||
// the cursor-park branch in onRender when a useDeclaredCursor caller
|
||||
// commits a declaration; this test bypasses React for hermeticity.
|
||||
peek(ink).displayCursor = { x: 5, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 8, y: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(-1)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 7, y: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(0, 2)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 7, y: 2 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('seeds displayCursor from frontFrame.cursor when nothing was parked', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
const base = { x: peek(ink).frontFrame.cursor.x, y: peek(ink).frontFrame.cursor.y }
|
||||
|
||||
ink.noteExternalCursorAdvance(4)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: base.x + 4, y: base.y })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('is a no-op when the delta is zero', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
ink.noteExternalCursorAdvance(0)
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
|
||||
ink.noteExternalCursorAdvance(0, 0)
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('skips displayCursor on alt-screen — CSI H resets every frame', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
peek(ink).displayCursor = { x: 5, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the default TUI wraps the
|
||||
// composer in <AlternateScreen>, so alt-screen is the production
|
||||
// path. CSI H only resets the log-update relative-move basis — the
|
||||
// declared cursor target is still consulted by onRender's alt-screen
|
||||
// park branch (`cursorPosition(row, col)` using rect + decl). So
|
||||
// cursorDeclaration MUST advance on alt-screen too, even though
|
||||
// displayCursor doesn't need to.
|
||||
it('still advances cursorDeclaration on alt-screen', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 7, relativeY: 0 }
|
||||
peek(ink).displayCursor = { x: 12, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
// displayCursor untouched on alt-screen
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 12, y: 0 })
|
||||
// declaration still advanced — onRender's alt-screen park reads this
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 10, relativeY: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot review feedback on PR #26717: even after the
|
||||
// TextInput-level fix where layout reads `curRef.current` directly,
|
||||
// there's still a window where a fast-echo wrote to stdout but the
|
||||
// current cursor declaration on Ink (set by an earlier render's
|
||||
// useDeclaredCursor commit) points at the PRE-keystroke caret
|
||||
// column. If we advanced only `displayCursor`, an unrelated re-render
|
||||
// in that window would re-run onRender's cursor-park branch with the
|
||||
// stale declaration and visually undo the fast-echo's advance. We
|
||||
// must bump BOTH so the cursor stays anchored to the physical caret
|
||||
// until the next React commit publishes a fresh declaration
|
||||
// (computed from `curRef.current` via the cursorLayout call in
|
||||
// textInput.tsx) that supersedes the bump.
|
||||
it('advances the active cursorDeclaration in lock-step with displayCursor', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 7, relativeY: 0 }
|
||||
peek(ink).displayCursor = { x: 12, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 15, y: 0 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 10, relativeY: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(-1)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 14, y: 0 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 9, relativeY: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the dy half of the notifier
|
||||
// contract was tested for `displayCursor` but not for
|
||||
// `cursorDeclaration.relativeY`. Newlines in fast-echoed text never
|
||||
// hit the bypass today (canFastAppendShape rejects '\n'), but `dy`
|
||||
// is part of the public API and must propagate symmetrically with
|
||||
// dx so future callers (e.g. multi-line paste shortcuts) don't get
|
||||
// a half-implemented contract.
|
||||
it('advances cursorDeclaration.relativeY when dy is non-zero', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 2, relativeY: 1 }
|
||||
peek(ink).displayCursor = { x: 4, y: 2 }
|
||||
|
||||
ink.noteExternalCursorAdvance(1, 3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 5 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 3, relativeY: 4 })
|
||||
|
||||
// Negative dy too — cursor moving up across visual rows.
|
||||
ink.noteExternalCursorAdvance(0, -2)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 3 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 3, relativeY: 2 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('leaves cursorDeclaration unchanged when no declaration is active', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
expect(peek(ink).cursorDeclaration).toBeNull()
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).cursorDeclaration).toBeNull()
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Box from './components/Box.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||
import { DISABLE_MOUSE_TRACKING } from './termio/dec.js'
|
||||
|
||||
/**
|
||||
* Focus-regain recovery (DECSET 1004 focus-in).
|
||||
*
|
||||
* Two properties are asserted against the RESULTING SCREEN, not against which
|
||||
* bytes were emitted:
|
||||
*
|
||||
* 1. Healing — a row that is stale on the physical screen but BLANK in the
|
||||
* new frame must be gone. The cell diff skips blank-over-blank, so a
|
||||
* buffer-only reset leaves it behind; the clear is what removes it.
|
||||
* 2. Atomicity — the clear must ride in the SAME write() as the repaint, so
|
||||
* no frame can be presented between "screen cleared" and "content drawn".
|
||||
* A separate erase write is the visible flash on an ordinary tab switch.
|
||||
*
|
||||
* Both hold on the alt screen and on the main screen (INLINE_MODE / Termux).
|
||||
*/
|
||||
|
||||
/** Minimal terminal emulator: replays ANSI into a cell grid. */
|
||||
class TermModel {
|
||||
private readonly rows: string[][]
|
||||
private cx = 0
|
||||
private cy = 0
|
||||
|
||||
constructor(
|
||||
private readonly width: number,
|
||||
private readonly height: number
|
||||
) {
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '))
|
||||
}
|
||||
|
||||
private put(ch: string): void {
|
||||
if (this.cy >= 0 && this.cy < this.height && this.cx >= 0 && this.cx < this.width) {
|
||||
this.rows[this.cy]![this.cx] = ch
|
||||
}
|
||||
|
||||
this.cx++
|
||||
|
||||
if (this.cx >= this.width) {
|
||||
this.cx = 0
|
||||
this.cy++
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
let i = 0
|
||||
|
||||
while (i < data.length) {
|
||||
const ch = data[i]!
|
||||
|
||||
if (ch === '\x1b') {
|
||||
if (data[i + 1] !== '[') {
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i + 2
|
||||
|
||||
while (j < data.length && !/[A-Za-z]/.test(data[j]!)) {
|
||||
j++
|
||||
}
|
||||
|
||||
const final = data[j]
|
||||
const params = data.slice(i + 2, j)
|
||||
i = j + 1
|
||||
|
||||
// DEC private modes (mouse, sync, cursor visibility) don't move cells.
|
||||
if (params.startsWith('?')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nums = params.split(';').map(p => (p === '' ? undefined : Number(p)))
|
||||
const n = nums[0] ?? 1
|
||||
|
||||
switch (final) {
|
||||
case 'H':
|
||||
this.cy = (nums[0] ?? 1) - 1
|
||||
this.cx = (nums[1] ?? 1) - 1
|
||||
|
||||
break
|
||||
|
||||
case 'J':
|
||||
if ((nums[0] ?? 0) === 2 || (nums[0] ?? 0) === 3) {
|
||||
for (const row of this.rows) {
|
||||
row.fill(' ')
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'K':
|
||||
if (this.cy >= 0 && this.cy < this.height) {
|
||||
for (let x = this.cx; x < this.width; x++) {
|
||||
this.rows[this.cy]![x] = ' '
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'A':
|
||||
this.cy -= n
|
||||
|
||||
break
|
||||
|
||||
case 'B':
|
||||
this.cy += n
|
||||
|
||||
break
|
||||
|
||||
case 'C':
|
||||
this.cx += n
|
||||
|
||||
break
|
||||
|
||||
case 'D':
|
||||
this.cx -= n
|
||||
|
||||
break
|
||||
|
||||
case 'G':
|
||||
this.cx = n - 1
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\r') {
|
||||
this.cx = 0
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\n') {
|
||||
this.cy++
|
||||
this.cx = 0
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
this.put(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.rows.map(r => r.join('').trimEnd()).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
drain(): string {
|
||||
const out = this.chunks.join('')
|
||||
this.chunks = []
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
type InkPrivate = {
|
||||
handleTerminalFocusChange: (isFocused: boolean) => void
|
||||
}
|
||||
|
||||
const peek = (ink: Ink): InkPrivate => ink as unknown as InkPrivate
|
||||
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
const STALE = 'STATUSROW downloading 42%'
|
||||
|
||||
// Tall frame -> short frame: the vacated row is BLANK in the new frame, which
|
||||
// is exactly the case the cell diff skips.
|
||||
const tall = () =>
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
React.createElement(Text, null, 'hello'),
|
||||
React.createElement(Text, null, STALE)
|
||||
)
|
||||
|
||||
const short = () => React.createElement(Box, { flexDirection: 'column' }, React.createElement(Text, null, 'hello'))
|
||||
|
||||
async function focusRegain(altScreen: boolean, env?: Record<string, string>) {
|
||||
const restore: Array<[string, string | undefined]> = []
|
||||
|
||||
for (const [k, v] of Object.entries(env ?? {})) {
|
||||
restore.push([k, process.env[k]])
|
||||
process.env[k] = v
|
||||
}
|
||||
|
||||
try {
|
||||
return await runFocusRegain(altScreen)
|
||||
} finally {
|
||||
for (const [k, v] of restore) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k]
|
||||
} else {
|
||||
process.env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFocusRegain(altScreen: boolean) {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
const term = new TermModel(40, 8)
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
if (altScreen) {
|
||||
ink.setAltScreenActive(true, 'all')
|
||||
}
|
||||
|
||||
ink.render(tall())
|
||||
ink.onRender()
|
||||
await tick()
|
||||
term.write(stdout.drain())
|
||||
|
||||
// Hidden/throttled tab: Ink emits the shrunk frame, the emulator drops it.
|
||||
// Ink's virtual frame now says "short"; the physical screen still shows the
|
||||
// status row.
|
||||
ink.render(short())
|
||||
ink.onRender()
|
||||
await tick()
|
||||
stdout.drain()
|
||||
|
||||
const beforeFocus = term.text()
|
||||
|
||||
peek(ink).handleTerminalFocusChange(true)
|
||||
await tick()
|
||||
|
||||
const chunks = [...stdout.chunks]
|
||||
term.write(stdout.drain())
|
||||
ink.unmount()
|
||||
|
||||
return { beforeFocus, afterFocus: term.text(), chunks }
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ altScreen: true, name: 'alt screen' },
|
||||
{ altScreen: false, name: 'main screen (INLINE_MODE)' }
|
||||
])('Ink focus recovery — $name', ({ altScreen }) => {
|
||||
it('clears the stale row and repaints the current frame', async () => {
|
||||
const { beforeFocus, afterFocus } = await focusRegain(altScreen)
|
||||
|
||||
// Precondition: the physical screen really is stale before focus-in.
|
||||
expect(beforeFocus).toContain(STALE)
|
||||
|
||||
expect(afterFocus).not.toContain(STALE)
|
||||
expect(afterFocus).toContain('hello')
|
||||
// The repaint must not duplicate content it just redrew.
|
||||
expect(afterFocus.match(/hello/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits the clear in the same write as the repaint', async () => {
|
||||
const { chunks } = await focusRegain(altScreen)
|
||||
|
||||
const eraseChunks = chunks.filter(c => c.includes(ERASE_SCREEN))
|
||||
|
||||
expect(eraseChunks).toHaveLength(1)
|
||||
// Atomic: clear + content in one write, so no blank frame can be shown.
|
||||
expect(eraseChunks[0]).toContain('hello')
|
||||
})
|
||||
|
||||
it('never erases scrollback (CSI 3J) on an ordinary focus regain', async () => {
|
||||
// Apple Terminal opts into a scrollback-deep erase, but only to clear
|
||||
// reflow artifacts after a RESIZE. A tab switch must not take the user's
|
||||
// history with it.
|
||||
const { chunks } = await focusRegain(altScreen, { TERM_PROGRAM: 'Apple_Terminal' })
|
||||
|
||||
expect(chunks.join('')).not.toContain(ERASE_SCROLLBACK)
|
||||
})
|
||||
|
||||
it('re-asserts terminal modes so mouse tracking survives a hidden pane', async () => {
|
||||
// An emulator that dropped the DEC mouse modes while hidden would
|
||||
// otherwise stay dead until the DECRQM watchdog's next probe. Mouse
|
||||
// tracking is alt-screen-scoped (reassertTerminalModes returns early on
|
||||
// main screen, where altScreenMouseTracking is always 'off'), so only
|
||||
// assert the re-arm where tracking exists.
|
||||
const { chunks } = await focusRegain(altScreen)
|
||||
|
||||
if (altScreen) {
|
||||
expect(chunks.join('')).toContain(DISABLE_MOUSE_TRACKING)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
describe('Ink resize healing', () => {
|
||||
it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
stdout.emit('resize')
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
// The heal may also erase scrollback (CSI 3J interposed between 2J and H)
|
||||
// depending on which recovery path runs, so assert the invariant — screen
|
||||
// erased, then content repainted after — rather than an exact byte run.
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Regression for issue #18449: dragging the terminal back and forth quickly
|
||||
// emits a BURST of resize events (the single-event test above only covers one
|
||||
// tick). Each tick resets the frame buffers and arms needsEraseBeforePaint, so
|
||||
// the burst must still converge to a clean erase+repaint — a stacked event
|
||||
// must never consume the erase and leave the final paint as a partial diff
|
||||
// that lets stale glyphs survive.
|
||||
it('converges to a clean erased frame after a rapid resize burst', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
// Wobble the dimensions like a drag — widen, shrink, grow rows — then
|
||||
// settle back on the STARTING geometry. Even though the net dimensions are
|
||||
// unchanged, a host reflow during the burst can have scattered glyphs, so
|
||||
// the renderer must still heal rather than treat the end state as a no-op.
|
||||
const wobble: Array<[number, number]> = [
|
||||
[30, 5],
|
||||
[12, 9],
|
||||
[25, 4],
|
||||
[20, 5]
|
||||
]
|
||||
|
||||
for (const [columns, rows] of wobble) {
|
||||
stdout.columns = columns
|
||||
stdout.rows = rows
|
||||
stdout.emit('resize')
|
||||
}
|
||||
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
// The heal can erase scrollback too (CSI 3J interposed), so assert the
|
||||
// semantic invariant rather than an exact byte sequence: the screen was
|
||||
// erased and the content was repainted AFTER the erase — i.e. the final
|
||||
// frame is a clean repaint, not a partial diff over drifted cells.
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// The burst above ends on a same-dimension event; this isolates that worst
|
||||
// case on its own — a resize event whose dims equal the last known geometry
|
||||
// (the terminal restored the buffer / reflowed without a net size change)
|
||||
// must still arm the erase, because the physical screen may carry drift the
|
||||
// diff path cannot see (see log-update "drift repro").
|
||||
it('heals a same-dimension resize even when no React commit changes the tree', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
// Dimensions are identical to the initial render — the tree never changes.
|
||||
stdout.emit('resize')
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
// Store all instances of Ink (instance.js) to ensure that consecutive render() calls
|
||||
// use the same instance of Ink and don't create a new one
|
||||
//
|
||||
// This map has to be stored in a separate file, because render.js creates instances,
|
||||
// but instance.js should delete itself from the map on unmount
|
||||
|
||||
import type Ink from './ink.js'
|
||||
|
||||
const instances = new Map<NodeJS.WriteStream, Ink>()
|
||||
export default instances
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { LayoutNode } from './node.js'
|
||||
import { createYogaLayoutNode } from './yoga.js'
|
||||
|
||||
export function createLayoutNode(): LayoutNode {
|
||||
return createYogaLayoutNode()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Size = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Rectangle = Point & Size
|
||||
|
||||
/** Edge insets (padding, margin, border) */
|
||||
export type Edges = {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
/** Create uniform edges */
|
||||
export function edges(all: number): Edges
|
||||
export function edges(vertical: number, horizontal: number): Edges
|
||||
export function edges(top: number, right: number, bottom: number, left: number): Edges
|
||||
|
||||
export function edges(a: number, b?: number, c?: number, d?: number): Edges {
|
||||
if (b === undefined) {
|
||||
return { top: a, right: a, bottom: a, left: a }
|
||||
}
|
||||
|
||||
if (c === undefined) {
|
||||
return { top: a, right: b, bottom: a, left: b }
|
||||
}
|
||||
|
||||
return { top: a, right: b, bottom: c, left: d! }
|
||||
}
|
||||
|
||||
/** Add two edge values */
|
||||
export function addEdges(a: Edges, b: Edges): Edges {
|
||||
return {
|
||||
top: a.top + b.top,
|
||||
right: a.right + b.right,
|
||||
bottom: a.bottom + b.bottom,
|
||||
left: a.left + b.left
|
||||
}
|
||||
}
|
||||
|
||||
/** Zero edges constant */
|
||||
export const ZERO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
|
||||
/** Convert partial edges to full edges with defaults */
|
||||
export function resolveEdges(partial?: Partial<Edges>): Edges {
|
||||
return {
|
||||
top: partial?.top ?? 0,
|
||||
right: partial?.right ?? 0,
|
||||
bottom: partial?.bottom ?? 0,
|
||||
left: partial?.left ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export function unionRect(a: Rectangle, b: Rectangle): Rectangle {
|
||||
const minX = Math.min(a.x, b.x)
|
||||
const minY = Math.min(a.y, b.y)
|
||||
const maxX = Math.max(a.x + a.width, b.x + b.width)
|
||||
const maxY = Math.max(a.y + a.height, b.y + b.height)
|
||||
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
||||
export function clampRect(rect: Rectangle, size: Size): Rectangle {
|
||||
const minX = Math.max(0, rect.x)
|
||||
const minY = Math.max(0, rect.y)
|
||||
const maxX = Math.min(size.width - 1, rect.x + rect.width - 1)
|
||||
const maxY = Math.min(size.height - 1, rect.y + rect.height - 1)
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: Math.max(0, maxX - minX + 1),
|
||||
height: Math.max(0, maxY - minY + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function withinBounds(size: Size, point: Point): boolean {
|
||||
return point.x >= 0 && point.y >= 0 && point.x < size.width && point.y < size.height
|
||||
}
|
||||
|
||||
export function clamp(value: number, min?: number, max?: number): number {
|
||||
if (min !== undefined && value < min) {
|
||||
return min
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return max
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// --
|
||||
// Adapter interface for the layout engine (Yoga)
|
||||
|
||||
export const LayoutEdge = {
|
||||
All: 'all',
|
||||
Horizontal: 'horizontal',
|
||||
Vertical: 'vertical',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Start: 'start',
|
||||
End: 'end'
|
||||
} as const
|
||||
export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge]
|
||||
|
||||
export const LayoutGutter = {
|
||||
All: 'all',
|
||||
Column: 'column',
|
||||
Row: 'row'
|
||||
} as const
|
||||
export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter]
|
||||
|
||||
export const LayoutDisplay = {
|
||||
Flex: 'flex',
|
||||
None: 'none'
|
||||
} as const
|
||||
export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay]
|
||||
|
||||
export const LayoutFlexDirection = {
|
||||
Row: 'row',
|
||||
RowReverse: 'row-reverse',
|
||||
Column: 'column',
|
||||
ColumnReverse: 'column-reverse'
|
||||
} as const
|
||||
export type LayoutFlexDirection = (typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection]
|
||||
|
||||
export const LayoutAlign = {
|
||||
Auto: 'auto',
|
||||
Stretch: 'stretch',
|
||||
FlexStart: 'flex-start',
|
||||
Center: 'center',
|
||||
FlexEnd: 'flex-end'
|
||||
} as const
|
||||
export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign]
|
||||
|
||||
export const LayoutJustify = {
|
||||
FlexStart: 'flex-start',
|
||||
Center: 'center',
|
||||
FlexEnd: 'flex-end',
|
||||
SpaceBetween: 'space-between',
|
||||
SpaceAround: 'space-around',
|
||||
SpaceEvenly: 'space-evenly'
|
||||
} as const
|
||||
export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify]
|
||||
|
||||
export const LayoutWrap = {
|
||||
NoWrap: 'nowrap',
|
||||
Wrap: 'wrap',
|
||||
WrapReverse: 'wrap-reverse'
|
||||
} as const
|
||||
export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap]
|
||||
|
||||
export const LayoutPositionType = {
|
||||
Relative: 'relative',
|
||||
Absolute: 'absolute'
|
||||
} as const
|
||||
export type LayoutPositionType = (typeof LayoutPositionType)[keyof typeof LayoutPositionType]
|
||||
|
||||
export const LayoutOverflow = {
|
||||
Visible: 'visible',
|
||||
Hidden: 'hidden',
|
||||
Scroll: 'scroll'
|
||||
} as const
|
||||
export type LayoutOverflow = (typeof LayoutOverflow)[keyof typeof LayoutOverflow]
|
||||
|
||||
export type LayoutMeasureFunc = (width: number, widthMode: LayoutMeasureMode) => { width: number; height: number }
|
||||
|
||||
export const LayoutMeasureMode = {
|
||||
Undefined: 'undefined',
|
||||
Exactly: 'exactly',
|
||||
AtMost: 'at-most'
|
||||
} as const
|
||||
export type LayoutMeasureMode = (typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode]
|
||||
|
||||
export type LayoutNode = {
|
||||
// Tree
|
||||
insertChild(child: LayoutNode, index: number): void
|
||||
removeChild(child: LayoutNode): void
|
||||
getChildCount(): number
|
||||
getParent(): LayoutNode | null
|
||||
|
||||
// Layout computation
|
||||
calculateLayout(width?: number, height?: number): void
|
||||
setMeasureFunc(fn: LayoutMeasureFunc): void
|
||||
unsetMeasureFunc(): void
|
||||
markDirty(): void
|
||||
|
||||
// Layout reading (post-layout)
|
||||
getComputedLeft(): number
|
||||
getComputedTop(): number
|
||||
getComputedWidth(): number
|
||||
getComputedHeight(): number
|
||||
getComputedBorder(edge: LayoutEdge): number
|
||||
getComputedPadding(edge: LayoutEdge): number
|
||||
|
||||
// Style setters
|
||||
setWidth(value: number): void
|
||||
setWidthPercent(value: number): void
|
||||
setWidthAuto(): void
|
||||
setHeight(value: number): void
|
||||
setHeightPercent(value: number): void
|
||||
setHeightAuto(): void
|
||||
setMinWidth(value: number): void
|
||||
setMinWidthPercent(value: number): void
|
||||
setMinHeight(value: number): void
|
||||
setMinHeightPercent(value: number): void
|
||||
setMaxWidth(value: number): void
|
||||
setMaxWidthPercent(value: number): void
|
||||
setMaxHeight(value: number): void
|
||||
setMaxHeightPercent(value: number): void
|
||||
setFlexDirection(dir: LayoutFlexDirection): void
|
||||
setFlexGrow(value: number): void
|
||||
setFlexShrink(value: number): void
|
||||
setFlexBasis(value: number): void
|
||||
setFlexBasisPercent(value: number): void
|
||||
setFlexWrap(wrap: LayoutWrap): void
|
||||
setAlignItems(align: LayoutAlign): void
|
||||
setAlignSelf(align: LayoutAlign): void
|
||||
setJustifyContent(justify: LayoutJustify): void
|
||||
setDisplay(display: LayoutDisplay): void
|
||||
getDisplay(): LayoutDisplay
|
||||
setPositionType(type: LayoutPositionType): void
|
||||
setPosition(edge: LayoutEdge, value: number): void
|
||||
setPositionPercent(edge: LayoutEdge, value: number): void
|
||||
setOverflow(overflow: LayoutOverflow): void
|
||||
setMargin(edge: LayoutEdge, value: number): void
|
||||
setPadding(edge: LayoutEdge, value: number): void
|
||||
setBorder(edge: LayoutEdge, value: number): void
|
||||
setGap(gutter: LayoutGutter, value: number): void
|
||||
|
||||
// Lifecycle
|
||||
free(): void
|
||||
freeRecursive(): void
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import Yoga, {
|
||||
Align,
|
||||
Direction,
|
||||
Display,
|
||||
Edge,
|
||||
FlexDirection,
|
||||
Gutter,
|
||||
Justify,
|
||||
MeasureMode,
|
||||
Overflow,
|
||||
PositionType,
|
||||
Wrap,
|
||||
type Node as YogaNode
|
||||
} from '../../native-ts/yoga-layout/index.js'
|
||||
|
||||
import {
|
||||
type LayoutAlign,
|
||||
LayoutDisplay,
|
||||
type LayoutEdge,
|
||||
type LayoutFlexDirection,
|
||||
type LayoutGutter,
|
||||
type LayoutJustify,
|
||||
type LayoutMeasureFunc,
|
||||
LayoutMeasureMode,
|
||||
type LayoutNode,
|
||||
type LayoutOverflow,
|
||||
type LayoutPositionType,
|
||||
type LayoutWrap
|
||||
} from './node.js'
|
||||
|
||||
// --
|
||||
// Edge/Gutter mapping
|
||||
|
||||
const EDGE_MAP: Record<LayoutEdge, Edge> = {
|
||||
all: Edge.All,
|
||||
horizontal: Edge.Horizontal,
|
||||
vertical: Edge.Vertical,
|
||||
left: Edge.Left,
|
||||
right: Edge.Right,
|
||||
top: Edge.Top,
|
||||
bottom: Edge.Bottom,
|
||||
start: Edge.Start,
|
||||
end: Edge.End
|
||||
}
|
||||
|
||||
const GUTTER_MAP: Record<LayoutGutter, Gutter> = {
|
||||
all: Gutter.All,
|
||||
column: Gutter.Column,
|
||||
row: Gutter.Row
|
||||
}
|
||||
|
||||
// --
|
||||
// Yoga adapter
|
||||
|
||||
export class YogaLayoutNode implements LayoutNode {
|
||||
readonly yoga: YogaNode
|
||||
|
||||
constructor(yoga: YogaNode) {
|
||||
this.yoga = yoga
|
||||
}
|
||||
|
||||
// Tree
|
||||
|
||||
insertChild(child: LayoutNode, index: number): void {
|
||||
this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
|
||||
}
|
||||
|
||||
removeChild(child: LayoutNode): void {
|
||||
this.yoga.removeChild((child as YogaLayoutNode).yoga)
|
||||
}
|
||||
|
||||
getChildCount(): number {
|
||||
return this.yoga.getChildCount()
|
||||
}
|
||||
|
||||
getParent(): LayoutNode | null {
|
||||
const p = this.yoga.getParent()
|
||||
|
||||
return p ? new YogaLayoutNode(p) : null
|
||||
}
|
||||
|
||||
// Layout
|
||||
|
||||
calculateLayout(width?: number, _height?: number): void {
|
||||
this.yoga.calculateLayout(width, undefined, Direction.LTR)
|
||||
}
|
||||
|
||||
setMeasureFunc(fn: LayoutMeasureFunc): void {
|
||||
this.yoga.setMeasureFunc((w, wMode) => {
|
||||
const mode =
|
||||
wMode === MeasureMode.Exactly
|
||||
? LayoutMeasureMode.Exactly
|
||||
: wMode === MeasureMode.AtMost
|
||||
? LayoutMeasureMode.AtMost
|
||||
: LayoutMeasureMode.Undefined
|
||||
|
||||
return fn(w, mode)
|
||||
})
|
||||
}
|
||||
|
||||
unsetMeasureFunc(): void {
|
||||
this.yoga.unsetMeasureFunc()
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
this.yoga.markDirty()
|
||||
}
|
||||
|
||||
// Computed layout
|
||||
|
||||
getComputedLeft(): number {
|
||||
return this.yoga.getComputedLeft()
|
||||
}
|
||||
|
||||
getComputedTop(): number {
|
||||
return this.yoga.getComputedTop()
|
||||
}
|
||||
|
||||
getComputedWidth(): number {
|
||||
return this.yoga.getComputedWidth()
|
||||
}
|
||||
|
||||
getComputedHeight(): number {
|
||||
return this.yoga.getComputedHeight()
|
||||
}
|
||||
|
||||
getComputedBorder(edge: LayoutEdge): number {
|
||||
return this.yoga.getComputedBorder(EDGE_MAP[edge]!)
|
||||
}
|
||||
|
||||
getComputedPadding(edge: LayoutEdge): number {
|
||||
return this.yoga.getComputedPadding(EDGE_MAP[edge]!)
|
||||
}
|
||||
|
||||
// Style setters
|
||||
|
||||
setWidth(value: number): void {
|
||||
this.yoga.setWidth(value)
|
||||
}
|
||||
setWidthPercent(value: number): void {
|
||||
this.yoga.setWidthPercent(value)
|
||||
}
|
||||
setWidthAuto(): void {
|
||||
this.yoga.setWidthAuto()
|
||||
}
|
||||
setHeight(value: number): void {
|
||||
this.yoga.setHeight(value)
|
||||
}
|
||||
setHeightPercent(value: number): void {
|
||||
this.yoga.setHeightPercent(value)
|
||||
}
|
||||
setHeightAuto(): void {
|
||||
this.yoga.setHeightAuto()
|
||||
}
|
||||
setMinWidth(value: number): void {
|
||||
this.yoga.setMinWidth(value)
|
||||
}
|
||||
setMinWidthPercent(value: number): void {
|
||||
this.yoga.setMinWidthPercent(value)
|
||||
}
|
||||
setMinHeight(value: number): void {
|
||||
this.yoga.setMinHeight(value)
|
||||
}
|
||||
setMinHeightPercent(value: number): void {
|
||||
this.yoga.setMinHeightPercent(value)
|
||||
}
|
||||
setMaxWidth(value: number): void {
|
||||
this.yoga.setMaxWidth(value)
|
||||
}
|
||||
setMaxWidthPercent(value: number): void {
|
||||
this.yoga.setMaxWidthPercent(value)
|
||||
}
|
||||
setMaxHeight(value: number): void {
|
||||
this.yoga.setMaxHeight(value)
|
||||
}
|
||||
setMaxHeightPercent(value: number): void {
|
||||
this.yoga.setMaxHeightPercent(value)
|
||||
}
|
||||
|
||||
setFlexDirection(dir: LayoutFlexDirection): void {
|
||||
const map: Record<LayoutFlexDirection, FlexDirection> = {
|
||||
row: FlexDirection.Row,
|
||||
'row-reverse': FlexDirection.RowReverse,
|
||||
column: FlexDirection.Column,
|
||||
'column-reverse': FlexDirection.ColumnReverse
|
||||
}
|
||||
|
||||
this.yoga.setFlexDirection(map[dir]!)
|
||||
}
|
||||
|
||||
setFlexGrow(value: number): void {
|
||||
this.yoga.setFlexGrow(value)
|
||||
}
|
||||
setFlexShrink(value: number): void {
|
||||
this.yoga.setFlexShrink(value)
|
||||
}
|
||||
setFlexBasis(value: number): void {
|
||||
this.yoga.setFlexBasis(value)
|
||||
}
|
||||
setFlexBasisPercent(value: number): void {
|
||||
this.yoga.setFlexBasisPercent(value)
|
||||
}
|
||||
|
||||
setFlexWrap(wrap: LayoutWrap): void {
|
||||
const map: Record<LayoutWrap, Wrap> = {
|
||||
nowrap: Wrap.NoWrap,
|
||||
wrap: Wrap.Wrap,
|
||||
'wrap-reverse': Wrap.WrapReverse
|
||||
}
|
||||
|
||||
this.yoga.setFlexWrap(map[wrap]!)
|
||||
}
|
||||
|
||||
setAlignItems(align: LayoutAlign): void {
|
||||
const map: Record<LayoutAlign, Align> = {
|
||||
auto: Align.Auto,
|
||||
stretch: Align.Stretch,
|
||||
'flex-start': Align.FlexStart,
|
||||
center: Align.Center,
|
||||
'flex-end': Align.FlexEnd
|
||||
}
|
||||
|
||||
this.yoga.setAlignItems(map[align]!)
|
||||
}
|
||||
|
||||
setAlignSelf(align: LayoutAlign): void {
|
||||
const map: Record<LayoutAlign, Align> = {
|
||||
auto: Align.Auto,
|
||||
stretch: Align.Stretch,
|
||||
'flex-start': Align.FlexStart,
|
||||
center: Align.Center,
|
||||
'flex-end': Align.FlexEnd
|
||||
}
|
||||
|
||||
this.yoga.setAlignSelf(map[align]!)
|
||||
}
|
||||
|
||||
setJustifyContent(justify: LayoutJustify): void {
|
||||
const map: Record<LayoutJustify, Justify> = {
|
||||
'flex-start': Justify.FlexStart,
|
||||
center: Justify.Center,
|
||||
'flex-end': Justify.FlexEnd,
|
||||
'space-between': Justify.SpaceBetween,
|
||||
'space-around': Justify.SpaceAround,
|
||||
'space-evenly': Justify.SpaceEvenly
|
||||
}
|
||||
|
||||
this.yoga.setJustifyContent(map[justify]!)
|
||||
}
|
||||
|
||||
setDisplay(display: LayoutDisplay): void {
|
||||
this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None)
|
||||
}
|
||||
|
||||
getDisplay(): LayoutDisplay {
|
||||
return this.yoga.getDisplay() === Display.None ? LayoutDisplay.None : LayoutDisplay.Flex
|
||||
}
|
||||
|
||||
setPositionType(type: LayoutPositionType): void {
|
||||
this.yoga.setPositionType(type === 'absolute' ? PositionType.Absolute : PositionType.Relative)
|
||||
}
|
||||
|
||||
setPosition(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPosition(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
|
||||
setPositionPercent(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPositionPercent(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
|
||||
setOverflow(overflow: LayoutOverflow): void {
|
||||
const map: Record<LayoutOverflow, Overflow> = {
|
||||
visible: Overflow.Visible,
|
||||
hidden: Overflow.Hidden,
|
||||
scroll: Overflow.Scroll
|
||||
}
|
||||
|
||||
this.yoga.setOverflow(map[overflow]!)
|
||||
}
|
||||
|
||||
setMargin(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setMargin(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setPadding(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPadding(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setBorder(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setBorder(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setGap(gutter: LayoutGutter, value: number): void {
|
||||
this.yoga.setGap(GUTTER_MAP[gutter]!, value)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
free(): void {
|
||||
this.yoga.free()
|
||||
}
|
||||
freeRecursive(): void {
|
||||
this.yoga.freeRecursive()
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
// Instance management
|
||||
//
|
||||
// The TS yoga-layout port is synchronous — no WASM loading, no linear memory
|
||||
// growth, so no preload/swap/reset machinery is needed. The Yoga instance is
|
||||
// just a plain JS object available at import time.
|
||||
|
||||
export function createYogaLayoutNode(): LayoutNode {
|
||||
return new YogaLayoutNode(Yoga.Node.create())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { lruEvict } from './lru.js'
|
||||
import { stringWidth } from './stringWidth.js'
|
||||
|
||||
// During streaming, text grows but completed lines are immutable.
|
||||
// Caching stringWidth per-line avoids re-measuring hundreds of
|
||||
// unchanged lines on every token (~50x reduction in stringWidth calls).
|
||||
const cache = new Map<string, number>()
|
||||
|
||||
const MAX_CACHE_SIZE = 4096
|
||||
|
||||
export function lineWidth(line: string): number {
|
||||
const cached = cache.get(line)
|
||||
|
||||
if (cached !== undefined) {
|
||||
cache.delete(line)
|
||||
cache.set(line, cached)
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const width = stringWidth(line)
|
||||
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
|
||||
cache.set(line, width)
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
export function lineWidthCacheSize(): number {
|
||||
return cache.size
|
||||
}
|
||||
|
||||
export function evictLineWidthCache(keepRatio = 0): void {
|
||||
lruEvict(cache, keepRatio)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Frame } from './frame.js'
|
||||
import { LogUpdate } from './log-update.js'
|
||||
import { CellWidth, CharPool, createScreen, HyperlinkPool, type Screen, setCellAt, StylePool } from './screen.js'
|
||||
|
||||
/**
|
||||
* Contract tests for LogUpdate.render() — the diff-to-ANSI path that owns
|
||||
* whether the terminal picks up each React commit correctly.
|
||||
*
|
||||
* These tests pin down a few load-bearing invariants so that any fix for
|
||||
* the "scattered letters after rapid resize" artifact in xterm.js hosts
|
||||
* can be grounded against them.
|
||||
*/
|
||||
|
||||
const stylePool = new StylePool()
|
||||
const charPool = new CharPool()
|
||||
const hyperlinkPool = new HyperlinkPool()
|
||||
|
||||
const mkScreen = (w: number, h: number) => createScreen(w, h, stylePool, charPool, hyperlinkPool)
|
||||
|
||||
const paint = (screen: Screen, y: number, text: string) => {
|
||||
for (let x = 0; x < text.length; x++) {
|
||||
setCellAt(screen, x, y, {
|
||||
char: text[x]!,
|
||||
styleId: stylePool.none,
|
||||
width: CellWidth.Narrow,
|
||||
hyperlink: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mkFrame = (screen: Screen, viewportW: number, viewportH: number, cursorY = 0): Frame => ({
|
||||
screen,
|
||||
viewport: { width: viewportW, height: viewportH },
|
||||
cursor: { x: 0, y: cursorY, visible: true }
|
||||
})
|
||||
|
||||
const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
|
||||
diff
|
||||
.filter(p => p.type === 'stdout')
|
||||
.map(p => (p as { type: 'stdout'; content: string }).content)
|
||||
.join('')
|
||||
|
||||
const ESC = '\u001b'
|
||||
const hasDecstbm = (text: string) => new RegExp(`${ESC}\\[\\d+;\\d+r`).test(text)
|
||||
|
||||
describe('LogUpdate.render diff contract', () => {
|
||||
it('emits only changed cells when most rows match', () => {
|
||||
const w = 20
|
||||
const h = 4
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'HELLO')
|
||||
paint(prev, 1, 'WORLD')
|
||||
paint(prev, 2, 'STAYSHERE')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'HELLO')
|
||||
paint(next, 1, 'CHANGE')
|
||||
paint(next, 2, 'STAYSHERE')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false)
|
||||
|
||||
const written = stdoutOnly(diff)
|
||||
expect(written).toContain('CHANGE')
|
||||
expect(written).not.toContain('HELLO')
|
||||
expect(written).not.toContain('STAYSHERE')
|
||||
})
|
||||
|
||||
it('width change emits a clearTerminal patch before repainting', () => {
|
||||
const prevW = 20
|
||||
const nextW = 15
|
||||
const h = 3
|
||||
|
||||
const prev = mkScreen(prevW, h)
|
||||
paint(prev, 0, 'thiswaswiderrow')
|
||||
|
||||
const next = mkScreen(nextW, h)
|
||||
paint(next, 0, 'shorterrownow')
|
||||
next.damage = { x: 0, y: 0, width: nextW, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, prevW, h), mkFrame(next, nextW, h), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('shorterrownow')
|
||||
})
|
||||
|
||||
it('height growth emits a clearTerminal patch before repainting', () => {
|
||||
const w = 20
|
||||
const prevH = 3
|
||||
const nextH = 6
|
||||
|
||||
const prev = mkScreen(w, prevH)
|
||||
paint(prev, 0, 'old rows')
|
||||
|
||||
const next = mkScreen(w, nextH)
|
||||
paint(next, 0, 'new rows')
|
||||
next.damage = { x: 0, y: 0, width: w, height: nextH }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, prevH), mkFrame(next, w, nextH), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('newrows')
|
||||
})
|
||||
|
||||
it('drift repro: identical prev/next emits no heal, even when the physical terminal is stale', () => {
|
||||
// Load-bearing theory for the rapid-resize scattered-letter bug: if the
|
||||
// physical terminal has stale cells that prev.screen doesn't know about
|
||||
// (e.g. resize-induced reflow wrote past ink's tracked range), the
|
||||
// renderer has no signal to heal them. LogUpdate.render only sees
|
||||
// prev/next — no view of the physical terminal — so when prev==next,
|
||||
// it emits nothing and any orphaned glyphs survive.
|
||||
//
|
||||
// The fix path is upstream of this diff: either (a) defensively
|
||||
// full-repaint on xterm.js frames where prevFrameContaminated is set,
|
||||
// or (b) close the drift window so prev.screen cannot diverge.
|
||||
const w = 20
|
||||
const h = 3
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'same')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'same')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false)
|
||||
|
||||
expect(stdoutOnly(diff)).toBe('')
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores main-screen scrollback-only changes instead of resetting repeatedly', () => {
|
||||
const w = 20
|
||||
const viewportH = 5
|
||||
const h = 8
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'timer 1s')
|
||||
paint(prev, 6, 'visible prompt')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'timer 2s')
|
||||
paint(next, 6, 'visible prompt')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), false, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
|
||||
expect(stdoutOnly(diff)).not.toContain('timer2s')
|
||||
})
|
||||
|
||||
it('keeps alt-screen full reset for unreachable scrollback row changes', () => {
|
||||
const w = 20
|
||||
const viewportH = 5
|
||||
const h = 8
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'timer 1s')
|
||||
paint(prev, 6, 'visible prompt')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'timer 2s')
|
||||
paint(next, 6, 'visible prompt')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('timer2s')
|
||||
})
|
||||
|
||||
it('keeps DECSTBM fast-path when scroll region stays above bottom row', () => {
|
||||
const w = 12
|
||||
const h = 6
|
||||
const prev = mkScreen(w, h)
|
||||
const next = mkScreen(w, h)
|
||||
|
||||
paint(prev, 1, 'row one')
|
||||
paint(next, 1, 'row one')
|
||||
|
||||
const prevFrame = mkFrame(prev, w, h)
|
||||
|
||||
const nextFrame: Frame = {
|
||||
...mkFrame(next, w, h),
|
||||
scrollHint: { top: 1, bottom: 4, delta: 1 }
|
||||
}
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||
|
||||
expect(hasDecstbm(stdoutOnly(diff))).toBe(true)
|
||||
})
|
||||
|
||||
it('skips DECSTBM when scroll region touches the bottom row', () => {
|
||||
const w = 12
|
||||
const h = 6
|
||||
const prev = mkScreen(w, h)
|
||||
const next = mkScreen(w, h)
|
||||
|
||||
paint(prev, 1, 'row one')
|
||||
paint(next, 1, 'row one')
|
||||
|
||||
const prevFrame = mkFrame(prev, w, h)
|
||||
|
||||
const nextFrame: Frame = {
|
||||
...mkFrame(next, w, h),
|
||||
scrollHint: { top: 1, bottom: 5, delta: 1 }
|
||||
}
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||
|
||||
expect(hasDecstbm(stdoutOnly(diff))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,753 @@
|
||||
import { type AnsiCode, ansiCodesToString } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
import { transitionAnsiCodes } from './ansi-transition.js'
|
||||
import type { Diff, FlickerReason, Frame } from './frame.js'
|
||||
import type { Point } from './layout/geometry.js'
|
||||
import {
|
||||
type Cell,
|
||||
cellAt,
|
||||
CellWidth,
|
||||
charInCellAt,
|
||||
diffEach,
|
||||
type Hyperlink,
|
||||
isEmptyCellAt,
|
||||
type Screen,
|
||||
shiftRows,
|
||||
type StylePool,
|
||||
visibleCellAtIndex
|
||||
} from './screen.js'
|
||||
import {
|
||||
scrollDown as csiScrollDown,
|
||||
scrollUp as csiScrollUp,
|
||||
CURSOR_HOME,
|
||||
RESET_SCROLL_REGION,
|
||||
setScrollRegion
|
||||
} from './termio/csi.js'
|
||||
import { LINK_END, link as oscLink } from './termio/osc.js'
|
||||
|
||||
type State = {
|
||||
previousOutput: string
|
||||
}
|
||||
|
||||
type Options = {
|
||||
isTTY: boolean
|
||||
stylePool: StylePool
|
||||
}
|
||||
|
||||
const CARRIAGE_RETURN = { type: 'carriageReturn' } as const
|
||||
const NEWLINE = { type: 'stdout', content: '\n' } as const
|
||||
|
||||
export class LogUpdate {
|
||||
private state: State
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.state = {
|
||||
previousOutput: ''
|
||||
}
|
||||
}
|
||||
|
||||
renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
|
||||
if (!this.options.isTTY) {
|
||||
// Non-TTY output is no longer supported (string output was removed)
|
||||
return [NEWLINE]
|
||||
}
|
||||
|
||||
return this.getRenderOpsForDone(prevFrame)
|
||||
}
|
||||
|
||||
// Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
|
||||
reset(): void {
|
||||
this.state.previousOutput = ''
|
||||
}
|
||||
|
||||
private renderFullFrame(frame: Frame): Diff {
|
||||
const { screen } = frame
|
||||
const lines: string[] = []
|
||||
let currentStyles: AnsiCode[] = []
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
|
||||
for (let y = 0; y < screen.height; y++) {
|
||||
let line = ''
|
||||
|
||||
for (let x = 0; x < screen.width; x++) {
|
||||
const cell = cellAt(screen, x, y)
|
||||
|
||||
if (cell && cell.width !== CellWidth.SpacerTail) {
|
||||
// Handle hyperlink transitions
|
||||
if (cell.hyperlink !== currentHyperlink) {
|
||||
if (currentHyperlink !== undefined) {
|
||||
line += LINK_END
|
||||
}
|
||||
|
||||
if (cell.hyperlink !== undefined) {
|
||||
line += oscLink(cell.hyperlink)
|
||||
}
|
||||
|
||||
currentHyperlink = cell.hyperlink
|
||||
}
|
||||
|
||||
const cellStyles = this.options.stylePool.get(cell.styleId)
|
||||
const styleDiff = transitionAnsiCodes(currentStyles, cellStyles)
|
||||
|
||||
if (styleDiff.length > 0) {
|
||||
line += ansiCodesToString(styleDiff)
|
||||
currentStyles = cellStyles
|
||||
}
|
||||
|
||||
line += cell.char
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open hyperlink before resetting styles
|
||||
if (currentHyperlink !== undefined) {
|
||||
line += LINK_END
|
||||
currentHyperlink = undefined
|
||||
}
|
||||
|
||||
// Reset styles at end of line so trimEnd doesn't leave dangling codes
|
||||
const resetCodes = transitionAnsiCodes(currentStyles, [])
|
||||
|
||||
if (resetCodes.length > 0) {
|
||||
line += ansiCodesToString(resetCodes)
|
||||
currentStyles = []
|
||||
}
|
||||
|
||||
lines.push(line.trimEnd())
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ type: 'stdout', content: lines.join('\n') }]
|
||||
}
|
||||
|
||||
private getRenderOpsForDone(prev: Frame): Diff {
|
||||
this.state.previousOutput = ''
|
||||
|
||||
if (!prev.cursor.visible) {
|
||||
return [{ type: 'cursorShow' }]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff {
|
||||
if (!this.options.isTTY) {
|
||||
return this.renderFullFrame(next)
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const stylePool = this.options.stylePool
|
||||
|
||||
// Terminal hosts can reflow/preserve old cells on any resize, including
|
||||
// height-only growth. A partial diff can then leave stale transcript rows
|
||||
// or cut off bordered content even when our virtual scrollTop is correct.
|
||||
// Resizing is rare enough that a full repaint is the safer tradeoff.
|
||||
if (
|
||||
next.viewport.height !== prev.viewport.height ||
|
||||
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
|
||||
) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
||||
}
|
||||
|
||||
// DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
|
||||
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
|
||||
// instead of rewriting the whole scroll region. The shiftRows on
|
||||
// prev.screen simulates the shift so the diff loop below naturally
|
||||
// finds only the rows that scrolled IN as diffs. prev.screen is
|
||||
// about to become backFrame (reused next render) so mutation is safe.
|
||||
// CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset
|
||||
// homes cursor per spec but terminal implementations vary.
|
||||
//
|
||||
// decstbmSafe: caller passes false when the DECSTBM→diff sequence
|
||||
// can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the
|
||||
// outer terminal renders the intermediate state — region scrolled,
|
||||
// edge rows not yet painted — a visible vertical jump on every frame
|
||||
// where scrollTop moves. Falling through to the diff loop writes all
|
||||
// shifted rows: more bytes, no intermediate state. next.screen from
|
||||
// render-node-to-output's blit+shift is correct either way.
|
||||
let scrollPatch: Diff = []
|
||||
|
||||
if (altScreen && next.scrollHint && decstbmSafe) {
|
||||
const { top, bottom, delta } = next.scrollHint
|
||||
|
||||
// Keep DECSTBM away from the terminal's last visible row. In alt-screen
|
||||
// layouts we reserve that lane for status/cursor parking, and scrolling
|
||||
// it can leave transient ghosting/bleed artifacts until a later repaint.
|
||||
if (top >= 0 && bottom < prev.screen.height - 1 && bottom < next.screen.height - 1) {
|
||||
shiftRows(prev.screen, top, bottom, delta)
|
||||
scrollPatch = [
|
||||
{
|
||||
type: 'stdout',
|
||||
content:
|
||||
setScrollRegion(top + 1, bottom + 1) +
|
||||
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
|
||||
RESET_SCROLL_REGION +
|
||||
CURSOR_HOME
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// We have to use purely relative operations to manipulate the cursor since
|
||||
// we don't know its starting point.
|
||||
//
|
||||
// When content height >= viewport height AND cursor is at the bottom,
|
||||
// the cursor restore at the end of the previous frame caused terminal scroll.
|
||||
// viewportY tells us how many rows are in scrollback from content overflow.
|
||||
// Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
|
||||
// We need fullReset if any changes are to rows that are now in scrollback.
|
||||
//
|
||||
// This early full-reset check only applies in "steady state" (not growing).
|
||||
// For growing, the viewportY calculation below (with cursorRestoreScroll)
|
||||
// catches unreachable scrollback rows in the diff loop instead.
|
||||
const cursorAtBottom = prev.cursor.y >= prev.screen.height
|
||||
const isGrowing = next.screen.height > prev.screen.height
|
||||
|
||||
// When content fills the viewport exactly (height == viewport) and the
|
||||
// cursor is at the bottom, the cursor-restore LF at the end of the
|
||||
// previous frame scrolled 1 row into scrollback. Use >= to catch this.
|
||||
const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height
|
||||
|
||||
const isShrinking = next.screen.height < prev.screen.height
|
||||
const nextFitsViewport = next.screen.height <= prev.viewport.height
|
||||
|
||||
// When shrinking from above-viewport to at-or-below-viewport, content that
|
||||
// was in scrollback should now be visible. Terminal clear operations can't
|
||||
// bring scrollback content into view, so we need a full reset.
|
||||
// Use <= (not <) because even when next height equals viewport height, the
|
||||
// scrollback depth from the previous render differs from a fresh render.
|
||||
if (prevHadScrollback && nextFitsViewport && isShrinking) {
|
||||
logForDebugging(
|
||||
`Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`
|
||||
)
|
||||
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
|
||||
}
|
||||
|
||||
if (
|
||||
altScreen &&
|
||||
prev.screen.height >= prev.viewport.height &&
|
||||
prev.screen.height > 0 &&
|
||||
cursorAtBottom &&
|
||||
!isGrowing
|
||||
) {
|
||||
// viewportY = rows in scrollback from content overflow
|
||||
// +1 for the row pushed by cursor-restore scroll
|
||||
const viewportY = prev.screen.height - prev.viewport.height
|
||||
const scrollbackRows = viewportY + 1
|
||||
|
||||
let scrollbackChangeY = -1
|
||||
diffEach(prev.screen, next.screen, (_x, y) => {
|
||||
if (y < scrollbackRows) {
|
||||
scrollbackChangeY = y
|
||||
|
||||
return true // early exit
|
||||
}
|
||||
})
|
||||
|
||||
if (scrollbackChangeY >= 0) {
|
||||
const prevLine = readLine(prev.screen, scrollbackChangeY)
|
||||
const nextLine = readLine(next.screen, scrollbackChangeY)
|
||||
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||
triggerY: scrollbackChangeY,
|
||||
prevLine,
|
||||
nextLine
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const screen = new VirtualScreen(prev.cursor, next.viewport.width)
|
||||
|
||||
// Treat empty screen as height 1 to avoid spurious adjustments on first render
|
||||
const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1)
|
||||
|
||||
const shrinking = heightDelta < 0
|
||||
const growing = heightDelta > 0
|
||||
|
||||
// Handle shrinking: clear lines from the bottom
|
||||
if (shrinking) {
|
||||
const linesToClear = prev.screen.height - next.screen.height
|
||||
|
||||
// eraseLines only works within the viewport - it can't clear scrollback.
|
||||
// If we need to clear more lines than fit in the viewport, some are in
|
||||
// scrollback, so we need a full reset.
|
||||
if (linesToClear > prev.viewport.height) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', this.options.stylePool)
|
||||
}
|
||||
|
||||
// clear(N) moves cursor UP by N-1 lines and to column 0
|
||||
// This puts us at line prev.screen.height - N = next.screen.height
|
||||
// But we want to be at next.screen.height - 1 (bottom of new screen)
|
||||
screen.txn(prev => [
|
||||
[
|
||||
{ type: 'clear', count: linesToClear },
|
||||
{ type: 'cursorMove', x: 0, y: -1 }
|
||||
],
|
||||
{ dx: -prev.x, dy: -linesToClear }
|
||||
])
|
||||
}
|
||||
|
||||
// viewportY = number of rows in scrollback (not visible on terminal).
|
||||
// For shrinking: use max(prev, next) because terminal clears don't scroll.
|
||||
// For growing: use prev state because new rows haven't scrolled old ones yet.
|
||||
// When prevHadScrollback, add 1 for the cursor-restore LF that scrolled
|
||||
// an additional row out of view at the end of the previous frame. Without
|
||||
// this, the diff loop treats that row as reachable — but the cursor clamps
|
||||
// at viewport top, causing writes to land 1 row off and garbling the output.
|
||||
const cursorRestoreScroll = prevHadScrollback ? 1 : 0
|
||||
|
||||
const viewportY = growing
|
||||
? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll)
|
||||
: Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll
|
||||
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
|
||||
// First pass: render changes to existing rows (rows < prev.screen.height)
|
||||
let needsFullReset = false
|
||||
let resetTriggerY = -1
|
||||
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
|
||||
// Skip new rows - we'll render them directly after
|
||||
if (growing && y >= prev.screen.height) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip spacers during rendering because the terminal will automatically
|
||||
// advance 2 columns when we write the wide character itself.
|
||||
// SpacerTail: Second cell of a wide character
|
||||
// SpacerHead: Marks line-end position where wide char wraps to next line
|
||||
if (added && (added.width === CellWidth.SpacerTail || added.width === CellWidth.SpacerHead)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (removed && (removed.width === CellWidth.SpacerTail || removed.width === CellWidth.SpacerHead) && !added) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip empty cells that don't need to overwrite existing content.
|
||||
// This prevents writing trailing spaces that would cause unnecessary
|
||||
// line wrapping at the edge of the screen.
|
||||
// Uses isEmptyCellAt to check if both packed words are zero (empty cell).
|
||||
if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the cell outside the viewport range has changed, we need to reset
|
||||
// because we can't move the cursor there to draw. In main-screen mode,
|
||||
// those rows are already in terminal scrollback and invisible; resetting
|
||||
// on every scrollback-only update can loop when a resize changes the
|
||||
// physical buffer. Shrink-to-visible cases are handled above.
|
||||
if (y < viewportY) {
|
||||
if (!altScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
needsFullReset = true
|
||||
resetTriggerY = y
|
||||
|
||||
return true // early exit
|
||||
}
|
||||
|
||||
moveCursorTo(screen, x, y)
|
||||
|
||||
if (added) {
|
||||
const targetHyperlink = added.hyperlink
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||
const styleStr = stylePool.transition(currentStyleId, added.styleId)
|
||||
|
||||
if (writeCellWithStyleStr(screen, added, styleStr)) {
|
||||
currentStyleId = added.styleId
|
||||
}
|
||||
} else if (removed) {
|
||||
// Cell was removed - clear it with a space
|
||||
// (This handles shrinking content)
|
||||
// Reset any active styles/hyperlinks first to avoid leaking into cleared cells
|
||||
const styleIdToReset = currentStyleId
|
||||
const hyperlinkToReset = currentHyperlink
|
||||
currentStyleId = stylePool.none
|
||||
currentHyperlink = undefined
|
||||
|
||||
screen.txn(() => {
|
||||
const patches: Diff = []
|
||||
transitionStyle(patches, stylePool, styleIdToReset, stylePool.none)
|
||||
transitionHyperlink(patches, hyperlinkToReset, undefined)
|
||||
patches.push({ type: 'stdout', content: ' ' })
|
||||
|
||||
return [patches, { dx: 1, dy: 0 }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (needsFullReset) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||
triggerY: resetTriggerY,
|
||||
prevLine: readLine(prev.screen, resetTriggerY),
|
||||
nextLine: readLine(next.screen, resetTriggerY)
|
||||
})
|
||||
}
|
||||
|
||||
// Reset styles before rendering new rows (they'll set their own styles)
|
||||
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
|
||||
// Handle growth: render new rows directly (they naturally scroll the terminal)
|
||||
if (growing) {
|
||||
renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool)
|
||||
}
|
||||
|
||||
// Restore cursor. Skipped in alt-screen: the cursor is hidden, its
|
||||
// position only matters as the starting point for the NEXT frame's
|
||||
// relative moves, and in alt-screen the next frame always begins with
|
||||
// CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This
|
||||
// saves a CR + cursorMove round-trip (~6-10 bytes) every frame.
|
||||
//
|
||||
// Main screen: if cursor needs to be past the last line of content
|
||||
// (typical: cursor.y = screen.height), emit \n to create that line
|
||||
// since cursor movement can't create new lines.
|
||||
if (altScreen) {
|
||||
// no-op; next frame's CSI H anchors cursor
|
||||
} else if (next.cursor.y >= next.screen.height) {
|
||||
// Move to column 0 of current line, then emit newlines to reach target row
|
||||
screen.txn(prev => {
|
||||
const rowsToCreate = next.cursor.y - prev.y
|
||||
|
||||
if (rowsToCreate > 0) {
|
||||
// Use CR to resolve pending wrap (if any) without advancing
|
||||
// to the next line, then LF to create each new row.
|
||||
const patches: Diff = new Array<Diff[number]>(1 + rowsToCreate)
|
||||
patches[0] = CARRIAGE_RETURN
|
||||
|
||||
for (let i = 0; i < rowsToCreate; i++) {
|
||||
patches[1 + i] = NEWLINE
|
||||
}
|
||||
|
||||
return [patches, { dx: -prev.x, dy: rowsToCreate }]
|
||||
}
|
||||
|
||||
// At or past target row - need to move cursor to correct position
|
||||
const dy = next.cursor.y - prev.y
|
||||
|
||||
if (dy !== 0 || prev.x !== next.cursor.x) {
|
||||
// Use CR to clear pending wrap (if any), then cursor move
|
||||
const patches: Diff = [CARRIAGE_RETURN]
|
||||
patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy })
|
||||
|
||||
return [patches, { dx: next.cursor.x - prev.x, dy }]
|
||||
}
|
||||
|
||||
return [[], { dx: 0, dy: 0 }]
|
||||
})
|
||||
} else {
|
||||
moveCursorTo(screen, next.cursor.x, next.cursor.y)
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
if (elapsed > 50) {
|
||||
const damage = next.screen.damage
|
||||
|
||||
const damageInfo = damage ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})` : 'none'
|
||||
|
||||
logForDebugging(
|
||||
`Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`
|
||||
)
|
||||
}
|
||||
|
||||
return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff
|
||||
}
|
||||
}
|
||||
|
||||
function transitionHyperlink(diff: Diff, current: Hyperlink, target: Hyperlink): Hyperlink {
|
||||
if (current !== target) {
|
||||
diff.push({ type: 'hyperlink', uri: target ?? '' })
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
function transitionStyle(diff: Diff, stylePool: StylePool, currentId: number, targetId: number): number {
|
||||
const str = stylePool.transition(currentId, targetId)
|
||||
|
||||
if (str.length > 0) {
|
||||
diff.push({ type: 'styleStr', str })
|
||||
}
|
||||
|
||||
return targetId
|
||||
}
|
||||
|
||||
function readLine(screen: Screen, y: number): string {
|
||||
let line = ''
|
||||
|
||||
for (let x = 0; x < screen.width; x++) {
|
||||
line += charInCellAt(screen, x, y) ?? ' '
|
||||
}
|
||||
|
||||
return line.trimEnd()
|
||||
}
|
||||
|
||||
function fullResetSequence_CAUSES_FLICKER(
|
||||
frame: Frame,
|
||||
reason: FlickerReason,
|
||||
stylePool: StylePool,
|
||||
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||
): Diff {
|
||||
// After clearTerminal, cursor is at (0, 0)
|
||||
const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width)
|
||||
renderFrame(screen, frame, stylePool)
|
||||
|
||||
return [{ type: 'clearTerminal', reason, debug }, ...screen.diff]
|
||||
}
|
||||
|
||||
function renderFrame(screen: VirtualScreen, frame: Frame, stylePool: StylePool): void {
|
||||
renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a slice of rows from the frame's screen.
|
||||
* Each row is rendered followed by a newline. Cursor ends at (0, endY).
|
||||
*/
|
||||
function renderFrameSlice(
|
||||
screen: VirtualScreen,
|
||||
frame: Frame,
|
||||
startY: number,
|
||||
endY: number,
|
||||
stylePool: StylePool
|
||||
): VirtualScreen {
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
// Track the styleId of the last rendered cell on this line (-1 if none).
|
||||
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
||||
let lastRenderedStyleId = -1
|
||||
|
||||
const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen
|
||||
|
||||
let index = startY * screenWidth
|
||||
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
// Advance cursor to this row using LF (not CSI CUD / cursor-down).
|
||||
// CSI CUD stops at the viewport bottom margin and cannot scroll,
|
||||
// but LF scrolls the viewport to create new lines. Without this,
|
||||
// when the cursor is at the viewport bottom, moveCursorTo's
|
||||
// cursor-down silently fails, creating a permanent off-by-one
|
||||
// between the virtual cursor and the real terminal cursor.
|
||||
if (screen.cursor.y < y) {
|
||||
const rowsToAdvance = y - screen.cursor.y
|
||||
screen.txn(prev => {
|
||||
const patches: Diff = new Array<Diff[number]>(1 + rowsToAdvance)
|
||||
patches[0] = CARRIAGE_RETURN
|
||||
|
||||
for (let i = 0; i < rowsToAdvance; i++) {
|
||||
patches[1 + i] = NEWLINE
|
||||
}
|
||||
|
||||
return [patches, { dx: -prev.x, dy: rowsToAdvance }]
|
||||
})
|
||||
}
|
||||
|
||||
// Reset at start of each line — no cell rendered yet
|
||||
lastRenderedStyleId = -1
|
||||
|
||||
for (let x = 0; x < screenWidth; x += 1, index += 1) {
|
||||
// Skip spacers, unstyled empty cells, and fg-only styled spaces that
|
||||
// match the last rendered style (since cursor-forward produces identical
|
||||
// visual result). visibleCellAtIndex handles the optimization internally
|
||||
// to avoid allocating Cell objects for skipped cells.
|
||||
const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId)
|
||||
|
||||
if (!cell) {
|
||||
continue
|
||||
}
|
||||
|
||||
moveCursorTo(screen, x, y)
|
||||
|
||||
// Handle hyperlink
|
||||
const targetHyperlink = cell.hyperlink
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||
|
||||
// Style transition — cached string, zero allocations after warmup
|
||||
const styleStr = stylePool.transition(currentStyleId, cell.styleId)
|
||||
|
||||
if (writeCellWithStyleStr(screen, cell, styleStr)) {
|
||||
currentStyleId = cell.styleId
|
||||
lastRenderedStyleId = cell.styleId
|
||||
}
|
||||
}
|
||||
|
||||
// Reset styles/hyperlinks before newline so background color doesn't
|
||||
// bleed into the next line when the terminal scrolls. The old code
|
||||
// reset implicitly by writing trailing unstyled spaces; now that we
|
||||
// skip empty cells, we must reset explicitly.
|
||||
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
// CR+LF at end of row — \r resets to column 0, \n moves to next line.
|
||||
// Without \r, the terminal cursor stays at whatever column content ended
|
||||
// (since we skip trailing spaces, this can be mid-row).
|
||||
screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }])
|
||||
}
|
||||
|
||||
// Reset any open style/hyperlink at end of slice
|
||||
transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
|
||||
return screen
|
||||
}
|
||||
|
||||
type Delta = { dx: number; dy: number }
|
||||
|
||||
/**
|
||||
* Write a cell with a pre-serialized style transition string (from
|
||||
* StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta
|
||||
* allocations on every cell.
|
||||
*
|
||||
* Returns true if the cell was written, false if skipped (wide char at
|
||||
* viewport edge). Callers MUST gate currentStyleId updates on this — when
|
||||
* skipped, styleStr is never pushed and the terminal's style state is
|
||||
* unchanged. Updating the virtual tracker anyway desyncs it from the
|
||||
* terminal, and the next transition is computed from phantom state.
|
||||
*/
|
||||
function writeCellWithStyleStr(screen: VirtualScreen, cell: Cell, styleStr: string): boolean {
|
||||
const cellWidth = cell.width === CellWidth.Wide ? 2 : 1
|
||||
const px = screen.cursor.x
|
||||
const vw = screen.viewportWidth
|
||||
|
||||
// Don't write wide chars that would cross the viewport edge.
|
||||
// Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint
|
||||
// graphemes (flags, ZWJ emoji) need stricter threshold.
|
||||
if (cellWidth === 2 && px < vw) {
|
||||
const threshold = cell.char.length > 2 ? vw : vw + 1
|
||||
|
||||
if (px + 2 >= threshold) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const diff = screen.diff
|
||||
|
||||
if (styleStr.length > 0) {
|
||||
diff.push({ type: 'styleStr', str: styleStr })
|
||||
}
|
||||
|
||||
const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char)
|
||||
|
||||
// On terminals with old wcwidth tables, a compensated emoji only advances
|
||||
// the cursor 1 column, so the CHA below skips column x+1 without painting
|
||||
// it. Write a styled space there first — on correct terminals the emoji
|
||||
// glyph (width 2) overwrites it harmlessly; on old terminals it fills the
|
||||
// gap with the emoji's background. Also clears any stale content at x+1.
|
||||
// CHA is 1-based, so column px+1 (0-based) is CHA target px+2.
|
||||
if (needsCompensation && px + 1 < vw) {
|
||||
diff.push({ type: 'cursorTo', col: px + 2 })
|
||||
diff.push({ type: 'stdout', content: ' ' })
|
||||
diff.push({ type: 'cursorTo', col: px + 1 })
|
||||
}
|
||||
|
||||
diff.push({ type: 'stdout', content: cell.char })
|
||||
|
||||
// Force terminal cursor to correct column after the emoji.
|
||||
if (needsCompensation) {
|
||||
diff.push({ type: 'cursorTo', col: px + cellWidth + 1 })
|
||||
}
|
||||
|
||||
// Update cursor — mutate in place to avoid Point allocation
|
||||
if (px >= vw) {
|
||||
screen.cursor.x = cellWidth
|
||||
screen.cursor.y++
|
||||
} else {
|
||||
screen.cursor.x = px + cellWidth
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
|
||||
screen.txn(prev => {
|
||||
const dx = targetX - prev.x
|
||||
const dy = targetY - prev.y
|
||||
const inPendingWrap = prev.x >= screen.viewportWidth
|
||||
|
||||
// If we're in pending wrap state (cursor.x >= width), use CR
|
||||
// to reset to column 0 on the current line without advancing
|
||||
// to the next line, then issue the cursor movement.
|
||||
if (inPendingWrap) {
|
||||
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||
}
|
||||
|
||||
// When moving to a different line, use carriage return (\r) to reset to
|
||||
// column 0 first, then cursor move.
|
||||
if (dy !== 0) {
|
||||
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||
}
|
||||
|
||||
// Standard same-line cursor move
|
||||
return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify emoji where the terminal's wcwidth may disagree with Unicode.
|
||||
* On terminals with correct tables, the CHA we emit is a harmless no-op.
|
||||
*
|
||||
* Two categories:
|
||||
* 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables.
|
||||
* 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1
|
||||
* in wcwidth, but VS16 triggers emoji presentation making it width 2.
|
||||
* Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764).
|
||||
*/
|
||||
function needsWidthCompensation(char: string): boolean {
|
||||
const cp = char.codePointAt(0)
|
||||
|
||||
if (cp === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0)
|
||||
// U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0)
|
||||
if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint
|
||||
// graphemes. Single BMP chars (length 1) and surrogate pairs without VS16
|
||||
// skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF).
|
||||
if (char.length >= 2) {
|
||||
for (let i = 0; i < char.length; i++) {
|
||||
if (char.charCodeAt(i) === 0xfe0f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class VirtualScreen {
|
||||
// Public for direct mutation by writeCellWithStyleStr (avoids txn overhead).
|
||||
// File-private class — not exposed outside log-update.ts.
|
||||
cursor: Point
|
||||
diff: Diff = []
|
||||
|
||||
constructor(
|
||||
origin: Point,
|
||||
readonly viewportWidth: number
|
||||
) {
|
||||
this.cursor = { ...origin }
|
||||
}
|
||||
|
||||
txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
|
||||
const [patches, next] = fn(this.cursor)
|
||||
|
||||
for (const patch of patches) {
|
||||
this.diff.push(patch)
|
||||
}
|
||||
|
||||
this.cursor.x += next.dx
|
||||
this.cursor.y += next.dy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Shared eviction for the hot Ink LRU caches (widthCache, wrapCache,
|
||||
// sliceCache, lineWidthCache). Hot-path touch-on-read stays inlined per
|
||||
// cache — only the bulk eviction is factored here.
|
||||
export function lruEvict<K, V>(cache: Map<K, V>, keepRatio: number): void {
|
||||
if (keepRatio <= 0) {
|
||||
return cache.clear()
|
||||
}
|
||||
|
||||
const target = Math.floor(cache.size * keepRatio)
|
||||
|
||||
while (cache.size > target) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
|
||||
type Output = {
|
||||
/**
|
||||
* Element width.
|
||||
*/
|
||||
width: number
|
||||
|
||||
/**
|
||||
* Element height.
|
||||
*/
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the dimensions of a particular `<Box>` element.
|
||||
*/
|
||||
const measureElement = (node: DOMElement): Output => ({
|
||||
width: node.yogaNode?.getComputedWidth() ?? 0,
|
||||
height: node.yogaNode?.getComputedHeight() ?? 0
|
||||
})
|
||||
|
||||
export default measureElement
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user