@oh-my-pi/pi-tui 3.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "3.15.0",
3
+ "version": "3.15.1",
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",
@@ -65,6 +65,7 @@ export class Editor implements Component {
65
65
  };
66
66
 
67
67
  private theme: EditorTheme;
68
+ private useTerminalCursor = false;
68
69
 
69
70
  // Store last render width for cursor navigation
70
71
  private lastWidth: number = 80;
@@ -114,6 +115,13 @@ export class Editor implements Component {
114
115
  this.topBorderContent = content;
115
116
  }
116
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
+
117
125
  /**
118
126
  * Add a prompt to history for up/down arrow navigation.
119
127
  * Called after successful submission.
@@ -224,7 +232,7 @@ export class Editor implements Component {
224
232
  let displayWidth = visibleWidth(layoutLine.text);
225
233
 
226
234
  // Add cursor if this line has it
227
- if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
235
+ if (!this.useTerminalCursor && layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
228
236
  const before = displayText.slice(0, layoutLine.cursorPos);
229
237
  const after = displayText.slice(layoutLine.cursorPos);
230
238
 
@@ -285,6 +293,36 @@ export class Editor implements Component {
285
293
  return result;
286
294
  }
287
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
+
288
326
  handleInput(data: string): void {
289
327
  // Handle bracketed paste mode
290
328
  // Start of paste: \x1b[200~
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;