diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3a286e53e..07ccf87ae 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,7 +1,23 @@ # Changelog ## [Unreleased] +### Added +- Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions +- Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions +- Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input + +### Changed + +- Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text +- Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates +- Changed terminal input handling to process complete cell-size responses without buffering partial input +- Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation + +### Fixed + +- Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text +- Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event ## [14.9.5] - 2026-05-12 ### Fixed diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 2ea7229e9..c6bdec1c9 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -156,12 +156,15 @@ export interface AutocompleteItem { hint?: string; } +type Awaitable = T | Promise; + export interface SlashCommand { name: string; description?: string; + argumentHint?: string; // Function to get argument completions for this command // Returns null if no argument completion is available - getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; + getArgumentCompletions?(argumentPrefix: string): Awaitable; /** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */ getInlineHint?(argumentText: string): string | null; } @@ -268,11 +271,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Score name matches higher than description matches const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0; const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0; + const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; + const desc = cmd.description ?? ""; + const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; return { value: name, label: "name" in cmd ? cmd.name : cmd.label, score: Math.max(nameScore, descScore), - ...(cmd.description && { description: cmd.description }), + ...(fullDesc && { description: fullDesc }), }; }) .sort((a, b) => b.score - a.score) @@ -297,8 +303,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return null; // No argument completion for this command } - const argumentSuggestions = command.getArgumentCompletions(argumentText); - if (!argumentSuggestions || argumentSuggestions.length === 0) { + const argumentSuggestions = await command.getArgumentCompletions(argumentText); + if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) { return null; } @@ -802,11 +808,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const lowerDesc = cmd.description?.toLowerCase() ?? ""; const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0; const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0; + const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; + const desc = cmd.description ?? ""; + const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; return { value: name, label: "name" in cmd ? cmd.name : cmd.label, score: Math.max(nameScore, descScore), - ...(cmd.description && { description: cmd.description }), + ...(fullDesc && { description: fullDesc }), } as AutocompleteItem & { score: number }; }) .sort((a, b) => b.score - a.score) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 55f8f05cd..b68c82ac8 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -174,28 +174,17 @@ type SpecialKey = | "f12"; type BaseKey = Letter | Digit | SymbolKey | SpecialKey; +type ModifierName = "ctrl" | "shift" | "alt" | "super"; + +type ModifiedKeyId = { + [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId>}`; +}[RemainingModifiers]; /** * Union type of all valid key identifiers. * Provides autocomplete and catches typos at compile time. */ -export type KeyId = - | BaseKey - | `ctrl+${BaseKey}` - | `shift+${BaseKey}` - | `alt+${BaseKey}` - | `ctrl+shift+${BaseKey}` - | `shift+ctrl+${BaseKey}` - | `ctrl+alt+${BaseKey}` - | `alt+ctrl+${BaseKey}` - | `shift+alt+${BaseKey}` - | `alt+shift+${BaseKey}` - | `ctrl+shift+alt+${BaseKey}` - | `ctrl+alt+shift+${BaseKey}` - | `shift+ctrl+alt+${BaseKey}` - | `shift+alt+ctrl+${BaseKey}` - | `alt+ctrl+shift+${BaseKey}` - | `alt+shift+ctrl+${BaseKey}`; +export type KeyId = BaseKey | ModifiedKeyId; // ============================================================================= // Kitty Protocol Parsing @@ -218,7 +207,10 @@ const KITTY_CSI_U_PATTERN = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\ const KITTY_MOD_SHIFT = 1; const KITTY_MOD_ALT = 2; const KITTY_MOD_CTRL = 4; +const KITTY_MOD_SUPER = 8; const KITTY_MOD_NUM_LOCK = 128; +const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock +const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/; const KITTY_KEYPAD_OPERATOR_TEXT: Record = { 57410: "/", 57411: "*", @@ -311,11 +303,11 @@ function decodeKittyPrintable(data: string): string | undefined { const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; - const effectiveMod = modifier & ~(64 + 128); - const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL; + const effectiveMod = modifier & ~KITTY_LOCK_MASK; + const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER; if (effectiveMod & ~supportedModifierMask) return undefined; - if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined; + if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER)) return undefined; const textField = match[6]; if (textField && textField.length > 0) { @@ -365,12 +357,59 @@ function decodeKittyPrintable(data: string): string | undefined { * keypad digits, keypad operators, and shifted symbols the same as direct character input. */ export function extractPrintableText(data: string): string | undefined { - const kittyText = decodeKittyPrintable(data); - if (kittyText) return kittyText; + const printable = decodePrintableKey(data); + if (printable !== undefined) return printable; if (data.length === 0 || hasControlChars(data)) return undefined; return data; } +interface ParsedModifyOtherKeysSequence { + codepoint: number; + modifier: number; +} + +/** + * Parse an xterm `modifyOtherKeys` format sequence: `CSI 27 ; modifiers ; keycode ~`. + * Modifier values are 1-indexed in the wire format; we normalize to a 0-based bitmask. + */ +function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null { + const match = data.match(MODIFY_OTHER_KEYS_PATTERN); + if (!match) return null; + const modValue = Number.parseInt(match[1] ?? "", 10); + const codepoint = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(modValue) || !Number.isFinite(codepoint)) return null; + return { codepoint, modifier: modValue - 1 }; +} + +/** + * Decode an xterm modifyOtherKeys sequence into the printable character it represents. + * + * Only sequences with no modifiers or Shift alone produce text; Ctrl/Alt/Super combos + * are treated as bindings, not text input. + */ +function decodeModifyOtherKeysPrintable(data: string): string | undefined { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return undefined; + const modifier = parsed.modifier & ~KITTY_LOCK_MASK; + if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined; + if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined; + try { + return String.fromCodePoint(parsed.codepoint); + } catch { + return undefined; + } +} + +/** + * Decode terminal input into the printable character it represents. + * + * Tries Kitty CSI-u first, then falls back to xterm modifyOtherKeys. Returns + * undefined for control sequences and modifier-only events. + */ +export function decodePrintableKey(data: string): string | undefined { + return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); +} + /** * Match input data against a key identifier string. * diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 097af9eda..845dc75e2 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -180,6 +180,14 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" { /** * Split accumulated buffer into complete sequences */ +function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined { + const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/); + if (!match) return undefined; + + const codepoint = parseInt(match[1]!, 10); + return codepoint >= 32 ? codepoint : undefined; +} + function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { const sequences: string[] = []; let pos = 0; @@ -245,6 +253,7 @@ export class StdinBuffer extends EventEmitter { readonly #timeoutMs: number; #pasteMode: boolean = false; #pasteBuffer: string = ""; + #pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); @@ -273,7 +282,7 @@ export class StdinBuffer extends EventEmitter { } if (str.length === 0 && this.#buffer.length === 0) { - this.emit("data", ""); + this.#emitDataSequence(""); return; } @@ -290,6 +299,7 @@ export class StdinBuffer extends EventEmitter { this.#pasteMode = false; this.#pasteBuffer = ""; + this.#pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); @@ -306,10 +316,11 @@ export class StdinBuffer extends EventEmitter { const beforePaste = this.#buffer.slice(0, startIndex); const result = extractCompleteSequences(beforePaste); for (const sequence of result.sequences) { - this.emit("data", sequence); + this.#emitDataSequence(sequence); } } + this.#pendingKittyPrintableCodepoint = undefined; this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length); this.#pasteMode = true; this.#pasteBuffer = this.#buffer; @@ -322,6 +333,7 @@ export class StdinBuffer extends EventEmitter { this.#pasteMode = false; this.#pasteBuffer = ""; + this.#pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); @@ -336,7 +348,7 @@ export class StdinBuffer extends EventEmitter { this.#buffer = result.remainder; for (const sequence of result.sequences) { - this.emit("data", sequence); + this.#emitDataSequence(sequence); } if (this.#buffer.length > 0) { @@ -344,12 +356,23 @@ export class StdinBuffer extends EventEmitter { const flushed = this.flush(); for (const sequence of flushed) { - this.emit("data", sequence); + this.#emitDataSequence(sequence); } }, this.#timeoutMs); } } + #emitDataSequence(sequence: string): void { + const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; + if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) { + this.#pendingKittyPrintableCodepoint = undefined; + return; + } + + this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence); + this.emit("data", sequence); + } + flush(): string[] { if (this.#timeout) { clearTimeout(this.#timeout); @@ -362,6 +385,7 @@ export class StdinBuffer extends EventEmitter { const sequences = [this.#buffer]; this.#buffer = ""; + this.#pendingKittyPrintableCodepoint = undefined; return sequences; } @@ -373,6 +397,7 @@ export class StdinBuffer extends EventEmitter { this.#buffer = ""; this.#pasteMode = false; this.#pasteBuffer = ""; + this.#pendingKittyPrintableCodepoint = undefined; } getBuffer(): string { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index abf13485d..78dd19bec 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -3,11 +3,20 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; +import { performance } from "node:perf_hooks"; import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils"; import { isKeyRelease, matchesKey } from "./keys"; import type { Terminal } from "./terminal"; import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities"; -import { Ellipsis, extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "./utils"; +import { + Ellipsis, + extractSegments, + normalizeTerminalOutput, + sliceByColumn, + sliceWithWidth, + truncateToWidth, + visibleWidth, +} from "./utils"; const SEGMENT_RESET = "\x1b[0m"; @@ -218,11 +227,12 @@ export class TUI extends Container { /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ onDebug?: () => void; #renderRequested = false; + #renderTimer: NodeJS.Timeout | undefined; + #lastRenderAt = 0; + static readonly #MIN_RENDER_INTERVAL_MS = 16; #cursorRow = 0; // Logical cursor row (end of rendered content) #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) #viewportTopRow = 0; // Content row currently mapped to screen row 0 - #inputBuffer = ""; // Buffer for parsing terminal responses - #cellSizeQueryPending = false; #sixelProbePendingDa = false; #sixelProbePendingGraphics = false; #sixelProbeBuffer = ""; @@ -540,13 +550,16 @@ export class TUI extends Container { } // Query terminal for cell size in pixels: CSI 16 t // Response format: CSI 6 ; height ; width t - this.#cellSizeQueryPending = true; this.terminal.write("\x1b[16t"); } stop(): void { this.#clearSixelProbeState(); this.#stopped = true; + if (this.#renderTimer) { + clearTimeout(this.#renderTimer); + this.#renderTimer = undefined; + } // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.#previousLines.length > 0) { const targetRow = this.#previousLines.length; // Line after the last content @@ -572,13 +585,44 @@ export class TUI extends Container { this.#hardwareCursorRow = 0; this.#viewportTopRow = 0; this.#maxLinesRendered = 0; + if (this.#renderTimer) { + clearTimeout(this.#renderTimer); + this.#renderTimer = undefined; + } + this.#renderRequested = true; + process.nextTick(() => { + if (this.#stopped || !this.#renderRequested) { + return; + } + this.#renderRequested = false; + this.#lastRenderAt = performance.now(); + this.#doRender(); + }); + return; } if (this.#renderRequested) return; this.#renderRequested = true; - process.nextTick(() => { + process.nextTick(() => this.#scheduleRender()); + } + + #scheduleRender(): void { + if (this.#stopped || this.#renderTimer || !this.#renderRequested) { + return; + } + const elapsed = performance.now() - this.#lastRenderAt; + const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed); + this.#renderTimer = setTimeout(() => { + this.#renderTimer = undefined; + if (this.#stopped || !this.#renderRequested) { + return; + } this.#renderRequested = false; + this.#lastRenderAt = performance.now(); this.#doRender(); - }); + if (this.#renderRequested) { + this.#scheduleRender(); + } + }, delay); } #handleInput(data: string): void { @@ -599,12 +643,9 @@ export class TUI extends Container { data = current; } - // If we're waiting for cell size response, buffer input and parse - if (this.#cellSizeQueryPending) { - this.#inputBuffer += data; - const filtered = this.#parseCellSizeResponse(); - if (filtered.length === 0) return; - data = filtered; + // Consume terminal cell size responses without blocking unrelated input. + if (this.#consumeCellSizeResponse(data)) { + return; } // Global debug key handler (Shift+Ctrl+D) @@ -639,46 +680,24 @@ export class TUI extends Container { } } - #parseCellSizeResponse(): string { + #consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t - // Match the response pattern - const responsePattern = /\x1b\[6;(\d+);(\d+)t/; - const match = this.#inputBuffer.match(responsePattern); - - if (match) { - const heightPx = parseInt(match[1], 10); - const widthPx = parseInt(match[2], 10); - - if (heightPx > 0 && widthPx > 0) { - setCellDimensions({ widthPx, heightPx }); - // Invalidate all components so images re-render with correct dimensions - this.invalidate(); - this.requestRender(); - } - - // Remove the response from buffer - this.#inputBuffer = this.#inputBuffer.replace(responsePattern, ""); - this.#cellSizeQueryPending = false; + const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); + if (!match) { + return false; } - // Check if we have a partial cell size response starting (wait for more data) - // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet) - const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; - if (partialCellSizePattern.test(this.#inputBuffer)) { - // Check if it's actually a complete different escape sequence (ends with a letter) - // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc. - const lastChar = this.#inputBuffer[this.#inputBuffer.length - 1]; - if (!/[a-zA-Z~]/.test(lastChar)) { - // Doesn't end with a terminator, might be incomplete - wait for more - return ""; - } + const heightPx = parseInt(match[1], 10); + const widthPx = parseInt(match[2], 10); + if (heightPx <= 0 || widthPx <= 0) { + return true; } - // No cell size response found, return buffered data as user input - const result = this.#inputBuffer; - this.#inputBuffer = ""; - this.#cellSizeQueryPending = false; // Give up waiting - return result; + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions. + this.invalidate(); + this.requestRender(); + return true; } /** @@ -1016,7 +1035,7 @@ export class TUI extends Container { for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; const line = newLines[i]; - buffer += TERMINAL.isImageLine(line) ? line : line + reset; + buffer += TERMINAL.isImageLine(line) ? line : normalizeTerminalOutput(line) + reset; } this.#cursorRow = Math.max(0, newLines.length - 1); const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow); @@ -1229,7 +1248,7 @@ export class TUI extends Container { } truncatedLine = truncateToWidth(line, width, Ellipsis.Omit); } - buffer += isImage ? truncatedLine : truncatedLine + SEGMENT_RESET; + buffer += isImage ? truncatedLine : normalizeTerminalOutput(truncatedLine) + SEGMENT_RESET; } // Track where cursor ended up after rendering diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 14ba4b0a8..19cfd7798 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -123,7 +123,7 @@ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; */ export function normalizeTerminalOutput(str: string): string { if (!THAI_LAO_AM_REGEX.test(str)) return str; - return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); + return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); } const makeBoolArray = (chars: string): Uint8Array => { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index c3890105e..406b20492 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -108,6 +108,11 @@ export class VirtualTerminal implements Terminal { this.xterm.write(`\x1b]0;${title}\x07`); } + setProgress(active: boolean): void { + // OSC 9;4 progress sequence; no-op in tests beyond writing through to xterm. + this.xterm.write(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07"); + } + // Test-specific methods not in Terminal interface /**