@oh-my-pi/pi-tui 5.5.0 → 5.6.70

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/src/terminal.ts CHANGED
@@ -136,7 +136,8 @@ export class ProcessTerminal implements Terminal {
136
136
  // Enable Kitty keyboard protocol (push flags)
137
137
  // Flag 1 = disambiguate escape codes
138
138
  // Flag 2 = report event types (press/repeat/release)
139
- process.stdout.write("\x1b[>3u");
139
+ // Flag 4 = report alternate keys
140
+ process.stdout.write("\x1b[>7u");
140
141
  return; // Don't forward protocol response to TUI
141
142
  }
142
143
  }
package/src/tui.ts CHANGED
@@ -44,6 +44,30 @@ export interface Component {
44
44
  invalidate(): void;
45
45
  }
46
46
 
47
+ /**
48
+ * Interface for components that can receive focus and display a hardware cursor.
49
+ * When focused, the component should emit CURSOR_MARKER at the cursor position
50
+ * in its render output. TUI will find this marker and position the hardware
51
+ * cursor there for proper IME candidate window positioning.
52
+ */
53
+ export interface Focusable {
54
+ /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
55
+ focused: boolean;
56
+ }
57
+
58
+ /** Type guard to check if a component implements Focusable */
59
+ export function isFocusable(component: Component | null): component is Component & Focusable {
60
+ return component !== null && "focused" in component;
61
+ }
62
+
63
+ /**
64
+ * Cursor position marker - APC (Application Program Command) sequence.
65
+ * This is a zero-width escape sequence that terminals ignore.
66
+ * Components emit this at the cursor position when focused.
67
+ * TUI finds and strips this marker, then positions the hardware cursor there.
68
+ */
69
+ export const CURSOR_MARKER = "\x1b_pi:c\x07";
70
+
47
71
  export { visibleWidth };
48
72
 
49
73
  /**
@@ -199,10 +223,11 @@ export class TUI extends Container {
199
223
  public onDebug?: () => void;
200
224
  private renderRequested = false;
201
225
  private rendering = false;
202
- private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
203
- private previousCursor: { row: number; col: number } | null = null;
226
+ private cursorRow = 0; // Logical cursor row (end of rendered content)
227
+ private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
204
228
  private inputBuffer = ""; // Buffer for parsing terminal responses
205
229
  private cellSizeQueryPending = false;
230
+ private showHardwareCursor = process.env.OMP_HARDWARE_CURSOR === "1";
206
231
 
207
232
  // Overlay stack for modal components rendered on top of base content
208
233
  private overlayStack: {
@@ -213,13 +238,39 @@ export class TUI extends Container {
213
238
  }[] = [];
214
239
  private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
215
240
 
216
- constructor(terminal: Terminal) {
241
+ constructor(terminal: Terminal, showHardwareCursor?: boolean) {
217
242
  super();
218
243
  this.terminal = terminal;
244
+ if (showHardwareCursor !== undefined) {
245
+ this.showHardwareCursor = showHardwareCursor;
246
+ }
247
+ }
248
+
249
+ getShowHardwareCursor(): boolean {
250
+ return this.showHardwareCursor;
251
+ }
252
+
253
+ setShowHardwareCursor(enabled: boolean): void {
254
+ if (this.showHardwareCursor === enabled) return;
255
+ this.showHardwareCursor = enabled;
256
+ if (!enabled) {
257
+ this.terminal.hideCursor();
258
+ }
259
+ this.requestRender();
219
260
  }
220
261
 
221
262
  setFocus(component: Component | null): void {
263
+ // Clear focused flag on old component
264
+ if (isFocusable(this.focusedComponent)) {
265
+ this.focusedComponent.focused = false;
266
+ }
267
+
222
268
  this.focusedComponent = component;
269
+
270
+ // Set focused flag on new component
271
+ if (isFocusable(component)) {
272
+ component.focused = true;
273
+ }
223
274
  }
224
275
 
225
276
  /**
@@ -338,7 +389,7 @@ export class TUI extends Container {
338
389
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
339
390
  if (this.previousLines.length > 0) {
340
391
  const targetRow = this.previousLines.length; // Line after the last content
341
- const lineDiff = targetRow - this.cursorRow;
392
+ const lineDiff = targetRow - this.hardwareCursorRow;
342
393
  if (lineDiff > 0) {
343
394
  this.terminal.write(`\x1b[${lineDiff}B`);
344
395
  } else if (lineDiff < 0) {
@@ -360,7 +411,7 @@ export class TUI extends Container {
360
411
  this.previousLines = [];
361
412
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
362
413
  this.cursorRow = 0;
363
- this.previousCursor = null;
414
+ this.hardwareCursorRow = 0;
364
415
  }
365
416
  if (this.renderRequested) return;
366
417
  this.renderRequested = true;
@@ -370,40 +421,18 @@ export class TUI extends Container {
370
421
  });
371
422
  }
372
423
 
373
- private areCursorsEqual(
374
- left: { row: number; col: number } | null,
375
- right: { row: number; col: number } | null,
376
- ): boolean {
377
- if (!left && !right) return true;
378
- if (!left || !right) return false;
379
- return left.row === right.row && left.col === right.col;
380
- }
381
-
382
- private updateHardwareCursor(
383
- width: number,
384
- totalLines: number,
385
- cursor: { row: number; col: number } | null,
386
- currentCursorRow: number,
387
- ): void {
388
- if (!cursor || totalLines <= 0) {
389
- this.terminal.hideCursor();
390
- return;
391
- }
392
-
393
- const targetRow = Math.max(0, Math.min(cursor.row, totalLines - 1));
394
- const targetCol = Math.max(0, Math.min(cursor.col, width - 1));
395
- const rowDelta = targetRow - currentCursorRow;
396
-
397
- let buffer = "";
398
- if (rowDelta > 0) {
399
- buffer += `\x1b[${rowDelta}B`;
400
- } else if (rowDelta < 0) {
401
- buffer += `\x1b[${-rowDelta}A`;
402
- }
403
- buffer += `\r\x1b[${targetCol + 1}G`;
404
- this.terminal.write(buffer);
405
- this.cursorRow = targetRow;
406
- this.terminal.showCursor();
424
+ async waitForRender(): Promise<void> {
425
+ if (!this.renderRequested && !this.rendering) return;
426
+ await new Promise<void>((resolve) => {
427
+ const check = () => {
428
+ if (!this.renderRequested && !this.rendering) {
429
+ resolve();
430
+ return;
431
+ }
432
+ setTimeout(check, 0);
433
+ };
434
+ check();
435
+ });
407
436
  }
408
437
 
409
438
  private handleInput(data: string): void {
@@ -726,6 +755,67 @@ export class TUI extends Container {
726
755
  return lines.map((line) => (this.containsImage(line) ? line : line + reset));
727
756
  }
728
757
 
758
+ /**
759
+ * Find and extract cursor position from rendered lines.
760
+ * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
761
+ * @returns Cursor position { row, col } or null if no marker found
762
+ */
763
+ private extractCursorPosition(lines: string[]): { row: number; col: number } | null {
764
+ for (let row = 0; row < lines.length; row++) {
765
+ const line = lines[row];
766
+ const markerIndex = line.indexOf(CURSOR_MARKER);
767
+ if (markerIndex !== -1) {
768
+ // Calculate visual column (width of text before marker)
769
+ const beforeMarker = line.slice(0, markerIndex);
770
+ const col = visibleWidth(beforeMarker);
771
+
772
+ // Strip marker from the line
773
+ lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
774
+
775
+ return { row, col };
776
+ }
777
+ }
778
+ return null;
779
+ }
780
+
781
+ /**
782
+ * Position the hardware cursor for IME candidate window.
783
+ * @param cursorPos The cursor position extracted from rendered output, or null
784
+ * @param totalLines Total number of rendered lines
785
+ */
786
+ private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
787
+ if (!cursorPos || totalLines <= 0) {
788
+ this.terminal.hideCursor();
789
+ return;
790
+ }
791
+
792
+ // Clamp cursor position to valid range
793
+ const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
794
+ const targetCol = Math.max(0, cursorPos.col);
795
+
796
+ // Move cursor from current position to target
797
+ const rowDelta = targetRow - this.hardwareCursorRow;
798
+ let buffer = "";
799
+ if (rowDelta > 0) {
800
+ buffer += `\x1b[${rowDelta}B`; // Move down
801
+ } else if (rowDelta < 0) {
802
+ buffer += `\x1b[${-rowDelta}A`; // Move up
803
+ }
804
+ // Move to absolute column (1-indexed)
805
+ buffer += `\x1b[${targetCol + 1}G`;
806
+
807
+ if (buffer) {
808
+ this.terminal.write(buffer);
809
+ }
810
+
811
+ this.hardwareCursorRow = targetRow;
812
+ if (this.showHardwareCursor) {
813
+ this.terminal.showCursor();
814
+ } else {
815
+ this.terminal.hideCursor();
816
+ }
817
+ }
818
+
729
819
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
730
820
  private compositeLineAt(
731
821
  baseLine: string,
@@ -793,8 +883,6 @@ export class TUI extends Container {
793
883
  // Capture terminal dimensions at start to ensure consistency throughout render
794
884
  const width = this.terminal.columns;
795
885
  const height = this.terminal.rows;
796
- // Snapshot cursor position at start of render for consistent viewport calculations
797
- const currentCursorRow = this.cursorRow;
798
886
 
799
887
  // Render all components to get new lines
800
888
  let newLines = this.render(width);
@@ -804,9 +892,10 @@ export class TUI extends Container {
804
892
  newLines = this.compositeOverlays(newLines, width, height);
805
893
  }
806
894
 
807
- newLines = this.applyLineResets(newLines);
895
+ // Extract cursor position before applying line resets (marker must be found first)
896
+ const cursorPos = this.extractCursorPosition(newLines);
808
897
 
809
- const cursorInfo = this.getCursorPosition(width);
898
+ newLines = this.applyLineResets(newLines);
810
899
 
811
900
  // Width changed - need full re-render
812
901
  const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
@@ -820,10 +909,10 @@ export class TUI extends Container {
820
909
  }
821
910
  buffer += "\x1b[?2026l"; // End synchronized output
822
911
  this.terminal.write(buffer);
823
- // After rendering N lines, cursor is at end of last line (line N-1)
824
- this.cursorRow = newLines.length - 1;
825
- this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
826
- this.previousCursor = cursorInfo;
912
+ // After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
913
+ this.cursorRow = Math.max(0, newLines.length - 1);
914
+ this.hardwareCursorRow = this.cursorRow;
915
+ this.positionHardwareCursor(cursorPos, newLines.length);
827
916
  this.previousLines = newLines;
828
917
  this.previousWidth = width;
829
918
  return;
@@ -839,9 +928,9 @@ export class TUI extends Container {
839
928
  }
840
929
  buffer += "\x1b[?2026l"; // End synchronized output
841
930
  this.terminal.write(buffer);
842
- this.cursorRow = newLines.length - 1;
843
- this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
844
- this.previousCursor = cursorInfo;
931
+ this.cursorRow = Math.max(0, newLines.length - 1);
932
+ this.hardwareCursorRow = this.cursorRow;
933
+ this.positionHardwareCursor(cursorPos, newLines.length);
845
934
  this.previousLines = newLines;
846
935
  this.previousWidth = width;
847
936
  return;
@@ -863,12 +952,9 @@ export class TUI extends Container {
863
952
  }
864
953
  }
865
954
 
866
- // No changes
955
+ // No changes - but still need to update hardware cursor position if it moved
867
956
  if (firstChanged === -1) {
868
- if (!this.areCursorsEqual(cursorInfo, this.previousCursor)) {
869
- this.updateHardwareCursor(width, newLines.length, cursorInfo, currentCursorRow);
870
- this.previousCursor = cursorInfo;
871
- }
957
+ this.positionHardwareCursor(cursorPos, newLines.length);
872
958
  return;
873
959
  }
874
960
 
@@ -876,9 +962,9 @@ export class TUI extends Container {
876
962
  if (firstChanged >= newLines.length) {
877
963
  if (this.previousLines.length > newLines.length) {
878
964
  let buffer = "\x1b[?2026h";
879
- // Move to end of new content
880
- const targetRow = newLines.length - 1;
881
- const lineDiff = targetRow - currentCursorRow;
965
+ // Move to end of new content (clamp to 0 for empty content)
966
+ const targetRow = Math.max(0, newLines.length - 1);
967
+ const lineDiff = targetRow - this.hardwareCursorRow;
882
968
  if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
883
969
  else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
884
970
  buffer += "\r";
@@ -890,18 +976,20 @@ export class TUI extends Container {
890
976
  buffer += `\x1b[${extraLines}A`;
891
977
  buffer += "\x1b[?2026l";
892
978
  this.terminal.write(buffer);
893
- this.cursorRow = newLines.length - 1;
979
+ this.cursorRow = targetRow;
980
+ this.hardwareCursorRow = targetRow;
894
981
  }
982
+ this.positionHardwareCursor(cursorPos, newLines.length);
895
983
  this.previousLines = newLines;
896
984
  this.previousWidth = width;
897
985
  return;
898
986
  }
899
987
 
900
988
  // Check if firstChanged is outside the viewport
901
- // Use snapshotted cursor position for consistent viewport calculation
902
- // Viewport shows lines from (currentCursorRow - height + 1) to currentCursorRow
989
+ // cursorRow is the line where cursor is (0-indexed)
990
+ // Viewport shows lines from (cursorRow - height + 1) to cursorRow
903
991
  // If firstChanged < viewportTop, we need full re-render
904
- const viewportTop = currentCursorRow - height + 1;
992
+ const viewportTop = this.cursorRow - height + 1;
905
993
  if (firstChanged < viewportTop) {
906
994
  // First change is above viewport - need full re-render
907
995
  let buffer = "\x1b[?2026h"; // Begin synchronized output
@@ -912,9 +1000,9 @@ export class TUI extends Container {
912
1000
  }
913
1001
  buffer += "\x1b[?2026l"; // End synchronized output
914
1002
  this.terminal.write(buffer);
915
- this.cursorRow = newLines.length - 1;
916
- this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
917
- this.previousCursor = cursorInfo;
1003
+ this.cursorRow = Math.max(0, newLines.length - 1);
1004
+ this.hardwareCursorRow = this.cursorRow;
1005
+ this.positionHardwareCursor(cursorPos, newLines.length);
918
1006
  this.previousLines = newLines;
919
1007
  this.previousWidth = width;
920
1008
  return;
@@ -924,8 +1012,8 @@ export class TUI extends Container {
924
1012
  // Build buffer with all updates wrapped in synchronized output
925
1013
  let buffer = "\x1b[?2026h"; // Begin synchronized output
926
1014
 
927
- // Move cursor to first changed line using snapshotted position
928
- const lineDiff = firstChanged - currentCursorRow;
1015
+ // Move cursor to first changed line (use hardwareCursorRow for actual position)
1016
+ const lineDiff = firstChanged - this.hardwareCursorRow;
929
1017
  if (lineDiff > 0) {
930
1018
  buffer += `\x1b[${lineDiff}B`; // Move down
931
1019
  } else if (lineDiff < 0) {
@@ -1003,8 +1091,10 @@ export class TUI extends Container {
1003
1091
 
1004
1092
  // Track cursor position for next render
1005
1093
  this.cursorRow = finalCursorRow;
1006
- this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
1007
- this.previousCursor = cursorInfo;
1094
+ this.hardwareCursorRow = finalCursorRow;
1095
+
1096
+ // Position hardware cursor for IME
1097
+ this.positionHardwareCursor(cursorPos, newLines.length);
1008
1098
 
1009
1099
  this.previousLines = newLines;
1010
1100
  this.previousWidth = width;