@oh-my-pi/pi-tui 18.8.6 → 18.8.7

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +18 -16
  2. package/README.md +7 -5
  3. package/dist/types/chat/assistant-message.d.ts +1 -0
  4. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  5. package/dist/types/chat/user-message.d.ts +2 -0
  6. package/dist/types/components/box.d.ts +1 -0
  7. package/dist/types/components/disclosure.d.ts +1 -0
  8. package/dist/types/components/image.d.ts +1 -0
  9. package/dist/types/components/key-value-list.d.ts +1 -0
  10. package/dist/types/components/layout/row.d.ts +1 -0
  11. package/dist/types/components/layout/stack.d.ts +1 -0
  12. package/dist/types/components/markdown.d.ts +1 -0
  13. package/dist/types/components/scroll-view.d.ts +1 -0
  14. package/dist/types/components/section.d.ts +1 -0
  15. package/dist/types/components/table.d.ts +1 -0
  16. package/dist/types/components/text.d.ts +1 -0
  17. package/dist/types/components/truncated-text.d.ts +1 -0
  18. package/dist/types/native/blobs.d.ts +3 -1
  19. package/dist/types/overlays/session-selector.d.ts +1 -1
  20. package/dist/types/render/output-pane.d.ts +1 -0
  21. package/dist/types/render/render-utils.d.ts +1 -0
  22. package/dist/types/render/tool-card.d.ts +6 -0
  23. package/dist/types/render/width-aware-text.d.ts +2 -0
  24. package/dist/types/terminal.d.ts +10 -2
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/tools/yield.d.ts +22 -0
  27. package/dist/types/tui.d.ts +11 -0
  28. package/package.json +9 -9
  29. package/src/chat/advisor-message.ts +11 -0
  30. package/src/chat/assistant-message.ts +6 -0
  31. package/src/chat/compaction-summary-message.ts +8 -0
  32. package/src/chat/read-tool-group.ts +6 -4
  33. package/src/chat/skill-message.ts +6 -0
  34. package/src/chat/table-chart.ts +17 -20
  35. package/src/chat/tool-execution.ts +18 -0
  36. package/src/chat/user-message.ts +14 -0
  37. package/src/chrome/transcript-container.ts +32 -1
  38. package/src/components/box.ts +5 -0
  39. package/src/components/disclosure.ts +8 -0
  40. package/src/components/image.ts +7 -5
  41. package/src/components/key-value-list.ts +4 -0
  42. package/src/components/layout/row.ts +7 -0
  43. package/src/components/layout/stack.ts +7 -0
  44. package/src/components/markdown.ts +8 -0
  45. package/src/components/scroll-view.ts +5 -0
  46. package/src/components/section.ts +5 -0
  47. package/src/components/table.ts +4 -0
  48. package/src/components/text.ts +7 -0
  49. package/src/components/truncated-text.ts +5 -0
  50. package/src/native/backend.ts +16 -15
  51. package/src/native/blobs.ts +43 -10
  52. package/src/native/reconcile.ts +4 -0
  53. package/src/overlays/session-selector.ts +29 -19
  54. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  55. package/src/prompt/attachment-chips.ts +13 -10
  56. package/src/render/output-pane.ts +6 -0
  57. package/src/render/render-utils.ts +11 -0
  58. package/src/render/tool-card.ts +15 -0
  59. package/src/render/width-aware-text.ts +5 -0
  60. package/src/terminal.ts +24 -3
  61. package/src/tools/bash.ts +10 -11
  62. package/src/tools/edit.ts +142 -120
  63. package/src/tools/fetch.ts +5 -6
  64. package/src/tools/index.ts +2 -0
  65. package/src/tools/mcp.ts +15 -2
  66. package/src/tools/read.ts +7 -5
  67. package/src/tools/write.ts +70 -56
  68. package/src/tools/yield.ts +225 -0
  69. package/src/tui.ts +25 -0
@@ -637,40 +637,49 @@ export const writeToolRenderer = {
637
637
  // back to the normalizing stringify.
638
638
  const content = typeof args.content === "string" ? args.content : normalizeDisplayText(args.content);
639
639
  const streamingCache = createRenderedStringCache();
640
- return framedToolCard(uiTheme, () => {
641
- const body = content
642
- ? formatStreamingContent(
643
- content,
644
- Boolean(options?.expanded),
645
- lang,
646
- uiTheme,
647
- options?.spinnerFrame,
648
- streamingCache,
649
- // `options` is the ToolExecutionComponent's persistent
650
- // render-state object — a stable identity across reveal ticks
651
- // that keys the incremental preview state. `argsComplete`
652
- // flushes the trailing line through the highlighter once.
653
- options,
654
- options?.argsComplete,
655
- )
656
- : "";
657
- const bodyLines = body ? body.split("\n") : [];
658
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
659
- return {
660
- header,
661
- sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
662
- phase: "pending",
663
- borderColor: "borderMuted",
664
- };
665
- });
640
+ return framedToolCard(
641
+ uiTheme,
642
+ () => {
643
+ const body = content
644
+ ? formatStreamingContent(
645
+ content,
646
+ Boolean(options?.expanded),
647
+ lang,
648
+ uiTheme,
649
+ options?.spinnerFrame,
650
+ streamingCache,
651
+ // `options` is the ToolExecutionComponent's persistent
652
+ // render-state object — a stable identity across reveal ticks
653
+ // that keys the incremental preview state. `argsComplete`
654
+ // flushes the trailing line through the highlighter once.
655
+ options,
656
+ options?.argsComplete,
657
+ )
658
+ : "";
659
+ const bodyLines = body ? body.split("\n") : [];
660
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
661
+ return {
662
+ header,
663
+ sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
664
+ phase: "pending",
665
+ borderColor: "borderMuted",
666
+ };
667
+ },
668
+ // The highlighted body in `streamingCache` does not depend on width, and re-highlighting every committed
669
+ // card on each resize replay costs seconds; only the incremental highlighter state goes.
670
+ { onReleaseRenderCaches: () => delete options?.[writeStreamingPreviewStateKey] },
671
+ );
666
672
  },
667
673
 
668
674
  renderResult(
669
675
  result: WriteResult,
670
- options: RenderResultOptions & { renderContext?: WriteRenderContext },
676
+ options: RenderResultOptions & WriteStreamingPreviewStateCarrier & { renderContext?: WriteRenderContext },
671
677
  uiTheme: Theme,
672
678
  args?: WriteRenderArgs,
673
679
  ): Component {
680
+ // Write merges call and result, so this builder runs once per display rebuild after the result arrives and the
681
+ // call card never renders again: its incremental highlighter state is dead from here on.
682
+ delete options[writeStreamingPreviewStateKey];
674
683
  const cardPath =
675
684
  typeof args?.path === "string" ? args.path : typeof args?.file_path === "string" ? args.file_path : "";
676
685
  const routed = writeUrlCard(cardPath, result.details);
@@ -732,36 +741,41 @@ export const writeToolRenderer = {
732
741
  const diagnostics = result.details?.diagnostics;
733
742
 
734
743
  const previewCache = createRenderedStringCache();
735
- return framedToolCard(uiTheme, () => {
736
- const { expanded } = options;
737
- let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache);
738
- if (isPartial && progressText) {
739
- const safeProgressText = truncateToWidth(
740
- replaceTabs(progressText),
741
- TRUNCATE_LENGTHS.LINE,
742
- Ellipsis.Unicode,
743
- );
744
- body = `${uiTheme.fg("muted", safeProgressText)}${body ? `\n${body}` : ""}`;
745
- }
746
- if (!isPartial && diagnostics) {
747
- const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
748
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
749
- );
750
- if (diagText.trim()) {
751
- const diagLines = diagText.split("\n");
752
- const firstNonEmpty = diagLines.findIndex(line => line.trim());
753
- if (firstNonEmpty >= 0) body += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
744
+ return framedToolCard(
745
+ uiTheme,
746
+ () => {
747
+ const { expanded } = options;
748
+ let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache);
749
+ if (isPartial && progressText) {
750
+ const safeProgressText = truncateToWidth(
751
+ replaceTabs(progressText),
752
+ TRUNCATE_LENGTHS.LINE,
753
+ Ellipsis.Unicode,
754
+ );
755
+ body = `${uiTheme.fg("muted", safeProgressText)}${body ? `\n${body}` : ""}`;
754
756
  }
755
- }
756
- const bodyLines = body.split("\n");
757
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
758
- return {
759
- header,
760
- sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
761
- phase: isPartial ? "partial" : "success",
762
- borderColor: "borderMuted",
763
- };
764
- });
757
+ if (!isPartial && diagnostics) {
758
+ const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
759
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
760
+ );
761
+ if (diagText.trim()) {
762
+ const diagLines = diagText.split("\n");
763
+ const firstNonEmpty = diagLines.findIndex(line => line.trim());
764
+ if (firstNonEmpty >= 0) body += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
765
+ }
766
+ }
767
+ const bodyLines = body.split("\n");
768
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
769
+ return {
770
+ header,
771
+ sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
772
+ phase: isPartial ? "partial" : "success",
773
+ borderColor: "borderMuted",
774
+ };
775
+ },
776
+ // No release hook: `previewCache` holds the width-independent highlighted preview, which resize replay
777
+ // would otherwise re-highlight for every committed card.
778
+ );
765
779
  },
766
780
  describeCall(
767
781
  args: WriteRenderArgs,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Transcript renderer for the subagent `yield` tool ("Submit Result"). Its
3
+ * reply ("Result submitted.") is for the model; the card shows what was
4
+ * submitted: the section labels or workpool item in the head, the data as a
5
+ * JSON tree (Markdown when it is a string), and the reported or rejected error.
6
+ */
7
+ import { formatCount, isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
8
+ import { compact, md } from "../native/describe";
9
+ import { OwnerMemo } from "../native/memo";
10
+ import type { NativeChild } from "../native/node";
11
+ import { formatOutputPaneLines, styleToolOutputLine } from "../render/output-pane";
12
+ import { formatExpandHint, sanitizeDisplayLines, truncateToWidth } from "../render/render-utils";
13
+ import type { StatusLineOptions } from "../render/status-line";
14
+ import { plainToolCard, type ToolCardPhase } from "../render/tool-card";
15
+ import type { Theme } from "../theme/theme";
16
+ import type { Component } from "../tui";
17
+ import { wrapTextWithAnsi } from "../utils";
18
+ import {
19
+ describeJsonTree,
20
+ JSON_TREE_MAX_DEPTH_COLLAPSED,
21
+ JSON_TREE_MAX_DEPTH_EXPANDED,
22
+ JSON_TREE_MAX_LINES_COLLAPSED,
23
+ JSON_TREE_MAX_LINES_EXPANDED,
24
+ JSON_TREE_SCALAR_LEN_COLLAPSED,
25
+ JSON_TREE_SCALAR_LEN_EXPANDED,
26
+ renderJsonTreeLines,
27
+ } from "./json-tree";
28
+ import { errorText, noteText, resultText } from "./native-view";
29
+ import type {
30
+ NativeToolView,
31
+ RenderResultContextOptions,
32
+ RenderResultOptions,
33
+ ToolRenderer,
34
+ ToolRenderResult,
35
+ } from "./renderer";
36
+ import type { YieldItem } from "./task";
37
+
38
+ const TITLE = "Submit Result";
39
+
40
+ /** Collapsed rows of string data (a report) in the ANSI card. */
41
+ const TEXT_ROWS_COLLAPSED = 4;
42
+
43
+ /** `yield` arguments as sent; strict-mode providers send omitted optionals as `null`. */
44
+ interface YieldRenderArgs {
45
+ type?: string | string[] | null;
46
+ data?: unknown;
47
+ error?: string | null;
48
+ /** 1-based workpool item number. */
49
+ key?: number;
50
+ }
51
+
52
+ /** One `yield` call as its card shows it, read from the args and, once settled, the result. */
53
+ interface Submission {
54
+ /** Section labels or workpool item the call fills; absent for a whole result. */
55
+ target?: string;
56
+ /** The accepted (normalized) data once settled, else the data as sent. */
57
+ data: unknown;
58
+ /** The failure the call reports, or why the tool rejected it. */
59
+ error?: string;
60
+ settled: boolean;
61
+ /** Accepted although it failed the output schema. */
62
+ overridden: boolean;
63
+ /** Where a settled call without data takes its result from. */
64
+ note?: string;
65
+ }
66
+
67
+ function readSubmission(
68
+ args: YieldRenderArgs | undefined,
69
+ result: ToolRenderResult<YieldItem> | undefined,
70
+ ): Submission {
71
+ const details = result?.isError ? undefined : result?.details;
72
+ const type = details?.type ?? args?.type ?? undefined;
73
+ const incremental = Array.isArray(type) && type.length > 0;
74
+ const data = details?.data !== undefined ? details.data : (args?.data ?? undefined);
75
+ let error = args?.error || undefined;
76
+ if (result?.isError) error = resultText(result) || `${TITLE} failed`;
77
+ else if (result && details?.status === "aborted") error = details.error || resultText(result) || "Aborted";
78
+ const settled = result !== undefined;
79
+ return {
80
+ target: typeof args?.key === "number" ? `item ${args.key}` : incremental ? type.join(", ") : undefined,
81
+ data,
82
+ error,
83
+ settled,
84
+ overridden: details?.schemaOverridden === true,
85
+ note:
86
+ settled && error === undefined && data === undefined
87
+ ? incremental
88
+ ? "Takes the last message as this section"
89
+ : "Ends with the sections submitted earlier, or the last message"
90
+ : undefined,
91
+ };
92
+ }
93
+
94
+ /** Head fact for container data (`13 fields`, `4 items`). */
95
+ function sizeMeta(data: unknown): string | undefined {
96
+ if (Array.isArray(data)) return formatCount("item", data.length);
97
+ if (isRecord(data)) return formatCount("field", Object.keys(data).length);
98
+ return undefined;
99
+ }
100
+
101
+ function describeData(data: unknown, streaming: boolean): NativeChild | undefined {
102
+ if (data === undefined) return undefined;
103
+ if (typeof data === "string") return data.trim() ? md(data, { stream: streaming }) : noteText("(empty)");
104
+ // Open to the depth cap: the data is the card's whole point, and the
105
+ // collapsed card's preview clamp already bounds what shows.
106
+ return describeJsonTree(data, { hiddenRootKeys: [], openDepth: JSON_TREE_MAX_DEPTH_EXPANDED });
107
+ }
108
+
109
+ function describeSubmission(s: Submission, streaming: boolean): NativeToolView {
110
+ const size = sizeMeta(s.data);
111
+ return {
112
+ tool: {
113
+ title: TITLE,
114
+ target: s.target,
115
+ targetKind: s.target ? "text" : undefined,
116
+ meta: size ? [size] : undefined,
117
+ badges: s.overridden ? [{ text: "schema overridden", tone: "warning" }] : undefined,
118
+ },
119
+ tone: s.settled && s.error !== undefined ? "error" : undefined,
120
+ body: compact([
121
+ s.error !== undefined ? errorText(s.error) : undefined,
122
+ describeData(s.data, streaming),
123
+ s.note ? noteText(s.note, "dim") : undefined,
124
+ ]),
125
+ };
126
+ }
127
+
128
+ function dataLines(data: unknown, expanded: boolean, width: number, theme: Theme): string[] {
129
+ if (data === undefined) return [];
130
+ if (typeof data === "string") {
131
+ return [
132
+ ...formatOutputPaneLines(
133
+ {
134
+ lines: sanitizeText(data).trimEnd().split("\n"),
135
+ expanded,
136
+ collapsedMaxLines: TEXT_ROWS_COLLAPSED,
137
+ styleLine: line => truncateToWidth(styleToolOutputLine(line, theme), width),
138
+ },
139
+ theme,
140
+ ).lines,
141
+ ];
142
+ }
143
+ const tree = renderJsonTreeLines(data, theme, {
144
+ maxDepth: expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED,
145
+ maxLines: expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED,
146
+ maxScalarLen: expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED,
147
+ sanitizeText,
148
+ hiddenRootKeys: [],
149
+ });
150
+ if (tree.truncated) tree.lines.push(expanded ? theme.fg("dim", "…") : formatExpandHint(theme, false, true));
151
+ return tree.lines;
152
+ }
153
+
154
+ function renderSubmission(s: Submission, options: RenderResultOptions, theme: Theme): Component {
155
+ // The body is spinner-invariant; rebuild it only on expansion or width change.
156
+ let memo: { expanded: boolean; width: number; lines: readonly string[] } | undefined;
157
+ return plainToolCard(
158
+ theme,
159
+ ({ contentWidth }) => {
160
+ const running = options.spinnerFrame !== undefined;
161
+ const failed = s.settled && s.error !== undefined;
162
+ const size = sizeMeta(s.data);
163
+ const status: StatusLineOptions = {
164
+ icon: !s.settled ? (running ? "running" : "pending") : failed ? "error" : "done",
165
+ spinnerFrame: options.spinnerFrame,
166
+ title: TITLE,
167
+ description: s.target,
168
+ badge: s.overridden ? { label: "schema overridden", color: "warning" } : undefined,
169
+ meta: size ? [size] : undefined,
170
+ };
171
+ const phase: ToolCardPhase = !s.settled ? (running ? "running" : "partial") : failed ? "error" : "success";
172
+ if (memo === undefined || memo.expanded !== options.expanded || memo.width !== contentWidth) {
173
+ const lines: string[] = [];
174
+ // Wrapped in full: a schema rejection names every offending field.
175
+ if (s.error !== undefined) {
176
+ for (const line of sanitizeDisplayLines(s.error)) {
177
+ lines.push(...wrapTextWithAnsi(theme.fg("error", line), Math.max(1, contentWidth)));
178
+ }
179
+ }
180
+ lines.push(...dataLines(s.data, options.expanded, contentWidth, theme));
181
+ if (s.note) lines.push(theme.fg("dim", s.note));
182
+ memo = { expanded: options.expanded, width: contentWidth, lines };
183
+ }
184
+ return { status, phase, body: memo.lines };
185
+ },
186
+ { paddingX: 1, paddingY: 1, ignoreTight: true, onInvalidate: () => (memo = undefined) },
187
+ );
188
+ }
189
+
190
+ const callMemo = new OwnerMemo<NativeToolView>();
191
+ const resultMemo = new OwnerMemo<NativeToolView>();
192
+
193
+ /** Shows a `yield` call's submitted data instead of its model-facing acknowledgement. */
194
+ export const yieldToolRenderer = {
195
+ renderCall(args: YieldRenderArgs, options: RenderResultOptions, theme: Theme): Component {
196
+ return renderSubmission(readSubmission(args, undefined), options, theme);
197
+ },
198
+
199
+ renderResult(
200
+ result: ToolRenderResult<YieldItem>,
201
+ options: RenderResultContextOptions,
202
+ theme: Theme,
203
+ args?: YieldRenderArgs,
204
+ ): Component {
205
+ return renderSubmission(readSubmission(args, result), options, theme);
206
+ },
207
+
208
+ describeCall(args: YieldRenderArgs, options: RenderResultOptions): NativeToolView {
209
+ const streaming = options.argsComplete === false;
210
+ return callMemo.get(args, [streaming], () => describeSubmission(readSubmission(args, undefined), streaming));
211
+ },
212
+
213
+ describeResult(
214
+ result: ToolRenderResult<YieldItem>,
215
+ _options: RenderResultContextOptions,
216
+ args?: YieldRenderArgs,
217
+ ): NativeToolView {
218
+ return resultMemo.get(result.content, [args, result.details, result.isError], () =>
219
+ describeSubmission(readSubmission(args, result), false),
220
+ );
221
+ },
222
+
223
+ mergeCallAndResult: true,
224
+ animatedPendingPreview: true,
225
+ } satisfies ToolRenderer<YieldRenderArgs, YieldItem>;
package/src/tui.ts CHANGED
@@ -314,6 +314,16 @@ export interface Component {
314
314
  * Called when theme changes or when component needs to re-render from scratch.
315
315
  */
316
316
  invalidate?(): void;
317
+ /**
318
+ * Optional hook to drop memoized render output (rows, parse and wrap state)
319
+ * that the next `render()` can rebuild from state the component keeps.
320
+ * Unlike {@link invalidate}, it must not rebuild anything eagerly: no
321
+ * renderer or extension callbacks, no image conversions, and no child
322
+ * replacement or disposal. The next render must return the same rows it
323
+ * would have returned without the release. Components without it are
324
+ * simply not released.
325
+ */
326
+ releaseRenderCaches?(): void;
317
327
  /**
318
328
  * Optional hook to set whether this component ignores tight layout mode.
319
329
  */
@@ -544,6 +554,7 @@ export class Container implements Component {
544
554
  clear(): void {
545
555
  this.children = [];
546
556
  this.#memoLines = undefined;
557
+ this.#memoChildLines = [];
547
558
  }
548
559
 
549
560
  /** Dispose every child, then detach it from this container. */
@@ -553,12 +564,23 @@ export class Container implements Component {
553
564
  }
554
565
 
555
566
  invalidate(): void {
567
+ // The per-child refs pin every row the children last rendered; dropping
568
+ // only the concatenation would keep all of them reachable.
556
569
  this.#memoLines = undefined;
570
+ this.#memoChildLines = [];
557
571
  for (const child of this.children) {
558
572
  child.invalidate?.();
559
573
  }
560
574
  }
561
575
 
576
+ releaseRenderCaches(): void {
577
+ this.#memoLines = undefined;
578
+ this.#memoChildLines = [];
579
+ for (const child of this.children) {
580
+ child.releaseRenderCaches?.();
581
+ }
582
+ }
583
+
562
584
  /**
563
585
  * Propagate teardown to children. Call when the container's children are
564
586
  * being permanently discarded (not when they are detached for reuse — use
@@ -2246,6 +2268,9 @@ export class TUI extends Container {
2246
2268
  this.terminal.enableInput?.();
2247
2269
  this.#querySixelSupport();
2248
2270
  this.#queryCellSize();
2271
+ // The probes went out over the painted frame; a terminal that could not
2272
+ // parse one left its bytes on the cursor row (see Terminal.enableInput).
2273
+ this.requestRender(true);
2249
2274
  }
2250
2275
 
2251
2276
  addStartListener(listener: StartListener): () => void {