@oh-my-pi/pi-tui 8.2.2 → 8.4.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.2.2",
3
+ "version": "8.4.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",
@@ -273,8 +273,9 @@ export class Editor implements Component, Focusable {
273
273
  private theme: EditorTheme;
274
274
  private useTerminalCursor = false;
275
275
 
276
- // Store last render width for cursor navigation
277
- private lastWidth: number = 80;
276
+ // Store last layout width for cursor navigation
277
+ private lastLayoutWidth: number = 80;
278
+ private paddingXOverride: number | undefined;
278
279
  private maxHeight?: number;
279
280
  private scrollOffset: number = 0;
280
281
 
@@ -352,6 +353,10 @@ export class Editor implements Component, Focusable {
352
353
  this.scrollOffset = 0;
353
354
  }
354
355
 
356
+ setPaddingX(paddingX: number): void {
357
+ this.paddingXOverride = Math.max(0, paddingX);
358
+ }
359
+
355
360
  setHistoryStorage(storage: HistoryStorage): void {
356
361
  this.historyStorage = storage;
357
362
  const recent = storage.getRecent(100);
@@ -382,15 +387,13 @@ export class Editor implements Component, Focusable {
382
387
  }
383
388
 
384
389
  private isOnFirstVisualLine(): boolean {
385
- const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
386
- const visualLines = this.buildVisualLineMap(contentWidth);
390
+ const visualLines = this.buildVisualLineMap(this.lastLayoutWidth);
387
391
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
388
392
  return currentVisualLine === 0;
389
393
  }
390
394
 
391
395
  private isOnLastVisualLine(): boolean {
392
- const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
393
- const visualLines = this.buildVisualLineMap(contentWidth);
396
+ const visualLines = this.buildVisualLineMap(this.lastLayoutWidth);
394
397
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
395
398
  return currentVisualLine === visualLines.length - 1;
396
399
  }
@@ -430,26 +433,31 @@ export class Editor implements Component, Focusable {
430
433
  }
431
434
 
432
435
  private getEditorPaddingX(): number {
433
- const padding = this.theme.editorPaddingX ?? 2;
434
- return Math.max(1, padding);
436
+ const padding = this.paddingXOverride ?? this.theme.editorPaddingX ?? 2;
437
+ return Math.max(0, padding);
435
438
  }
436
439
 
437
440
  private getContentWidth(width: number, paddingX: number): number {
438
441
  return Math.max(0, width - 2 * (paddingX + 1));
439
442
  }
440
443
 
444
+ private getLayoutWidth(width: number, paddingX: number): number {
445
+ const contentWidth = this.getContentWidth(width, paddingX);
446
+ return Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0));
447
+ }
448
+
441
449
  private getVisibleContentHeight(contentLines: number): number {
442
450
  if (this.maxHeight === undefined) return contentLines;
443
451
  return Math.max(1, this.maxHeight - 2);
444
452
  }
445
453
 
446
- private updateScrollOffset(contentWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
454
+ private updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
447
455
  if (layoutLines.length <= visibleHeight) {
448
456
  this.scrollOffset = 0;
449
457
  return;
450
458
  }
451
459
 
452
- const visualLines = this.buildVisualLineMap(contentWidth);
460
+ const visualLines = this.buildVisualLineMap(layoutWidth);
453
461
  const cursorLine = this.findCurrentVisualLine(visualLines);
454
462
  if (cursorLine < this.scrollOffset) {
455
463
  this.scrollOffset = cursorLine;
@@ -462,26 +470,23 @@ export class Editor implements Component, Focusable {
462
470
  }
463
471
 
464
472
  render(width: number): string[] {
465
- // Store width for cursor navigation
466
- this.lastWidth = width;
473
+ const paddingX = this.getEditorPaddingX();
474
+ const contentAreaWidth = this.getContentWidth(width, paddingX);
475
+ const layoutWidth = this.getLayoutWidth(width, paddingX);
476
+ this.lastLayoutWidth = layoutWidth;
467
477
 
468
478
  // Box-drawing characters for rounded corners
469
479
  const box = this.theme.symbols.boxRound;
470
- const paddingX = this.getEditorPaddingX();
471
480
  const borderWidth = paddingX + 1;
472
481
  const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
473
482
  const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
474
483
  const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`);
475
- const bottomRight = this.borderColor(
476
- `${" ".repeat(Math.max(0, paddingX - 1))}${box.horizontal}${box.bottomRight}`,
477
- );
478
484
  const horizontal = this.borderColor(box.horizontal);
479
485
 
480
486
  // Layout the text
481
- const contentAreaWidth = this.getContentWidth(width, paddingX);
482
- const layoutLines = this.layoutText(contentAreaWidth);
487
+ const layoutLines = this.layoutText(layoutWidth);
483
488
  const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
484
- this.updateScrollOffset(contentAreaWidth, layoutLines, visibleContentHeight);
489
+ this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
485
490
  const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight);
486
491
 
487
492
  const result: string[] = [];
@@ -513,15 +518,22 @@ export class Editor implements Component, Focusable {
513
518
  for (const layoutLine of visibleLayoutLines) {
514
519
  let displayText = layoutLine.text;
515
520
  let displayWidth = visibleWidth(layoutLine.text);
521
+ let cursorInPadding = false;
516
522
 
517
523
  // Add cursor if this line has it
518
- if (!this.useTerminalCursor && layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
524
+ const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
525
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
526
+
527
+ if (hasCursor && this.useTerminalCursor) {
528
+ if (marker) {
529
+ const before = displayText.slice(0, layoutLine.cursorPos);
530
+ const after = displayText.slice(layoutLine.cursorPos);
531
+ displayText = before + marker + after;
532
+ }
533
+ } else if (hasCursor && !this.useTerminalCursor) {
519
534
  const before = displayText.slice(0, layoutLine.cursorPos);
520
535
  const after = displayText.slice(layoutLine.cursorPos);
521
536
 
522
- // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
523
- const marker = emitCursorMarker ? CURSOR_MARKER : "";
524
-
525
537
  if (after.length > 0) {
526
538
  // Cursor is on a character (grapheme) - replace it with highlighted version
527
539
  // Get the first grapheme from 'after'
@@ -532,26 +544,13 @@ export class Editor implements Component, Focusable {
532
544
  displayText = before + marker + cursor + restAfter;
533
545
  // displayWidth stays the same - we're replacing, not adding
534
546
  } else {
535
- // Cursor is at the end - add thin blinking bar cursor
547
+ // Cursor is at the end - add thin cursor glyph
536
548
  const cursorChar = this.theme.symbols.inputCursor;
537
549
  const cursor = `\x1b[5m${cursorChar}\x1b[0m`;
538
550
  displayText = before + marker + cursor;
539
551
  displayWidth += visibleWidth(cursorChar);
540
- if (displayWidth > lineContentWidth) {
541
- // Line is at full width - use reverse video on last grapheme if possible
542
- // or just show cursor at the end without adding space
543
- const beforeGraphemes = [...segmenter.segment(before)];
544
- if (beforeGraphemes.length > 0) {
545
- const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
546
- const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
547
- // Rebuild 'before' without the last grapheme
548
- const beforeWithoutLast = beforeGraphemes
549
- .slice(0, -1)
550
- .map(g => g.segment)
551
- .join("");
552
- displayText = beforeWithoutLast + marker + cursor;
553
- displayWidth -= 1; // Back to original width (reverse video replaces, doesn't add)
554
- }
552
+ if (displayWidth > lineContentWidth && paddingX > 0) {
553
+ cursorInPadding = true;
555
554
  }
556
555
  }
557
556
  }
@@ -560,11 +559,16 @@ export class Editor implements Component, Focusable {
560
559
  const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1];
561
560
  const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth));
562
561
 
562
+ const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0));
563
563
  if (isLastLine) {
564
- result.push(`${bottomLeft}${displayText}${padding}${bottomRight}`);
564
+ const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0));
565
+ const bottomRightAdjusted = this.borderColor(
566
+ `${" ".repeat(bottomRightPadding)}${box.horizontal}${box.bottomRight}`,
567
+ );
568
+ result.push(`${bottomLeft}${displayText}${padding}${bottomRightAdjusted}`);
565
569
  } else {
566
570
  const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`);
567
- const rightBorder = this.borderColor(`${" ".repeat(paddingX)}${box.vertical}`);
571
+ const rightBorder = this.borderColor(`${" ".repeat(rightPaddingWidth)}${box.vertical}`);
568
572
  result.push(leftBorder + displayText + padding + rightBorder);
569
573
  }
570
574
  }
@@ -583,12 +587,12 @@ export class Editor implements Component, Focusable {
583
587
 
584
588
  const paddingX = this.getEditorPaddingX();
585
589
  const borderWidth = paddingX + 1;
586
- const contentWidth = this.getContentWidth(width, paddingX);
587
- if (contentWidth <= 0) return null;
590
+ const layoutWidth = this.getLayoutWidth(width, paddingX);
591
+ if (layoutWidth <= 0) return null;
588
592
 
589
- const layoutLines = this.layoutText(contentWidth);
593
+ const layoutLines = this.layoutText(layoutWidth);
590
594
  const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
591
- this.updateScrollOffset(contentWidth, layoutLines, visibleContentHeight);
595
+ this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
592
596
 
593
597
  for (let i = 0; i < layoutLines.length; i++) {
594
598
  if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue;
@@ -598,7 +602,7 @@ export class Editor implements Component, Focusable {
598
602
  const lineWidth = visibleWidth(layoutLine.text);
599
603
  const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length;
600
604
 
601
- if (isCursorAtLineEnd && lineWidth >= contentWidth && layoutLine.text.length > 0) {
605
+ if (isCursorAtLineEnd && lineWidth >= layoutWidth && layoutLine.text.length > 0) {
602
606
  const graphemes = [...segmenter.segment(layoutLine.text)];
603
607
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
604
608
  const lastWidth = visibleWidth(lastGrapheme) || 1;
@@ -1686,7 +1690,7 @@ export class Editor implements Component, Focusable {
1686
1690
 
1687
1691
  private moveCursor(deltaLine: number, deltaCol: number): void {
1688
1692
  this.resetKillSequence();
1689
- const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
1693
+ const contentWidth = this.lastLayoutWidth;
1690
1694
 
1691
1695
  if (deltaLine !== 0) {
1692
1696
  // Build visual line map for navigation
@@ -62,4 +62,7 @@ export interface EditorComponent extends Component {
62
62
 
63
63
  /** Border color function */
64
64
  borderColor?: (str: string) => string;
65
+
66
+ /** Set horizontal padding */
67
+ setPaddingX?(padding: number): void;
65
68
  }
@@ -60,7 +60,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
60
60
  // Deletion
61
61
  deleteCharBackward: "backspace",
62
62
  deleteCharForward: "delete",
63
- deleteWordBackward: ["ctrl+w", "alt+backspace"],
63
+ deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
64
64
  deleteWordForward: ["alt+delete", "alt+d"],
65
65
  deleteToLineStart: "ctrl+u",
66
66
  deleteToLineEnd: "ctrl+k",
package/src/keys.ts CHANGED
@@ -796,7 +796,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
796
796
  if (modifier === 0) {
797
797
  return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
798
798
  }
799
- return matchesKittySequence(data, CODEPOINTS.backspace, modifier);
799
+ return (
800
+ matchesKittySequence(data, CODEPOINTS.backspace, modifier) ||
801
+ matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier)
802
+ );
800
803
 
801
804
  case "insert":
802
805
  if (modifier === 0) {
package/src/terminal.ts CHANGED
@@ -76,6 +76,7 @@ export class ProcessTerminal implements Terminal {
76
76
  private _kittyProtocolActive = false;
77
77
  private stdinBuffer?: StdinBuffer;
78
78
  private stdinDataHandler?: (data: string) => void;
79
+ private dead = false;
79
80
 
80
81
  get kittyProtocolActive(): boolean {
81
82
  return this._kittyProtocolActive;
@@ -223,12 +224,14 @@ export class ProcessTerminal implements Terminal {
223
224
  }
224
225
 
225
226
  private safeWrite(data: string): void {
227
+ if (this.dead) return;
226
228
  try {
227
229
  process.stdout.write(data);
228
230
  } catch (err) {
229
- // EIO means terminal is dead - exit gracefully instead of crashing
231
+ // EIO means terminal is dead - mark dead and skip all future writes
230
232
  if (err && typeof err === "object" && (err as { code?: string }).code === "EIO") {
231
- process.exit(1);
233
+ this.dead = true;
234
+ return;
232
235
  }
233
236
  throw err;
234
237
  }