@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "9.1.1",
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.1.1",
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",
@@ -60,6 +60,7 @@ async function walkDirectoryWithFd(
60
60
  const proc = Bun.spawn([fdPath, ...args], {
61
61
  stdout: "pipe",
62
62
  stderr: "pipe",
63
+ windowsHide: true,
63
64
  });
64
65
 
65
66
  const exitCode = await proc.exited;
@@ -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
- const normalizedText = this.text.replace(/\t/g, " ");
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 quoteText = this.renderInlineTokens(token.tokens || []);
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
- lines.push(
346
- this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) +
347
- this.theme.quote(this.theme.italic(quoteLine)),
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
- // Apply default style to plain text
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
- // Apply bold, then reapply default style after
402
- const boldContent = this.renderInlineTokens(token.tokens || []);
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
- // Apply italic, then reapply default style after
409
- const italicContent = this.renderInlineTokens(token.tokens || []);
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
- // Apply code styling without backticks
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
- if (token.text === token.href) {
424
- result += this.theme.link(this.theme.underline(linkText)) + this.getDefaultStylePrefix();
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
- this.getDefaultStylePrefix();
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) + this.getDefaultStylePrefix();
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 += this.applyDefaultStyle(token.raw);
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 += this.applyDefaultStyle(token.text);
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 no instance tracked - covers edge cases
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;