feat(tui): added tui autocomplete awaitable slash cmd returns with hints

- Added promise-based slash command completions with Awaitable returns and argumentHint metadata.
- Added kitty modifier-key decoding with super-mask support and validation of Alt/Ctrl/Super printable events.
- Added kitty printable key deduplication by filtering pending unmodified follow-up codepoints in the stdin buffer.
- Added render throttling with stop-time finalization, output normalization, and OSC 9;4 progress updates.
This commit is contained in:
can1357
2026-05-12 10:17:04 +02:00
parent cb9eef1b34
commit 718ab4e3ae
7 changed files with 194 additions and 81 deletions
+16
View File
@@ -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
+14 -5
View File
@@ -156,12 +156,15 @@ export interface AutocompleteItem {
hint?: string;
}
type Awaitable<T> = T | Promise<T>;
export interface SlashCommand {
name: string;
description?: string;
argumentHint?: string;
// Function to get argument completions for this command
// Returns null if no argument completion is available
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
/** 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)
+61 -22
View File
@@ -174,28 +174,17 @@ type SpecialKey =
| "f12";
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
type ModifierName = "ctrl" | "shift" | "alt" | "super";
type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
}[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<BaseKey>;
// =============================================================================
// 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<number, string> = {
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.
*
+29 -4
View File
@@ -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<StdinBufferEventMap> {
readonly #timeoutMs: number;
#pasteMode: boolean = false;
#pasteBuffer: string = "";
#pendingKittyPrintableCodepoint: number | undefined;
constructor(options: StdinBufferOptions = {}) {
super();
@@ -273,7 +282,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
}
if (str.length === 0 && this.#buffer.length === 0) {
this.emit("data", "");
this.#emitDataSequence("");
return;
}
@@ -290,6 +299,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.#pasteMode = false;
this.#pasteBuffer = "";
this.#pendingKittyPrintableCodepoint = undefined;
this.emit("paste", pastedContent);
@@ -306,10 +316,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
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<StdinBufferEventMap> {
this.#pasteMode = false;
this.#pasteBuffer = "";
this.#pendingKittyPrintableCodepoint = undefined;
this.emit("paste", pastedContent);
@@ -336,7 +348,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
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<StdinBufferEventMap> {
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<StdinBufferEventMap> {
const sequences = [this.#buffer];
this.#buffer = "";
this.#pendingKittyPrintableCodepoint = undefined;
return sequences;
}
@@ -373,6 +397,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.#buffer = "";
this.#pasteMode = false;
this.#pasteBuffer = "";
this.#pendingKittyPrintableCodepoint = undefined;
}
getBuffer(): string {
+68 -49
View File
@@ -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
+1 -1
View File
@@ -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 => {
+5
View File
@@ -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
/**