@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 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.18.1",
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.18.1",
37
- "@oh-my-pi/pi-utils": "12.18.1",
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
+ }
@@ -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
- #pasteBuffer: string = "";
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
- // Start of paste: \x1b[200~
700
- // End of paste: \x1b[201~
701
-
702
- // Check if we're starting a bracketed paste
703
- if (data.includes("\x1b[200~")) {
704
- this.#isInPaste = true;
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
@@ -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
- #pasteBuffer: string = "";
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
- // Start of paste: \x1b[200~
48
- // End of paste: \x1b[201~
49
-
50
- // Check if we're starting a bracketed paste
51
- if (data.includes("\x1b[200~")) {
52
- this.#isInPaste = true;
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.length === 0) {
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.length === 0) {
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> => {