@oh-my-pi/pi-tui 12.18.1 → 12.19.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/CHANGELOG.md +10 -0
- package/package.json +3 -3
- package/src/bracketed-paste.ts +47 -0
- package/src/components/editor.ts +19 -40
- package/src/components/input.ts +8 -33
- package/src/tui.ts +13 -0
- package/src/utils.ts +2 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [12.19.0] - 2026-02-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `getTopBorderAvailableWidth()` method to calculate available width for top border content accounting for border characters and padding
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed stale viewport rows appearing when terminal height increases by triggering full re-render on height changes
|
|
14
|
+
|
|
5
15
|
## [12.18.0] - 2026-02-21
|
|
6
16
|
### Fixed
|
|
7
17
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.19.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Bölük",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"test": "bun test test/*.test.ts"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "12.
|
|
37
|
-
"@oh-my-pi/pi-utils": "12.
|
|
36
|
+
"@oh-my-pi/pi-natives": "12.19.0",
|
|
37
|
+
"@oh-my-pi/pi-utils": "12.19.0",
|
|
38
38
|
"@types/mime-types": "^3.0.1",
|
|
39
39
|
"chalk": "^5.6.2",
|
|
40
40
|
"marked": "^17.0.2",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const PASTE_START = "\x1b[200~";
|
|
2
|
+
const PASTE_END = "\x1b[201~";
|
|
3
|
+
|
|
4
|
+
export type PasteResult = { handled: false } | { handled: true; pasteContent?: string; remaining: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handles bracketed paste mode buffering for terminal input components.
|
|
8
|
+
*
|
|
9
|
+
* Bracketed paste mode wraps pasted content between start (\x1b[200~) and
|
|
10
|
+
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
11
|
+
* This class buffers incoming data and assembles complete paste payloads.
|
|
12
|
+
*/
|
|
13
|
+
export class BracketedPasteHandler {
|
|
14
|
+
#buffer = "";
|
|
15
|
+
#active = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Process incoming terminal data for bracketed paste sequences.
|
|
19
|
+
*
|
|
20
|
+
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
+
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
+
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
+
* paste has been assembled; omitted when still buffering.
|
|
24
|
+
*/
|
|
25
|
+
process(data: string): PasteResult {
|
|
26
|
+
if (data.includes(PASTE_START)) {
|
|
27
|
+
this.#active = true;
|
|
28
|
+
this.#buffer = "";
|
|
29
|
+
data = data.replace(PASTE_START, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!this.#active) return { handled: false };
|
|
33
|
+
|
|
34
|
+
this.#buffer += data;
|
|
35
|
+
|
|
36
|
+
const endIndex = this.#buffer.indexOf(PASTE_END);
|
|
37
|
+
if (endIndex === -1) return { handled: true, remaining: "" };
|
|
38
|
+
|
|
39
|
+
const pasteContent = this.#buffer.substring(0, endIndex);
|
|
40
|
+
const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
|
|
41
|
+
|
|
42
|
+
this.#buffer = "";
|
|
43
|
+
this.#active = false;
|
|
44
|
+
|
|
45
|
+
return { handled: true, pasteContent, remaining };
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/components/editor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
|
|
2
2
|
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
|
|
3
|
+
import { BracketedPasteHandler } from "../bracketed-paste";
|
|
3
4
|
import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings";
|
|
4
5
|
import { matchesKey } from "../keys";
|
|
5
6
|
import { KillRing } from "../kill-ring";
|
|
@@ -338,8 +339,7 @@ export class Editor implements Component, Focusable {
|
|
|
338
339
|
#pasteCounter: number = 0;
|
|
339
340
|
|
|
340
341
|
// Bracketed paste mode buffering
|
|
341
|
-
#
|
|
342
|
-
#isInPaste: boolean = false;
|
|
342
|
+
#pasteHandler = new BracketedPasteHandler();
|
|
343
343
|
|
|
344
344
|
// Prompt history for up/down navigation
|
|
345
345
|
#history: string[] = [];
|
|
@@ -379,6 +379,16 @@ export class Editor implements Component, Focusable {
|
|
|
379
379
|
this.#topBorderContent = content;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Get the available width for top border content given a total terminal width.
|
|
384
|
+
* Accounts for the border characters and horizontal padding.
|
|
385
|
+
*/
|
|
386
|
+
getTopBorderAvailableWidth(terminalWidth: number): number {
|
|
387
|
+
const paddingX = this.#getEditorPaddingX();
|
|
388
|
+
const borderWidth = paddingX + 1;
|
|
389
|
+
return Math.max(0, terminalWidth - borderWidth * 2);
|
|
390
|
+
}
|
|
391
|
+
|
|
382
392
|
/**
|
|
383
393
|
* Use the real terminal cursor instead of rendering a cursor glyph.
|
|
384
394
|
*/
|
|
@@ -696,46 +706,15 @@ export class Editor implements Component, Focusable {
|
|
|
696
706
|
}
|
|
697
707
|
|
|
698
708
|
// Handle bracketed paste mode
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
this.#pasteBuffer = "";
|
|
706
|
-
// Remove the start marker and keep the rest
|
|
707
|
-
data = data.replace("\x1b[200~", "");
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// If we're in a paste, buffer the data
|
|
711
|
-
if (this.#isInPaste) {
|
|
712
|
-
// Append data to buffer first (end marker could be split across chunks)
|
|
713
|
-
this.#pasteBuffer += data;
|
|
714
|
-
|
|
715
|
-
// Check if the accumulated buffer contains the end marker
|
|
716
|
-
const endIndex = this.#pasteBuffer.indexOf("\x1b[201~");
|
|
717
|
-
if (endIndex !== -1) {
|
|
718
|
-
// Extract content before the end marker
|
|
719
|
-
const pasteContent = this.#pasteBuffer.substring(0, endIndex);
|
|
720
|
-
|
|
721
|
-
// Process the complete paste
|
|
722
|
-
this.#handlePaste(pasteContent);
|
|
723
|
-
|
|
724
|
-
// Reset paste state
|
|
725
|
-
this.#isInPaste = false;
|
|
726
|
-
|
|
727
|
-
// Process any remaining data after the end marker
|
|
728
|
-
const remaining = this.#pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
|
729
|
-
this.#pasteBuffer = "";
|
|
730
|
-
|
|
731
|
-
if (remaining.length > 0) {
|
|
732
|
-
this.handleInput(remaining);
|
|
709
|
+
const paste = this.#pasteHandler.process(data);
|
|
710
|
+
if (paste.handled) {
|
|
711
|
+
if (paste.pasteContent !== undefined) {
|
|
712
|
+
this.#handlePaste(paste.pasteContent);
|
|
713
|
+
if (paste.remaining.length > 0) {
|
|
714
|
+
this.handleInput(paste.remaining);
|
|
733
715
|
}
|
|
734
|
-
return;
|
|
735
|
-
} else {
|
|
736
|
-
// Still accumulating, wait for more data
|
|
737
|
-
return;
|
|
738
716
|
}
|
|
717
|
+
return;
|
|
739
718
|
}
|
|
740
719
|
|
|
741
720
|
// Handle special key combinations first
|
package/src/components/input.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BracketedPasteHandler } from "../bracketed-paste";
|
|
1
2
|
import { getEditorKeybindings } from "../keybindings";
|
|
2
3
|
import { KillRing } from "../kill-ring";
|
|
3
4
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
@@ -23,8 +24,7 @@ export class Input implements Component, Focusable {
|
|
|
23
24
|
focused: boolean = false;
|
|
24
25
|
|
|
25
26
|
// Bracketed paste mode buffering
|
|
26
|
-
#
|
|
27
|
-
#isInPaste: boolean = false;
|
|
27
|
+
#pasteHandler = new BracketedPasteHandler();
|
|
28
28
|
|
|
29
29
|
// Kill ring for Emacs-style kill/yank operations
|
|
30
30
|
#killRing = new KillRing();
|
|
@@ -44,37 +44,12 @@ export class Input implements Component, Focusable {
|
|
|
44
44
|
|
|
45
45
|
handleInput(data: string): void {
|
|
46
46
|
// Handle bracketed paste mode
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.#pasteBuffer = "";
|
|
54
|
-
data = data.replace("\x1b[200~", "");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// If we're in a paste, buffer the data
|
|
58
|
-
if (this.#isInPaste) {
|
|
59
|
-
// Check if this chunk contains the end marker
|
|
60
|
-
this.#pasteBuffer += data;
|
|
61
|
-
|
|
62
|
-
const endIndex = this.#pasteBuffer.indexOf("\x1b[201~");
|
|
63
|
-
if (endIndex !== -1) {
|
|
64
|
-
// Extract the pasted content
|
|
65
|
-
const pasteContent = this.#pasteBuffer.substring(0, endIndex);
|
|
66
|
-
|
|
67
|
-
// Process the complete paste
|
|
68
|
-
this.#handlePaste(pasteContent);
|
|
69
|
-
|
|
70
|
-
// Reset paste state
|
|
71
|
-
this.#isInPaste = false;
|
|
72
|
-
|
|
73
|
-
// Handle any remaining input after the paste marker
|
|
74
|
-
const remaining = this.#pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
|
75
|
-
this.#pasteBuffer = "";
|
|
76
|
-
if (remaining) {
|
|
77
|
-
this.handleInput(remaining);
|
|
47
|
+
const paste = this.#pasteHandler.process(data);
|
|
48
|
+
if (paste.handled) {
|
|
49
|
+
if (paste.pasteContent !== undefined) {
|
|
50
|
+
this.#handlePaste(paste.pasteContent);
|
|
51
|
+
if (paste.remaining.length > 0) {
|
|
52
|
+
this.handleInput(paste.remaining);
|
|
78
53
|
}
|
|
79
54
|
}
|
|
80
55
|
return;
|
package/src/tui.ts
CHANGED
|
@@ -203,6 +203,7 @@ export class TUI extends Container {
|
|
|
203
203
|
terminal: Terminal;
|
|
204
204
|
#previousLines: string[] = [];
|
|
205
205
|
#previousWidth = 0;
|
|
206
|
+
#previousHeight = 0;
|
|
206
207
|
#focusedComponent: Component | null = null;
|
|
207
208
|
#inputListeners = new Set<InputListener>();
|
|
208
209
|
|
|
@@ -427,6 +428,7 @@ export class TUI extends Container {
|
|
|
427
428
|
if (force) {
|
|
428
429
|
this.#previousLines = [];
|
|
429
430
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
431
|
+
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
430
432
|
this.#cursorRow = 0;
|
|
431
433
|
this.#hardwareCursorRow = 0;
|
|
432
434
|
this.#maxLinesRendered = 0;
|
|
@@ -877,6 +879,7 @@ export class TUI extends Container {
|
|
|
877
879
|
|
|
878
880
|
// Width changed - need full re-render (line wrapping changes)
|
|
879
881
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
882
|
+
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
880
883
|
|
|
881
884
|
// Helper to clear scrollback and viewport and render all new lines
|
|
882
885
|
const fullRender = (clear: boolean): void => {
|
|
@@ -904,6 +907,7 @@ export class TUI extends Container {
|
|
|
904
907
|
this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
905
908
|
this.#previousLines = newLines;
|
|
906
909
|
this.#previousWidth = width;
|
|
910
|
+
this.#previousHeight = height;
|
|
907
911
|
};
|
|
908
912
|
|
|
909
913
|
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
|
@@ -928,6 +932,13 @@ export class TUI extends Container {
|
|
|
928
932
|
return;
|
|
929
933
|
}
|
|
930
934
|
|
|
935
|
+
// Height changed - full re-render to clear newly revealed rows and avoid stale scrollback artifacts
|
|
936
|
+
if (heightChanged) {
|
|
937
|
+
logRedraw(`height changed (${this.#previousHeight} -> ${height})`);
|
|
938
|
+
fullRender(true);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
931
942
|
// Content shrunk below the working area and no overlays - re-render to clear empty rows.
|
|
932
943
|
// When an overlay is active, avoid clearing to reduce flicker and avoid resetting scrollback.
|
|
933
944
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
@@ -997,6 +1008,7 @@ export class TUI extends Container {
|
|
|
997
1008
|
this.#cursorRow = targetRow;
|
|
998
1009
|
this.#previousLines = newLines;
|
|
999
1010
|
this.#previousWidth = width;
|
|
1011
|
+
this.#previousHeight = height;
|
|
1000
1012
|
this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
1001
1013
|
return;
|
|
1002
1014
|
}
|
|
@@ -1140,6 +1152,7 @@ export class TUI extends Container {
|
|
|
1140
1152
|
this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
1141
1153
|
this.#previousLines = newLines;
|
|
1142
1154
|
this.#previousWidth = width;
|
|
1155
|
+
this.#previousHeight = height;
|
|
1143
1156
|
}
|
|
1144
1157
|
|
|
1145
1158
|
/**
|
package/src/utils.ts
CHANGED
|
@@ -31,15 +31,11 @@ export function getSegmenter(): Intl.Segmenter {
|
|
|
31
31
|
return segmenter;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// Cache for non-ASCII strings
|
|
35
|
-
//const WIDTH_CACHE_SIZE = 512;
|
|
36
|
-
//const widthCache = new Map<string, number>();
|
|
37
|
-
|
|
38
34
|
/**
|
|
39
35
|
* Calculate the visible width of a string in terminal columns.
|
|
40
36
|
*/
|
|
41
37
|
export function visibleWidthRaw(str: string): number {
|
|
42
|
-
if (str
|
|
38
|
+
if (!str) {
|
|
43
39
|
return 0;
|
|
44
40
|
}
|
|
45
41
|
|
|
@@ -64,31 +60,8 @@ export function visibleWidthRaw(str: string): number {
|
|
|
64
60
|
* Calculate the visible width of a string in terminal columns.
|
|
65
61
|
*/
|
|
66
62
|
export function visibleWidth(str: string): number {
|
|
67
|
-
if (str
|
|
68
|
-
return 0;
|
|
69
|
-
}
|
|
63
|
+
if (!str) return 0;
|
|
70
64
|
return visibleWidthRaw(str);
|
|
71
|
-
|
|
72
|
-
// === Disabled cache ===
|
|
73
|
-
|
|
74
|
-
/*
|
|
75
|
-
// Check cache
|
|
76
|
-
const cached = widthCache.get(str);
|
|
77
|
-
if (cached !== undefined) {
|
|
78
|
-
return cached;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const width = visibleWidthRaw(str);
|
|
82
|
-
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
|
83
|
-
const firstKey = widthCache.keys().next().value;
|
|
84
|
-
if (firstKey !== undefined) {
|
|
85
|
-
widthCache.delete(firstKey);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
widthCache.set(str, width);
|
|
89
|
-
|
|
90
|
-
return width;
|
|
91
|
-
*/
|
|
92
65
|
}
|
|
93
66
|
|
|
94
67
|
const makeBoolArray = (chars: string): ReadonlyArray<boolean> => {
|