@oh-my-pi/pi-tui 14.9.5 → 14.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.9.8] - 2026-05-12
6
+
7
+ ### Added
8
+
9
+ - Added `Terminal.setProgress(active)` to emit OSC 9;4 progress sequences with a ~1s keepalive interval so Ghostty does not clear the indicator during long-running work (ports pi-mono `a900d251` + `76bc605a`)
10
+ - Added optional `argumentHint?: string` to `SlashCommand`; rendered before the description in the autocomplete dropdown (ports pi-mono `aa25726e`)
11
+ - Added `VirtualTerminal.waitForRender()` test helper for the throttled render pipeline (ports pi-mono `41377ee8`)
12
+
13
+ ### Changed
14
+
15
+ - `ProcessTerminal` `columns`/`rows` getters consult `Bun.env.COLUMNS` / `Bun.env.LINES` before falling back to 80×24, so piped/non-TTY runs honour environment-provided dimensions (ports pi-mono `32f7fc6a`)
16
+ - `requestRender()` non-force calls are coalesced to a ~16ms frame budget; `requestRender(true)` still flushes immediately via `process.nextTick` (ports pi-mono `6f5f37f8`)
17
+ - `KNOWN_TERMINALS.base` / `KNOWN_TERMINALS.trueColor` default `hyperlinks: false`; tmux and screen (`TMUX` env or `TERM` starts with `tmux`/`screen`) force `hyperlinks: false` even when the outer terminal would advertise OSC 8 (adapts pi-mono `30a8a41f`)
18
+ - `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`)
19
+ - Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`)
20
+ - Plain `@<query>` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`)
21
+
22
+ ### Fixed
23
+
24
+ - Fixed editor corruption on Thai Sara Am (U+0E33) and Lao AM (U+0EB3) vowels by normalizing to their compatibility decompositions on the terminal-write path while keeping editor content logically unchanged (ports pi-mono `bc668826` + `338ce3a3` + `20ca45d5`)
25
+ - Fixed cell-size detection (`CSI 6;h;w t` response) to consume only exact replies, so a bare `Escape` keystroke is no longer swallowed while waiting for terminal image metadata (ports pi-mono `49c0d860`)
26
+ - Fixed Kitty CSI-u printable input duplicating on layouts (e.g. Italian) where the terminal also emits the raw character: the immediately-following matching codepoint is now suppressed (ports pi-mono `bdb416cb`)
27
+ - Fixed bracketed-paste CSI-u `Ctrl+<letter>` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[<code>;5u` into the editor; control bytes are decoded back to their literal byte before per-char filtering (ports pi-mono `d06db09a`)
28
+ - Fixed xterm `modifyOtherKeys` shifted printable input so uppercase letters inserted via `CSI 27;mod;codepoint~` reach the editor correctly (ports pi-mono `6b55d685`)
29
+ - Fixed `super`-modified Kitty shortcuts (`super+k`, `ctrl+super+enter`, …) to parse and match via the new `KITTY_MOD_SUPER` mask (ports pi-mono `ddb8454c` + `5ed46003`)
30
+ - Fixed `ctrl+alt+<letter>` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC<ctrl-char>` form does not match (ports pi-mono `6cf5098f`)
31
+ - Fixed Markdown strikethrough requiring strict `~~text~~` delimiters with non-whitespace boundaries; single tildes no longer render strikethrough (ports pi-mono `db5274b4`)
32
+
33
+ - Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions
34
+ - Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions
35
+ - Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input
36
+
37
+ ### Changed
38
+
39
+ - Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text
40
+ - Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates
41
+ - Changed terminal input handling to process complete cell-size responses without buffering partial input
42
+ - Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation
43
+
44
+ ### Fixed
45
+
46
+ - Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text
47
+ - Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event
5
48
 
6
49
  ## [14.9.5] - 2026-05-12
7
50
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "14.9.5",
4
+ "version": "14.9.8",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "14.9.5",
41
- "@oh-my-pi/pi-utils": "14.9.5",
40
+ "@oh-my-pi/pi-natives": "14.9.8",
41
+ "@oh-my-pi/pi-utils": "14.9.8",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -156,12 +156,15 @@ export interface AutocompleteItem {
156
156
  hint?: string;
157
157
  }
158
158
 
159
+ type Awaitable<T> = T | Promise<T>;
160
+
159
161
  export interface SlashCommand {
160
162
  name: string;
161
163
  description?: string;
164
+ argumentHint?: string;
162
165
  // Function to get argument completions for this command
163
166
  // Returns null if no argument completion is available
164
- getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
167
+ getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
165
168
  /** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
166
169
  getInlineHint?(argumentText: string): string | null;
167
170
  }
@@ -268,11 +271,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
268
271
  // Score name matches higher than description matches
269
272
  const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
270
273
  const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
274
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
275
+ const desc = cmd.description ?? "";
276
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
271
277
  return {
272
278
  value: name,
273
279
  label: "name" in cmd ? cmd.name : cmd.label,
274
280
  score: Math.max(nameScore, descScore),
275
- ...(cmd.description && { description: cmd.description }),
281
+ ...(fullDesc && { description: fullDesc }),
276
282
  };
277
283
  })
278
284
  .sort((a, b) => b.score - a.score)
@@ -297,8 +303,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
297
303
  return null; // No argument completion for this command
298
304
  }
299
305
 
300
- const argumentSuggestions = command.getArgumentCompletions(argumentText);
301
- if (!argumentSuggestions || argumentSuggestions.length === 0) {
306
+ const argumentSuggestions = await command.getArgumentCompletions(argumentText);
307
+ if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
302
308
  return null;
303
309
  }
304
310
 
@@ -802,11 +808,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
802
808
  const lowerDesc = cmd.description?.toLowerCase() ?? "";
803
809
  const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
804
810
  const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
811
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
812
+ const desc = cmd.description ?? "";
813
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
805
814
  return {
806
815
  value: name,
807
816
  label: "name" in cmd ? cmd.name : cmd.label,
808
817
  score: Math.max(nameScore, descScore),
809
- ...(cmd.description && { description: cmd.description }),
818
+ ...(fullDesc && { description: fullDesc }),
810
819
  } as AutocompleteItem & { score: number };
811
820
  })
812
821
  .sort((a, b) => b.score - a.score)
@@ -1543,8 +1543,20 @@ export class Editor implements Component, Focusable {
1543
1543
  this.#recordUndoState();
1544
1544
 
1545
1545
  this.#withUndoSuspended(() => {
1546
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1547
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1548
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1549
+ // per-char filter below preserves newlines instead of stripping ESC and
1550
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1551
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1552
+ const cp = Number(code);
1553
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1554
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1555
+ return match;
1556
+ });
1557
+
1546
1558
  // Clean the pasted text
1547
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1559
+ const cleanText = decodedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1548
1560
 
1549
1561
  // Convert tabs to spaces (4 spaces per tab)
1550
1562
  const tabExpandedText = cleanText.replace(/\t/g, " ");
@@ -2427,7 +2439,7 @@ export class Editor implements Component, Focusable {
2427
2439
  );
2428
2440
  if (requestId !== this.#autocompleteRequestId) return;
2429
2441
 
2430
- if (suggestions && suggestions.items.length > 0) {
2442
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2431
2443
  this.#autocompletePrefix = suggestions.prefix;
2432
2444
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2433
2445
  this.#autocompleteState = "regular";
@@ -2491,7 +2503,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2491
2503
  );
2492
2504
  if (requestId !== this.#autocompleteRequestId) return;
2493
2505
 
2494
- if (suggestions && suggestions.items.length > 0) {
2506
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2495
2507
  // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
2496
2508
  if (explicitTab && suggestions.items.length === 1) {
2497
2509
  const item = suggestions.items[0]!;
@@ -2557,7 +2569,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2557
2569
  );
2558
2570
  if (requestId !== this.#autocompleteRequestId) return;
2559
2571
 
2560
- if (suggestions && suggestions.items.length > 0) {
2572
+ if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2561
2573
  this.#autocompletePrefix = suggestions.prefix;
2562
2574
  // Always create new SelectList to ensure update
2563
2575
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
@@ -1,10 +1,34 @@
1
1
  import { LRUCache } from "lru-cache/raw";
2
- import { marked, type Token, type Tokens } from "marked";
2
+ import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
3
3
  import type { SymbolTheme } from "../symbols";
4
4
  import { TERMINAL } from "../terminal-capabilities";
5
5
  import type { Component } from "../tui";
6
6
  import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
7
7
 
8
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
9
+
10
+ class StrictStrikethroughTokenizer extends Tokenizer {
11
+ override del(src: string): Tokens.Del | undefined {
12
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
13
+ if (!match) {
14
+ return undefined;
15
+ }
16
+
17
+ const text = match[2];
18
+ return {
19
+ type: "del",
20
+ raw: match[0],
21
+ text,
22
+ tokens: this.lexer.inlineTokens(text),
23
+ };
24
+ }
25
+ }
26
+
27
+ const markdownParser = new Marked();
28
+ markdownParser.setOptions({
29
+ tokenizer: new StrictStrikethroughTokenizer(),
30
+ });
31
+
8
32
  // ---------------------------------------------------------------------------
9
33
  // Module-level LRU render cache
10
34
  // ---------------------------------------------------------------------------
@@ -172,7 +196,18 @@ export class Markdown implements Component {
172
196
  // L2: module-level LRU — survives component disposal/recreation across
173
197
  // session-tree navigations. Key encodes every dimension that affects the
174
198
  // render output so different configurations never collide.
175
- const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}`;
199
+ // Encode terminal capability state and theme/style function output samples
200
+ // so that capability shifts (image protocol changes, hyperlink toggle) or
201
+ // caller-supplied theme/bgColor functions that mutate their output without
202
+ // changing object identity invalidate the cache entry.
203
+ // bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
204
+ // pass arbitrary bytes through verbatim, so this is safe and minimizes the
205
+ // risk of clashing with a function that returns text verbatim.
206
+ // theme.heading is used as the representative theme probe — it's required
207
+ // by MarkdownTheme and is one of the most styling-sensitive entries.
208
+ const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
209
+ const headingProbe = this.#theme.heading("");
210
+ const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
176
211
  const cached = renderCache.get(cacheKey);
177
212
  if (cached !== undefined) {
178
213
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
@@ -183,7 +218,7 @@ export class Markdown implements Component {
183
218
  }
184
219
 
185
220
  // Parse markdown to HTML-like tokens
186
- const tokens = marked.lexer(normalizedText);
221
+ const tokens = markdownParser.lexer(normalizedText);
187
222
 
188
223
  // Convert tokens to styled terminal output
189
224
  const renderedLines: string[] = [];
@@ -582,6 +617,14 @@ export class Markdown implements Component {
582
617
  }
583
618
  }
584
619
 
620
+ // Strip dangling re-opened-default SGR prefix left over from the last inline
621
+ // token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
622
+ // at its last styled segment instead of carrying an unmatched SGR open into
623
+ // the next line. Matches upstream behavior.
624
+ while (stylePrefix && result.endsWith(stylePrefix)) {
625
+ result = result.slice(0, -stylePrefix.length);
626
+ }
627
+
585
628
  return result;
586
629
  }
587
630
 
package/src/keys.ts CHANGED
@@ -174,28 +174,17 @@ type SpecialKey =
174
174
  | "f12";
175
175
 
176
176
  type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
177
+ type ModifierName = "ctrl" | "shift" | "alt" | "super";
178
+
179
+ type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
180
+ [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
181
+ }[RemainingModifiers];
177
182
 
178
183
  /**
179
184
  * Union type of all valid key identifiers.
180
185
  * Provides autocomplete and catches typos at compile time.
181
186
  */
182
- export type KeyId =
183
- | BaseKey
184
- | `ctrl+${BaseKey}`
185
- | `shift+${BaseKey}`
186
- | `alt+${BaseKey}`
187
- | `ctrl+shift+${BaseKey}`
188
- | `shift+ctrl+${BaseKey}`
189
- | `ctrl+alt+${BaseKey}`
190
- | `alt+ctrl+${BaseKey}`
191
- | `shift+alt+${BaseKey}`
192
- | `alt+shift+${BaseKey}`
193
- | `ctrl+shift+alt+${BaseKey}`
194
- | `ctrl+alt+shift+${BaseKey}`
195
- | `shift+ctrl+alt+${BaseKey}`
196
- | `shift+alt+ctrl+${BaseKey}`
197
- | `alt+ctrl+shift+${BaseKey}`
198
- | `alt+shift+ctrl+${BaseKey}`;
187
+ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
199
188
 
200
189
  // =============================================================================
201
190
  // Kitty Protocol Parsing
@@ -218,7 +207,10 @@ const KITTY_CSI_U_PATTERN = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\
218
207
  const KITTY_MOD_SHIFT = 1;
219
208
  const KITTY_MOD_ALT = 2;
220
209
  const KITTY_MOD_CTRL = 4;
210
+ const KITTY_MOD_SUPER = 8;
221
211
  const KITTY_MOD_NUM_LOCK = 128;
212
+ const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
213
+ const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
222
214
  const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
223
215
  57410: "/",
224
216
  57411: "*",
@@ -311,11 +303,11 @@ function decodeKittyPrintable(data: string): string | undefined {
311
303
  const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
312
304
  const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
313
305
  const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
314
- const effectiveMod = modifier & ~(64 + 128);
315
- const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL;
306
+ const effectiveMod = modifier & ~KITTY_LOCK_MASK;
307
+ const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER;
316
308
 
317
309
  if (effectiveMod & ~supportedModifierMask) return undefined;
318
- if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
310
+ if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER)) return undefined;
319
311
 
320
312
  const textField = match[6];
321
313
  if (textField && textField.length > 0) {
@@ -365,12 +357,59 @@ function decodeKittyPrintable(data: string): string | undefined {
365
357
  * keypad digits, keypad operators, and shifted symbols the same as direct character input.
366
358
  */
367
359
  export function extractPrintableText(data: string): string | undefined {
368
- const kittyText = decodeKittyPrintable(data);
369
- if (kittyText) return kittyText;
360
+ const printable = decodePrintableKey(data);
361
+ if (printable !== undefined) return printable;
370
362
  if (data.length === 0 || hasControlChars(data)) return undefined;
371
363
  return data;
372
364
  }
373
365
 
366
+ interface ParsedModifyOtherKeysSequence {
367
+ codepoint: number;
368
+ modifier: number;
369
+ }
370
+
371
+ /**
372
+ * Parse an xterm `modifyOtherKeys` format sequence: `CSI 27 ; modifiers ; keycode ~`.
373
+ * Modifier values are 1-indexed in the wire format; we normalize to a 0-based bitmask.
374
+ */
375
+ function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null {
376
+ const match = data.match(MODIFY_OTHER_KEYS_PATTERN);
377
+ if (!match) return null;
378
+ const modValue = Number.parseInt(match[1] ?? "", 10);
379
+ const codepoint = Number.parseInt(match[2] ?? "", 10);
380
+ if (!Number.isFinite(modValue) || !Number.isFinite(codepoint)) return null;
381
+ return { codepoint, modifier: modValue - 1 };
382
+ }
383
+
384
+ /**
385
+ * Decode an xterm modifyOtherKeys sequence into the printable character it represents.
386
+ *
387
+ * Only sequences with no modifiers or Shift alone produce text; Ctrl/Alt/Super combos
388
+ * are treated as bindings, not text input.
389
+ */
390
+ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
391
+ const parsed = parseModifyOtherKeysSequence(data);
392
+ if (!parsed) return undefined;
393
+ const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
394
+ if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
395
+ if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined;
396
+ try {
397
+ return String.fromCodePoint(parsed.codepoint);
398
+ } catch {
399
+ return undefined;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Decode terminal input into the printable character it represents.
405
+ *
406
+ * Tries Kitty CSI-u first, then falls back to xterm modifyOtherKeys. Returns
407
+ * undefined for control sequences and modifier-only events.
408
+ */
409
+ export function decodePrintableKey(data: string): string | undefined {
410
+ return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
411
+ }
412
+
374
413
  /**
375
414
  * Match input data against a key identifier string.
376
415
  *
@@ -180,6 +180,14 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" {
180
180
  /**
181
181
  * Split accumulated buffer into complete sequences
182
182
  */
183
+ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
184
+ const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
185
+ if (!match) return undefined;
186
+
187
+ const codepoint = parseInt(match[1]!, 10);
188
+ return codepoint >= 32 ? codepoint : undefined;
189
+ }
190
+
183
191
  function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
184
192
  const sequences: string[] = [];
185
193
  let pos = 0;
@@ -245,6 +253,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
245
253
  readonly #timeoutMs: number;
246
254
  #pasteMode: boolean = false;
247
255
  #pasteBuffer: string = "";
256
+ #pendingKittyPrintableCodepoint: number | undefined;
248
257
 
249
258
  constructor(options: StdinBufferOptions = {}) {
250
259
  super();
@@ -273,7 +282,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
273
282
  }
274
283
 
275
284
  if (str.length === 0 && this.#buffer.length === 0) {
276
- this.emit("data", "");
285
+ this.#emitDataSequence("");
277
286
  return;
278
287
  }
279
288
 
@@ -290,6 +299,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
290
299
 
291
300
  this.#pasteMode = false;
292
301
  this.#pasteBuffer = "";
302
+ this.#pendingKittyPrintableCodepoint = undefined;
293
303
 
294
304
  this.emit("paste", pastedContent);
295
305
 
@@ -306,10 +316,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
306
316
  const beforePaste = this.#buffer.slice(0, startIndex);
307
317
  const result = extractCompleteSequences(beforePaste);
308
318
  for (const sequence of result.sequences) {
309
- this.emit("data", sequence);
319
+ this.#emitDataSequence(sequence);
310
320
  }
311
321
  }
312
322
 
323
+ this.#pendingKittyPrintableCodepoint = undefined;
313
324
  this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
314
325
  this.#pasteMode = true;
315
326
  this.#pasteBuffer = this.#buffer;
@@ -322,6 +333,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
322
333
 
323
334
  this.#pasteMode = false;
324
335
  this.#pasteBuffer = "";
336
+ this.#pendingKittyPrintableCodepoint = undefined;
325
337
 
326
338
  this.emit("paste", pastedContent);
327
339
 
@@ -336,7 +348,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
336
348
  this.#buffer = result.remainder;
337
349
 
338
350
  for (const sequence of result.sequences) {
339
- this.emit("data", sequence);
351
+ this.#emitDataSequence(sequence);
340
352
  }
341
353
 
342
354
  if (this.#buffer.length > 0) {
@@ -344,12 +356,23 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
344
356
  const flushed = this.flush();
345
357
 
346
358
  for (const sequence of flushed) {
347
- this.emit("data", sequence);
359
+ this.#emitDataSequence(sequence);
348
360
  }
349
361
  }, this.#timeoutMs);
350
362
  }
351
363
  }
352
364
 
365
+ #emitDataSequence(sequence: string): void {
366
+ const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
367
+ if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
368
+ this.#pendingKittyPrintableCodepoint = undefined;
369
+ return;
370
+ }
371
+
372
+ this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
373
+ this.emit("data", sequence);
374
+ }
375
+
353
376
  flush(): string[] {
354
377
  if (this.#timeout) {
355
378
  clearTimeout(this.#timeout);
@@ -362,6 +385,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
362
385
 
363
386
  const sequences = [this.#buffer];
364
387
  this.#buffer = "";
388
+ this.#pendingKittyPrintableCodepoint = undefined;
365
389
  return sequences;
366
390
  }
367
391
 
@@ -373,6 +397,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
373
397
  this.#buffer = "";
374
398
  this.#pasteMode = false;
375
399
  this.#pasteBuffer = "";
400
+ this.#pendingKittyPrintableCodepoint = undefined;
376
401
  }
377
402
 
378
403
  getBuffer(): string {
@@ -102,8 +102,8 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
102
102
  }
103
103
  const KNOWN_TERMINALS = Object.freeze({
104
104
  // Fallback terminals
105
- base: new TerminalInfo("base", null, false, true, NotifyProtocol.Bell),
106
- trueColor: new TerminalInfo("trueColor", null, true, true, NotifyProtocol.Bell),
105
+ base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
106
+ trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
107
107
  // Recognized terminals
108
108
  kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99),
109
109
  ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
@@ -157,19 +157,19 @@ export const TERMINAL_ID: TerminalId = (() => {
157
157
  export const TERMINAL = (() => {
158
158
  const terminal = getTerminalInfo(TERMINAL_ID);
159
159
  const forcedImageProtocol = getForcedImageProtocol();
160
+ let resolved = terminal;
160
161
  if (forcedImageProtocol !== undefined) {
161
- return new TerminalInfo(
162
+ resolved = new TerminalInfo(
162
163
  terminal.id,
163
164
  forcedImageProtocol,
164
165
  terminal.trueColor,
165
166
  terminal.hyperlinks,
166
167
  terminal.notifyProtocol,
167
168
  );
168
- }
169
- if (!terminal.imageProtocol) {
169
+ } else if (!terminal.imageProtocol) {
170
170
  const fallbackImageProtocol = getFallbackImageProtocol(terminal.id);
171
171
  if (fallbackImageProtocol) {
172
- return new TerminalInfo(
172
+ resolved = new TerminalInfo(
173
173
  terminal.id,
174
174
  fallbackImageProtocol,
175
175
  terminal.trueColor,
@@ -178,7 +178,19 @@ export const TERMINAL = (() => {
178
178
  );
179
179
  }
180
180
  }
181
- return terminal;
181
+ // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
182
+ // to the outer terminal, so force them off regardless of detected terminal.
183
+ const term = Bun.env.TERM?.toLowerCase() ?? "";
184
+ if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
185
+ resolved = new TerminalInfo(
186
+ resolved.id,
187
+ resolved.imageProtocol,
188
+ resolved.trueColor,
189
+ false,
190
+ resolved.notifyProtocol,
191
+ );
192
+ }
193
+ return resolved;
182
194
  })();
183
195
 
184
196
  type MutableTerminalInfo = {
package/src/terminal.ts CHANGED
@@ -4,6 +4,10 @@ import { $env, logger } from "@oh-my-pi/pi-utils";
4
4
  import { setKittyProtocolActive } from "./keys";
5
5
  import { StdinBuffer } from "./stdin-buffer";
6
6
 
7
+ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
8
+ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
9
+ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
10
+
7
11
  /**
8
12
  * Minimal terminal interface for TUI
9
13
  */
@@ -85,6 +89,9 @@ export interface Terminal {
85
89
  // Title operations
86
90
  setTitle(title: string): void; // Set terminal window title
87
91
 
92
+ // Progress indicator (OSC 9;4)
93
+ setProgress(active: boolean): void;
94
+
88
95
  /**
89
96
  * Register a callback for terminal appearance (dark/light) changes.
90
97
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
@@ -123,6 +130,7 @@ export class ProcessTerminal implements Terminal {
123
130
  #pendingDa1Sentinels = 0;
124
131
  #osc11PollTimer?: Timer;
125
132
  #mode2031DebounceTimer?: Timer;
133
+ #progressTimer?: ReturnType<typeof setInterval>;
126
134
 
127
135
  get kittyProtocolActive(): boolean {
128
136
  return this.#kittyProtocolActive;
@@ -505,6 +513,10 @@ export class ProcessTerminal implements Terminal {
505
513
  activeTerminal = null;
506
514
  }
507
515
 
516
+ if (this.#clearProgressTimer()) {
517
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
518
+ }
519
+
508
520
  // Disable bracketed paste mode
509
521
  this.#safeWrite("\x1b[?2004l");
510
522
 
@@ -589,11 +601,11 @@ export class ProcessTerminal implements Terminal {
589
601
  }
590
602
 
591
603
  get columns(): number {
592
- return process.stdout.columns || 80;
604
+ return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
593
605
  }
594
606
 
595
607
  get rows(): number {
596
- return process.stdout.rows || 24;
608
+ return process.stdout.rows || Number(Bun.env.LINES) || 24;
597
609
  }
598
610
 
599
611
  moveBy(lines: number): void {
@@ -631,4 +643,26 @@ export class ProcessTerminal implements Terminal {
631
643
  // OSC 0;title BEL - set terminal window title
632
644
  this.#safeWrite(`\x1b]0;${title}\x07`);
633
645
  }
646
+
647
+ setProgress(active: boolean): void {
648
+ if (active) {
649
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
650
+ if (!this.#progressTimer) {
651
+ this.#progressTimer = setInterval(() => {
652
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
653
+ }, TERMINAL_PROGRESS_KEEPALIVE_MS);
654
+ this.#progressTimer.unref?.();
655
+ }
656
+ } else {
657
+ this.#clearProgressTimer();
658
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
659
+ }
660
+ }
661
+
662
+ #clearProgressTimer(): boolean {
663
+ if (!this.#progressTimer) return false;
664
+ clearInterval(this.#progressTimer);
665
+ this.#progressTimer = undefined;
666
+ return true;
667
+ }
634
668
  }
package/src/tui.ts CHANGED
@@ -3,13 +3,29 @@
3
3
  */
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
+ import { performance } from "node:perf_hooks";
6
7
  import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
7
8
  import { isKeyRelease, matchesKey } from "./keys";
8
9
  import type { Terminal } from "./terminal";
9
10
  import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
10
- import { Ellipsis, extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "./utils";
11
+ import {
12
+ Ellipsis,
13
+ extractSegments,
14
+ normalizeTerminalOutput,
15
+ sliceByColumn,
16
+ sliceWithWidth,
17
+ truncateToWidth,
18
+ visibleWidth,
19
+ } from "./utils";
11
20
 
12
21
  const SEGMENT_RESET = "\x1b[0m";
22
+ /**
23
+ * Per-line terminator written at the end of every non-image line. Closes both
24
+ * SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
25
+ * across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
26
+ * diffing so `#previousLines` mirrors what was actually written.
27
+ */
28
+ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
13
29
 
14
30
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
15
31
  type InputListener = (data: string) => InputListenerResult;
@@ -218,11 +234,12 @@ export class TUI extends Container {
218
234
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
219
235
  onDebug?: () => void;
220
236
  #renderRequested = false;
237
+ #renderTimer: NodeJS.Timeout | undefined;
238
+ #lastRenderAt = 0;
239
+ static readonly #MIN_RENDER_INTERVAL_MS = 16;
221
240
  #cursorRow = 0; // Logical cursor row (end of rendered content)
222
241
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
223
242
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
224
- #inputBuffer = ""; // Buffer for parsing terminal responses
225
- #cellSizeQueryPending = false;
226
243
  #sixelProbePendingDa = false;
227
244
  #sixelProbePendingGraphics = false;
228
245
  #sixelProbeBuffer = "";
@@ -540,13 +557,16 @@ export class TUI extends Container {
540
557
  }
541
558
  // Query terminal for cell size in pixels: CSI 16 t
542
559
  // Response format: CSI 6 ; height ; width t
543
- this.#cellSizeQueryPending = true;
544
560
  this.terminal.write("\x1b[16t");
545
561
  }
546
562
 
547
563
  stop(): void {
548
564
  this.#clearSixelProbeState();
549
565
  this.#stopped = true;
566
+ if (this.#renderTimer) {
567
+ clearTimeout(this.#renderTimer);
568
+ this.#renderTimer = undefined;
569
+ }
550
570
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
551
571
  if (this.#previousLines.length > 0) {
552
572
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -572,13 +592,44 @@ export class TUI extends Container {
572
592
  this.#hardwareCursorRow = 0;
573
593
  this.#viewportTopRow = 0;
574
594
  this.#maxLinesRendered = 0;
595
+ if (this.#renderTimer) {
596
+ clearTimeout(this.#renderTimer);
597
+ this.#renderTimer = undefined;
598
+ }
599
+ this.#renderRequested = true;
600
+ process.nextTick(() => {
601
+ if (this.#stopped || !this.#renderRequested) {
602
+ return;
603
+ }
604
+ this.#renderRequested = false;
605
+ this.#lastRenderAt = performance.now();
606
+ this.#doRender();
607
+ });
608
+ return;
575
609
  }
576
610
  if (this.#renderRequested) return;
577
611
  this.#renderRequested = true;
578
- process.nextTick(() => {
612
+ process.nextTick(() => this.#scheduleRender());
613
+ }
614
+
615
+ #scheduleRender(): void {
616
+ if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
617
+ return;
618
+ }
619
+ const elapsed = performance.now() - this.#lastRenderAt;
620
+ const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
621
+ this.#renderTimer = setTimeout(() => {
622
+ this.#renderTimer = undefined;
623
+ if (this.#stopped || !this.#renderRequested) {
624
+ return;
625
+ }
579
626
  this.#renderRequested = false;
627
+ this.#lastRenderAt = performance.now();
580
628
  this.#doRender();
581
- });
629
+ if (this.#renderRequested) {
630
+ this.#scheduleRender();
631
+ }
632
+ }, delay);
582
633
  }
583
634
 
584
635
  #handleInput(data: string): void {
@@ -599,12 +650,9 @@ export class TUI extends Container {
599
650
  data = current;
600
651
  }
601
652
 
602
- // If we're waiting for cell size response, buffer input and parse
603
- if (this.#cellSizeQueryPending) {
604
- this.#inputBuffer += data;
605
- const filtered = this.#parseCellSizeResponse();
606
- if (filtered.length === 0) return;
607
- data = filtered;
653
+ // Consume terminal cell size responses without blocking unrelated input.
654
+ if (this.#consumeCellSizeResponse(data)) {
655
+ return;
608
656
  }
609
657
 
610
658
  // Global debug key handler (Shift+Ctrl+D)
@@ -639,46 +687,24 @@ export class TUI extends Container {
639
687
  }
640
688
  }
641
689
 
642
- #parseCellSizeResponse(): string {
690
+ #consumeCellSizeResponse(data: string): boolean {
643
691
  // Response format: ESC [ 6 ; height ; width t
644
- // Match the response pattern
645
- const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
646
- const match = this.#inputBuffer.match(responsePattern);
647
-
648
- if (match) {
649
- const heightPx = parseInt(match[1], 10);
650
- const widthPx = parseInt(match[2], 10);
651
-
652
- if (heightPx > 0 && widthPx > 0) {
653
- setCellDimensions({ widthPx, heightPx });
654
- // Invalidate all components so images re-render with correct dimensions
655
- this.invalidate();
656
- this.requestRender();
657
- }
658
-
659
- // Remove the response from buffer
660
- this.#inputBuffer = this.#inputBuffer.replace(responsePattern, "");
661
- this.#cellSizeQueryPending = false;
692
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
693
+ if (!match) {
694
+ return false;
662
695
  }
663
696
 
664
- // Check if we have a partial cell size response starting (wait for more data)
665
- // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
666
- const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
667
- if (partialCellSizePattern.test(this.#inputBuffer)) {
668
- // Check if it's actually a complete different escape sequence (ends with a letter)
669
- // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
670
- const lastChar = this.#inputBuffer[this.#inputBuffer.length - 1];
671
- if (!/[a-zA-Z~]/.test(lastChar)) {
672
- // Doesn't end with a terminator, might be incomplete - wait for more
673
- return "";
674
- }
697
+ const heightPx = parseInt(match[1], 10);
698
+ const widthPx = parseInt(match[2], 10);
699
+ if (heightPx <= 0 || widthPx <= 0) {
700
+ return true;
675
701
  }
676
702
 
677
- // No cell size response found, return buffered data as user input
678
- const result = this.#inputBuffer;
679
- this.#inputBuffer = "";
680
- this.#cellSizeQueryPending = false; // Give up waiting
681
- return result;
703
+ setCellDimensions({ widthPx, heightPx });
704
+ // Invalidate all components so images re-render with correct dimensions.
705
+ this.invalidate();
706
+ this.requestRender();
707
+ return true;
682
708
  }
683
709
 
684
710
  /**
@@ -978,6 +1004,26 @@ export class TUI extends Container {
978
1004
  return null;
979
1005
  }
980
1006
 
1007
+ /**
1008
+ * Append the per-line terminator ({@link LINE_TERMINATOR}) to every
1009
+ * non-image line and normalize for terminal rendering. Mutates the input
1010
+ * array in place so downstream diffing/storage sees exactly the bytes
1011
+ * written to the terminal — without this, the diff cache disagrees with
1012
+ * emitted output and OSC 8 hyperlink state can leak across lines.
1013
+ */
1014
+ #applyLineResets(lines: string[]): string[] {
1015
+ for (let i = 0; i < lines.length; i++) {
1016
+ const line = lines[i];
1017
+ if (TERMINAL.isImageLine(line)) continue;
1018
+ const normalized = normalizeTerminalOutput(line);
1019
+ // Only close OSC 8 hyperlinks when the line actually opened one;
1020
+ // emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
1021
+ // parser for no reason (measurable cost in xterm.js parse loop).
1022
+ lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1023
+ }
1024
+ return lines;
1025
+ }
1026
+
981
1027
  #doRender(): void {
982
1028
  if (this.#stopped) return;
983
1029
  const width = this.terminal.columns;
@@ -1002,6 +1048,12 @@ export class TUI extends Container {
1002
1048
  // Extract cursor position (marker must be found before diff comparison)
1003
1049
  const cursorPos = this.#extractCursorPosition(newLines, height);
1004
1050
 
1051
+ // Terminate every non-image line so #previousLines mirrors emitted bytes
1052
+ // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
1053
+ // because the marker is embedded mid-line, and before any diff/full render
1054
+ // path so cache comparisons stay byte-accurate.
1055
+ newLines = this.#applyLineResets(newLines);
1056
+
1005
1057
  // Width changed - need full re-render (line wrapping changes)
1006
1058
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
1007
1059
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
@@ -1012,11 +1064,11 @@ export class TUI extends Container {
1012
1064
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1013
1065
  // Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
1014
1066
  if (clear) buffer += isMultiplexer ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1015
- const reset = SEGMENT_RESET;
1016
1067
  for (let i = 0; i < newLines.length; i++) {
1017
1068
  if (i > 0) buffer += "\r\n";
1018
- const line = newLines[i];
1019
- buffer += TERMINAL.isImageLine(line) ? line : line + reset;
1069
+ // Lines were pre-terminated/normalized by #applyLineResets; image
1070
+ // lines were left untouched there.
1071
+ buffer += newLines[i];
1020
1072
  }
1021
1073
  this.#cursorRow = Math.max(0, newLines.length - 1);
1022
1074
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
@@ -1151,29 +1203,13 @@ export class TUI extends Container {
1151
1203
  return;
1152
1204
  }
1153
1205
 
1154
- // Check if firstChanged is above what was previously visible
1155
- const previousContentViewportTop = Math.max(0, this.#previousLines.length - height);
1156
- if (firstChanged < previousContentViewportTop) {
1157
- const newViewportTop = Math.max(0, newLines.length - height);
1158
- if (newViewportTop < previousContentViewportTop) {
1159
- // Viewport needs to shift up — can only be done with a full redraw
1160
- logRedraw(`viewport shift up (new=${newViewportTop} < prev=${previousContentViewportTop})`);
1161
- fullRender(true);
1162
- return;
1163
- }
1164
- // Viewport is stable or shifting down — skip invisible above-viewport changes
1165
- firstChanged = previousContentViewportTop;
1166
- if (lastChanged < firstChanged) {
1167
- // All changes are above the viewport — nothing visible to update
1168
- this.#cursorRow = Math.max(0, newLines.length - 1);
1169
- this.#maxLinesRendered = newLines.length;
1170
- this.#viewportTopRow = Math.max(0, newLines.length - height);
1171
- this.#writeCursorPosition(cursorPos, newLines.length);
1172
- this.#previousLines = newLines;
1173
- this.#previousWidth = width;
1174
- this.#previousHeight = height;
1175
- return;
1176
- }
1206
+ // Differential rendering can only touch what was actually visible.
1207
+ // Any change above the previous viewport requires a full redraw so terminal
1208
+ // scrollback ends up consistent with the new transcript state.
1209
+ if (firstChanged < prevViewportTop) {
1210
+ logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1211
+ fullRender(true);
1212
+ return;
1177
1213
  }
1178
1214
 
1179
1215
  // Render from first changed line to end
@@ -1228,8 +1264,15 @@ export class TUI extends Container {
1228
1264
  }
1229
1265
  }
1230
1266
  truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1267
+ // Re-append the terminator: truncateToWidth removes trailing
1268
+ // content past the visible-width budget, which may also drop the
1269
+ // terminator appended by #applyLineResets. Match the conditional
1270
+ // OSC 8 close strategy used there.
1271
+ truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1231
1272
  }
1232
- buffer += isImage ? truncatedLine : truncatedLine + SEGMENT_RESET;
1273
+ // Non-image lines are pre-terminated/normalized by #applyLineResets;
1274
+ // truncated lines re-append LINE_TERMINATOR above.
1275
+ buffer += truncatedLine;
1233
1276
  }
1234
1277
 
1235
1278
  // Track where cursor ended up after rendering
package/src/utils.ts CHANGED
@@ -112,12 +112,26 @@ export function visibleWidth(str: string): number {
112
112
  return visibleWidthRaw(str);
113
113
  }
114
114
 
115
- const makeBoolArray = (chars: string): ReadonlyArray<boolean> => {
116
- const table = Array.from({ length: 128 }, () => false);
115
+ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
116
+ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
117
+
118
+ /**
119
+ * Normalize text for terminal output without changing logical editor content.
120
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
121
+ * differential repaint. Their compatibility decompositions have the same cell
122
+ * width but avoid stale-cell artifacts in terminal renderers.
123
+ */
124
+ export function normalizeTerminalOutput(str: string): string {
125
+ if (!THAI_LAO_AM_REGEX.test(str)) return str;
126
+ return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
127
+ }
128
+
129
+ const makeBoolArray = (chars: string): Uint8Array => {
130
+ const table = new Uint8Array(128);
117
131
  for (let i = 0; i < chars.length; i++) {
118
132
  const code = chars.charCodeAt(i);
119
133
  if (code < table.length) {
120
- table[code] = true;
134
+ table[code] = 1;
121
135
  }
122
136
  }
123
137
  return table;
@@ -129,8 +143,8 @@ const ASCII_WHITESPACE = makeBoolArray("\x09\x0a\x0b\x0c\x0d\x20");
129
143
  * Check if a character is whitespace.
130
144
  */
131
145
  export function isWhitespaceChar(char: string): boolean {
132
- const code = char.codePointAt(0) || 0;
133
- return ASCII_WHITESPACE[code] ?? false;
146
+ const code = char.codePointAt(0) ?? 0;
147
+ return code < 128 && ASCII_WHITESPACE[code] === 1;
134
148
  }
135
149
 
136
150
  const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
@@ -139,8 +153,8 @@ const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
139
153
  * Check if a character is punctuation.
140
154
  */
141
155
  export function isPunctuationChar(char: string): boolean {
142
- const code = char.codePointAt(0) || 0;
143
- return ASCII_PUNCTUATION[code] ?? false;
156
+ const code = char.codePointAt(0) ?? 0;
157
+ return code < 128 && ASCII_PUNCTUATION[code] === 1;
144
158
  }
145
159
 
146
160
  export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";