@oh-my-pi/pi-tui 9.1.1 → 9.2.1
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 +1 -0
- package/src/components/editor.ts +6 -0
- package/src/components/markdown.ts +69 -26
- package/src/terminal.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.2.1",
|
|
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": "9.
|
|
50
|
+
"@oh-my-pi/pi-natives": "9.2.1",
|
|
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
package/src/components/editor.ts
CHANGED
|
@@ -933,6 +933,9 @@ export class Editor implements Component, Focusable {
|
|
|
933
933
|
this.navigateHistory(-1); // Start browsing history
|
|
934
934
|
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
935
935
|
this.navigateHistory(-1); // Navigate to older history entry
|
|
936
|
+
} else if (this.isOnFirstVisualLine()) {
|
|
937
|
+
// Already at top - jump to start of line
|
|
938
|
+
this.moveToLineStart();
|
|
936
939
|
} else {
|
|
937
940
|
this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
|
|
938
941
|
}
|
|
@@ -940,6 +943,9 @@ export class Editor implements Component, Focusable {
|
|
|
940
943
|
// Down - history navigation or cursor movement
|
|
941
944
|
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
942
945
|
this.navigateHistory(1); // Navigate to newer history entry or clear
|
|
946
|
+
} else if (this.isOnLastVisualLine()) {
|
|
947
|
+
// Already at bottom - jump to end of line
|
|
948
|
+
this.moveToLineEnd();
|
|
943
949
|
} else {
|
|
944
950
|
this.moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
945
951
|
}
|
|
@@ -53,6 +53,11 @@ export interface MarkdownTheme {
|
|
|
53
53
|
symbols: SymbolTheme;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
interface InlineStyleContext {
|
|
57
|
+
applyText: (text: string) => string;
|
|
58
|
+
stylePrefix: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export class Markdown implements Component {
|
|
57
62
|
private text: string;
|
|
58
63
|
private paddingX: number; // Left/right padding
|
|
@@ -115,7 +120,11 @@ export class Markdown implements Component {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
// Replace tabs with 3 spaces for consistent rendering
|
|
118
|
-
|
|
123
|
+
let normalizedText = this.text.replace(/\t/g, " ");
|
|
124
|
+
|
|
125
|
+
// Fix inline code fences: text:```lang or text```lang should have newline before ```
|
|
126
|
+
// This handles malformed markdown from LLM thinking output
|
|
127
|
+
normalizedText = normalizedText.replace(/([^\n])```(\w*)\n/g, "$1\n```$2\n");
|
|
119
128
|
|
|
120
129
|
// Parse markdown to HTML-like tokens
|
|
121
130
|
const tokens = marked.lexer(normalizedText);
|
|
@@ -252,6 +261,20 @@ export class Markdown implements Component {
|
|
|
252
261
|
return this.defaultStylePrefix;
|
|
253
262
|
}
|
|
254
263
|
|
|
264
|
+
private getStylePrefix(styleFn: (text: string) => string): string {
|
|
265
|
+
const sentinel = "\u0000";
|
|
266
|
+
const styled = styleFn(sentinel);
|
|
267
|
+
const sentinelIndex = styled.indexOf(sentinel);
|
|
268
|
+
return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private getDefaultInlineStyleContext(): InlineStyleContext {
|
|
272
|
+
return {
|
|
273
|
+
applyText: (text: string) => this.applyDefaultStyle(text),
|
|
274
|
+
stylePrefix: this.getDefaultStylePrefix(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
255
278
|
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
|
256
279
|
const lines: string[] = [];
|
|
257
280
|
|
|
@@ -339,13 +362,23 @@ export class Markdown implements Component {
|
|
|
339
362
|
}
|
|
340
363
|
|
|
341
364
|
case "blockquote": {
|
|
342
|
-
const
|
|
365
|
+
const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text));
|
|
366
|
+
const quoteStyleContext: InlineStyleContext = {
|
|
367
|
+
applyText: quoteStyle,
|
|
368
|
+
stylePrefix: this.getStylePrefix(quoteStyle),
|
|
369
|
+
};
|
|
370
|
+
const quoteText = this.renderInlineTokens(token.tokens || [], quoteStyleContext);
|
|
343
371
|
const quoteLines = quoteText.split("\n");
|
|
372
|
+
|
|
373
|
+
// Calculate available width for quote content (subtract border + space = 2 chars)
|
|
374
|
+
const quoteContentWidth = Math.max(1, width - 2);
|
|
375
|
+
|
|
344
376
|
for (const quoteLine of quoteLines) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
377
|
+
// Wrap the styled line, then add border to each wrapped line
|
|
378
|
+
const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth);
|
|
379
|
+
for (const wrappedLine of wrappedLines) {
|
|
380
|
+
lines.push(this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) + wrappedLine);
|
|
381
|
+
}
|
|
349
382
|
}
|
|
350
383
|
if (nextTokenType !== "space") {
|
|
351
384
|
lines.push(""); // Add spacing after blockquotes (unless space token follows)
|
|
@@ -382,51 +415,61 @@ export class Markdown implements Component {
|
|
|
382
415
|
return lines;
|
|
383
416
|
}
|
|
384
417
|
|
|
385
|
-
private renderInlineTokens(tokens: Token[]): string {
|
|
418
|
+
private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
|
|
386
419
|
let result = "";
|
|
420
|
+
const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
|
|
421
|
+
const { applyText, stylePrefix } = resolvedStyleContext;
|
|
422
|
+
const applyTextWithNewlines = (text: string): string => {
|
|
423
|
+
const segments: string[] = text.split("\n");
|
|
424
|
+
return segments.map((segment: string) => applyText(segment)).join("\n");
|
|
425
|
+
};
|
|
387
426
|
|
|
388
427
|
for (const token of tokens) {
|
|
389
428
|
switch (token.type) {
|
|
390
429
|
case "text":
|
|
391
430
|
// Text tokens in list items can have nested tokens for inline formatting
|
|
392
431
|
if (token.tokens && token.tokens.length > 0) {
|
|
393
|
-
result += this.renderInlineTokens(token.tokens);
|
|
432
|
+
result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
|
|
394
433
|
} else {
|
|
395
|
-
|
|
396
|
-
result += this.applyDefaultStyle(token.text);
|
|
434
|
+
result += applyTextWithNewlines(token.text);
|
|
397
435
|
}
|
|
398
436
|
break;
|
|
399
437
|
|
|
438
|
+
case "paragraph":
|
|
439
|
+
// Paragraph tokens contain nested inline tokens
|
|
440
|
+
result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
441
|
+
break;
|
|
442
|
+
|
|
400
443
|
case "strong": {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
result += this.theme.bold(boldContent) + this.getDefaultStylePrefix();
|
|
444
|
+
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
445
|
+
result += this.theme.bold(boldContent) + stylePrefix;
|
|
404
446
|
break;
|
|
405
447
|
}
|
|
406
448
|
|
|
407
449
|
case "em": {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
result += this.theme.italic(italicContent) + this.getDefaultStylePrefix();
|
|
450
|
+
const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
451
|
+
result += this.theme.italic(italicContent) + stylePrefix;
|
|
411
452
|
break;
|
|
412
453
|
}
|
|
413
454
|
|
|
414
455
|
case "codespan":
|
|
415
|
-
|
|
416
|
-
result += this.theme.code(token.text) + this.getDefaultStylePrefix();
|
|
456
|
+
result += this.theme.code(token.text) + stylePrefix;
|
|
417
457
|
break;
|
|
418
458
|
|
|
419
459
|
case "link": {
|
|
420
|
-
const linkText = this.renderInlineTokens(token.tokens || []);
|
|
460
|
+
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
421
461
|
// If link text matches href, only show the link once
|
|
422
462
|
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
|
|
423
|
-
|
|
424
|
-
|
|
463
|
+
// For mailto: links, strip the prefix before comparing (autolinked emails have
|
|
464
|
+
// text="foo@bar.com" but href="mailto:foo@bar.com")
|
|
465
|
+
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
|
|
466
|
+
if (token.text === token.href || token.text === hrefForComparison) {
|
|
467
|
+
result += this.theme.link(this.theme.underline(linkText)) + stylePrefix;
|
|
425
468
|
} else {
|
|
426
469
|
result +=
|
|
427
470
|
this.theme.link(this.theme.underline(linkText)) +
|
|
428
471
|
this.theme.linkUrl(` (${token.href})`) +
|
|
429
|
-
|
|
472
|
+
stylePrefix;
|
|
430
473
|
}
|
|
431
474
|
break;
|
|
432
475
|
}
|
|
@@ -436,22 +479,22 @@ export class Markdown implements Component {
|
|
|
436
479
|
break;
|
|
437
480
|
|
|
438
481
|
case "del": {
|
|
439
|
-
const delContent = this.renderInlineTokens(token.tokens || []);
|
|
440
|
-
result += this.theme.strikethrough(delContent) +
|
|
482
|
+
const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
483
|
+
result += this.theme.strikethrough(delContent) + stylePrefix;
|
|
441
484
|
break;
|
|
442
485
|
}
|
|
443
486
|
|
|
444
487
|
case "html":
|
|
445
488
|
// Render inline HTML as plain text
|
|
446
489
|
if ("raw" in token && typeof token.raw === "string") {
|
|
447
|
-
result +=
|
|
490
|
+
result += applyTextWithNewlines(token.raw);
|
|
448
491
|
}
|
|
449
492
|
break;
|
|
450
493
|
|
|
451
494
|
default:
|
|
452
495
|
// Handle any other inline token types as plain text
|
|
453
496
|
if ("text" in token && typeof token.text === "string") {
|
|
454
|
-
result +=
|
|
497
|
+
result += applyTextWithNewlines(token.text);
|
|
455
498
|
}
|
|
456
499
|
}
|
|
457
500
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { StdinBuffer } from "./stdin-buffer";
|
|
|
9
9
|
|
|
10
10
|
// Track active terminal for emergency cleanup on crash
|
|
11
11
|
let activeTerminal: ProcessTerminal | null = null;
|
|
12
|
+
// Track if a terminal was ever started (for emergency restore logic)
|
|
13
|
+
let terminalEverStarted = false;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
@@ -20,8 +22,9 @@ export function emergencyTerminalRestore(): void {
|
|
|
20
22
|
if (terminal) {
|
|
21
23
|
terminal.stop();
|
|
22
24
|
terminal.showCursor();
|
|
23
|
-
} else {
|
|
24
|
-
// Blind restore if
|
|
25
|
+
} else if (terminalEverStarted) {
|
|
26
|
+
// Blind restore only if we know a terminal was started but lost track of it
|
|
27
|
+
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
25
28
|
process.stdout.write(
|
|
26
29
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
27
30
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
@@ -91,6 +94,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
91
94
|
|
|
92
95
|
// Register for emergency cleanup
|
|
93
96
|
activeTerminal = this;
|
|
97
|
+
terminalEverStarted = true;
|
|
94
98
|
|
|
95
99
|
// Save previous state and enable raw mode
|
|
96
100
|
this.wasRaw = process.stdin.isRaw || false;
|