@oh-my-pi/pi-tui 8.12.10 → 9.0.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": "8.12.10",
3
+ "version": "9.0.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",
@@ -47,7 +47,7 @@
47
47
  "bun": ">=1.3.7"
48
48
  },
49
49
  "dependencies": {
50
- "@oh-my-pi/pi-natives": "8.12.10",
50
+ "@oh-my-pi/pi-natives": "9.0.0",
51
51
  "@types/mime-types": "^3.0.1",
52
52
  "chalk": "^5.6.2",
53
53
  "marked": "^17.0.1",
@@ -363,9 +363,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
363
363
  return pathPrefix;
364
364
  }
365
365
 
366
- // Return empty string only if we're at the beginning of the line or after a space
367
- // (not after quotes or other delimiters that don't suggest file paths)
368
- if (pathPrefix === "" && (text === "" || text.endsWith(" "))) {
366
+ // Return empty string only after a space (not for completely empty text)
367
+ // Empty text should not trigger file suggestions - that's for forced Tab completion
368
+ if (pathPrefix === "" && text.endsWith(" ")) {
369
369
  return pathPrefix;
370
370
  }
371
371
 
@@ -289,9 +289,10 @@ export class Editor implements Component, Focusable {
289
289
  // Autocomplete support
290
290
  private autocompleteProvider?: AutocompleteProvider;
291
291
  private autocompleteList?: SelectList;
292
- private isAutocompleting: boolean = false;
292
+ private autocompleteState: "regular" | "force" | null = null;
293
293
  private autocompletePrefix: string = "";
294
294
  private autocompleteRequestId: number = 0;
295
+ private autocompleteMaxVisible: number = 5;
295
296
  public onAutocompleteUpdate?: () => void;
296
297
 
297
298
  // Paste tracking for large pastes
@@ -301,7 +302,6 @@ export class Editor implements Component, Focusable {
301
302
  // Bracketed paste mode buffering
302
303
  private pasteBuffer: string = "";
303
304
  private isInPaste: boolean = false;
304
- private pendingShiftEnter: boolean = false;
305
305
 
306
306
  // Prompt history for up/down navigation
307
307
  private history: string[] = [];
@@ -357,6 +357,17 @@ export class Editor implements Component, Focusable {
357
357
  this.paddingXOverride = Math.max(0, paddingX);
358
358
  }
359
359
 
360
+ getAutocompleteMaxVisible(): number {
361
+ return this.autocompleteMaxVisible;
362
+ }
363
+
364
+ setAutocompleteMaxVisible(maxVisible: number): void {
365
+ const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
366
+ if (this.autocompleteMaxVisible !== newMaxVisible) {
367
+ this.autocompleteMaxVisible = newMaxVisible;
368
+ }
369
+ }
370
+
360
371
  setHistoryStorage(storage: HistoryStorage): void {
361
372
  this.historyStorage = storage;
362
373
  const recent = storage.getRecent(100);
@@ -512,7 +523,7 @@ export class Editor implements Component, Focusable {
512
523
 
513
524
  // Render each layout line
514
525
  // Emit hardware cursor marker only when focused and not showing autocomplete
515
- const emitCursorMarker = this.focused && !this.isAutocompleting;
526
+ const emitCursorMarker = this.focused && !this.autocompleteState;
516
527
  const lineContentWidth = contentAreaWidth;
517
528
 
518
529
  for (const layoutLine of visibleLayoutLines) {
@@ -574,7 +585,7 @@ export class Editor implements Component, Focusable {
574
585
  }
575
586
 
576
587
  // Add autocomplete list if active
577
- if (this.isAutocompleting && this.autocompleteList) {
588
+ if (this.autocompleteState && this.autocompleteList) {
578
589
  const autocompleteResult = this.autocompleteList.render(width);
579
590
  result.push(...autocompleteResult);
580
591
  }
@@ -664,21 +675,6 @@ export class Editor implements Component, Focusable {
664
675
 
665
676
  // Handle special key combinations first
666
677
 
667
- if (this.pendingShiftEnter) {
668
- if (data === "\r") {
669
- this.pendingShiftEnter = false;
670
- this.addNewLine();
671
- return;
672
- }
673
- this.pendingShiftEnter = false;
674
- this.insertCharacter("\\");
675
- }
676
-
677
- if (data === "\\") {
678
- this.pendingShiftEnter = true;
679
- return;
680
- }
681
-
682
678
  // Ctrl+C - Exit (let parent handle this)
683
679
  if (matchesKey(data, "ctrl+c")) {
684
680
  return;
@@ -691,7 +687,7 @@ export class Editor implements Component, Focusable {
691
687
  }
692
688
 
693
689
  // Handle autocomplete special keys first (but don't block other input)
694
- if (this.isAutocompleting && this.autocompleteList) {
690
+ if (this.autocompleteState && this.autocompleteList) {
695
691
  // Escape - cancel autocomplete
696
692
  if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
697
693
  this.cancelAutocomplete(true);
@@ -798,7 +794,7 @@ export class Editor implements Component, Focusable {
798
794
  }
799
795
 
800
796
  // Tab key - context-aware completion (but not when already autocompleting)
801
- if (matchesKey(data, "tab") && !this.isAutocompleting) {
797
+ if (matchesKey(data, "tab") && !this.autocompleteState) {
802
798
  this.handleTabCompletion();
803
799
  return;
804
800
  }
@@ -853,8 +849,7 @@ export class Editor implements Component, Focusable {
853
849
  data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
854
850
  matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits)
855
851
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
856
- (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
857
- data === "\\\r" // Shift+Enter in VS Code terminal
852
+ (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
858
853
  ) {
859
854
  // Modifier + Enter = new line
860
855
  this.addNewLine();
@@ -866,6 +861,15 @@ export class Editor implements Component, Focusable {
866
861
  return;
867
862
  }
868
863
 
864
+ // Workaround for terminals without Shift+Enter support:
865
+ // If char before cursor is \, delete it and insert newline instead of submitting.
866
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
867
+ if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
868
+ this.handleBackspace();
869
+ this.addNewLine();
870
+ return;
871
+ }
872
+
869
873
  this.resetKillSequence();
870
874
 
871
875
  // Get text and substitute paste markers with actual content
@@ -1120,7 +1124,7 @@ export class Editor implements Component, Focusable {
1120
1124
  }
1121
1125
 
1122
1126
  // Check if we should trigger or update autocomplete
1123
- if (!this.isAutocompleting) {
1127
+ if (!this.autocompleteState) {
1124
1128
  // Auto-trigger for "/" at the start of a line (slash commands)
1125
1129
  if (char === "/" && this.isAtStartOfMessage()) {
1126
1130
  this.tryTriggerAutocomplete();
@@ -1275,7 +1279,7 @@ export class Editor implements Component, Focusable {
1275
1279
  }
1276
1280
 
1277
1281
  // Update or re-trigger autocomplete after backspace
1278
- if (this.isAutocompleting) {
1282
+ if (this.autocompleteState) {
1279
1283
  this.debouncedUpdateAutocomplete();
1280
1284
  } else {
1281
1285
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
@@ -1361,7 +1365,7 @@ export class Editor implements Component, Focusable {
1361
1365
  this.onChange(this.getText());
1362
1366
  }
1363
1367
 
1364
- if (this.isAutocompleting) {
1368
+ if (this.autocompleteState) {
1365
1369
  this.debouncedUpdateAutocomplete();
1366
1370
  } else {
1367
1371
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1586,7 +1590,7 @@ export class Editor implements Component, Focusable {
1586
1590
  }
1587
1591
 
1588
1592
  // Update or re-trigger autocomplete after forward delete
1589
- if (this.isAutocompleting) {
1593
+ if (this.autocompleteState) {
1590
1594
  this.debouncedUpdateAutocomplete();
1591
1595
  } else {
1592
1596
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1839,8 +1843,8 @@ export class Editor implements Component, Focusable {
1839
1843
 
1840
1844
  if (suggestions && suggestions.items.length > 0) {
1841
1845
  this.autocompletePrefix = suggestions.prefix;
1842
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1843
- this.isAutocompleting = true;
1846
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1847
+ this.autocompleteState = "regular";
1844
1848
  this.onAutocompleteUpdate?.();
1845
1849
  } else {
1846
1850
  this.cancelAutocomplete();
@@ -1858,7 +1862,7 @@ export class Editor implements Component, Focusable {
1858
1862
  if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) {
1859
1863
  this.handleSlashCommandCompletion();
1860
1864
  } else {
1861
- this.forceFileAutocomplete();
1865
+ this.forceFileAutocomplete(true);
1862
1866
  }
1863
1867
  }
1864
1868
 
@@ -1871,7 +1875,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
1871
1875
  17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
1872
1876
  536643416/job/55932288317 havea look at .gi
1873
1877
  */
1874
- private async forceFileAutocomplete(): Promise<void> {
1878
+ private async forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
1875
1879
  if (!this.autocompleteProvider) return;
1876
1880
 
1877
1881
  // Check if provider supports force file suggestions via runtime check
@@ -1892,9 +1896,30 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
1892
1896
  if (requestId !== this.autocompleteRequestId) return;
1893
1897
 
1894
1898
  if (suggestions && suggestions.items.length > 0) {
1899
+ // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
1900
+ if (explicitTab && suggestions.items.length === 1) {
1901
+ const item = suggestions.items[0]!;
1902
+ const result = this.autocompleteProvider.applyCompletion(
1903
+ this.state.lines,
1904
+ this.state.cursorLine,
1905
+ this.state.cursorCol,
1906
+ item,
1907
+ suggestions.prefix,
1908
+ );
1909
+
1910
+ this.state.lines = result.lines;
1911
+ this.state.cursorLine = result.cursorLine;
1912
+ this.state.cursorCol = result.cursorCol;
1913
+
1914
+ if (this.onChange) {
1915
+ this.onChange(this.getText());
1916
+ }
1917
+ return;
1918
+ }
1919
+
1895
1920
  this.autocompletePrefix = suggestions.prefix;
1896
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1897
- this.isAutocompleting = true;
1921
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1922
+ this.autocompleteState = "force";
1898
1923
  this.onAutocompleteUpdate?.();
1899
1924
  } else {
1900
1925
  this.cancelAutocomplete();
@@ -1903,10 +1928,10 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
1903
1928
  }
1904
1929
 
1905
1930
  private cancelAutocomplete(notifyCancel: boolean = false): void {
1906
- const wasAutocompleting = this.isAutocompleting;
1931
+ const wasAutocompleting = this.autocompleteState !== null;
1907
1932
  this.clearAutocompleteTimeout();
1908
1933
  this.autocompleteRequestId += 1;
1909
- this.isAutocompleting = false;
1934
+ this.autocompleteState = null;
1910
1935
  this.autocompleteList = undefined;
1911
1936
  this.autocompletePrefix = "";
1912
1937
  if (notifyCancel && wasAutocompleting) {
@@ -1915,11 +1940,18 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
1915
1940
  }
1916
1941
 
1917
1942
  public isShowingAutocomplete(): boolean {
1918
- return this.isAutocompleting;
1943
+ return this.autocompleteState !== null;
1919
1944
  }
1920
1945
 
1921
1946
  private async updateAutocomplete(): Promise<void> {
1922
- if (!this.isAutocompleting || !this.autocompleteProvider) return;
1947
+ if (!this.autocompleteState || !this.autocompleteProvider) return;
1948
+
1949
+ // In force mode, use forceFileAutocomplete to get suggestions
1950
+ if (this.autocompleteState === "force") {
1951
+ this.forceFileAutocomplete();
1952
+ return;
1953
+ }
1954
+
1923
1955
  const requestId = ++this.autocompleteRequestId;
1924
1956
 
1925
1957
  const suggestions = await this.autocompleteProvider.getSuggestions(
@@ -1932,7 +1964,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
1932
1964
  if (suggestions && suggestions.items.length > 0) {
1933
1965
  this.autocompletePrefix = suggestions.prefix;
1934
1966
  // Always create new SelectList to ensure update
1935
- this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
1967
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1936
1968
  this.onAutocompleteUpdate?.();
1937
1969
  } else {
1938
1970
  this.cancelAutocomplete();
@@ -19,7 +19,6 @@ export class Input implements Component, Focusable {
19
19
  // Bracketed paste mode buffering
20
20
  private pasteBuffer: string = "";
21
21
  private isInPaste: boolean = false;
22
- private pendingShiftEnter: boolean = false;
23
22
 
24
23
  getValue(): string {
25
24
  return this.value;
@@ -68,22 +67,6 @@ export class Input implements Component, Focusable {
68
67
  return;
69
68
  }
70
69
 
71
- if (this.pendingShiftEnter) {
72
- if (data === "\r") {
73
- this.pendingShiftEnter = false;
74
- if (this.onSubmit) this.onSubmit(this.value);
75
- return;
76
- }
77
- this.pendingShiftEnter = false;
78
- this.value = `${this.value.slice(0, this.cursor)}\\${this.value.slice(this.cursor)}`;
79
- this.cursor += 1;
80
- }
81
-
82
- if (data === "\\") {
83
- this.pendingShiftEnter = true;
84
- return;
85
- }
86
-
87
70
  const kb = getEditorKeybindings();
88
71
 
89
72
  // Escape/Cancel
@@ -561,6 +561,21 @@ export class Markdown implements Component {
561
561
  return lines;
562
562
  }
563
563
 
564
+ /**
565
+ * Get the visible width of the longest word in a string.
566
+ */
567
+ private getLongestWordWidth(text: string, maxWidth?: number): number {
568
+ const words = text.split(/\s+/).filter(word => word.length > 0);
569
+ let longest = 0;
570
+ for (const word of words) {
571
+ longest = Math.max(longest, visibleWidth(word));
572
+ }
573
+ if (maxWidth === undefined) {
574
+ return longest;
575
+ }
576
+ return Math.min(longest, maxWidth);
577
+ }
578
+
564
579
  /**
565
580
  * Wrap a table cell to fit into a column.
566
581
  *
@@ -589,56 +604,101 @@ export class Markdown implements Component {
589
604
  // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
590
605
  // = 2 + (n-1) * 3 + 2 = 3n + 1
591
606
  const borderOverhead = 3 * numCols + 1;
592
-
593
- // Minimum width for a bordered table with at least 1 char per column.
594
- const minTableWidth = borderOverhead + numCols;
595
- if (availableWidth < minTableWidth) {
607
+ const availableForCells = availableWidth - borderOverhead;
608
+ if (availableForCells < numCols) {
596
609
  // Too narrow to render a stable table. Fall back to raw markdown.
597
610
  const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
598
611
  fallbackLines.push("");
599
612
  return fallbackLines;
600
613
  }
601
614
 
615
+ const maxUnbrokenWordWidth = 30;
616
+
602
617
  // Calculate natural column widths (what each column needs without constraints)
603
618
  const naturalWidths: number[] = [];
619
+ const minWordWidths: number[] = [];
604
620
  for (let i = 0; i < numCols; i++) {
605
621
  const headerText = this.renderInlineTokens(token.header[i].tokens || []);
606
622
  naturalWidths[i] = visibleWidth(headerText);
623
+ minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));
607
624
  }
608
625
  for (const row of token.rows) {
609
626
  for (let i = 0; i < row.length; i++) {
610
627
  const cellText = this.renderInlineTokens(row[i].tokens || []);
611
628
  naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
629
+ minWordWidths[i] = Math.max(
630
+ minWordWidths[i] || 1,
631
+ this.getLongestWordWidth(cellText, maxUnbrokenWordWidth),
632
+ );
612
633
  }
613
634
  }
614
635
 
636
+ let minColumnWidths = minWordWidths;
637
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
638
+
639
+ if (minCellsWidth > availableForCells) {
640
+ minColumnWidths = new Array(numCols).fill(1);
641
+ const remaining = availableForCells - numCols;
642
+
643
+ if (remaining > 0) {
644
+ const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
645
+ const growth = minWordWidths.map(width => {
646
+ const weight = Math.max(0, width - 1);
647
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
648
+ });
649
+
650
+ for (let i = 0; i < numCols; i++) {
651
+ minColumnWidths[i] += growth[i] ?? 0;
652
+ }
653
+
654
+ const allocated = growth.reduce((total, width) => total + width, 0);
655
+ let leftover = remaining - allocated;
656
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
657
+ minColumnWidths[i]++;
658
+ leftover--;
659
+ }
660
+ }
661
+
662
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
663
+ }
664
+
615
665
  // Calculate column widths that fit within available width
616
666
  const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
617
667
  let columnWidths: number[];
618
668
 
619
669
  if (totalNaturalWidth <= availableWidth) {
620
670
  // Everything fits naturally
621
- columnWidths = naturalWidths;
671
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
622
672
  } else {
623
673
  // Need to shrink columns to fit
624
- const availableForCells = availableWidth - borderOverhead;
625
- if (availableForCells <= numCols) {
626
- // Extremely narrow - give each column at least 1 char
627
- columnWidths = naturalWidths.map(() => Math.max(1, Math.floor(availableForCells / numCols)));
628
- } else {
629
- // Distribute space proportionally based on natural widths
630
- const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
631
- columnWidths = naturalWidths.map(w => {
632
- const proportion = w / totalNatural;
633
- return Math.max(1, Math.floor(proportion * availableForCells));
634
- });
674
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
675
+ return total + Math.max(0, width - minColumnWidths[index]);
676
+ }, 0);
677
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth);
678
+ columnWidths = minColumnWidths.map((minWidth, index) => {
679
+ const naturalWidth = naturalWidths[index];
680
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth);
681
+ let grow = 0;
682
+ if (totalGrowPotential > 0) {
683
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
684
+ }
685
+ return minWidth + grow;
686
+ });
635
687
 
636
- // Adjust for rounding errors - distribute remaining space
637
- const allocated = columnWidths.reduce((a, b) => a + b, 0);
638
- let remaining = availableForCells - allocated;
639
- for (let i = 0; remaining > 0 && i < numCols; i++) {
640
- columnWidths[i]++;
641
- remaining--;
688
+ // Adjust for rounding errors - distribute remaining space
689
+ const allocated = columnWidths.reduce((a, b) => a + b, 0);
690
+ let remaining = availableForCells - allocated;
691
+ while (remaining > 0) {
692
+ let grew = false;
693
+ for (let i = 0; i < numCols && remaining > 0; i++) {
694
+ if (columnWidths[i] < naturalWidths[i]) {
695
+ columnWidths[i]++;
696
+ remaining--;
697
+ grew = true;
698
+ }
699
+ }
700
+ if (!grew) {
701
+ break;
642
702
  }
643
703
  }
644
704
  }
@@ -669,10 +729,12 @@ export class Markdown implements Component {
669
729
 
670
730
  // Render separator
671
731
  const separatorCells = columnWidths.map(w => h.repeat(w));
672
- lines.push(`${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`);
732
+ const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
733
+ lines.push(separatorLine);
673
734
 
674
735
  // Render rows with wrapping
675
- for (const row of token.rows) {
736
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
737
+ const row = token.rows[rowIndex];
676
738
  const rowCellLines: string[][] = row.map((cell, i) => {
677
739
  const text = this.renderInlineTokens(cell.tokens || []);
678
740
  return this.wrapCellText(text, columnWidths[i]);
@@ -686,6 +748,10 @@ export class Markdown implements Component {
686
748
  });
687
749
  lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
688
750
  }
751
+
752
+ if (rowIndex < token.rows.length - 1) {
753
+ lines.push(separatorLine);
754
+ }
689
755
  }
690
756
 
691
757
  // Render bottom border
@@ -53,13 +53,13 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
53
53
  cursorDown: "down",
54
54
  cursorLeft: "left",
55
55
  cursorRight: "right",
56
- cursorWordLeft: ["alt+left", "ctrl+left"],
57
- cursorWordRight: ["alt+right", "ctrl+right"],
56
+ cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
57
+ cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
58
58
  cursorLineStart: ["home", "ctrl+a"],
59
59
  cursorLineEnd: ["end", "ctrl+e"],
60
60
  // Deletion
61
61
  deleteCharBackward: "backspace",
62
- deleteCharForward: "delete",
62
+ deleteCharForward: ["delete", "ctrl+d"],
63
63
  deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
64
64
  deleteWordForward: ["alt+delete", "alt+d"],
65
65
  deleteToLineStart: "ctrl+u",
@@ -1,9 +1,10 @@
1
1
  export type ImageProtocol = "kitty" | "iterm2" | null;
2
2
 
3
3
  export interface TerminalCapabilities {
4
- images: ImageProtocol;
5
- trueColor: boolean;
6
- hyperlinks: boolean;
4
+ readonly images: ImageProtocol;
5
+ readonly trueColor: boolean;
6
+ readonly hyperlinks: boolean;
7
+ containsImage(line: string): boolean;
7
8
  }
8
9
 
9
10
  export interface CellDimensions {
@@ -35,37 +36,72 @@ export function setCellDimensions(dims: CellDimensions): void {
35
36
  cellDimensions = dims;
36
37
  }
37
38
 
39
+ const kBaseCaps: TerminalCapabilities = {
40
+ images: null,
41
+ trueColor: false,
42
+ hyperlinks: true,
43
+ containsImage() {
44
+ return false;
45
+ },
46
+ };
47
+
48
+ function createTerminalCaps(parts: Partial<TerminalCapabilities>): TerminalCapabilities {
49
+ return Object.freeze({ ...kBaseCaps, ...parts });
50
+ }
51
+
52
+ const kKittyCaps = createTerminalCaps({
53
+ images: "kitty",
54
+ trueColor: true,
55
+ hyperlinks: true,
56
+ containsImage(line: string) {
57
+ return line.includes("\x1b_G");
58
+ },
59
+ });
60
+ const kGhosttyCaps = kKittyCaps;
61
+ const kWeztermCaps = kKittyCaps;
62
+
63
+ const kIterm2Caps = createTerminalCaps({
64
+ images: "iterm2",
65
+ trueColor: true,
66
+ hyperlinks: true,
67
+ containsImage(line: string) {
68
+ return line.includes("\x1b]1337;File=");
69
+ },
70
+ });
71
+
72
+ const kTrueColorCaps = createTerminalCaps({
73
+ ...kBaseCaps,
74
+ trueColor: true,
75
+ });
76
+
77
+ const kVscodeCaps = kTrueColorCaps;
78
+ const kAlacrittyCaps = kTrueColorCaps;
79
+
38
80
  export function detectCapabilities(): TerminalCapabilities {
39
81
  const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
40
82
  const term = process.env.TERM?.toLowerCase() || "";
41
83
  const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
42
84
 
43
85
  if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
44
- return { images: "kitty", trueColor: true, hyperlinks: true };
86
+ return kKittyCaps;
45
87
  }
46
-
47
88
  if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
48
- return { images: "kitty", trueColor: true, hyperlinks: true };
89
+ return kGhosttyCaps;
49
90
  }
50
-
51
91
  if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
52
- return { images: "kitty", trueColor: true, hyperlinks: true };
92
+ return kWeztermCaps;
53
93
  }
54
-
55
94
  if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
56
- return { images: "iterm2", trueColor: true, hyperlinks: true };
95
+ return kIterm2Caps;
57
96
  }
58
-
59
97
  if (termProgram === "vscode") {
60
- return { images: null, trueColor: true, hyperlinks: true };
98
+ return kVscodeCaps;
61
99
  }
62
-
63
100
  if (termProgram === "alacritty") {
64
- return { images: null, trueColor: true, hyperlinks: true };
101
+ return kAlacrittyCaps;
65
102
  }
66
-
67
103
  const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
68
- return { images: null, trueColor, hyperlinks: true };
104
+ return trueColor ? kTrueColorCaps : kBaseCaps;
69
105
  }
70
106
 
71
107
  export function getCapabilities(): TerminalCapabilities {
package/src/tui.ts CHANGED
@@ -541,8 +541,8 @@ export class TUI extends Container {
541
541
  return result;
542
542
  }
543
543
 
544
- private containsImage(line: string): boolean {
545
- return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
544
+ static containsImage(line: string): boolean {
545
+ return getCapabilities().containsImage(line);
546
546
  }
547
547
 
548
548
  /**
@@ -764,7 +764,13 @@ export class TUI extends Container {
764
764
 
765
765
  private applyLineResets(lines: string[]): string[] {
766
766
  const reset = TUI.SEGMENT_RESET;
767
- return lines.map(line => (this.containsImage(line) ? line : line + reset));
767
+ for (let i = 0; i < lines.length; i++) {
768
+ const line = lines[i];
769
+ if (!TUI.containsImage(line)) {
770
+ lines[i] = line + reset;
771
+ }
772
+ }
773
+ return lines;
768
774
  }
769
775
 
770
776
  /**
@@ -839,7 +845,7 @@ export class TUI extends Container {
839
845
  overlayWidth: number,
840
846
  totalWidth: number,
841
847
  ): string {
842
- if (this.containsImage(baseLine)) return baseLine;
848
+ if (TUI.containsImage(baseLine)) return baseLine;
843
849
 
844
850
  // Single pass through baseLine extracts both before and after segments
845
851
  const afterStart = startCol + overlayWidth;
@@ -1081,7 +1087,7 @@ export class TUI extends Container {
1081
1087
  if (i > firstChanged) buffer += "\r\n";
1082
1088
  buffer += "\x1b[2K"; // Clear current line
1083
1089
  const line = newLines[i];
1084
- const isImageLine = this.containsImage(line);
1090
+ const isImageLine = TUI.containsImage(line);
1085
1091
  if (!isImageLine && visibleWidth(line) > width) {
1086
1092
  // Log all lines to crash file for debugging
1087
1093
  const crashLogPath = path.join(os.homedir(), ".omp", "agent", "omp-crash.log");