pi-open-tui 0.3.13 → 0.3.14

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.
@@ -40,6 +40,8 @@ export interface InlineFooterRenderer {
40
40
 
41
41
  interface HiddenThinkingLabelComponent {
42
42
  children: unknown[];
43
+ outputPad?: unknown;
44
+ render(width: number): string[];
43
45
  setHiddenThinkingLabel(label: string): void;
44
46
  setHideThinkingBlock(hide: boolean): void;
45
47
  updateContent(message: unknown, isStreaming?: boolean): void;
@@ -48,6 +50,7 @@ interface HiddenThinkingLabelComponent {
48
50
  function isHiddenThinkingLabelComponent(value: object): value is HiddenThinkingLabelComponent {
49
51
  const component = value as unknown as Record<string, unknown>;
50
52
  return Array.isArray(component.children)
53
+ && typeof component.render === "function"
51
54
  && typeof component.setHiddenThinkingLabel === "function"
52
55
  && typeof component.setHideThinkingBlock === "function"
53
56
  && typeof component.updateContent === "function";
@@ -71,6 +74,13 @@ function findLatestHiddenThinkingLabel(tui: TUI): HiddenThinkingLabelComponent {
71
74
  return latest;
72
75
  }
73
76
 
77
+ /** Width Pi's hidden-thinking `Text(label, outputPad, 0)` wraps at when its message renders at `width`. */
78
+ function hiddenThinkingTextWidth(width: number, outputPad: unknown): number {
79
+ const pad = typeof outputPad === "number" && Number.isFinite(outputPad) ? Math.max(0, Math.floor(outputPad)) : 1;
80
+ const paddingX = Math.min(pad, Math.max(0, Math.floor((width - 1) / 2)));
81
+ return Math.max(1, width - paddingX * 2);
82
+ }
83
+
74
84
  function removeSoftwareCursor(line: string, cursorMarker = ""): string {
75
85
  return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor: string) => {
76
86
  const replacement = `${cursorMarker}${cursor}`;
@@ -308,6 +318,42 @@ export class OpenTuiEditor extends CustomEditor {
308
318
  }
309
319
  }
310
320
 
321
+ type StreamWrite = (...args: unknown[]) => boolean;
322
+
323
+ /**
324
+ * Calls `requestRepaint` after each raw write to a TTY stream. Pi's fullscreen renderer only
325
+ * rewrites changed rows, so text other extensions print straight to the terminal would
326
+ * otherwise stay over the editor and footer until a resize.
327
+ */
328
+ export function repaintAfterStreamWrites(
329
+ requestRepaint: () => void,
330
+ stream: NodeJS.WriteStream = process.stderr,
331
+ ): () => void {
332
+ if (!stream.isTTY) return () => {};
333
+ const original = stream.write as unknown as StreamWrite;
334
+ let enabled = true;
335
+ const patched = ((...args: unknown[]) => {
336
+ const callback = typeof args[args.length - 1] === "function"
337
+ ? (args.pop() as (error?: Error | null) => void)
338
+ : undefined;
339
+ // Repaint from the write callback so the redraw lands after the stray text.
340
+ return original.call(stream, ...args, (error?: Error | null) => {
341
+ try {
342
+ if (enabled) requestRepaint();
343
+ } catch {
344
+ // A failed repaint must not break the caller's write; logging it here would recurse.
345
+ }
346
+ callback?.(error);
347
+ });
348
+ }) as typeof stream.write;
349
+ stream.write = patched;
350
+ return () => {
351
+ enabled = false;
352
+ // A later wrapper may still delegate to ours; leave it as a passthrough then.
353
+ if (stream.write === patched) stream.write = original as unknown as typeof stream.write;
354
+ };
355
+ }
356
+
311
357
  export function installEditor(
312
358
  _pi: ExtensionAPI,
313
359
  ctx: ExtensionContext,
@@ -319,41 +365,69 @@ export function installEditor(
319
365
  let previousHardwareCursor: boolean | undefined;
320
366
  let currentCursorStyle = cursorStyle;
321
367
  let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
368
+ let restoreHiddenThinkingRender: (() => void) | undefined;
369
+ let buildHiddenThinkingLabel: ((width: number) => string) | undefined;
322
370
  const getActiveTui = (): TUI => {
323
371
  if (!activeTui) throw new Error("Open TUI editor is not mounted");
324
372
  return activeTui;
325
373
  };
374
+ const resetHiddenThinkingTarget = (): void => {
375
+ restoreHiddenThinkingRender?.();
376
+ restoreHiddenThinkingRender = undefined;
377
+ hiddenThinkingTarget = undefined;
378
+ buildHiddenThinkingLabel = undefined;
379
+ };
380
+ // Build the label at the width Pi actually renders the message with; layout
381
+ // extensions (e.g. pi-sidebar) can make it much narrower than the terminal.
382
+ const patchHiddenThinkingRender = (target: HiddenThinkingLabelComponent): void => {
383
+ const originalRender = target.render;
384
+ let lastWidth: number | undefined;
385
+ let lastBuild: ((width: number) => string) | undefined;
386
+ target.render = function (this: HiddenThinkingLabelComponent, width: number): string[] {
387
+ const build = buildHiddenThinkingLabel;
388
+ const textWidth = hiddenThinkingTextWidth(width, this.outputPad);
389
+ if (build && (build !== lastBuild || textWidth !== lastWidth)) {
390
+ lastBuild = build;
391
+ lastWidth = textWidth;
392
+ this.setHiddenThinkingLabel(build(textWidth));
393
+ }
394
+ return originalRender.call(this, width);
395
+ };
396
+ restoreHiddenThinkingRender = () => {
397
+ target.render = originalRender;
398
+ };
399
+ };
400
+ // Only the fullscreen renderer clears and rewrites the whole screen on a forced render.
401
+ const restoreStderr = repaintAfterStreamWrites(() => {
402
+ if (activeTui?.mode === "fullscreen") activeTui.requestRender(true);
403
+ });
326
404
 
327
405
  ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
328
406
  activeTui = tui;
329
- hiddenThinkingTarget = undefined;
407
+ resetHiddenThinkingTarget();
330
408
  previousHardwareCursor = tui.getShowHardwareCursor();
331
409
  activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
332
410
  return activeEditor;
333
411
  });
334
412
  return {
335
- getViewportWidth(): number {
336
- const columns = getActiveTui().terminal.columns;
337
- if (typeof columns !== "number" || !Number.isFinite(columns)) {
338
- throw new Error("Open TUI editor terminal has an invalid width");
339
- }
340
- return Math.max(1, Math.floor(columns));
341
- },
342
- setLatestHiddenThinkingLabel(label: string): void {
413
+ /** `build` receives the hidden-thinking text width and runs again whenever that width changes. */
414
+ setLatestHiddenThinkingLabel(build: (width: number) => string): void {
343
415
  const tui = getActiveTui();
344
- hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
345
- hiddenThinkingTarget.setHiddenThinkingLabel(label);
416
+ if (!hiddenThinkingTarget) {
417
+ hiddenThinkingTarget = findLatestHiddenThinkingLabel(tui);
418
+ patchHiddenThinkingRender(hiddenThinkingTarget);
419
+ }
420
+ buildHiddenThinkingLabel = build;
346
421
  tui.requestRender();
347
422
  },
348
- resetHiddenThinkingLabelTarget(): void {
349
- hiddenThinkingTarget = undefined;
350
- },
423
+ resetHiddenThinkingLabelTarget: resetHiddenThinkingTarget,
351
424
  setCursorStyle(nextCursorStyle: CursorStyle): void {
352
425
  currentCursorStyle = nextCursorStyle;
353
426
  activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
354
427
  },
355
428
  cleanup(): void {
356
- hiddenThinkingTarget = undefined;
429
+ restoreStderr();
430
+ resetHiddenThinkingTarget();
357
431
  ctx.ui.setEditorComponent(undefined);
358
432
  if (activeTui) {
359
433
  if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
@@ -24,9 +24,6 @@ import {
24
24
  } from "./peek.ts";
25
25
 
26
26
  type PendingUiChange = "install" | "uninstall";
27
- const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
28
- // Pi's fullscreen transcript can reserve one more column for its scrollbar.
29
- const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
30
27
 
31
28
  export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
32
29
  if (enabled === active) return undefined;
@@ -78,29 +75,16 @@ export default function (pi: ExtensionAPI) {
78
75
  /** Pi decides whether this label is visible; our toggle only controls updates. */
79
76
  const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
80
77
 
81
- /** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
82
- const hiddenThinkingLabelWidth = (): number => {
83
- if (!editor) throw new Error("Open TUI editor is not installed");
84
- return Math.max(
85
- 1,
86
- editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
87
- );
88
- };
89
-
90
78
  const setPeekLabel = (ctx?: ExtensionContext): void => {
91
79
  if (!isPeekEnabled() || peek.phase === "idle") return;
92
80
  const target = ctx ?? lastCtx;
93
81
  if (!target || !isTuiContext(target)) return;
94
82
  if (!editor) throw new Error("Open TUI editor is not installed");
95
- editor.setLatestHiddenThinkingLabel(
96
- buildPeekLabel(
97
- peek,
98
- peekFrame,
99
- resolveGlyphs(config.icons.mode),
100
- hiddenThinkingLabelWidth(),
101
- config.thinkingPeek.lines,
102
- ),
103
- );
83
+ const snapshot = { ...peek };
84
+ const frame = peekFrame;
85
+ const glyphs = resolveGlyphs(config.icons.mode);
86
+ const lines = config.thinkingPeek.lines;
87
+ editor.setLatestHiddenThinkingLabel((width) => buildPeekLabel(snapshot, frame, glyphs, width, lines));
104
88
  peekLabelActive = true;
105
89
  };
106
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages.",
5
5
  "type": "module",
6
6
  "repository": {