@yaag/extension 0.8.3 → 0.9.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.
package/docs/cli.md CHANGED
@@ -60,6 +60,53 @@ Runs an Orchestration Program. It maps to `yaag run`.
60
60
  | `config` | `--config <file>` | Reads one more config file for this Run. |
61
61
  | `noConfig` | `--no-config` | Ignores the global config and the project config. |
62
62
 
63
+ ### The Run block in a pi session
64
+
65
+ A `yaag_run` call is two lines in the transcript. The first line reads
66
+ `yaag_run(inline:review) ... (ctrl+o to expand)`, and the second reads
67
+ `r1 · running 1m14s`.
68
+
69
+ The first line is the call. A file Run shows the path of its program. An inline
70
+ Run shows `inline`, or `inline:<name>` when the Run told yaag the name that the
71
+ program declares. The name comes from the Run, not from the source text.
72
+
73
+ The second line is the state of the Run: its Run id, its status, and the time.
74
+ The five statuses are:
75
+
76
+ | status | meaning |
77
+ |---|---|
78
+ | `running` | The Run is live and the call waits for it. |
79
+ | `started` | The Run is in the background and the call returned. |
80
+ | `finished` | The Run ended and its program completed. |
81
+ | `failed` | The Run failed, or the call itself failed. |
82
+ | `interrupted` | The Run stopped, paused, or died before its program ended. |
83
+
84
+ The hint at the end of the call line names the key that expands the block.
85
+ pi binds `ctrl+o` by default. The hint is not shown when the key is not bound.
86
+
87
+ The time of a Run that did not end is the time from its start to the last draw
88
+ of the block. The time of a Run that ended is the time the Run took.
89
+
90
+ The expanded block shows each parameter that the call gives, one on each line,
91
+ and then the full Run tree. A parameter that the call does not give is not
92
+ shown. `script` shows the full program source. A very large source is cut, and
93
+ the block then shows the marker `… (truncated, N bytes total)`. The error
94
+ output of a failed Run is also shown in the expanded block only.
95
+
96
+ Each Agent row of the Run tree shows two times: `active` and `idle`. The active
97
+ time is the sum of the times of the Asks of the Agent, and it includes the Ask
98
+ that runs now. The idle time is the remainder of the life of the Agent. The two
99
+ times stop when the Agent exits.
100
+
101
+ The times move each second in the interactive Run tree of `/yaag`, and in the
102
+ inline frame of a blocking Run. In the tool block of the transcript they move
103
+ when a new progress frame comes in. The times stop when the Run ends.
104
+
105
+ A tree that yaag builds from a stored Run — the tree of `yaag_status`, and the
106
+ tree of a Run that ended before the view opened — keeps no record of each Ask.
107
+ The active time of such a tree counts only the Ask that runs now, so it is a
108
+ minimum, and the idle time is then too large.
109
+
63
110
  ### yaag_status
64
111
 
65
112
  Reports the Runs of the session, or one Run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@earendil-works/pi-tui": "^0.84.0",
28
- "@yaag/cli": "0.8.3",
29
- "@yaag/runtime": "0.8.3",
30
- "@yaag/tui": "0.8.3",
28
+ "@yaag/cli": "0.9.0",
29
+ "@yaag/runtime": "0.9.0",
30
+ "@yaag/tui": "0.9.0",
31
31
  "nanoid": "^6.0.1"
32
32
  },
33
33
  "peerDependencies": {
@@ -10,6 +10,7 @@ export {
10
10
  programExpansion,
11
11
  programLabel,
12
12
  programTarget,
13
+ type RunCallParams,
13
14
  } from "./run-program-param.ts";
14
15
  export type { RunLaunch, RunRecord } from "./run-record.ts";
15
16
  export {
@@ -15,6 +15,15 @@ export interface ProgramParams {
15
15
  readonly resume?: string;
16
16
  }
17
17
 
18
+ /** Every `yaag_run` parameter, as the expanded call view lists them. */
19
+ export interface RunCallParams extends ProgramParams {
20
+ readonly args?: string;
21
+ readonly background?: boolean;
22
+ readonly record?: string;
23
+ readonly config?: string;
24
+ readonly noConfig?: boolean;
25
+ }
26
+
18
27
  /** The expanded view keeps at most this many source lines. */
19
28
  const EXPANSION_LINES = 200;
20
29
  /** The expanded view keeps at most this many source bytes. */
@@ -94,30 +103,34 @@ export async function confirmProgramTarget(target: ProgramTarget): Promise<Progr
94
103
  * The label never quotes the source. `yaag_run` has no approval gate, so any
95
104
  * text taken from an Inline Program would be a trust-bearing string the model
96
105
  * chose — a comment or an unrelated `name:` could name a program that is not
97
- * the one that runs. The Inline Program branch states only what the extension
98
- * measured: its kind and its size in bytes. The source itself is shown, whole,
99
- * in the expanded view ({@link programExpansion}).
106
+ * the one that runs. An Inline Program is therefore `inline`, and `inline:<name>`
107
+ * only when `name` is given: the caller reads that name from the Run's own
108
+ * `run_start` event, so it is the name the program that actually ran declared.
109
+ * The source itself is shown, whole, in the expanded view ({@link programExpansion}).
100
110
  *
101
- * The two caller-controlled branches — a file path and a Checkpoint pathare
111
+ * Every caller-controlled branch — a file path and a program nameis
102
112
  * sanitized to one line and clamped to {@link LABEL_WIDTH} columns, so a
103
- * hostile path can neither emit an escape byte nor overflow the call line. The
104
- * clamp covers the whole `Inline Program from …` text, prefix included.
113
+ * hostile path can neither emit an escape byte nor overflow the call line.
105
114
  */
106
- export function programLabel(params: ProgramParams): string {
115
+ export function programLabel(params: ProgramParams, name?: string): string {
107
116
  const { file, script } = params;
108
117
  if (file !== undefined && script !== undefined) return "(invalid call)";
109
118
  if (script !== undefined) {
110
119
  if (script.trim() === "") return "(empty script)";
111
- const bytes = byteCount(script);
112
- return `Inline Program, ${bytes} ${bytes === 1 ? "byte" : "bytes"}`;
120
+ return inlineLabel(name);
113
121
  }
114
122
  if (file !== undefined) return safeLabel(file);
115
123
  // A resume with no file and no script is a valid call: the source comes from
116
- // the Run record, and the label names the Checkpoint the source belongs to.
117
- if (params.resume !== undefined) return safeLabel(`Inline Program from ${params.resume}`);
124
+ // the Run record, so the label names the Inline Program, and the Checkpoint
125
+ // path is one of the parameters the expanded view lists.
126
+ if (params.resume !== undefined) return inlineLabel(name);
118
127
  return "(invalid call)";
119
128
  }
120
129
 
130
+ function inlineLabel(name: string | undefined): string {
131
+ return name === undefined || name.trim() === "" ? "inline" : safeLabel(`inline:${name}`);
132
+ }
133
+
121
134
  function safeLabel(value: string): string {
122
135
  return clampToWidth(sanitizeTerminalLine(value), LABEL_WIDTH);
123
136
  }
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * A fully typed `ExtensionUIContext` for the tui-mode test context.
3
3
  *
4
+ * `custom` mirrors pi's interactive mode: when the component resolves `done`,
5
+ * the view closes and `component.dispose?.()` runs, so a test sees the same
6
+ * teardown a Host Session gives.
7
+ *
4
8
  * `custom`, `notify`, `setWidget`, `setStatus`, and a scripted `select` work;
5
9
  * every other capability throws, so a dependency a view is not supposed to
6
10
  * have fails loudly. The
@@ -69,20 +73,32 @@ export class FakeExtensionUi implements ExtensionUIContext {
69
73
  done: (result: T) => void,
70
74
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
71
75
  ): Promise<T> {
76
+ let closed = false;
77
+ let component: (Component & { dispose?(): void }) | undefined;
72
78
  let resolve: (value: T) => void = () => {};
73
79
  const promise = new Promise<T>((settle) => {
74
- resolve = settle;
80
+ resolve = (value) => {
81
+ if (closed) return;
82
+ closed = true;
83
+ // Pi disposes the component when the view closes; the fake must not
84
+ // diverge, or a leak test would pass falsely.
85
+ component?.dispose?.();
86
+ settle(value);
87
+ };
75
88
  });
76
89
  // Synchronous on purpose: a test drives the component right after the call,
77
90
  // with no scheduling of its own.
78
- const component = factory(this.#tui, this.theme, loadPiKeybindings(), resolve);
79
- if (component instanceof Promise) {
91
+ const opened = factory(this.#tui, this.theme, loadPiKeybindings(), resolve);
92
+ if (opened instanceof Promise) {
80
93
  throw new TypeError("FakeExtensionUi.custom: the factory must return a component directly");
81
94
  }
95
+ component = opened;
96
+ // A factory that resolved `done` before it returned closed the view already.
97
+ if (closed) opened.dispose?.();
82
98
  const tui = this.#tui;
83
99
  this.opened = {
84
- render: (width) => component.render(width),
85
- input: (data) => component.handleInput?.(data),
100
+ render: (width) => opened.render(width),
101
+ input: (data) => opened.handleInput?.(data),
86
102
  get renders() {
87
103
  return tui.renders;
88
104
  },
@@ -5,7 +5,7 @@
5
5
  * context needs one. The table is written out in full: TypeScript checks it
6
6
  * against `ThemeColor`/`ThemeBg`, which keeps the double honest without a cast.
7
7
  */
8
- import { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  /** Default 256-colour indexes; every colour renders as plain text. */
11
11
  const FOREGROUND = {
@@ -66,7 +66,12 @@ const BACKGROUND = {
66
66
  toolErrorBg: 0,
67
67
  } as const satisfies Record<string, number>;
68
68
 
69
- /** Builds the test theme. Colours are inert, so frames stay comparable. */
70
- export function fakeTheme(): Theme {
71
- return new Theme(FOREGROUND, BACKGROUND, "256color", { name: "test" });
69
+ /**
70
+ * Builds the test theme. Colours are inert, so frames stay comparable.
71
+ *
72
+ * `overrides` gives single colours a distinct index, for a test that asserts
73
+ * two theme colours differ.
74
+ */
75
+ export function fakeTheme(overrides: Partial<Record<ThemeColor, number>> = {}): Theme {
76
+ return new Theme({ ...FOREGROUND, ...overrides }, BACKGROUND, "256color", { name: "test" });
72
77
  }
@@ -4,7 +4,15 @@
4
4
  */
5
5
  export { fakeStart } from "./fake-run-start.ts";
6
6
  export { loadPiKeybindings } from "./pi-keybindings.ts";
7
- export { renderCallLabel, renderCallLines } from "./run-call-render.ts";
7
+ export {
8
+ type RenderedBlock,
9
+ type RunParams,
10
+ renderBlock,
11
+ renderBlockLines,
12
+ renderCallLabel,
13
+ renderCallLines,
14
+ renderResultLines,
15
+ } from "./run-call-render.ts";
8
16
  export {
9
17
  asError,
10
18
  fixture,
@@ -1,7 +1,15 @@
1
+ /**
2
+ * Render seams for the `yaag_run` transcript block: the call render, the result
3
+ * render, and the two together through one shared row state, in pi's own order.
4
+ */
5
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
6
+ import type { Component } from "@earendil-works/pi-tui";
1
7
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
8
+ import { initialSummary } from "@yaag/runtime";
2
9
  import { resolveBun, resolveCliEntry } from "../process/index.ts";
3
10
  import { RunRegistry } from "../record/index.ts";
4
11
  import { createRunTool } from "../tool/index.ts";
12
+ import type { RunDetails } from "../view/index.ts";
5
13
  import { fakeTheme } from "./fake-theme.ts";
6
14
 
7
15
  const cli = resolveCliEntry();
@@ -20,40 +28,149 @@ export interface RunParams {
20
28
  readonly noConfig?: boolean;
21
29
  }
22
30
 
23
- function renderCallComponent(params: RunParams, expanded: boolean) {
24
- const tool = createRunTool({
31
+ /** How one render pass sees the row. */
32
+ export interface RenderView {
33
+ readonly expanded?: boolean;
34
+ readonly isPartial?: boolean;
35
+ readonly isError?: boolean;
36
+ /** The result's text content. */
37
+ readonly content?: string;
38
+ }
39
+
40
+ function tool() {
41
+ return createRunTool({
25
42
  bun,
26
43
  cli,
27
44
  registry: new RunRegistry(),
28
45
  sendMessage: () => undefined,
29
46
  });
30
- const component = tool.renderCall?.(params, fakeTheme(), {
47
+ }
48
+
49
+ interface Row {
50
+ readonly state: Record<string, unknown>;
51
+ invalidate: () => void;
52
+ }
53
+
54
+ function context(params: RunParams, view: RenderView, row: Row, last?: Component) {
55
+ return {
31
56
  args: params,
32
57
  toolCallId: "call-1",
33
- invalidate: () => {},
34
- lastComponent: undefined,
35
- state: undefined,
58
+ invalidate: () => row.invalidate(),
59
+ lastComponent: last,
60
+ state: row.state,
36
61
  cwd: process.cwd(),
37
62
  executionStarted: false,
38
63
  argsComplete: true,
39
- isPartial: false,
40
- expanded,
64
+ isPartial: view.isPartial ?? false,
65
+ expanded: view.expanded ?? false,
41
66
  showImages: false,
42
- isError: false,
43
- });
44
- return component;
67
+ isError: view.isError ?? false,
68
+ };
69
+ }
70
+
71
+ function newRow(): Row {
72
+ return { state: {}, invalidate: () => {} };
73
+ }
74
+
75
+ /**
76
+ * The result pi hands a render. A result restored from a transcript, and an
77
+ * errored call, carry no `details` at all: the key is removed rather than set
78
+ * to undefined, which is the shape the render meets at run time.
79
+ */
80
+ function toolResult(details: RunDetails | undefined, text: string): AgentToolResult<RunDetails> {
81
+ const content = [{ type: "text" as const, text }];
82
+ const summary: RunDetails = details ?? { summary: initialSummary() };
83
+ const result: AgentToolResult<RunDetails> = { content, details: summary };
84
+ if (details === undefined) Reflect.deleteProperty(result, "details");
85
+ return result;
86
+ }
87
+
88
+ function lines(component: { render: (width: number) => string[] } | undefined): string[] {
89
+ return (component?.render(80) ?? []).map(stripTerminalSequences);
45
90
  }
46
91
 
47
92
  /** The call line, stripped of styling, as the collapsed transcript shows it. */
48
93
  export function renderCallLabel(params: RunParams): string {
49
- return stripTerminalSequences(renderCallComponent(params, false)?.render(80)[0] ?? "");
94
+ return lines(tool().renderCall?.(params, fakeTheme(), context(params, {}, newRow())))[0] ?? "";
50
95
  }
51
96
 
52
97
  /** Every rendered line of the call, stripped of styling. */
53
- export function renderCallLines(
54
- params: RunParams,
55
- options: { readonly expanded?: boolean } = {},
98
+ export function renderCallLines(params: RunParams, view: RenderView = {}): readonly string[] {
99
+ return lines(tool().renderCall?.(params, fakeTheme(), context(params, view, newRow())));
100
+ }
101
+
102
+ /** Every rendered line of the result block, stripped of styling. */
103
+ export function renderResultLines(
104
+ details: RunDetails | undefined,
105
+ view: RenderView = {},
56
106
  ): readonly string[] {
57
- const component = renderCallComponent(params, options.expanded ?? false);
58
- return (component?.render(80) ?? []).map(stripTerminalSequences);
107
+ const result = toolResult(details, view.content ?? "");
108
+ const options = { expanded: view.expanded ?? false, isPartial: view.isPartial ?? false };
109
+ return lines(tool().renderResult?.(result, options, fakeTheme(), context({}, view, newRow())));
110
+ }
111
+
112
+ /** What one replay of pi's row rendering produced. */
113
+ export interface RenderedBlock {
114
+ /** Every line the row's children draw, in order and stripped of styling. */
115
+ readonly lines: readonly string[];
116
+ /** The components the row container holds; pi clears it on each pass. */
117
+ readonly children: number;
118
+ }
119
+
120
+ /**
121
+ * Replays pi's own `ToolExecutionComponent.updateDisplay()`: the row container
122
+ * is cleared, the call renderer runs, then the result renderer, each with the
123
+ * component it returned last time. `invalidate()` re-runs the whole pass, the
124
+ * way pi's does, so a renderer that invalidates from inside a pass shows up
125
+ * here as a second child rather than as a silent extra frame.
126
+ *
127
+ * The returned promise settles after the pending microtasks, which is where a
128
+ * deferred `invalidate()` lands.
129
+ */
130
+ export async function renderBlock(
131
+ params: RunParams,
132
+ details: RunDetails | undefined,
133
+ view: RenderView = {},
134
+ ): Promise<RenderedBlock> {
135
+ const definition = tool();
136
+ const result = toolResult(details, view.content ?? "");
137
+ const options = { expanded: view.expanded ?? false, isPartial: view.isPartial ?? false };
138
+ let call: Component | undefined;
139
+ let block: Component | undefined;
140
+ let children: Component[] = [];
141
+ const row: Row = { state: {}, invalidate: () => pass() };
142
+
143
+ function pass(): void {
144
+ children = [];
145
+ call = definition.renderCall?.(params, fakeTheme(), context(params, view, row, call));
146
+ if (call !== undefined) children.push(call);
147
+ block = definition.renderResult?.(
148
+ result,
149
+ options,
150
+ fakeTheme(),
151
+ context(params, view, row, block),
152
+ );
153
+ if (block !== undefined) children.push(block);
154
+ }
155
+
156
+ pass();
157
+ await flushMicrotasks();
158
+ return { lines: children.flatMap((child) => lines(child)), children: children.length };
159
+ }
160
+
161
+ /**
162
+ * Lets every pending microtask run, which is where a deferred `invalidate()`
163
+ * lands, and any pass it starts in turn.
164
+ */
165
+ async function flushMicrotasks(): Promise<void> {
166
+ for (let round = 0; round < 4; round += 1) await Promise.resolve();
167
+ }
168
+
169
+ /** Every line of the whole transcript block, stripped of styling. */
170
+ export async function renderBlockLines(
171
+ params: RunParams,
172
+ details: RunDetails | undefined,
173
+ view: RenderView = {},
174
+ ): Promise<readonly string[]> {
175
+ return (await renderBlock(params, details, view)).lines;
59
176
  }
@@ -11,7 +11,7 @@ import {
11
11
  foregroundInline,
12
12
  foregroundResult,
13
13
  inlineAvailable,
14
- RunTreeComponent,
14
+ RunBlockComponent,
15
15
  RunTreeStore,
16
16
  } from "../view/index.ts";
17
17
 
@@ -161,18 +161,32 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
161
161
  renderCall(params, theme, context) {
162
162
  const text =
163
163
  context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
164
- text.setText(callBody({ params, theme, expanded: context.expanded }));
164
+ const name = programNameOf(context.state);
165
+ text.setText(
166
+ callBody({
167
+ params,
168
+ theme,
169
+ expanded: context.expanded,
170
+ ...(name === undefined ? {} : { name }),
171
+ }),
172
+ );
165
173
  return text;
166
174
  },
167
- renderResult(result, _options, _theme, context) {
175
+ renderResult(result, options, _theme, context) {
168
176
  const details = result.details;
169
- if (details === undefined) return new Text(contentText(result), 0, 0);
170
- const tree =
171
- context.lastComponent instanceof RunTreeComponent
177
+ const block =
178
+ context.lastComponent instanceof RunBlockComponent
172
179
  ? context.lastComponent
173
- : new RunTreeComponent(details);
174
- tree.update(details);
175
- return tree;
180
+ : new RunBlockComponent();
181
+ const text = contentText(result);
182
+ block.update(details, {
183
+ expanded: options.expanded,
184
+ isPartial: options.isPartial,
185
+ isError: context.isError,
186
+ ...(text === "" ? {} : { errorText: text }),
187
+ });
188
+ publishProgramName(block.programName(), context);
189
+ return block;
176
190
  },
177
191
  async execute(_id, params, signal, onUpdate, ctx) {
178
192
  // Shape only, before the Bun check: an exactly-one-of violation, an empty
@@ -232,6 +246,42 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
232
246
  };
233
247
  }
234
248
 
249
+ /**
250
+ * The declared program name the result render left in pi's per-row state, which
251
+ * pi types loosely; the call render reads it back through a real guard.
252
+ */
253
+ function programNameOf(state: unknown): string | undefined {
254
+ if (!isRowState(state)) return undefined;
255
+ const name = state.programName;
256
+ return typeof name === "string" ? name : undefined;
257
+ }
258
+
259
+ /**
260
+ * Writes the name into the shared row state and asks pi to draw the row again,
261
+ * because the call render of this pass already ran without the name.
262
+ *
263
+ * The redraw is deferred off this pass: pi's `invalidate()` re-runs the row's
264
+ * `updateDisplay()` at once (pi
265
+ * `dist/modes/interactive/components/tool-execution.js`, `invalidate()` and
266
+ * `updateDisplay()`), which clears the row container and renders the call and
267
+ * the result again, so a call from inside the result render would add a second
268
+ * result component to the same row. The state is written first, so the deferred
269
+ * pass finds the name already published and stops there.
270
+ */
271
+ function publishProgramName(
272
+ name: string | undefined,
273
+ context: { readonly state: unknown; readonly invalidate: () => void },
274
+ ): void {
275
+ if (name === undefined || !isRowState(context.state)) return;
276
+ if (context.state.programName === name) return;
277
+ context.state.programName = name;
278
+ queueMicrotask(() => context.invalidate());
279
+ }
280
+
281
+ function isRowState(value: unknown): value is Record<string, unknown> {
282
+ return typeof value === "object" && value !== null && !Array.isArray(value);
283
+ }
284
+
235
285
  /**
236
286
  * Cassette and config paths are `resolve`d here; the CLI owns every rule about
237
287
  * them — combinations, validation, refusals — and its own message surfaces on
package/src/view/index.ts CHANGED
@@ -19,7 +19,14 @@ export {
19
19
  inlineAvailable,
20
20
  } from "./run-foreground.ts";
21
21
  export { boundedRuns, RUN_LISTING_CAP } from "./run-listing.ts";
22
+ export { RunBlockComponent, type RunBlockView } from "./run-result-block.ts";
23
+ export {
24
+ type RunBlockStatus,
25
+ runBlockStatus,
26
+ runStatusLine,
27
+ } from "./run-status-line.ts";
22
28
  export { RunTreeComponent } from "./run-tree-component.ts";
23
29
  export { RunTreeStore } from "./run-trees.ts";
30
+ export { themeStyler } from "./theme-styler.ts";
24
31
  export { toUsage } from "./usage.ts";
25
32
  export { createYaagCommand } from "./yaag-command.ts";
@@ -1,42 +1,49 @@
1
1
  /**
2
- * The text of one `yaag_run` call, as the transcript shows it: the call head,
3
- * and under it either the Inline Program source or the hint that names the key
4
- * which shows that source.
2
+ * The text of one `yaag_run` call, as the transcript shows it: the call head
3
+ * alone when the row is collapsed, and the head over every parameter the call
4
+ * passed when it is expanded.
5
+ *
6
+ * The call render stays parameter-only: the Run id, the status and the tree
7
+ * belong to the result render (`run-result-block.ts`).
5
8
  */
6
9
  import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
7
- import { type ProgramParams, programExpansion, programLabel } from "../record/index.ts";
10
+ import { programLabel, type RunCallParams } from "../record/index.ts";
11
+ import { callParamLines } from "./run-call-params.ts";
8
12
 
9
13
  /** What the call render needs: the call itself, its styling, and its state. */
10
14
  export interface CallBodyOptions {
11
- readonly params: ProgramParams;
15
+ readonly params: RunCallParams;
12
16
  readonly theme: Theme;
13
- /** True while pi shows the call expanded, where the source replaces the hint. */
17
+ /** True while pi shows the row expanded, where the parameter listing appears. */
14
18
  readonly expanded: boolean;
19
+ /** The declared name of the program that ran, when the result render knows it. */
20
+ readonly name?: string;
15
21
  }
16
22
 
17
23
  /**
18
- * The head alone, the head over the program source, or the head over the expand
19
- * hint. A call with no source to show gets no hint, because the hint would
20
- * promise an empty view.
24
+ * The head, with the expand hint appended while the row is collapsed, so the
25
+ * collapsed block stays exactly one call line over one status line. Expanded,
26
+ * the head carries the parameter listing under it.
21
27
  */
22
28
  export function callBody(options: CallBodyOptions): string {
23
29
  const { params, theme } = options;
24
30
  const head =
25
- theme.fg("toolTitle", theme.bold("yaag_run")) + theme.fg("muted", `(${programLabel(params)})`);
26
- const expansion = programExpansion(params);
27
- if (expansion === "") return head;
28
- if (options.expanded) return `${head}\n${theme.fg("muted", expansion)}`;
29
- const hint = expandHint(theme);
30
- return hint === "" ? head : `${head}\n${hint}`;
31
+ theme.fg("toolTitle", theme.bold("yaag_run")) +
32
+ theme.fg("muted", `(${programLabel(params, options.name)})`);
33
+ if (!options.expanded) {
34
+ const hint = expandHint(theme);
35
+ return hint === "" ? head : `${head} ${hint}`;
36
+ }
37
+ const lines = callParamLines(params);
38
+ return lines.length === 0 ? head : `${head}\n${theme.fg("muted", lines.join("\n"))}`;
31
39
  }
32
40
 
33
41
  /**
34
- * The muted second line that tells the reader which key shows the program
35
- * source. It reads the bound key, so a rebound keymap stays correct, and it is
36
- * empty when the action has no key at all, because a hint that names no key
37
- * tells the reader nothing.
42
+ * The muted hint that tells the reader which key expands the row. It reads the
43
+ * bound key, so a rebound keymap stays correct, and it is empty when the action
44
+ * has no key at all, because a hint that names no key tells the reader nothing.
38
45
  *
39
- * The caller's `Theme` styles the whole line `muted`. pi's own `keyHint` (pi
46
+ * The caller's `Theme` styles the whole hint `muted`. pi's own `keyHint` (pi
40
47
  * `docs/extensions.md`, keybinding hints) styles the key `dim` and the
41
48
  * description `muted` from a module-global theme instead, which a Host Session
42
49
  * installs but a test does not.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The parameter listing of an expanded `yaag_run` call: one line per parameter
3
+ * the caller actually passed, in a fixed order, with the Inline Program source
4
+ * last. A parameter that was not passed prints nothing — the view never invents
5
+ * a default the call did not state.
6
+ */
7
+ import { clampToWidth, sanitizeTerminalLine } from "@yaag/tui";
8
+ import { programExpansion, type RunCallParams } from "../record/index.ts";
9
+
10
+ /** The widest a listed parameter value may be, in display columns. */
11
+ const VALUE_WIDTH = 100;
12
+
13
+ /**
14
+ * The listing, without styling. `script` is rendered as `script:` over the whole
15
+ * program source, which {@link programExpansion} caps for transcript volume.
16
+ */
17
+ export function callParamLines(params: RunCallParams): string[] {
18
+ const lines: string[] = [];
19
+ if (params.file !== undefined) lines.push(valueLine("file", params.file));
20
+ if (params.args !== undefined) lines.push(valueLine("args", params.args));
21
+ if (params.background !== undefined) lines.push(valueLine("background", `${params.background}`));
22
+ if (params.record !== undefined) lines.push(valueLine("record", params.record));
23
+ if (params.resume !== undefined) lines.push(valueLine("resume", params.resume));
24
+ if (params.config !== undefined) lines.push(valueLine("config", params.config));
25
+ if (params.noConfig !== undefined) lines.push(valueLine("noConfig", `${params.noConfig}`));
26
+ if (params.script !== undefined) {
27
+ lines.push(" script:");
28
+ const source = programExpansion(params);
29
+ if (source !== "") lines.push(...source.split("\n").map((line) => ` ${line}`));
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ function valueLine(name: string, value: string): string {
35
+ return ` ${name}: ${clampToWidth(sanitizeTerminalLine(value), VALUE_WIDTH)}`;
36
+ }
@@ -221,6 +221,8 @@ function isEvent(value: unknown): value is LifecycleEvent {
221
221
  natural(value.attempt) &&
222
222
  isReason(value.reason)
223
223
  );
224
+ case "agent_model":
225
+ return strings(value.agent, value.model);
224
226
  case "agent_usage":
225
227
  return strings(value.agent) && isTokens(value.tokens) && number(value.cost);
226
228
  case "agent_exit":
@@ -285,8 +287,15 @@ function isTokens(value: unknown): value is TokenBreakdown {
285
287
  );
286
288
  }
287
289
 
290
+ /** Every member of `RunOutcome`; `interrupted` marks a Run that died mid-flight. */
288
291
  function isOutcome(value: unknown): boolean {
289
- return value === "completed" || value === "failed" || value === "stopped" || value === "paused";
292
+ return (
293
+ value === "completed" ||
294
+ value === "failed" ||
295
+ value === "stopped" ||
296
+ value === "paused" ||
297
+ value === "interrupted"
298
+ );
290
299
  }
291
300
 
292
301
  function optionalString(value: unknown): value is string | undefined {
@@ -7,7 +7,7 @@
7
7
  * keeps its input loop while the tool call blocks.
8
8
  */
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { renderInlineRun, type TreeState } from "@yaag/tui";
10
+ import { renderInlineRun, startLiveTicker, type TickerSchedule, type TreeState } from "@yaag/tui";
11
11
  import type { LiveRun, RunRegistry, RunSettlement } from "../record/index.ts";
12
12
  import { failure, observedSettlement, toError } from "../record/index.ts";
13
13
  import type { RunDetails } from "./run-details.ts";
@@ -64,6 +64,13 @@ export interface InlineOptions extends ForegroundOptions {
64
64
  readonly store: RunTreeStore;
65
65
  /** Registers the `yaag-run-complete` follow-up; called only on detach. */
66
66
  announce(): void;
67
+ /**
68
+ * The interval seam of the 1-second redraw (spec D11); defaults to a real
69
+ * `setInterval`. Tests inject a fake.
70
+ */
71
+ readonly schedule?: TickerSchedule;
72
+ /** The frame's clock; defaults to `Date.now`. Tests inject a fake. */
73
+ now?(): number;
67
74
  }
68
75
 
69
76
  /** Whether this call may draw the inline frame; tui mode only (architecture §9). */
@@ -102,7 +109,7 @@ export async function foregroundResult(options: ForegroundOptions): Promise<Fore
102
109
  */
103
110
  export async function foregroundInline(options: InlineOptions): Promise<ForegroundResult> {
104
111
  const { run, registry, signal, ctx } = options;
105
- const frame = createFrame(ctx, run.id, options.state);
112
+ const frame = createFrame(ctx, run.id, options.state, options.now ?? Date.now);
106
113
  let detached = false;
107
114
  // Resolved by the detach itself, so the abort needs no second listener and
108
115
  // leaves nothing registered on a signal the tool call outlives.
@@ -122,6 +129,13 @@ export async function foregroundInline(options: InlineOptions): Promise<Foregrou
122
129
  if (id === run.id && !detached) frame.draw();
123
130
  });
124
131
  frame.draw();
132
+ // While the Run runs, the frame redraws each second, so its times advance
133
+ // between fd 3 frames (spec D11); the `finally` stops it on settle, on detach
134
+ // and on abort.
135
+ const ticker = startLiveTicker({
136
+ tick: frame.draw,
137
+ ...(options.schedule === undefined ? {} : { schedule: options.schedule }),
138
+ });
125
139
  try {
126
140
  if (signal?.aborted === true) detach();
127
141
  if (detached) return detachedResult(run);
@@ -141,6 +155,7 @@ export async function foregroundInline(options: InlineOptions): Promise<Foregrou
141
155
  }
142
156
  return finalResult(run.id, settlement);
143
157
  } finally {
158
+ ticker.stop();
144
159
  signal?.removeEventListener("abort", detach);
145
160
  unsubscribe();
146
161
  frame.clear();
@@ -153,7 +168,12 @@ interface InlineFrame {
153
168
  clear(): void;
154
169
  }
155
170
 
156
- function createFrame(ctx: ExtensionContext, id: string, state: TreeState): InlineFrame {
171
+ function createFrame(
172
+ ctx: ExtensionContext,
173
+ id: string,
174
+ state: TreeState,
175
+ now: () => number,
176
+ ): InlineFrame {
157
177
  const key = foregroundWidgetKey(id);
158
178
  let cleared = false;
159
179
  let drawn: readonly string[] | undefined;
@@ -161,7 +181,7 @@ function createFrame(ctx: ExtensionContext, id: string, state: TreeState): Inlin
161
181
  draw(): void {
162
182
  if (cleared) return;
163
183
  const lines = renderInlineRun(state, {
164
- now: Date.now(),
184
+ now: now(),
165
185
  width: WIDGET_WIDTH,
166
186
  label: id,
167
187
  maxLines: WIDGET_MAX_LINES,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The `yaag_run` result block: one status line when the transcript row is
3
+ * collapsed, and the status line over the whole Run tree when it is expanded.
4
+ *
5
+ * It delegates to `RunTreeComponent` rather than extending it: the tree owns the
6
+ * projection, this component owns what the row shows of it.
7
+ */
8
+ import { clampToWidth, sanitizeTerminalText } from "@yaag/tui";
9
+ import type { RunDetails } from "./run-details.ts";
10
+ import { type RunBlockStatus, runBlockStatus, runStatusLine } from "./run-status-line.ts";
11
+ import { RunTreeComponent } from "./run-tree-component.ts";
12
+
13
+ /** The runtime's fallback program name; it names no program, so it is not shown. */
14
+ const DEFAULT_PROGRAM = "program";
15
+
16
+ /** Injection points for the block; all optional. */
17
+ export interface RunBlockComponentOptions {
18
+ /** The clock the block reads for elapsed times; defaults to `Date.now`. */
19
+ readonly now?: () => number;
20
+ }
21
+
22
+ /** What pi's render context tells the block about this row. */
23
+ export interface RunBlockView {
24
+ readonly expanded: boolean;
25
+ readonly isPartial: boolean;
26
+ readonly isError: boolean;
27
+ /** The result's own text: the tail of the Run's error output when it failed. */
28
+ readonly errorText?: string;
29
+ }
30
+
31
+ /** A pi component: the status line, and the Run tree behind the expand key. */
32
+ export class RunBlockComponent {
33
+ readonly #now: () => number;
34
+ #tree: RunTreeComponent | undefined;
35
+ #details: RunDetails | undefined;
36
+ #view: RunBlockView = { expanded: false, isPartial: false, isError: false };
37
+
38
+ constructor(options: RunBlockComponentOptions = {}) {
39
+ this.#now = options.now ?? Date.now;
40
+ }
41
+
42
+ /**
43
+ * Folds one result or progress frame. A render with no details keeps the last
44
+ * ones, so a Run that fails after it streamed still shows its id and status.
45
+ */
46
+ update(details: RunDetails | undefined, view: RunBlockView): void {
47
+ this.#view = view;
48
+ if (details === undefined) return;
49
+ this.#details = details;
50
+ this.#tree = this.#tree ?? new RunTreeComponent(details, { now: this.#now });
51
+ this.#tree.update(details);
52
+ }
53
+
54
+ /**
55
+ * The declared name of the program that ran, for the call label.
56
+ *
57
+ * It is the name the executed program declared (`run_start.program`), never
58
+ * text scraped from the source. The label itself sanitizes and clamps it
59
+ * (`run-program-param.ts`), so this component states the fact only.
60
+ */
61
+ programName(): string | undefined {
62
+ const name = this.#details?.summary.program ?? "";
63
+ return name === "" || name === DEFAULT_PROGRAM ? undefined : name;
64
+ }
65
+
66
+ /**
67
+ * Collapsed, the block is exactly one status line, whatever the Run did: the
68
+ * error output of a failed Run belongs behind the expand key, where it cannot
69
+ * push the next tool call off the screen. A block that has no Run to describe
70
+ * — a call that failed before it started — shows the error text instead.
71
+ *
72
+ * Expanded, the error output follows the tree, and only for a failed Run: the
73
+ * tree already draws the Result region of a Run that ended well, so the same
74
+ * text under it would say everything twice.
75
+ */
76
+ render(width: number): string[] {
77
+ const details = this.#details;
78
+ if (details === undefined) return this.#errorLines(width);
79
+ const lines = [this.#statusLine(details)];
80
+ if (!this.#view.expanded) return lines;
81
+ if (this.#tree !== undefined) lines.push(...this.#tree.render(width));
82
+ return this.#view.isError ? [...lines, ...this.#errorLines(width)] : lines;
83
+ }
84
+
85
+ invalidate(): void {
86
+ this.#tree?.invalidate();
87
+ }
88
+
89
+ /**
90
+ * The result text, sanitized and clamped line by line: it carries the tail of
91
+ * the Run's own error output, which no Host Session may print unfiltered.
92
+ * `sanitizeTerminalText` keeps the newlines and strips every other control
93
+ * sequence, so each line only has to be clamped to the width.
94
+ */
95
+ #errorLines(width: number): string[] {
96
+ const text = this.#view.errorText;
97
+ if (text === undefined || text === "") return [];
98
+ return sanitizeTerminalText(text)
99
+ .replace(/\n+$/, "")
100
+ .split("\n")
101
+ .map((line) => clampToWidth(line, width));
102
+ }
103
+
104
+ #statusLine(details: RunDetails): string {
105
+ const status: RunBlockStatus = runBlockStatus({
106
+ summary: details.summary,
107
+ isPartial: this.#view.isPartial,
108
+ isError: this.#view.isError,
109
+ });
110
+ return runStatusLine({
111
+ ...(details.id === undefined ? {} : { id: details.id }),
112
+ summary: details.summary,
113
+ status,
114
+ now: this.#now(),
115
+ });
116
+ }
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one status line the collapsed `yaag_run` block shows under its call line:
3
+ * `<run id> · <status> <elapsed>`. Pure, with the clock injected, so a test
4
+ * states an elapsed time instead of waiting for one.
5
+ */
6
+ import type { RunSummary } from "@yaag/runtime";
7
+ import { clampToWidth, durationText, sanitizeTerminalLine } from "@yaag/tui";
8
+
9
+ /** What the block says a Run is doing, in the transcript's own vocabulary. */
10
+ export type RunBlockStatus = "running" | "started" | "finished" | "failed" | "interrupted";
11
+
12
+ /** The widest a Run id may be on the status line, in display columns. */
13
+ const ID_WIDTH = 24;
14
+
15
+ /** What the status classification reads: the Summary and the render's own flags. */
16
+ export interface RunBlockStatusOptions {
17
+ readonly summary: RunSummary;
18
+ /** True while pi still streams this result; a live foreground Run. */
19
+ readonly isPartial: boolean;
20
+ /** True when the tool call itself threw. */
21
+ readonly isError: boolean;
22
+ }
23
+
24
+ /**
25
+ * Classifies one Run for the status line.
26
+ *
27
+ * A Run that still runs is `running` while pi streams the result, and `started`
28
+ * once the call returned — that is the background acknowledgement. A settled
29
+ * Run reads its outcome: `stopped`, `paused` and `interrupted` all say
30
+ * `interrupted`, because each one names a Run that ended before its program did.
31
+ */
32
+ export function runBlockStatus(options: RunBlockStatusOptions): RunBlockStatus {
33
+ const { summary } = options;
34
+ if (summary.runState === "running") {
35
+ if (options.isError) return "failed";
36
+ return options.isPartial ? "running" : "started";
37
+ }
38
+ if (options.isError || summary.outcome === "failed") return "failed";
39
+ return summary.outcome === "completed" ? "finished" : "interrupted";
40
+ }
41
+
42
+ /** What one status line needs: the id, the Run, its status, and the clock. */
43
+ export interface RunStatusLineOptions {
44
+ readonly id?: string;
45
+ readonly summary: RunSummary;
46
+ readonly status: RunBlockStatus;
47
+ /** The current time, in epoch milliseconds. */
48
+ readonly now: number;
49
+ }
50
+
51
+ /**
52
+ * `<id> · <status> <elapsed>`; the id and its separator are dropped when the
53
+ * block knows no Run id, because a bare separator names nothing.
54
+ *
55
+ * The id is read back from a persisted transcript, so it is sanitized to one
56
+ * line and clamped before it is printed: no stored value may forge a second
57
+ * line or overflow the status line.
58
+ */
59
+ export function runStatusLine(options: RunStatusLineOptions): string {
60
+ const body = `${options.status} ${durationText(elapsedMs(options))}`;
61
+ if (options.id === undefined) return body;
62
+ const id = clampToWidth(sanitizeTerminalLine(options.id), ID_WIDTH);
63
+ return id === "" ? body : `${id} · ${body}`;
64
+ }
65
+
66
+ /**
67
+ * A settled Run reports the duration its own Summary measured. A live Run has
68
+ * no duration yet, so the line counts from its start; a background Run
69
+ * therefore reports how long it has run, on each redraw of its row.
70
+ */
71
+ function elapsedMs(options: RunStatusLineOptions): number {
72
+ const { summary } = options;
73
+ if (summary.runState === "ended") return summary.durationMs;
74
+ if (summary.startedAt === null) return summary.durationMs;
75
+ return options.now - summary.startedAt;
76
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Maps the tui styling tokens onto pi's `Theme` (spec D10).
3
+ *
4
+ * pi's `fg` resets the foreground only (`\x1b[39m`) and `bg` resets the
5
+ * background only (`\x1b[49m`), so a background wrapped around already
6
+ * foregrounded spans survives to the end of the line. That is how pi paints
7
+ * its own selected line (`modes/interactive/components/session-selector`).
8
+ */
9
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
10
+ import type { BackgroundToken, StyleToken, TreeStyler } from "@yaag/tui";
11
+
12
+ /** The four state colours are distinct, and none of them is `accent`. */
13
+ const FOREGROUND: Readonly<Record<StyleToken, ThemeColor>> = Object.freeze({
14
+ accent: "accent",
15
+ running: "warning",
16
+ exited: "success",
17
+ failed: "error",
18
+ idle: "dim",
19
+ muted: "muted",
20
+ });
21
+
22
+ // `ThemeBg` is not exported from pi's package root, so the table borrows the
23
+ // parameter type of `Theme["bg"]` instead of casting.
24
+ const BACKGROUND: Readonly<Record<BackgroundToken, Parameters<Theme["bg"]>[0]>> = Object.freeze({
25
+ selected: "selectedBg",
26
+ });
27
+
28
+ /** Builds the styler the `/yaag` view passes into the tree renderer. */
29
+ export function themeStyler(theme: Theme): TreeStyler {
30
+ return {
31
+ fg: (token: StyleToken, text: string): string => theme.fg(FOREGROUND[token], text),
32
+ bg: (token: BackgroundToken, line: string): string => theme.bg(BACKGROUND[token], line),
33
+ };
34
+ }
@@ -13,13 +13,21 @@ import type {
13
13
  ExtensionUIContext,
14
14
  RegisteredCommand,
15
15
  } from "@earendil-works/pi-coding-agent";
16
- import { createRunTreeView, type RunTreeViewHost, type RunViewResult, TreeState } from "@yaag/tui";
16
+ import {
17
+ createRunTreeView,
18
+ type RunTreeView,
19
+ type RunTreeViewHost,
20
+ type RunViewResult,
21
+ type TickerSchedule,
22
+ TreeState,
23
+ } from "@yaag/tui";
17
24
  import type { RunRegistry, RunSettlement } from "../record/index.ts";
18
25
  import { observedSettlement, viewResult } from "../record/index.ts";
19
26
  import { restoredViewResult } from "./restored-run-text.ts";
20
27
  import { type PickableRun, pickableRuns, runFromLabel, runPickerLabel } from "./run-picker.ts";
21
28
  import { createRunTreeHost } from "./run-tree-host.ts";
22
29
  import type { RunTreeStore } from "./run-trees.ts";
30
+ import { themeStyler } from "./theme-styler.ts";
23
31
 
24
32
  type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
25
33
 
@@ -29,6 +37,11 @@ const DESCRIPTION = "Open the interactive Run tree for a Run of this session";
29
37
  export interface YaagCommandOptions {
30
38
  readonly registry: RunRegistry;
31
39
  readonly store: RunTreeStore;
40
+ /**
41
+ * The interval seam of the view's 1-second redraw (spec D11); defaults to a
42
+ * real `setInterval`. Tests inject a fake.
43
+ */
44
+ readonly schedule?: TickerSchedule;
32
45
  }
33
46
 
34
47
  /** Everything `/yaag` needs from its pi context, and nothing more. */
@@ -96,12 +109,15 @@ async function openRun(
96
109
  ? TreeState.fromSummary(run.summary)
97
110
  : (options.store.get(run.id) ?? TreeState.fromSummary(run.summary));
98
111
  let unsubscribe: (() => void) | undefined;
112
+ // Held outside the factory, so the close path stops the view's ticker even
113
+ // when `ui.custom` rejects rather than closing the view itself.
114
+ let view: RunTreeView | undefined;
99
115
  // The observer below outlives no more than the `ctx.ui.custom()` call: once
100
116
  // the view exits or the UI call rejects, a later settlement must touch
101
117
  // neither the view nor the renderer.
102
118
  let live = true;
103
119
  try {
104
- await ctx.ui.custom<void>((tui, _theme, keybindings, done) => {
120
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
105
121
  const host: RunTreeViewHost = {
106
122
  ...createRunTreeHost({ ui: ctx.ui, tui, keybindings, state }),
107
123
  // A restored orphan is never re-attached, so there is nothing to stop
@@ -113,23 +129,26 @@ async function openRun(
113
129
  // hand over, because a Run of this session is already addressable.
114
130
  done: () => done(),
115
131
  };
116
- const view = createRunTreeView({
132
+ const opened = createRunTreeView({
117
133
  state,
118
134
  host,
119
135
  label: found.state === "restored" ? `${run.id} (restored)` : run.id,
136
+ styler: themeStyler(theme),
137
+ ...(options.schedule === undefined ? {} : { schedule: options.schedule }),
120
138
  });
121
- if (found.state === "restored") view.settle(restoredViewResult(found.run.record));
139
+ view = opened;
140
+ if (found.state === "restored") opened.settle(restoredViewResult(found.run.record));
122
141
  else if (found.state === "finished")
123
- settle(state, view, found.run.outcome, options.registry, run.id);
142
+ settle(state, opened, found.run.outcome, options.registry, run.id);
124
143
  else if (found.state === "live") {
125
144
  unsubscribe = options.store.subscribe((id) => {
126
- if (id === run.id && live) view.touch();
145
+ if (id === run.id && live) opened.touch();
127
146
  });
128
147
  // A view opened while live must reach the settled phase itself, or `esc`
129
148
  // would keep live behavior after the Run ended (ADR-0008).
130
149
  const onSettled = (settlement: RunSettlement): void => {
131
150
  if (!live) return;
132
- settle(state, view, settlement, options.registry, run.id);
151
+ settle(state, opened, settlement, options.registry, run.id);
133
152
  };
134
153
  void observedSettlement(run.id, found.run.outcome, options.registry)
135
154
  .then(onSettled, (reason: unknown) => {
@@ -138,11 +157,13 @@ async function openRun(
138
157
  // A throwing view or renderer must not become an unhandled rejection.
139
158
  .catch(() => {});
140
159
  }
141
- return view;
160
+ return opened;
142
161
  });
143
162
  } finally {
144
163
  live = false;
145
164
  unsubscribe?.();
165
+ // `dispose` is idempotent, so the call pi already made costs nothing here.
166
+ view?.dispose();
146
167
  }
147
168
  }
148
169