@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 +2 -2
- package/src/autocomplete.ts +3 -3
- package/src/components/editor.ts +70 -38
- package/src/components/input.ts +0 -17
- package/src/components/markdown.ts +90 -24
- package/src/keybindings.ts +3 -3
- package/src/terminal-image.ts +52 -16
- package/src/tui.ts +11 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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",
|
package/src/autocomplete.ts
CHANGED
|
@@ -363,9 +363,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
363
363
|
return pathPrefix;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
// Return empty string only
|
|
367
|
-
//
|
|
368
|
-
if (pathPrefix === "" &&
|
|
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
|
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
1843
|
-
this.
|
|
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,
|
|
1897
|
-
this.
|
|
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.
|
|
1931
|
+
const wasAutocompleting = this.autocompleteState !== null;
|
|
1907
1932
|
this.clearAutocompleteTimeout();
|
|
1908
1933
|
this.autocompleteRequestId += 1;
|
|
1909
|
-
this.
|
|
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.
|
|
1943
|
+
return this.autocompleteState !== null;
|
|
1919
1944
|
}
|
|
1920
1945
|
|
|
1921
1946
|
private async updateAutocomplete(): Promise<void> {
|
|
1922
|
-
if (!this.
|
|
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,
|
|
1967
|
+
this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
|
|
1936
1968
|
this.onAutocompleteUpdate?.();
|
|
1937
1969
|
} else {
|
|
1938
1970
|
this.cancelAutocomplete();
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
package/src/keybindings.ts
CHANGED
|
@@ -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",
|
package/src/terminal-image.ts
CHANGED
|
@@ -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
|
|
86
|
+
return kKittyCaps;
|
|
45
87
|
}
|
|
46
|
-
|
|
47
88
|
if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
|
|
48
|
-
return
|
|
89
|
+
return kGhosttyCaps;
|
|
49
90
|
}
|
|
50
|
-
|
|
51
91
|
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
|
|
52
|
-
return
|
|
92
|
+
return kWeztermCaps;
|
|
53
93
|
}
|
|
54
|
-
|
|
55
94
|
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
|
|
56
|
-
return
|
|
95
|
+
return kIterm2Caps;
|
|
57
96
|
}
|
|
58
|
-
|
|
59
97
|
if (termProgram === "vscode") {
|
|
60
|
-
return
|
|
98
|
+
return kVscodeCaps;
|
|
61
99
|
}
|
|
62
|
-
|
|
63
100
|
if (termProgram === "alacritty") {
|
|
64
|
-
return
|
|
101
|
+
return kAlacrittyCaps;
|
|
65
102
|
}
|
|
66
|
-
|
|
67
103
|
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
|
68
|
-
return
|
|
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
|
-
|
|
545
|
-
return
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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");
|