@oh-my-pi/pi-tui 3.14.0 → 3.15.1
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/README.md +45 -0
- package/package.json +1 -1
- package/src/components/editor.ts +55 -14
- package/src/components/loader.ts +4 -0
- package/src/components/markdown.ts +19 -10
- package/src/components/select-list.ts +9 -6
- package/src/index.ts +1 -0
- package/src/symbols.ts +25 -0
- package/src/tui.ts +69 -0
package/README.md
CHANGED
|
@@ -154,9 +154,52 @@ input.getValue();
|
|
|
154
154
|
Multi-line text editor with autocomplete, file completion, and paste handling.
|
|
155
155
|
|
|
156
156
|
```typescript
|
|
157
|
+
interface SymbolTheme {
|
|
158
|
+
cursor: string;
|
|
159
|
+
ellipsis: string;
|
|
160
|
+
boxRound: {
|
|
161
|
+
topLeft: string;
|
|
162
|
+
topRight: string;
|
|
163
|
+
bottomLeft: string;
|
|
164
|
+
bottomRight: string;
|
|
165
|
+
horizontal: string;
|
|
166
|
+
vertical: string;
|
|
167
|
+
};
|
|
168
|
+
boxSharp: {
|
|
169
|
+
topLeft: string;
|
|
170
|
+
topRight: string;
|
|
171
|
+
bottomLeft: string;
|
|
172
|
+
bottomRight: string;
|
|
173
|
+
horizontal: string;
|
|
174
|
+
vertical: string;
|
|
175
|
+
teeDown: string;
|
|
176
|
+
teeUp: string;
|
|
177
|
+
teeLeft: string;
|
|
178
|
+
teeRight: string;
|
|
179
|
+
cross: string;
|
|
180
|
+
};
|
|
181
|
+
table: {
|
|
182
|
+
topLeft: string;
|
|
183
|
+
topRight: string;
|
|
184
|
+
bottomLeft: string;
|
|
185
|
+
bottomRight: string;
|
|
186
|
+
horizontal: string;
|
|
187
|
+
vertical: string;
|
|
188
|
+
teeDown: string;
|
|
189
|
+
teeUp: string;
|
|
190
|
+
teeLeft: string;
|
|
191
|
+
teeRight: string;
|
|
192
|
+
cross: string;
|
|
193
|
+
};
|
|
194
|
+
quoteBorder: string;
|
|
195
|
+
hrChar: string;
|
|
196
|
+
spinnerFrames: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
157
199
|
interface EditorTheme {
|
|
158
200
|
borderColor: (str: string) => string;
|
|
159
201
|
selectList: SelectListTheme;
|
|
202
|
+
symbols: SymbolTheme;
|
|
160
203
|
}
|
|
161
204
|
|
|
162
205
|
const editor = new Editor(theme);
|
|
@@ -206,6 +249,7 @@ interface MarkdownTheme {
|
|
|
206
249
|
strikethrough: (text: string) => string;
|
|
207
250
|
underline: (text: string) => string;
|
|
208
251
|
highlightCode?: (code: string, lang?: string) => string[];
|
|
252
|
+
symbols: SymbolTheme;
|
|
209
253
|
}
|
|
210
254
|
|
|
211
255
|
interface DefaultTextStyle {
|
|
@@ -289,6 +333,7 @@ interface SelectListTheme {
|
|
|
289
333
|
description: (text: string) => string;
|
|
290
334
|
scrollInfo: (text: string) => string;
|
|
291
335
|
noMatch: (text: string) => string;
|
|
336
|
+
symbols: SymbolTheme;
|
|
292
337
|
}
|
|
293
338
|
|
|
294
339
|
const list = new SelectList(
|
package/package.json
CHANGED
package/src/components/editor.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isShiftEnter,
|
|
26
26
|
isTab,
|
|
27
27
|
} from "../keys";
|
|
28
|
+
import type { SymbolTheme } from "../symbols";
|
|
28
29
|
import type { Component } from "../tui";
|
|
29
30
|
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils";
|
|
30
31
|
import { SelectList, type SelectListTheme } from "./select-list";
|
|
@@ -46,6 +47,7 @@ interface LayoutLine {
|
|
|
46
47
|
export interface EditorTheme {
|
|
47
48
|
borderColor: (str: string) => string;
|
|
48
49
|
selectList: SelectListTheme;
|
|
50
|
+
symbols: SymbolTheme;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface EditorTopBorder {
|
|
@@ -63,6 +65,7 @@ export class Editor implements Component {
|
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
private theme: EditorTheme;
|
|
68
|
+
private useTerminalCursor = false;
|
|
66
69
|
|
|
67
70
|
// Store last render width for cursor navigation
|
|
68
71
|
private lastWidth: number = 80;
|
|
@@ -112,6 +115,13 @@ export class Editor implements Component {
|
|
|
112
115
|
this.topBorderContent = content;
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Use the real terminal cursor instead of rendering a cursor glyph.
|
|
120
|
+
*/
|
|
121
|
+
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
122
|
+
this.useTerminalCursor = useTerminalCursor;
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
/**
|
|
116
126
|
* Add a prompt to history for up/down arrow navigation.
|
|
117
127
|
* Called after successful submission.
|
|
@@ -181,11 +191,12 @@ export class Editor implements Component {
|
|
|
181
191
|
this.lastWidth = width;
|
|
182
192
|
|
|
183
193
|
// Box-drawing characters for rounded corners
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
194
|
+
const box = this.theme.symbols.boxRound;
|
|
195
|
+
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal}`);
|
|
196
|
+
const topRight = this.borderColor(`${box.horizontal}${box.topRight}`);
|
|
197
|
+
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}`);
|
|
198
|
+
const bottomRight = this.borderColor(`${box.horizontal}${box.bottomRight}`);
|
|
199
|
+
const horizontal = this.borderColor(box.horizontal);
|
|
189
200
|
|
|
190
201
|
// Layout the text - content area is width minus 6 for borders (3 left + 3 right)
|
|
191
202
|
const contentAreaWidth = width - 6;
|
|
@@ -201,13 +212,13 @@ export class Editor implements Component {
|
|
|
201
212
|
if (statusWidth <= topFillWidth) {
|
|
202
213
|
// Status fits - add fill after it
|
|
203
214
|
const fillWidth = topFillWidth - statusWidth;
|
|
204
|
-
result.push(topLeft + content + this.borderColor(
|
|
215
|
+
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
205
216
|
} else {
|
|
206
217
|
// Status too long - truncate it
|
|
207
|
-
const truncated = truncateToWidth(content, topFillWidth - 1, this.borderColor(
|
|
218
|
+
const truncated = truncateToWidth(content, topFillWidth - 1, this.borderColor(this.theme.symbols.ellipsis));
|
|
208
219
|
const truncatedWidth = visibleWidth(truncated);
|
|
209
220
|
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
|
210
|
-
result.push(topLeft + truncated + this.borderColor(
|
|
221
|
+
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
211
222
|
}
|
|
212
223
|
} else {
|
|
213
224
|
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
|
@@ -221,7 +232,7 @@ export class Editor implements Component {
|
|
|
221
232
|
let displayWidth = visibleWidth(layoutLine.text);
|
|
222
233
|
|
|
223
234
|
// Add cursor if this line has it
|
|
224
|
-
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
235
|
+
if (!this.useTerminalCursor && layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
225
236
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
226
237
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
227
238
|
|
|
@@ -236,10 +247,10 @@ export class Editor implements Component {
|
|
|
236
247
|
// displayWidth stays the same - we're replacing, not adding
|
|
237
248
|
} else {
|
|
238
249
|
// Cursor is at the end - add thin blinking bar cursor
|
|
239
|
-
|
|
240
|
-
const cursor =
|
|
250
|
+
const cursorChar = this.theme.symbols.inputCursor;
|
|
251
|
+
const cursor = `\x1b[5m${cursorChar}\x1b[0m`;
|
|
241
252
|
displayText = before + cursor;
|
|
242
|
-
displayWidth +=
|
|
253
|
+
displayWidth += visibleWidth(cursorChar);
|
|
243
254
|
if (displayWidth > lineContentWidth) {
|
|
244
255
|
// Line is at full width - use reverse video on last grapheme if possible
|
|
245
256
|
// or just show cursor at the end without adding space
|
|
@@ -267,8 +278,8 @@ export class Editor implements Component {
|
|
|
267
278
|
// Last line: "╰─ " (3) + content + padding + " ─╯" (3) = 6 chars border
|
|
268
279
|
result.push(`${bottomLeft} ${displayText}${padding} ${bottomRight}`);
|
|
269
280
|
} else {
|
|
270
|
-
const leftBorder = this.borderColor(
|
|
271
|
-
const rightBorder = this.borderColor(
|
|
281
|
+
const leftBorder = this.borderColor(`${box.vertical} `);
|
|
282
|
+
const rightBorder = this.borderColor(` ${box.vertical}`);
|
|
272
283
|
result.push(leftBorder + displayText + padding + rightBorder);
|
|
273
284
|
}
|
|
274
285
|
}
|
|
@@ -282,6 +293,36 @@ export class Editor implements Component {
|
|
|
282
293
|
return result;
|
|
283
294
|
}
|
|
284
295
|
|
|
296
|
+
getCursorPosition(width: number): { row: number; col: number } | null {
|
|
297
|
+
if (!this.useTerminalCursor) return null;
|
|
298
|
+
|
|
299
|
+
const contentWidth = width - 6;
|
|
300
|
+
if (contentWidth <= 0) return null;
|
|
301
|
+
|
|
302
|
+
const layoutLines = this.layoutText(contentWidth);
|
|
303
|
+
for (let i = 0; i < layoutLines.length; i++) {
|
|
304
|
+
const layoutLine = layoutLines[i];
|
|
305
|
+
if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
|
|
306
|
+
|
|
307
|
+
const lineWidth = visibleWidth(layoutLine.text);
|
|
308
|
+
const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length;
|
|
309
|
+
|
|
310
|
+
if (isCursorAtLineEnd && lineWidth >= contentWidth && layoutLine.text.length > 0) {
|
|
311
|
+
const graphemes = [...segmenter.segment(layoutLine.text)];
|
|
312
|
+
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
313
|
+
const lastWidth = visibleWidth(lastGrapheme) || 1;
|
|
314
|
+
const colOffset = 3 + Math.max(0, lineWidth - lastWidth);
|
|
315
|
+
return { row: 1 + i, col: colOffset };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const before = layoutLine.text.slice(0, layoutLine.cursorPos);
|
|
319
|
+
const colOffset = 3 + visibleWidth(before);
|
|
320
|
+
return { row: 1 + i, col: colOffset };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
285
326
|
handleInput(data: string): void {
|
|
286
327
|
// Handle bracketed paste mode
|
|
287
328
|
// Start of paste: \x1b[200~
|
package/src/components/loader.ts
CHANGED
|
@@ -15,9 +15,13 @@ export class Loader extends Text {
|
|
|
15
15
|
private spinnerColorFn: (str: string) => string,
|
|
16
16
|
private messageColorFn: (str: string) => string,
|
|
17
17
|
private message: string = "Loading...",
|
|
18
|
+
spinnerFrames?: string[],
|
|
18
19
|
) {
|
|
19
20
|
super("", 1, 0);
|
|
20
21
|
this.ui = ui;
|
|
22
|
+
if (spinnerFrames && spinnerFrames.length > 0) {
|
|
23
|
+
this.frames = spinnerFrames;
|
|
24
|
+
}
|
|
21
25
|
this.start();
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { marked, type Token } from "marked";
|
|
2
|
+
import type { SymbolTheme } from "../symbols";
|
|
2
3
|
import type { Component } from "../tui";
|
|
3
4
|
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
5
|
|
|
@@ -41,6 +42,7 @@ export interface MarkdownTheme {
|
|
|
41
42
|
strikethrough: (text: string) => string;
|
|
42
43
|
underline: (text: string) => string;
|
|
43
44
|
highlightCode?: (code: string, lang?: string) => string[];
|
|
45
|
+
symbols: SymbolTheme;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export class Markdown implements Component {
|
|
@@ -301,7 +303,10 @@ export class Markdown implements Component {
|
|
|
301
303
|
const quoteText = this.renderInlineTokens(token.tokens || []);
|
|
302
304
|
const quoteLines = quoteText.split("\n");
|
|
303
305
|
for (const quoteLine of quoteLines) {
|
|
304
|
-
lines.push(
|
|
306
|
+
lines.push(
|
|
307
|
+
this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) +
|
|
308
|
+
this.theme.quote(this.theme.italic(quoteLine)),
|
|
309
|
+
);
|
|
305
310
|
}
|
|
306
311
|
if (nextTokenType !== "space") {
|
|
307
312
|
lines.push(""); // Add spacing after blockquotes (unless space token follows)
|
|
@@ -310,7 +315,7 @@ export class Markdown implements Component {
|
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
case "hr":
|
|
313
|
-
lines.push(this.theme.hr(
|
|
318
|
+
lines.push(this.theme.hr(this.theme.symbols.hrChar.repeat(Math.min(width, 80))));
|
|
314
319
|
if (nextTokenType !== "space") {
|
|
315
320
|
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
|
|
316
321
|
}
|
|
@@ -595,9 +600,13 @@ export class Markdown implements Component {
|
|
|
595
600
|
}
|
|
596
601
|
}
|
|
597
602
|
|
|
603
|
+
const t = this.theme.symbols.table;
|
|
604
|
+
const h = t.horizontal;
|
|
605
|
+
const v = t.vertical;
|
|
606
|
+
|
|
598
607
|
// Render top border
|
|
599
|
-
const topBorderCells = columnWidths.map((w) =>
|
|
600
|
-
lines.push(
|
|
608
|
+
const topBorderCells = columnWidths.map((w) => h.repeat(w));
|
|
609
|
+
lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
|
|
601
610
|
|
|
602
611
|
// Render header with wrapping
|
|
603
612
|
const headerCellLines: string[][] = token.header.map((cell, i) => {
|
|
@@ -612,12 +621,12 @@ export class Markdown implements Component {
|
|
|
612
621
|
const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
|
|
613
622
|
return this.theme.bold(padded);
|
|
614
623
|
});
|
|
615
|
-
lines.push(
|
|
624
|
+
lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
|
|
616
625
|
}
|
|
617
626
|
|
|
618
627
|
// Render separator
|
|
619
|
-
const separatorCells = columnWidths.map((w) =>
|
|
620
|
-
lines.push(
|
|
628
|
+
const separatorCells = columnWidths.map((w) => h.repeat(w));
|
|
629
|
+
lines.push(`${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`);
|
|
621
630
|
|
|
622
631
|
// Render rows with wrapping
|
|
623
632
|
for (const row of token.rows) {
|
|
@@ -632,13 +641,13 @@ export class Markdown implements Component {
|
|
|
632
641
|
const text = cellLines[lineIdx] || "";
|
|
633
642
|
return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
|
|
634
643
|
});
|
|
635
|
-
lines.push(
|
|
644
|
+
lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
|
|
636
645
|
}
|
|
637
646
|
}
|
|
638
647
|
|
|
639
648
|
// Render bottom border
|
|
640
|
-
const bottomBorderCells = columnWidths.map((w) =>
|
|
641
|
-
lines.push(
|
|
649
|
+
const bottomBorderCells = columnWidths.map((w) => h.repeat(w));
|
|
650
|
+
lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
|
|
642
651
|
|
|
643
652
|
lines.push(""); // Add spacing after table
|
|
644
653
|
return lines;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys";
|
|
2
|
+
import type { SymbolTheme } from "../symbols";
|
|
2
3
|
import type { Component } from "../tui";
|
|
3
|
-
import { truncateToWidth } from "../utils";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "../utils";
|
|
4
5
|
|
|
5
6
|
export interface SelectItem {
|
|
6
7
|
value: string;
|
|
@@ -14,6 +15,7 @@ export interface SelectListTheme {
|
|
|
14
15
|
description: (text: string) => string;
|
|
15
16
|
scrollInfo: (text: string) => string;
|
|
16
17
|
noMatch: (text: string) => string;
|
|
18
|
+
symbols: SymbolTheme;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export class SelectList implements Component {
|
|
@@ -74,7 +76,8 @@ export class SelectList implements Component {
|
|
|
74
76
|
let line = "";
|
|
75
77
|
if (isSelected) {
|
|
76
78
|
// Use arrow indicator for selection - entire line uses selectedText color
|
|
77
|
-
const
|
|
79
|
+
const prefix = `${this.theme.symbols.cursor} `;
|
|
80
|
+
const prefixWidth = visibleWidth(prefix);
|
|
78
81
|
const displayValue = item.label || item.value;
|
|
79
82
|
|
|
80
83
|
if (item.description && width > 40) {
|
|
@@ -90,20 +93,20 @@ export class SelectList implements Component {
|
|
|
90
93
|
if (remainingWidth > 10) {
|
|
91
94
|
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
|
92
95
|
// Apply selectedText to entire line content
|
|
93
|
-
line = this.theme.selectedText(
|
|
96
|
+
line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
|
94
97
|
} else {
|
|
95
98
|
// Not enough space for description
|
|
96
99
|
const maxWidth = width - prefixWidth - 2;
|
|
97
|
-
line = this.theme.selectedText(
|
|
100
|
+
line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`);
|
|
98
101
|
}
|
|
99
102
|
} else {
|
|
100
103
|
// No description or not enough width
|
|
101
104
|
const maxWidth = width - prefixWidth - 2;
|
|
102
|
-
line = this.theme.selectedText(
|
|
105
|
+
line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`);
|
|
103
106
|
}
|
|
104
107
|
} else {
|
|
105
108
|
const displayValue = item.label || item.value;
|
|
106
|
-
const prefix = "
|
|
109
|
+
const prefix = " ".repeat(visibleWidth(`${this.theme.symbols.cursor} `));
|
|
107
110
|
|
|
108
111
|
if (item.description && width > 40) {
|
|
109
112
|
// Calculate how much space we have for value + description
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,7 @@ export {
|
|
|
61
61
|
isTab,
|
|
62
62
|
Keys,
|
|
63
63
|
} from "./keys";
|
|
64
|
+
export type { BoxSymbols, SymbolTheme } from "./symbols";
|
|
64
65
|
// Terminal interface and implementations
|
|
65
66
|
export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
|
|
66
67
|
// Terminal image support
|
package/src/symbols.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface BoxSymbols {
|
|
2
|
+
topLeft: string;
|
|
3
|
+
topRight: string;
|
|
4
|
+
bottomLeft: string;
|
|
5
|
+
bottomRight: string;
|
|
6
|
+
horizontal: string;
|
|
7
|
+
vertical: string;
|
|
8
|
+
teeDown: string;
|
|
9
|
+
teeUp: string;
|
|
10
|
+
teeLeft: string;
|
|
11
|
+
teeRight: string;
|
|
12
|
+
cross: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SymbolTheme {
|
|
16
|
+
cursor: string;
|
|
17
|
+
inputCursor: string;
|
|
18
|
+
ellipsis: string;
|
|
19
|
+
boxRound: Omit<BoxSymbols, "teeDown" | "teeUp" | "teeLeft" | "teeRight" | "cross">;
|
|
20
|
+
boxSharp: BoxSymbols;
|
|
21
|
+
table: BoxSymbols;
|
|
22
|
+
quoteBorder: string;
|
|
23
|
+
hrChar: string;
|
|
24
|
+
spinnerFrames: string[];
|
|
25
|
+
}
|
package/src/tui.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface Component {
|
|
|
26
26
|
*/
|
|
27
27
|
handleInput?(data: string): void;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Optional cursor position within the rendered output (0-based row/col).
|
|
31
|
+
*/
|
|
32
|
+
getCursorPosition?(width: number): { row: number; col: number } | null;
|
|
33
|
+
|
|
29
34
|
/**
|
|
30
35
|
* Invalidate any cached rendering state.
|
|
31
36
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
@@ -62,6 +67,19 @@ export class Container implements Component {
|
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
|
|
70
|
+
getCursorPosition(width: number): { row: number; col: number } | null {
|
|
71
|
+
let rowOffset = 0;
|
|
72
|
+
for (const child of this.children) {
|
|
73
|
+
const lines = child.render(width);
|
|
74
|
+
const childCursor = child.getCursorPosition?.(width) ?? null;
|
|
75
|
+
if (childCursor) {
|
|
76
|
+
return { row: rowOffset + childCursor.row, col: childCursor.col };
|
|
77
|
+
}
|
|
78
|
+
rowOffset += lines.length;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
render(width: number): string[] {
|
|
66
84
|
const lines: string[] = [];
|
|
67
85
|
for (const child of this.children) {
|
|
@@ -84,6 +102,7 @@ export class TUI extends Container {
|
|
|
84
102
|
public onDebug?: () => void;
|
|
85
103
|
private renderRequested = false;
|
|
86
104
|
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
|
|
105
|
+
private previousCursor: { row: number; col: number } | null = null;
|
|
87
106
|
private inputBuffer = ""; // Buffer for parsing terminal responses
|
|
88
107
|
private cellSizeQueryPending = false;
|
|
89
108
|
private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
|
|
@@ -132,6 +151,7 @@ export class TUI extends Container {
|
|
|
132
151
|
this.previousLines = [];
|
|
133
152
|
this.previousWidth = 0;
|
|
134
153
|
this.cursorRow = 0;
|
|
154
|
+
this.previousCursor = null;
|
|
135
155
|
}
|
|
136
156
|
if (this.renderRequested) return;
|
|
137
157
|
this.renderRequested = true;
|
|
@@ -141,6 +161,42 @@ export class TUI extends Container {
|
|
|
141
161
|
});
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
private areCursorsEqual(
|
|
165
|
+
left: { row: number; col: number } | null,
|
|
166
|
+
right: { row: number; col: number } | null,
|
|
167
|
+
): boolean {
|
|
168
|
+
if (!left && !right) return true;
|
|
169
|
+
if (!left || !right) return false;
|
|
170
|
+
return left.row === right.row && left.col === right.col;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private updateHardwareCursor(
|
|
174
|
+
width: number,
|
|
175
|
+
totalLines: number,
|
|
176
|
+
cursor: { row: number; col: number } | null,
|
|
177
|
+
currentCursorRow: number,
|
|
178
|
+
): void {
|
|
179
|
+
if (!cursor || totalLines <= 0) {
|
|
180
|
+
this.terminal.hideCursor();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const targetRow = Math.max(0, Math.min(cursor.row, totalLines - 1));
|
|
185
|
+
const targetCol = Math.max(0, Math.min(cursor.col, width - 1));
|
|
186
|
+
const rowDelta = targetRow - currentCursorRow;
|
|
187
|
+
|
|
188
|
+
let buffer = "";
|
|
189
|
+
if (rowDelta > 0) {
|
|
190
|
+
buffer += `\x1b[${rowDelta}B`;
|
|
191
|
+
} else if (rowDelta < 0) {
|
|
192
|
+
buffer += `\x1b[${-rowDelta}A`;
|
|
193
|
+
}
|
|
194
|
+
buffer += `\r\x1b[${targetCol + 1}G`;
|
|
195
|
+
this.terminal.write(buffer);
|
|
196
|
+
this.cursorRow = targetRow;
|
|
197
|
+
this.terminal.showCursor();
|
|
198
|
+
}
|
|
199
|
+
|
|
144
200
|
private handleInput(data: string): void {
|
|
145
201
|
// If we're waiting for cell size response, buffer input and parse
|
|
146
202
|
if (this.cellSizeQueryPending) {
|
|
@@ -235,6 +291,7 @@ export class TUI extends Container {
|
|
|
235
291
|
|
|
236
292
|
// Render all components to get new lines
|
|
237
293
|
const newLines = this.render(width);
|
|
294
|
+
const cursorInfo = this.getCursorPosition(width);
|
|
238
295
|
|
|
239
296
|
// Width changed - need full re-render
|
|
240
297
|
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
@@ -250,6 +307,8 @@ export class TUI extends Container {
|
|
|
250
307
|
this.terminal.write(buffer);
|
|
251
308
|
// After rendering N lines, cursor is at end of last line (line N-1)
|
|
252
309
|
this.cursorRow = newLines.length - 1;
|
|
310
|
+
this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
|
|
311
|
+
this.previousCursor = cursorInfo;
|
|
253
312
|
this.previousLines = newLines;
|
|
254
313
|
this.previousWidth = width;
|
|
255
314
|
return;
|
|
@@ -266,6 +325,8 @@ export class TUI extends Container {
|
|
|
266
325
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
267
326
|
this.terminal.write(buffer);
|
|
268
327
|
this.cursorRow = newLines.length - 1;
|
|
328
|
+
this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
|
|
329
|
+
this.previousCursor = cursorInfo;
|
|
269
330
|
this.previousLines = newLines;
|
|
270
331
|
this.previousWidth = width;
|
|
271
332
|
return;
|
|
@@ -287,6 +348,10 @@ export class TUI extends Container {
|
|
|
287
348
|
|
|
288
349
|
// No changes
|
|
289
350
|
if (firstChanged === -1) {
|
|
351
|
+
if (!this.areCursorsEqual(cursorInfo, this.previousCursor)) {
|
|
352
|
+
this.updateHardwareCursor(width, newLines.length, cursorInfo, currentCursorRow);
|
|
353
|
+
this.previousCursor = cursorInfo;
|
|
354
|
+
}
|
|
290
355
|
return;
|
|
291
356
|
}
|
|
292
357
|
|
|
@@ -306,6 +371,8 @@ export class TUI extends Container {
|
|
|
306
371
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
307
372
|
this.terminal.write(buffer);
|
|
308
373
|
this.cursorRow = newLines.length - 1;
|
|
374
|
+
this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
|
|
375
|
+
this.previousCursor = cursorInfo;
|
|
309
376
|
this.previousLines = newLines;
|
|
310
377
|
this.previousWidth = width;
|
|
311
378
|
return;
|
|
@@ -372,6 +439,8 @@ export class TUI extends Container {
|
|
|
372
439
|
|
|
373
440
|
// Cursor is now at end of last line
|
|
374
441
|
this.cursorRow = newLines.length - 1;
|
|
442
|
+
this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
|
|
443
|
+
this.previousCursor = cursorInfo;
|
|
375
444
|
|
|
376
445
|
this.previousLines = newLines;
|
|
377
446
|
this.previousWidth = width;
|