@oh-my-pi/pi-tui 4.5.0 → 4.6.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 +1 -1
- package/src/components/editor.ts +29 -0
- package/src/components/input.ts +54 -66
- package/src/fuzzy.ts +107 -0
- package/src/index.ts +2 -0
- package/src/keybindings.ts +1 -1
- package/src/keys.ts +26 -4
- package/src/stdin-buffer.ts +1 -1
- package/src/terminal.ts +31 -73
- package/src/tui.ts +64 -8
package/package.json
CHANGED
package/src/components/editor.ts
CHANGED
|
@@ -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
|
}
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
-
//
|
|
92
|
+
// Submit
|
|
89
93
|
if (kb.matches(data, "submit") || data === "\n") {
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
133
|
-
|
|
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 (
|
|
145
|
-
|
|
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 (
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
this.
|
|
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 (
|
|
163
|
-
|
|
164
|
-
|
|
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 (
|
|
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 (
|
|
176
|
-
|
|
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 (
|
|
169
|
+
if (kb.matches(data, "cursorWordLeft")) {
|
|
182
170
|
this.moveWordBackwards();
|
|
183
171
|
return;
|
|
184
172
|
}
|
|
185
173
|
|
|
186
|
-
if (
|
|
174
|
+
if (kb.matches(data, "cursorWordRight")) {
|
|
187
175
|
this.moveWordForwards();
|
|
188
176
|
return;
|
|
189
177
|
}
|
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,
|
package/src/keybindings.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/stdin-buffer.ts
CHANGED
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
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
513
|
-
// This
|
|
514
|
-
|
|
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
|
-
//
|
|
570
|
-
this.cursorRow =
|
|
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
|
|