@hheei/pi-ext-ui 0.5.2 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  import { copyToClipboard as copyToSystemClipboard, getLanguageFromPath, highlightCode, } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
3
- import { inlinePresentation } from "./renderer.js";
3
+ import { extractChildToolCopyText, inlinePresentation, nativeRequestCopy } from "./native-tools.js";
4
4
  import { formatToolHeaderText, ToolView, toContentRows, transitionFoldLevel, } from "./tool-view.js";
5
5
  function truncate(text, width) {
6
6
  return truncateToWidth(text, Math.max(1, width));
@@ -8,53 +8,15 @@ function truncate(text, width) {
8
8
  function isRecord(value) {
9
9
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
10
  }
11
- export function extractChildToolCopyText(call, full) {
12
- const toolName = "toolName" in call ? call.toolName : call.name;
13
- const result = "result" in call ? call.result : undefined;
14
- const args = "args" in call ? call.args : undefined;
15
- if (toolName === "write" && isRecord(args) && typeof args.content === "string") {
16
- return args.content;
11
+ /**
12
+ * A copy keeps the request half byte-exact and strips only what follows it: the result is display
13
+ * text, while the request is what the model sent and may legitimately contain escapes.
14
+ */
15
+ function copyText(text, request) {
16
+ if (request === undefined || request === "" || !text.startsWith(request)) {
17
+ return stripTerminalSequences(text);
17
18
  }
18
- const heavyCommand = toolName === "bash" && isRecord(args) && typeof args.command === "string"
19
- ? args.command
20
- : toolName === "codemode" && isRecord(args) && typeof args.code === "string"
21
- ? args.code
22
- : undefined;
23
- if (heavyCommand !== undefined && heavyCommand.trim() !== "") {
24
- if (full !== true)
25
- return heavyCommand;
26
- const output = resultText(result);
27
- return output === undefined ? heavyCommand : `${heavyCommand}\n---\n${output}`;
28
- }
29
- if (result && Array.isArray(result.content)) {
30
- const texts = result.content
31
- .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
32
- .map((c) => c.text);
33
- if (texts.length > 0)
34
- return texts.join("\n");
35
- }
36
- if (args !== undefined) {
37
- if (typeof args === "string")
38
- return args;
39
- try {
40
- return JSON.stringify(args, null, 2);
41
- }
42
- catch {
43
- return String(args);
44
- }
45
- }
46
- if ("summary" in call && typeof call.summary === "string") {
47
- return call.summary;
48
- }
49
- return "";
50
- }
51
- function resultText(result) {
52
- if (result === undefined || !Array.isArray(result.content))
53
- return undefined;
54
- const texts = result.content
55
- .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
56
- .map((c) => c.text);
57
- return texts.length === 0 ? undefined : texts.join("\n");
19
+ return request + stripTerminalSequences(text.slice(request.length));
58
20
  }
59
21
  function formatDuration(ms) {
60
22
  if (ms === undefined || !Number.isFinite(ms))
@@ -67,6 +29,7 @@ export class NestedCallsView {
67
29
  #props;
68
30
  #inMemoryLevels = new Map();
69
31
  #copiedIndex = undefined;
32
+ #failedIndex = undefined;
70
33
  #copiedTimer = undefined;
71
34
  #lineMapping = [];
72
35
  #renderCache;
@@ -96,14 +59,11 @@ export class NestedCallsView {
96
59
  typeof copiedState.until === "number") {
97
60
  const remaining = copiedState.until - Date.now();
98
61
  if (remaining > 0) {
99
- this.#copiedIndex = copiedState.index;
62
+ const failed = copiedState.failed === true;
63
+ this.#copiedIndex = failed ? undefined : copiedState.index;
64
+ this.#failedIndex = failed ? copiedState.index : undefined;
100
65
  this.#copiedTimer = setTimeout(() => {
101
- this.#copiedIndex = undefined;
102
- this.#renderCache = undefined;
103
- const s = this.#props.stateHolder;
104
- if (s !== undefined) {
105
- delete s[NESTED_CALLS_COPIED_KEY];
106
- }
66
+ this.#clearCopyFeedback();
107
67
  this.#props.invalidate?.();
108
68
  }, remaining);
109
69
  this.#copiedTimer.unref?.();
@@ -162,9 +122,9 @@ export class NestedCallsView {
162
122
  */
163
123
  #callContext(call) {
164
124
  const { context } = this.#props;
165
- const toolCallId = "toolCallId" in call && typeof call.toolCallId === "string"
166
- ? call.toolCallId
167
- : context.toolCallId;
125
+ // A captured call carries its own id. A persisted record has none, so it falls back to the
126
+ // parent's id — the only identity the host offers, and never a child's guess.
127
+ const toolCallId = ("id" in call ? call.id : undefined) ?? context.toolCallId;
168
128
  return {
169
129
  ...context,
170
130
  lastComponent: undefined,
@@ -182,44 +142,62 @@ export class NestedCallsView {
182
142
  return;
183
143
  const provider = this.#props.resolvePresentation?.(call.toolName);
184
144
  const presentation = provider?.("args" in call ? call.args : undefined, "result" in call ? call.result : undefined, this.#props.theme, this.#callContext(call));
185
- const text = stripTerminalSequences((full ? presentation?.copy?.full : presentation?.copy?.primary) ??
186
- extractChildToolCopyText(call, full));
187
- if (!text)
145
+ const chosen = full ? presentation?.copy?.full : presentation?.copy?.primary;
146
+ const text = chosen ?? copyText(extractChildToolCopyText(call, full), nativeRequestCopy(call));
147
+ const durationMs = 1500;
148
+ if (text === "") {
149
+ // Nothing was recorded to copy. A summary is not the request, so the copy reports failure.
150
+ this.#showCopyFeedback(callIndex, durationMs, true);
188
151
  return;
152
+ }
189
153
  const writeClip = this.#props.copyToClipboard ?? copyToSystemClipboard;
190
- void writeClip(text).catch(() => { });
191
- const durationMs = 1500;
154
+ // Feedback starts when the write settles: a rejected clipboard write is not a copy.
155
+ void writeClip(text).then(() => this.#showCopyFeedback(callIndex, durationMs, false), () => this.#showCopyFeedback(callIndex, durationMs, true));
156
+ }
157
+ /** One feedback window on one row: ✓ when the text reached the clipboard, ✗ when it did not. */
158
+ #showCopyFeedback(callIndex, durationMs, failed) {
192
159
  const until = Date.now() + durationMs;
193
- this.#copiedIndex = callIndex;
160
+ this.#copiedIndex = failed ? undefined : callIndex;
161
+ this.#failedIndex = failed ? callIndex : undefined;
194
162
  this.#renderCache = undefined;
195
163
  const state = this.#props.stateHolder;
196
164
  if (state !== undefined) {
197
165
  state[NESTED_CALLS_COPIED_KEY] = {
198
166
  index: callIndex,
199
167
  until,
168
+ ...(failed ? { failed: true } : {}),
200
169
  };
201
170
  }
202
171
  if (this.#copiedTimer !== undefined) {
203
172
  clearTimeout(this.#copiedTimer);
204
173
  }
205
174
  this.#copiedTimer = setTimeout(() => {
206
- this.#copiedIndex = undefined;
207
- this.#renderCache = undefined;
208
- const s = this.#props.stateHolder;
209
- if (s !== undefined) {
210
- delete s[NESTED_CALLS_COPIED_KEY];
211
- }
175
+ this.#clearCopyFeedback();
212
176
  this.#props.invalidate?.();
213
177
  }, durationMs);
214
178
  this.#copiedTimer.unref?.();
215
179
  this.#props.invalidate?.();
216
180
  }
181
+ /** Drops the copy marker, its timer, and the shared record it left behind. */
182
+ #clearCopyFeedback() {
183
+ if (this.#copiedTimer !== undefined) {
184
+ clearTimeout(this.#copiedTimer);
185
+ this.#copiedTimer = undefined;
186
+ }
187
+ this.#copiedIndex = undefined;
188
+ this.#failedIndex = undefined;
189
+ this.#renderCache = undefined;
190
+ const state = this.#props.stateHolder;
191
+ if (state !== undefined)
192
+ delete state[NESTED_CALLS_COPIED_KEY];
193
+ }
217
194
  render(width) {
218
195
  const safeWidth = Math.max(1, width);
219
196
  const levelsKey = this.#getLevelsKey();
220
197
  if (this.#renderCache !== undefined &&
221
198
  this.#renderCache.width === safeWidth &&
222
199
  this.#renderCache.copiedIndex === this.#copiedIndex &&
200
+ this.#renderCache.failedIndex === this.#failedIndex &&
223
201
  this.#renderCache.levelsKey === levelsKey) {
224
202
  this.#lineMapping = this.#renderCache.lineMapping;
225
203
  return this.#renderCache.lines;
@@ -240,21 +218,32 @@ export class NestedCallsView {
240
218
  const isExpanded = expansionLevel !== 0;
241
219
  const rawSummary = "summary" in call ? call.summary : undefined;
242
220
  const rawArgs = "args" in call ? call.args : undefined;
243
- const inline = inlinePresentation(toolName, rawArgs ?? rawSummary, "result" in call ? call.result?.details : undefined, isError);
221
+ // A record's summary is the fact its owner stated; the arguments only fill in for a capture.
222
+ const inline = inlinePresentation(toolName, rawSummary ?? rawArgs, "result" in call ? call.result?.details : undefined, isError);
244
223
  const argText = typeof rawArgs === "string" ? rawArgs : typeof rawSummary === "string" ? rawSummary : "";
245
224
  const provider = this.#props.resolvePresentation?.(toolName);
246
225
  const presentation = provider?.(rawArgs, "result" in call ? call.result : undefined, theme, this.#callContext(call));
247
226
  // The child row is the child's own header minus the fold pin, so it carries the same token
248
227
  // assignment as the row the same call would draw at top level.
249
- const childSummary = stripTerminalSequences(presentation?.summary ?? inline?.summary ?? argText);
250
- const duration = formatDuration(call.durationMs);
228
+ const ownSummary = presentation?.summary === undefined
229
+ ? this.#props.headerSummary?.(toolName, rawArgs ?? rawSummary, theme)
230
+ : undefined;
231
+ const childSummary = ownSummary === undefined || ownSummary === ""
232
+ ? stripTerminalSequences(presentation?.summary ?? inline?.summary ?? argText)
233
+ : ownSummary;
251
234
  const leftContent = formatToolHeaderText({
252
235
  status: isRunning ? "running" : isError ? "error" : "ok",
253
236
  title: toolName,
254
- summary: [childSummary, duration].filter(Boolean).join(" · "),
237
+ summary: childSummary,
238
+ ...(ownSummary === undefined || ownSummary === "" ? {} : { summaryStyled: true }),
239
+ meta: formatDuration(call.durationMs),
255
240
  }, theme);
256
241
  const isCopied = this.#copiedIndex === i;
257
- const copyIcon = isCopied ? theme.fg("success", "✓") : theme.fg("dim", "󰆏");
242
+ const copyIcon = this.#failedIndex === i
243
+ ? theme.fg("error", "✗")
244
+ : isCopied
245
+ ? theme.fg("success", "✓")
246
+ : theme.fg("dim", "󰆏");
258
247
  const copySlotWidth = 2 + rightMargin;
259
248
  const maxLeft = Math.max(1, width - copySlotWidth);
260
249
  const truncatedLeft = truncate(leftContent, maxLeft);
@@ -290,6 +279,7 @@ export class NestedCallsView {
290
279
  this.#renderCache = {
291
280
  width: safeWidth,
292
281
  copiedIndex: this.#copiedIndex,
282
+ failedIndex: this.#failedIndex,
293
283
  levelsKey,
294
284
  lines,
295
285
  lineMapping: this.#lineMapping,
@@ -304,22 +294,17 @@ export class NestedCallsView {
304
294
  if (result && resolveChildRenderer) {
305
295
  const renderer = resolveChildRenderer(toolName);
306
296
  if (renderer?.renderResult) {
307
- try {
308
- const childContext = {
309
- ...this.#callContext(call),
310
- state: undefined,
311
- };
312
- const comp = renderer.renderResult(result, { expanded: expansionLevel === 2, isPartial: false }, theme, childContext);
313
- if (comp instanceof ToolView) {
314
- comp.setFoldLevel(expansionLevel);
315
- // Render body cleanly via deep headless interface without outer rail or header row
316
- return comp.renderBody(width, { noRail: true });
317
- }
318
- return comp.render(width);
319
- }
320
- catch {
321
- // Fall through to plain text extraction on renderer failure
297
+ const childContext = {
298
+ ...this.#callContext(call),
299
+ state: undefined,
300
+ };
301
+ const comp = renderer.renderResult(result, { expanded: expansionLevel === 2, isPartial: false }, theme, childContext);
302
+ if (comp instanceof ToolView) {
303
+ comp.setFoldLevel(expansionLevel);
304
+ // Render body cleanly via deep headless interface without outer rail or header row
305
+ return comp.renderBody(width, { noRail: true });
322
306
  }
307
+ return comp.render(width);
323
308
  }
324
309
  }
325
310
  // Fallback rendering
@@ -363,11 +348,29 @@ export class NestedCallsView {
363
348
  if ("error" in call && typeof call.error === "string" && call.error) {
364
349
  return call.error.split("\n").map((l) => theme.fg("error", l));
365
350
  }
351
+ if ("argsTruncated" in call && call.argsTruncated === true) {
352
+ const bytes = "argsBytes" in call && typeof call.argsBytes === "number" ? call.argsBytes : 0;
353
+ return [
354
+ theme.fg("dim", bytes > 0 ? `(request not recorded: ${String(bytes)} bytes)` : "(request not recorded)"),
355
+ ];
356
+ }
357
+ // The request as the model sent it: the command or source, not a JSON wrapper around it.
358
+ const request = nativeRequestCopy(call);
359
+ if (request !== undefined) {
360
+ return stripTerminalSequences(request)
361
+ .split("\n")
362
+ .map((line) => theme.fg("dim", line));
363
+ }
366
364
  if (args !== undefined) {
367
- const text = typeof args === "string" ? args : JSON.stringify(args, null, 2);
365
+ const text = typeof args === "string" ? stripTerminalSequences(args) : JSON.stringify(args, null, 2);
368
366
  return text.split("\n").map((l) => theme.fg("dim", l));
369
367
  }
370
- return [theme.fg("dim", "(no output)")];
368
+ // A persisted record keeps a summary, not its request: say so instead of implying an empty call.
369
+ return [
370
+ theme.fg("dim", "summary" in call && typeof call.summary === "string" && call.summary !== ""
371
+ ? "(request not recorded; summary only)"
372
+ : "(no output)"),
373
+ ];
371
374
  }
372
375
  handleMouse(event) {
373
376
  if (event.type !== "click" || event.button !== "left")
@@ -390,17 +393,13 @@ export class NestedCallsView {
390
393
  return { handled: true };
391
394
  }
392
395
  dispose() {
393
- if (this.#copiedTimer !== undefined) {
394
- clearTimeout(this.#copiedTimer);
395
- this.#copiedTimer = undefined;
396
- }
396
+ this.#clearCopyFeedback();
397
397
  this.#renderCache = undefined;
398
398
  }
399
399
  collapseAll() {
400
- if (this.#copiedTimer !== undefined) {
401
- clearTimeout(this.#copiedTimer);
402
- this.#copiedTimer = undefined;
403
- }
400
+ // The marker points at a row that is no longer on screen; leaving it behind would bring a
401
+ // permanent checkmark back when the same view expands again.
402
+ this.#clearCopyFeedback();
404
403
  this.#inMemoryLevels.clear();
405
404
  const state = this.#props.stateHolder;
406
405
  if (state !== undefined) {
@@ -1,12 +1,17 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
- import { type DiffSource } from "./diff-view.js";
3
2
  import type { ToolStatusKind } from "./tool-view.js";
4
- export declare const EDIT_TOOL_NAME = "edit";
5
- export declare const WRITE_TOOL_NAME = "write";
6
- export declare const CODEMODE_OPTIONS_PREFIX = "// @options:";
7
- export declare const CODEMODE_SCRIPT_HEADER: RegExp;
3
+ export declare const RESOLVED_CALLS_KEY = "__piExtUiResolvedCalls";
8
4
  export interface NestedCallRecord {
9
5
  readonly toolName: string;
6
+ /** The host's call id (`<parent>/<n>`); the key its saved arguments are indexed under. */
7
+ readonly id?: string | undefined;
8
+ /** The request bytes when this process or the session branch still has them. */
9
+ readonly args?: unknown;
10
+ /** The session saved the size of the request but not the request itself. */
11
+ readonly argsTruncated?: boolean | undefined;
12
+ readonly argsBytes?: number | undefined;
13
+ /** Positive fact only: the host proved this call resolved to a real tool. */
14
+ readonly resolved?: boolean | undefined;
10
15
  readonly summary?: string | undefined;
11
16
  readonly status?: "running" | "ok" | "error" | "cancelled" | undefined;
12
17
  readonly isError?: boolean | undefined;
@@ -21,15 +26,8 @@ export declare function formatDurationMs(ms: number | undefined): string;
21
26
  export declare function formatCostUsd(cost: number | undefined): string;
22
27
  /** One header fact for a deadline, in the same shape codemode's `timeout_ms` renders as. */
23
28
  export declare function formatTimeoutFact(timeoutMs: number): string;
24
- export declare function extractCodemodeHeaderFacts(args: unknown): string;
25
29
  export declare function parseCallArgs(raw: unknown): Record<string, unknown> | undefined;
26
- export declare function extractSummaryArg(toolName: string, args: unknown): string;
27
- export declare function extractHeavyInputCommand(toolName: string, args: unknown): string | undefined;
28
30
  export declare function extractNestedCalls(result: AgentToolResult<unknown> | undefined): readonly NestedCallRecord[];
29
- export declare function extractResultText(toolName: string, result: AgentToolResult<unknown>): {
30
- readonly outputText: string;
31
- readonly wallTimeSeconds: number | undefined;
32
- };
33
31
  export declare function callStatsParts(total: number, failCount: number): string[];
34
32
  export declare function resolveContainerStatus(params: {
35
33
  readonly isError: boolean;
@@ -37,19 +35,3 @@ export declare function resolveContainerStatus(params: {
37
35
  readonly hasChildren: boolean;
38
36
  readonly selfFailed: boolean;
39
37
  }): ToolStatusKind;
40
- export declare function toolPath(args: unknown): string;
41
- /** Compact nested-row result: a one-line summary plus the status it reports. */
42
- export interface InlinePresentation {
43
- readonly summary: string;
44
- readonly status: ToolStatusKind;
45
- }
46
- export declare function fileToolPresentation(toolName: string, args: unknown, _details: unknown, isError: boolean): InlinePresentation | undefined;
47
- /** Pi native edit persists a unified patch plus the same change as a numbered display diff. */
48
- export declare function editDiffSource(details: unknown): DiffSource | undefined;
49
- export declare function displayDiffSource(diff: string): DiffSource | undefined;
50
- /**
51
- * One compact nested summary for a tool call. Adopts the tool's standard collapsed
52
- * single-line format when recognized, degrading to undefined so callers can use
53
- * the default argument rendering.
54
- */
55
- export declare function inlinePresentation(toolName: string, args: unknown, details: unknown, isError: boolean): InlinePresentation | undefined;