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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user