@oh-my-pi/pi-tui 4.5.0 → 4.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "4.5.0",
3
+ "version": "4.7.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -278,6 +278,7 @@ export class Editor implements Component {
278
278
  // Bracketed paste mode buffering
279
279
  private pasteBuffer: string = "";
280
280
  private isInPaste: boolean = false;
281
+ private pendingShiftEnter: boolean = false;
281
282
 
282
283
  // Prompt history for up/down navigation
283
284
  private history: string[] = [];
@@ -573,6 +574,21 @@ export class Editor implements Component {
573
574
 
574
575
  // Handle special key combinations first
575
576
 
577
+ if (this.pendingShiftEnter) {
578
+ if (data === "\r") {
579
+ this.pendingShiftEnter = false;
580
+ this.addNewLine();
581
+ return;
582
+ }
583
+ this.pendingShiftEnter = false;
584
+ this.insertCharacter("\\");
585
+ }
586
+
587
+ if (data === "\\") {
588
+ this.pendingShiftEnter = true;
589
+ return;
590
+ }
591
+
576
592
  // Ctrl+C - Exit (let parent handle this)
577
593
  if (isCtrlC(data)) {
578
594
  return;
@@ -904,6 +920,19 @@ export class Editor implements Component {
904
920
  return this.state.lines.join("\n");
905
921
  }
906
922
 
923
+ /**
924
+ * Get text with paste markers expanded to their actual content.
925
+ * Use this when you need the full content (e.g., for external editor).
926
+ */
927
+ getExpandedText(): string {
928
+ let result = this.state.lines.join("\n");
929
+ for (const [pasteId, pasteContent] of this.pastes) {
930
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
931
+ result = result.replace(markerRegex, pasteContent);
932
+ }
933
+ return result;
934
+ }
935
+
907
936
  getLines(): string[] {
908
937
  return [...this.state.lines];
909
938
  }
@@ -1,20 +1,4 @@
1
1
  import { getEditorKeybindings } from "../keybindings";
2
- import {
3
- isAltBackspace,
4
- isAltLeft,
5
- isAltRight,
6
- isArrowLeft,
7
- isArrowRight,
8
- isBackspace,
9
- isCtrlA,
10
- isCtrlE,
11
- isCtrlK,
12
- isCtrlLeft,
13
- isCtrlRight,
14
- isCtrlU,
15
- isCtrlW,
16
- isDelete,
17
- } from "../keys";
18
2
  import type { Component } from "../tui";
19
3
  import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils";
20
4
 
@@ -32,6 +16,7 @@ export class Input implements Component {
32
16
  // Bracketed paste mode buffering
33
17
  private pasteBuffer: string = "";
34
18
  private isInPaste: boolean = false;
19
+ private pendingShiftEnter: boolean = false;
35
20
 
36
21
  getValue(): string {
37
22
  return this.value;
@@ -79,23 +64,39 @@ export class Input implements Component {
79
64
  }
80
65
  return;
81
66
  }
67
+
68
+ if (this.pendingShiftEnter) {
69
+ if (data === "\r") {
70
+ this.pendingShiftEnter = false;
71
+ if (this.onSubmit) this.onSubmit(this.value);
72
+ return;
73
+ }
74
+ this.pendingShiftEnter = false;
75
+ this.value = `${this.value.slice(0, this.cursor)}\\${this.value.slice(this.cursor)}`;
76
+ this.cursor += 1;
77
+ }
78
+
79
+ if (data === "\\") {
80
+ this.pendingShiftEnter = true;
81
+ return;
82
+ }
83
+
82
84
  const kb = getEditorKeybindings();
85
+
86
+ // Escape/Cancel
83
87
  if (kb.matches(data, "selectCancel")) {
84
- this.onEscape?.();
88
+ if (this.onEscape) this.onEscape();
85
89
  return;
86
90
  }
87
91
 
88
- // Handle special keys
92
+ // Submit
89
93
  if (kb.matches(data, "submit") || data === "\n") {
90
- // Enter - submit
91
- if (this.onSubmit) {
92
- this.onSubmit(this.value);
93
- }
94
+ if (this.onSubmit) this.onSubmit(this.value);
94
95
  return;
95
96
  }
96
97
 
97
- if (isBackspace(data)) {
98
- // Backspace - delete grapheme before cursor (handles emojis, etc.)
98
+ // Deletion
99
+ if (kb.matches(data, "deleteCharBackward")) {
99
100
  if (this.cursor > 0) {
100
101
  const beforeCursor = this.value.slice(0, this.cursor);
101
102
  const graphemes = [...segmenter.segment(beforeCursor)];
@@ -107,83 +108,70 @@ export class Input implements Component {
107
108
  return;
108
109
  }
109
110
 
110
- if (isArrowLeft(data)) {
111
- // Left arrow - move by one grapheme (handles emojis, etc.)
112
- if (this.cursor > 0) {
113
- const beforeCursor = this.value.slice(0, this.cursor);
114
- const graphemes = [...segmenter.segment(beforeCursor)];
115
- const lastGrapheme = graphemes[graphemes.length - 1];
116
- this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
117
- }
118
- return;
119
- }
120
-
121
- if (isArrowRight(data)) {
122
- // Right arrow - move by one grapheme (handles emojis, etc.)
111
+ if (kb.matches(data, "deleteCharForward")) {
123
112
  if (this.cursor < this.value.length) {
124
113
  const afterCursor = this.value.slice(this.cursor);
125
114
  const graphemes = [...segmenter.segment(afterCursor)];
126
115
  const firstGrapheme = graphemes[0];
127
- this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
116
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
117
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
128
118
  }
129
119
  return;
130
120
  }
131
121
 
132
- if (isDelete(data)) {
133
- // Delete - delete grapheme at cursor (handles emojis, etc.)
134
- if (this.cursor < this.value.length) {
135
- const afterCursor = this.value.slice(this.cursor);
136
- const graphemes = [...segmenter.segment(afterCursor)];
137
- const firstGrapheme = graphemes[0];
138
- const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
139
- this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
140
- }
122
+ if (kb.matches(data, "deleteWordBackward")) {
123
+ this.deleteWordBackwards();
141
124
  return;
142
125
  }
143
126
 
144
- if (isCtrlA(data)) {
145
- // Ctrl+A - beginning of line
127
+ if (kb.matches(data, "deleteToLineStart")) {
128
+ this.value = this.value.slice(this.cursor);
146
129
  this.cursor = 0;
147
130
  return;
148
131
  }
149
132
 
150
- if (isCtrlE(data)) {
151
- // Ctrl+E - end of line
152
- this.cursor = this.value.length;
133
+ if (kb.matches(data, "deleteToLineEnd")) {
134
+ this.value = this.value.slice(0, this.cursor);
153
135
  return;
154
136
  }
155
137
 
156
- if (isCtrlW(data)) {
157
- // Ctrl+W - delete word backwards
158
- this.deleteWordBackwards();
138
+ // Cursor movement
139
+ if (kb.matches(data, "cursorLeft")) {
140
+ if (this.cursor > 0) {
141
+ const beforeCursor = this.value.slice(0, this.cursor);
142
+ const graphemes = [...segmenter.segment(beforeCursor)];
143
+ const lastGrapheme = graphemes[graphemes.length - 1];
144
+ this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
145
+ }
159
146
  return;
160
147
  }
161
148
 
162
- if (isAltBackspace(data)) {
163
- // Option/Alt+Backspace - delete word backwards
164
- this.deleteWordBackwards();
149
+ if (kb.matches(data, "cursorRight")) {
150
+ if (this.cursor < this.value.length) {
151
+ const afterCursor = this.value.slice(this.cursor);
152
+ const graphemes = [...segmenter.segment(afterCursor)];
153
+ const firstGrapheme = graphemes[0];
154
+ this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
155
+ }
165
156
  return;
166
157
  }
167
158
 
168
- if (isCtrlU(data)) {
169
- // Ctrl+U - delete from cursor to start of line
170
- this.value = this.value.slice(this.cursor);
159
+ if (kb.matches(data, "cursorLineStart")) {
171
160
  this.cursor = 0;
172
161
  return;
173
162
  }
174
163
 
175
- if (isCtrlK(data)) {
176
- // Ctrl+K - delete from cursor to end of line
177
- this.value = this.value.slice(0, this.cursor);
164
+ if (kb.matches(data, "cursorLineEnd")) {
165
+ this.cursor = this.value.length;
178
166
  return;
179
167
  }
180
168
 
181
- if (isCtrlLeft(data) || isAltLeft(data)) {
169
+ if (kb.matches(data, "cursorWordLeft")) {
182
170
  this.moveWordBackwards();
183
171
  return;
184
172
  }
185
173
 
186
- if (isCtrlRight(data) || isAltRight(data)) {
174
+ if (kb.matches(data, "cursorWordRight")) {
187
175
  this.moveWordForwards();
188
176
  return;
189
177
  }
@@ -136,10 +136,8 @@ export class Markdown implements Component {
136
136
  if (bgFn) {
137
137
  contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
138
138
  } else {
139
- // No background - just pad to width
140
- const visibleLen = visibleWidth(lineWithMargins);
141
- const paddingNeeded = Math.max(0, width - visibleLen);
142
- contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
139
+ // No background - don't pad (avoids trailing spaces when copying)
140
+ contentLines.push(lineWithMargins);
143
141
  }
144
142
  }
145
143
 
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils";
2
+ import { applyBackgroundToLine, wrapTextWithAnsi } from "../utils";
3
3
 
4
4
  /**
5
5
  * Text component - displays multi-line text with word wrapping
@@ -83,10 +83,8 @@ export class Text implements Component {
83
83
  if (this.customBgFn) {
84
84
  contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
85
85
  } else {
86
- // No background - just pad to width with spaces
87
- const visibleLen = visibleWidth(lineWithMargins);
88
- const paddingNeeded = Math.max(0, width - visibleLen);
89
- contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
86
+ // No background - don't pad (avoids trailing spaces when copying)
87
+ contentLines.push(lineWithMargins);
90
88
  }
91
89
  }
92
90
 
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import { truncateToWidth, visibleWidth } from "../utils";
2
+ import { truncateToWidth } from "../utils";
3
3
 
4
4
  /**
5
5
  * Text component that truncates to fit viewport width
@@ -48,12 +48,8 @@ export class TruncatedText implements Component {
48
48
  const rightPadding = " ".repeat(this.paddingX);
49
49
  const lineWithPadding = leftPadding + displayText + rightPadding;
50
50
 
51
- // Pad line to exactly width characters
52
- const lineVisibleWidth = visibleWidth(lineWithPadding);
53
- const paddingNeeded = Math.max(0, width - lineVisibleWidth);
54
- const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
55
-
56
- result.push(finalLine);
51
+ // Don't pad to full width - avoids trailing spaces when copying
52
+ result.push(lineWithPadding);
57
53
 
58
54
  // Add vertical padding below
59
55
  for (let i = 0; i < this.paddingY; i++) {
package/src/fuzzy.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Fuzzy matching utilities.
3
+ * Matches if all query characters appear in order (not necessarily consecutive).
4
+ * Lower score = better match.
5
+ */
6
+
7
+ export interface FuzzyMatch {
8
+ matches: boolean;
9
+ score: number;
10
+ }
11
+
12
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
13
+ const queryLower = query.toLowerCase();
14
+ const textLower = text.toLowerCase();
15
+
16
+ if (queryLower.length === 0) {
17
+ return { matches: true, score: 0 };
18
+ }
19
+
20
+ if (queryLower.length > textLower.length) {
21
+ return { matches: false, score: 0 };
22
+ }
23
+
24
+ let queryIndex = 0;
25
+ let score = 0;
26
+ let lastMatchIndex = -1;
27
+ let consecutiveMatches = 0;
28
+
29
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
30
+ if (textLower[i] === queryLower[queryIndex]) {
31
+ const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
32
+
33
+ // Reward consecutive matches
34
+ if (lastMatchIndex === i - 1) {
35
+ consecutiveMatches++;
36
+ score -= consecutiveMatches * 5;
37
+ } else {
38
+ consecutiveMatches = 0;
39
+ // Penalize gaps
40
+ if (lastMatchIndex >= 0) {
41
+ score += (i - lastMatchIndex - 1) * 2;
42
+ }
43
+ }
44
+
45
+ // Reward word boundary matches
46
+ if (isWordBoundary) {
47
+ score -= 10;
48
+ }
49
+
50
+ // Slight penalty for later matches
51
+ score += i * 0.1;
52
+
53
+ lastMatchIndex = i;
54
+ queryIndex++;
55
+ }
56
+ }
57
+
58
+ if (queryIndex < queryLower.length) {
59
+ return { matches: false, score: 0 };
60
+ }
61
+
62
+ return { matches: true, score };
63
+ }
64
+
65
+ /**
66
+ * Filter and sort items by fuzzy match quality (best matches first).
67
+ * Supports space-separated tokens: all tokens must match.
68
+ */
69
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
70
+ if (!query.trim()) {
71
+ return items;
72
+ }
73
+
74
+ const tokens = query
75
+ .trim()
76
+ .split(/\s+/)
77
+ .filter((t) => t.length > 0);
78
+
79
+ if (tokens.length === 0) {
80
+ return items;
81
+ }
82
+
83
+ const results: { item: T; totalScore: number }[] = [];
84
+
85
+ for (const item of items) {
86
+ const text = getText(item);
87
+ let totalScore = 0;
88
+ let allMatch = true;
89
+
90
+ for (const token of tokens) {
91
+ const match = fuzzyMatch(token, text);
92
+ if (match.matches) {
93
+ totalScore += match.score;
94
+ } else {
95
+ allMatch = false;
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (allMatch) {
101
+ results.push({ item, totalScore });
102
+ }
103
+ }
104
+
105
+ results.sort((a, b) => a.totalScore - b.totalScore);
106
+ return results.map((r) => r.item);
107
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export { Text } from "./components/text";
23
23
  export { TruncatedText } from "./components/truncated-text";
24
24
  // Editor component interface (for custom editors)
25
25
  export type { EditorComponent } from "./editor-component";
26
+ // Fuzzy matching
27
+ export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy";
26
28
  // Keybindings
27
29
  export {
28
30
  DEFAULT_EDITOR_KEYBINDINGS,
@@ -61,7 +61,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
61
61
  deleteToLineStart: "ctrl+u",
62
62
  deleteToLineEnd: "ctrl+k",
63
63
  // Text input
64
- newLine: ["shift+enter", "alt+enter"],
64
+ newLine: "shift+enter",
65
65
  submit: "enter",
66
66
  tab: "tab",
67
67
  // Selection/autocomplete
package/src/keys.ts CHANGED
@@ -311,7 +311,17 @@ interface ParsedKittySequence {
311
311
  * Only meaningful when Kitty keyboard protocol with flag 2 is active.
312
312
  */
313
313
  export function isKeyRelease(data: string): boolean {
314
- return (
314
+ // Don't treat bracketed paste content as key release, even if it contains
315
+ // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5").
316
+ // Terminal.ts re-wraps paste content with bracketed paste markers before
317
+ // passing to TUI, so pasted data will always contain \x1b[200~.
318
+ if (data.includes("\x1b[200~")) {
319
+ return false;
320
+ }
321
+
322
+ // Quick check: release events with flag 2 contain ":3"
323
+ // Format: \x1b[<codepoint>;<modifier>:3u
324
+ if (
315
325
  data.includes(":3u") ||
316
326
  data.includes(":3~") ||
317
327
  data.includes(":3A") ||
@@ -320,7 +330,10 @@ export function isKeyRelease(data: string): boolean {
320
330
  data.includes(":3D") ||
321
331
  data.includes(":3H") ||
322
332
  data.includes(":3F")
323
- );
333
+ ) {
334
+ return true;
335
+ }
336
+ return false;
324
337
  }
325
338
 
326
339
  /**
@@ -328,7 +341,13 @@ export function isKeyRelease(data: string): boolean {
328
341
  * Only meaningful when Kitty keyboard protocol with flag 2 is active.
329
342
  */
330
343
  export function isKeyRepeat(data: string): boolean {
331
- return (
344
+ // Don't treat bracketed paste content as key repeat, even if it contains
345
+ // patterns like ":2F". See isKeyRelease() for details.
346
+ if (data.includes("\x1b[200~")) {
347
+ return false;
348
+ }
349
+
350
+ if (
332
351
  data.includes(":2u") ||
333
352
  data.includes(":2~") ||
334
353
  data.includes(":2A") ||
@@ -337,7 +356,10 @@ export function isKeyRepeat(data: string): boolean {
337
356
  data.includes(":2D") ||
338
357
  data.includes(":2H") ||
339
358
  data.includes(":2F")
340
- );
359
+ ) {
360
+ return true;
361
+ }
362
+ return false;
341
363
  }
342
364
 
343
365
  function parseEventType(eventTypeStr: string | undefined): KeyEventType {
@@ -17,7 +17,7 @@
17
17
  * MIT License - Copyright (c) 2025 opentui
18
18
  */
19
19
 
20
- import { EventEmitter } from "node:events";
20
+ import { EventEmitter } from "events";
21
21
 
22
22
  const ESC = "\x1b";
23
23
  const BRACKETED_PASTE_START = "\x1b[200~";
package/src/terminal.ts CHANGED
@@ -98,6 +98,12 @@ export class ProcessTerminal implements Terminal {
98
98
  // Set up resize handler immediately
99
99
  process.stdout.on("resize", this.resizeHandler);
100
100
 
101
+ // Refresh terminal dimensions - they may be stale after suspend/resume
102
+ // (SIGWINCH is lost while process is stopped). Unix only.
103
+ if (process.platform !== "win32") {
104
+ process.kill(process.pid, "SIGWINCH");
105
+ }
106
+
101
107
  // Query and enable Kitty keyboard protocol
102
108
  // The query handler intercepts input temporarily, then installs the user's handler
103
109
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
@@ -107,13 +113,34 @@ export class ProcessTerminal implements Terminal {
107
113
  /**
108
114
  * Set up StdinBuffer to split batched input into individual sequences.
109
115
  * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
110
- * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query.
116
+ *
117
+ * Also watches for Kitty protocol response and enables it when detected.
118
+ * This is done here (after stdinBuffer parsing) rather than on raw stdin
119
+ * to handle the case where the response arrives split across multiple events.
111
120
  */
112
121
  private setupStdinBuffer(): void {
113
122
  this.stdinBuffer = new StdinBuffer({ timeout: 10 });
114
123
 
124
+ // Kitty protocol response pattern: \x1b[?<flags>u
125
+ const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
126
+
115
127
  // Forward individual sequences to the input handler
116
128
  this.stdinBuffer.on("data", (sequence: string) => {
129
+ // Check for Kitty protocol response (only if not already enabled)
130
+ if (!this._kittyProtocolActive) {
131
+ const match = sequence.match(kittyResponsePattern);
132
+ if (match) {
133
+ this._kittyProtocolActive = true;
134
+ setKittyProtocolActive(true);
135
+
136
+ // Enable Kitty keyboard protocol (push flags)
137
+ // Flag 1 = disambiguate escape codes
138
+ // Flag 2 = report event types (press/repeat/release)
139
+ process.stdout.write("\x1b[>3u");
140
+ return; // Don't forward protocol response to TUI
141
+ }
142
+ }
143
+
117
144
  if (this.inputHandler) {
118
145
  this.inputHandler(sequence);
119
146
  }
@@ -127,7 +154,6 @@ export class ProcessTerminal implements Terminal {
127
154
  });
128
155
 
129
156
  // Handler that pipes stdin data through the buffer
130
- // Registration happens after Kitty protocol query completes
131
157
  this.stdinDataHandler = (data: string) => {
132
158
  this.stdinBuffer!.process(data);
133
159
  };
@@ -139,81 +165,13 @@ export class ProcessTerminal implements Terminal {
139
165
  * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
140
166
  * it supports the protocol and we enable it with CSI > 1 u.
141
167
  *
142
- * Non-supporting terminals won't respond, so we use a timeout.
168
+ * The response is detected in setupStdinBuffer's data handler, which properly
169
+ * handles the case where the response arrives split across multiple stdin events.
143
170
  */
144
171
  private queryAndEnableKittyProtocol(): void {
145
- const QUERY_TIMEOUT_MS = 100;
146
- let resolved = false;
147
- let buffer = "";
148
-
149
- // Kitty protocol response pattern: \x1b[?<flags>u
150
- const kittyResponsePattern = /\x1b\[\?(\d+)u/;
151
-
152
- const queryHandler = (data: string) => {
153
- if (resolved) {
154
- // Query phase done, forward to StdinBuffer
155
- if (this.stdinBuffer) {
156
- this.stdinBuffer.process(data);
157
- }
158
- return;
159
- }
160
-
161
- buffer += data;
162
-
163
- // Check if we have a Kitty protocol response
164
- const match = buffer.match(kittyResponsePattern);
165
- if (match) {
166
- resolved = true;
167
- this._kittyProtocolActive = true;
168
- setKittyProtocolActive(true);
169
-
170
- // Enable Kitty keyboard protocol (push flags)
171
- // Flag 1 = disambiguate escape codes
172
- // Flag 2 = report event types (press/repeat/release)
173
- process.stdout.write("\x1b[>3u");
174
-
175
- // Remove the response from buffer, forward any remaining input through StdinBuffer
176
- const remaining = buffer.replace(kittyResponsePattern, "");
177
- if (remaining && this.stdinBuffer) {
178
- this.stdinBuffer.process(remaining);
179
- }
180
-
181
- // Replace query handler with StdinBuffer handler
182
- process.stdin.removeListener("data", queryHandler);
183
- if (this.stdinDataHandler) {
184
- process.stdin.on("data", this.stdinDataHandler);
185
- }
186
- }
187
- };
188
-
189
- // Set up StdinBuffer before query (it will receive input after query completes)
190
172
  this.setupStdinBuffer();
191
-
192
- // Temporarily intercept input for the query (before StdinBuffer)
193
- process.stdin.on("data", queryHandler);
194
-
195
- // Send query
173
+ process.stdin.on("data", this.stdinDataHandler!);
196
174
  process.stdout.write("\x1b[?u");
197
-
198
- // Timeout: if no response, terminal doesn't support Kitty protocol
199
- setTimeout(() => {
200
- if (!resolved) {
201
- resolved = true;
202
- this._kittyProtocolActive = false;
203
- setKittyProtocolActive(false);
204
-
205
- // Forward any buffered input that wasn't a Kitty response through StdinBuffer
206
- if (buffer && this.stdinBuffer) {
207
- this.stdinBuffer.process(buffer);
208
- }
209
-
210
- // Replace query handler with StdinBuffer handler
211
- process.stdin.removeListener("data", queryHandler);
212
- if (this.stdinDataHandler) {
213
- process.stdin.on("data", this.stdinDataHandler);
214
- }
215
- }
216
- }, QUERY_TIMEOUT_MS);
217
175
  }
218
176
 
219
177
  stop(): void {
package/src/tui.ts CHANGED
@@ -175,6 +175,18 @@ export class TUI extends Container {
175
175
  }
176
176
 
177
177
  stop(): void {
178
+ // Move cursor to the end of the content to prevent overwriting/artifacts on exit
179
+ if (this.previousLines.length > 0) {
180
+ const targetRow = this.previousLines.length; // Line after the last content
181
+ const lineDiff = targetRow - this.cursorRow;
182
+ if (lineDiff > 0) {
183
+ this.terminal.write(`\x1b[${lineDiff}B`);
184
+ } else if (lineDiff < 0) {
185
+ this.terminal.write(`\x1b[${-lineDiff}A`);
186
+ }
187
+ this.terminal.write("\r\n");
188
+ }
189
+
178
190
  this.terminal.showCursor();
179
191
  this.terminal.stop();
180
192
  }
@@ -186,7 +198,7 @@ export class TUI extends Container {
186
198
  requestRender(force = false): void {
187
199
  if (force) {
188
200
  this.previousLines = [];
189
- this.previousWidth = 0;
201
+ this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
190
202
  this.cursorRow = 0;
191
203
  this.previousCursor = null;
192
204
  }
@@ -351,6 +363,11 @@ export class TUI extends Container {
351
363
 
352
364
  private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
353
365
 
366
+ private applyLineResets(lines: string[]): string[] {
367
+ const reset = TUI.SEGMENT_RESET;
368
+ return lines.map((line) => (this.containsImage(line) ? line : line + reset));
369
+ }
370
+
354
371
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
355
372
  private compositeLineAt(
356
373
  baseLine: string,
@@ -408,13 +425,15 @@ export class TUI extends Container {
408
425
  newLines = this.compositeOverlays(newLines, width, height);
409
426
  }
410
427
 
428
+ newLines = this.applyLineResets(newLines);
429
+
411
430
  const cursorInfo = this.getCursorPosition(width);
412
431
 
413
432
  // Width changed - need full re-render
414
433
  const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
415
434
 
416
- // First render - just output everything without clearing
417
- if (this.previousLines.length === 0) {
435
+ // First render - just output everything without clearing (assumes clean screen)
436
+ if (this.previousLines.length === 0 && !widthChanged) {
418
437
  let buffer = "\x1b[?2026h"; // Begin synchronized output
419
438
  for (let i = 0; i < newLines.length; i++) {
420
439
  if (i > 0) buffer += "\r\n";
@@ -451,6 +470,7 @@ export class TUI extends Container {
451
470
 
452
471
  // Find first and last changed lines
453
472
  let firstChanged = -1;
473
+ let lastChanged = -1;
454
474
  const maxLines = Math.max(newLines.length, this.previousLines.length);
455
475
  for (let i = 0; i < maxLines; i++) {
456
476
  const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
@@ -460,6 +480,7 @@ export class TUI extends Container {
460
480
  if (firstChanged === -1) {
461
481
  firstChanged = i;
462
482
  }
483
+ lastChanged = i;
463
484
  }
464
485
  }
465
486
 
@@ -472,6 +493,31 @@ export class TUI extends Container {
472
493
  return;
473
494
  }
474
495
 
496
+ // All changes are in deleted lines (nothing to render, just clear)
497
+ if (firstChanged >= newLines.length) {
498
+ if (this.previousLines.length > newLines.length) {
499
+ let buffer = "\x1b[?2026h";
500
+ // Move to end of new content
501
+ const targetRow = newLines.length - 1;
502
+ const lineDiff = targetRow - currentCursorRow;
503
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
504
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
505
+ buffer += "\r";
506
+ // Clear extra lines
507
+ const extraLines = this.previousLines.length - newLines.length;
508
+ for (let i = 0; i < extraLines; i++) {
509
+ buffer += "\r\n\x1b[2K";
510
+ }
511
+ buffer += `\x1b[${extraLines}A`;
512
+ buffer += "\x1b[?2026l";
513
+ this.terminal.write(buffer);
514
+ this.cursorRow = newLines.length - 1;
515
+ }
516
+ this.previousLines = newLines;
517
+ this.previousWidth = width;
518
+ return;
519
+ }
520
+
475
521
  // Check if firstChanged is outside the viewport
476
522
  // Use snapshotted cursor position for consistent viewport calculation
477
523
  // Viewport shows lines from (currentCursorRow - height + 1) to currentCursorRow
@@ -509,9 +555,10 @@ export class TUI extends Container {
509
555
 
510
556
  buffer += "\r"; // Move to column 0
511
557
 
512
- // Render from first changed line to end, clearing each line before writing
513
- // This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js
514
- for (let i = firstChanged; i < newLines.length; i++) {
558
+ // Only render changed lines (firstChanged to lastChanged), not all lines to end
559
+ // This reduces flicker when only a single line changes (e.g., spinner animation)
560
+ const renderEnd = Math.min(lastChanged, newLines.length - 1);
561
+ for (let i = firstChanged; i <= renderEnd; i++) {
515
562
  if (i > firstChanged) buffer += "\r\n";
516
563
  buffer += "\x1b[2K"; // Clear current line
517
564
  const line = newLines[i];
@@ -551,8 +598,17 @@ export class TUI extends Container {
551
598
  buffer += line;
552
599
  }
553
600
 
601
+ // Track where cursor ended up after rendering
602
+ let finalCursorRow = renderEnd;
603
+
554
604
  // If we had more lines before, clear them and move cursor back
555
605
  if (this.previousLines.length > newLines.length) {
606
+ // Move to end of new content first if we stopped before it
607
+ if (renderEnd < newLines.length - 1) {
608
+ const moveDown = newLines.length - 1 - renderEnd;
609
+ buffer += `\x1b[${moveDown}B`;
610
+ finalCursorRow = newLines.length - 1;
611
+ }
556
612
  const extraLines = this.previousLines.length - newLines.length;
557
613
  for (let i = newLines.length; i < this.previousLines.length; i++) {
558
614
  buffer += "\r\n\x1b[2K";
@@ -566,8 +622,8 @@ export class TUI extends Container {
566
622
  // Write entire buffer at once
567
623
  this.terminal.write(buffer);
568
624
 
569
- // Cursor is now at end of last line
570
- this.cursorRow = newLines.length - 1;
625
+ // Track cursor position for next render
626
+ this.cursorRow = finalCursorRow;
571
627
  this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
572
628
  this.previousCursor = cursorInfo;
573
629