@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 +1 -1
- package/src/components/editor.ts +51 -47
- package/src/editor-component.ts +3 -0
- package/src/keybindings.ts +1 -1
- package/src/keys.ts +4 -1
- package/src/terminal.ts +5 -2
package/package.json
CHANGED
package/src/components/editor.ts
CHANGED
|
@@ -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
|
|
277
|
-
private
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
466
|
-
|
|
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
|
|
482
|
-
const layoutLines = this.layoutText(contentAreaWidth);
|
|
487
|
+
const layoutLines = this.layoutText(layoutWidth);
|
|
483
488
|
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
|
484
|
-
this.updateScrollOffset(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
587
|
-
if (
|
|
590
|
+
const layoutWidth = this.getLayoutWidth(width, paddingX);
|
|
591
|
+
if (layoutWidth <= 0) return null;
|
|
588
592
|
|
|
589
|
-
const layoutLines = this.layoutText(
|
|
593
|
+
const layoutLines = this.layoutText(layoutWidth);
|
|
590
594
|
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
|
591
|
-
this.updateScrollOffset(
|
|
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 >=
|
|
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.
|
|
1693
|
+
const contentWidth = this.lastLayoutWidth;
|
|
1690
1694
|
|
|
1691
1695
|
if (deltaLine !== 0) {
|
|
1692
1696
|
// Build visual line map for navigation
|
package/src/editor-component.ts
CHANGED
package/src/keybindings.ts
CHANGED
|
@@ -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
|
|
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 -
|
|
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
|
-
|
|
233
|
+
this.dead = true;
|
|
234
|
+
return;
|
|
232
235
|
}
|
|
233
236
|
throw err;
|
|
234
237
|
}
|