@yaag/extension 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@earendil-works/pi-tui": "^0.84.0",
27
- "@yaag/cli": "0.2.0",
28
- "@yaag/runtime": "0.2.0",
29
- "@yaag/tui": "0.2.0",
27
+ "@yaag/cli": "0.2.1",
28
+ "@yaag/runtime": "0.2.1",
29
+ "@yaag/tui": "0.2.1",
30
30
  "nanoid": "^6.0.1"
31
31
  },
32
32
  "peerDependencies": {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The footer segment for every live background Run (architecture §9).
3
+ *
4
+ * A background Run costs no scrollback and owns no widget: it reports itself in
5
+ * one cooperative footer segment, `yaag: 2 live · $0.42`, which pi lays out
6
+ * beside the segments of every other extension. The segment is cleared as soon
7
+ * as the last background Run ends.
8
+ */
9
+ import { costText } from "@yaag/tui";
10
+ import type { RunRegistry } from "./run-registry.ts";
11
+ import type { RunTreeStore } from "./run-trees.ts";
12
+
13
+ /** The one footer key this extension owns. */
14
+ export const STATUS_KEY = "yaag";
15
+
16
+ /** The only pi capability the footer segment needs. */
17
+ export interface StatusSurface {
18
+ setStatus(key: string, text: string | undefined): void;
19
+ }
20
+
21
+ /** What the footer segment reads: the Run registry and the projection store. */
22
+ export interface BackgroundStatusOptions {
23
+ readonly registry: RunRegistry;
24
+ readonly store: RunTreeStore;
25
+ }
26
+
27
+ /** The footer controller: bind it once, then refresh it on every occurrence. */
28
+ export interface BackgroundStatus {
29
+ /**
30
+ * Binds the pi UI surface, once the Host Session has one, and writes the
31
+ * current text. A `StatusSurface.setStatus()` that throws propagates here.
32
+ */
33
+ bind(surface: StatusSurface): void;
34
+ /**
35
+ * Rewrites the segment; drops the write before `bind`, and skips unchanged
36
+ * text. A bound `StatusSurface.setStatus()` that throws propagates here.
37
+ */
38
+ refresh(): void;
39
+ }
40
+
41
+ /**
42
+ * Builds the footer segment over this session's Run registry and projections.
43
+ *
44
+ * An unbound controller drops every refresh, and a Run without a projection is
45
+ * skipped. The controller lives for the whole Host Session, so it never
46
+ * unsubscribes. Construction itself never throws, but `bind()` and `refresh()`
47
+ * call the caller's `StatusSurface.setStatus()` and propagate whatever it
48
+ * throws; the store subscription also propagates it into the `ingest()` that
49
+ * caused the rewrite.
50
+ */
51
+ export function createBackgroundStatus(options: BackgroundStatusOptions): BackgroundStatus {
52
+ let surface: StatusSurface | undefined;
53
+ let written: string | undefined;
54
+
55
+ const refresh = (): void => {
56
+ if (surface === undefined) return;
57
+ const text = statusText(options);
58
+ if (written === text) return;
59
+ written = text;
60
+ surface.setStatus(STATUS_KEY, text);
61
+ };
62
+
63
+ options.store.subscribe(() => refresh());
64
+
65
+ return {
66
+ bind(bound: StatusSurface): void {
67
+ surface = bound;
68
+ written = undefined;
69
+ refresh();
70
+ },
71
+ refresh,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The segment text, or undefined when no background Run is live.
77
+ *
78
+ * The cost is the sum over every live background Run, and it carries
79
+ * `+incomplete` when any of those Runs reports its cost as a floor (ADR-0012).
80
+ */
81
+ function statusText(options: BackgroundStatusOptions): string | undefined {
82
+ let count = 0;
83
+ let cost: number | null = null;
84
+ let incomplete = false;
85
+ for (const run of options.registry.live) {
86
+ if (options.store.kind(run.id) !== "background") continue;
87
+ count += 1;
88
+ const summary = run.summary;
89
+ if (summary.cost !== null) cost = (cost ?? 0) + summary.cost;
90
+ if (summary.incomplete) incomplete = true;
91
+ }
92
+ if (count === 0) return undefined;
93
+ return `${STATUS_KEY}: ${count} live · ${costText(cost, incomplete)}`;
94
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * A fully typed `ExtensionUIContext` for the tui-mode test context.
3
3
  *
4
- * `custom`, `notify`, `editor`, `setWidget`, and a scripted `select` work;
4
+ * `custom`, `notify`, `editor`, `setWidget`, `setStatus`, and a scripted
5
+ * `select` work;
5
6
  * every other capability throws, so a dependency a view is not supposed to
6
7
  * have fails loudly. The
7
8
  * class implements the interface, so pi's UI surface is checked at compile
@@ -54,6 +55,8 @@ export class FakeExtensionUi implements ExtensionUIContext {
54
55
  readonly edits: OpenedEditor[] = [];
55
56
  /** The latest frame per widget key; `undefined` means the key was cleared. */
56
57
  readonly widgets = new Map<string, string[] | undefined>();
58
+ /** The latest text per footer key; `undefined` means the key was cleared. */
59
+ readonly statuses = new Map<string, string | undefined>();
57
60
  /** Answers `select` in order; an empty queue answers `undefined`, i.e. esc. */
58
61
  readonly selections: string[] = [];
59
62
  /** The component `custom` opened, once a Run opened one. */
@@ -116,8 +119,8 @@ export class FakeExtensionUi implements ExtensionUIContext {
116
119
  onTerminalInput(): () => void {
117
120
  return unavailable("ui.onTerminalInput");
118
121
  }
119
- setStatus(): void {
120
- unavailable("ui.setStatus");
122
+ setStatus(key: string, text: string | undefined): void {
123
+ this.statuses.set(key, text);
121
124
  }
122
125
  setWorkingMessage(): void {
123
126
  unavailable("ui.setWorkingMessage");
package/src/fake-tui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Fully typed `TUI` and `Terminal` doubles for the tui-mode test context.
3
3
  *
4
- * The foreground Run view uses three capabilities only: `terminal.rows`,
4
+ * The Run view uses three capabilities only: `terminal.rows`,
5
5
  * `terminal.columns`, and `requestRender()`. Every other member of `Terminal`
6
6
  * and `TUI` — fields and getters included — throws on access, so a component
7
7
  * that starts to depend on the terminal fails loudly in the test instead of
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createBackgroundWidget } from "./background-widget.ts";
2
+ import { createBackgroundStatus } from "./background-status.ts";
3
3
  import { createDescribeTool } from "./describe-tool.ts";
4
4
  import { findProgramDirectories } from "./program-directories.ts";
5
5
  import { resolveBun } from "./resolve-bun.ts";
@@ -36,12 +36,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
36
36
  // Host Session keeps every Run id addressable (ticket 02).
37
37
  const registry = new RunRegistry({ store: new RunStore() });
38
38
  await registry.restore();
39
- // The renderer projections and the one combined inline widget both live for
39
+ // The renderer projections and the background footer segment both live for
40
40
  // the session, beside the registry, so a background Run keeps a tree.
41
41
  const store = new RunTreeStore();
42
- const widget = createBackgroundWidget({ registry, store });
42
+ const status = createBackgroundStatus({ registry, store });
43
43
  pi.registerTool(
44
- createRunTool({ bun, cli, registry, store, widget, sendMessage: pi.sendMessage.bind(pi) }),
44
+ createRunTool({ bun, cli, registry, store, status, sendMessage: pi.sendMessage.bind(pi) }),
45
45
  );
46
46
  pi.registerTool(createStatusTool(registry));
47
47
  pi.registerMessageRenderer("yaag-run-complete", createRunCompleteRenderer());
@@ -58,7 +58,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
58
58
 
59
59
  pi.on("session_start", (_event, ctx) => {
60
60
  if (bun === null) ctx.ui.notify(report, "error");
61
- if (ctx.hasUI) widget.bind(ctx.ui);
61
+ if (ctx.hasUI) status.bind(ctx.ui);
62
62
  });
63
63
 
64
64
  // Commands and tools have separate namespaces: this CLI-resolution command
@@ -1,19 +1,36 @@
1
1
  /**
2
- * The two blocking `yaag_run` paths (spec §4): the details-only stream every
3
- * client gets, and the interactive tree a tui-mode Host Session gets.
2
+ * The two blocking `yaag_run` paths (architecture §9): the details-only stream every
3
+ * client gets, and the inline frame a tui-mode Host Session also draws.
4
4
  *
5
- * Both return the same model-visible contract, so opening the interactive view
6
- * changes nothing the model can see.
5
+ * Both return the same model-visible contract, so drawing the frame changes
6
+ * nothing the model can see, and neither path opens a modal: the Host Session
7
+ * keeps its input loop while the tool call blocks.
7
8
  */
8
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import type { TreeState } from "@yaag/tui";
10
+ import { renderInlineRun, type TreeState } from "@yaag/tui";
10
11
  import type { RunDetails } from "./run-details.ts";
11
- import { openRunTreeView } from "./run-foreground-view.ts";
12
12
  import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
13
- import { failure, observedSettlement, toError, viewResult } from "./run-settlement.ts";
13
+ import { failure, observedSettlement, toError } from "./run-settlement.ts";
14
14
  import type { RunTreeStore } from "./run-trees.ts";
15
15
  import { toUsage } from "./usage.ts";
16
16
 
17
+ /**
18
+ * The pi widget key one blocking Run draws under.
19
+ *
20
+ * The key carries the Run id, because pi executes sibling tool calls
21
+ * concurrently: two blocking Runs keep two frames, and each one clears only
22
+ * its own.
23
+ */
24
+ export function foregroundWidgetKey(id: string): string {
25
+ return `yaag-run:${id}`;
26
+ }
27
+
28
+ /** Line budget for the inline frame; pi truncates a widget past 10 lines. */
29
+ const WIDGET_MAX_LINES = 10;
30
+
31
+ /** Width the inline frame is clamped to; pi wraps nothing for a widget. */
32
+ const WIDGET_WIDTH = 100;
33
+
17
34
  /** What `execute` returns for a blocking Run. */
18
35
  export interface ForegroundResult {
19
36
  content: { type: "text"; text: string }[];
@@ -34,25 +51,23 @@ export interface ForegroundOptions {
34
51
  }
35
52
 
36
53
  /**
37
- * `ForegroundOptions` plus what opening the interactive tree needs.
54
+ * `ForegroundOptions` plus what drawing the inline frame needs.
38
55
  *
39
- * Only valid when `interactiveAvailable()` is true; `ctx.ui` is touched on no
40
- * other path.
56
+ * Only valid when `inlineAvailable()` is true; `ctx.ui` is touched on no other
57
+ * path.
41
58
  */
42
- export interface InteractiveOptions extends ForegroundOptions {
59
+ export interface InlineOptions extends ForegroundOptions {
43
60
  readonly ctx: ExtensionContext;
44
- /** Ingested from Run start, so the tree misses no early Ask. */
61
+ /** Ingested from Run start by the caller, so the frame misses no early Ask. */
45
62
  readonly state: TreeState;
46
- /** Detaching hands the Run to the combined inline widget. */
63
+ /** Detaching hands the Run to the footer segment. */
47
64
  readonly store: RunTreeStore;
48
65
  /** Registers the `yaag-run-complete` follow-up; called only on detach. */
49
66
  announce(): void;
50
- /** Receives the redraw handle, so a pushed fd 3 update can reach the view. */
51
- onOpen?(view: { touch(): void }): void;
52
67
  }
53
68
 
54
- /** Whether this call may open the interactive tree (spec §4: tui mode only). */
55
- export function interactiveAvailable(ctx: ExtensionContext, background: boolean): boolean {
69
+ /** Whether this call may draw the inline frame; tui mode only (architecture §9). */
70
+ export function inlineAvailable(ctx: ExtensionContext, background: boolean): boolean {
56
71
  return !background && ctx.mode === "tui" && ctx.hasUI;
57
72
  }
58
73
 
@@ -77,63 +92,95 @@ export async function foregroundResult(options: ForegroundOptions): Promise<Fore
77
92
  }
78
93
 
79
94
  /**
80
- * The interactive path: the reader watches the tree while the Run executes, then
81
- * dismisses it or detaches the Run.
95
+ * The inline path: a read-only frame of the Run tree is drawn beside the chat
96
+ * while the tool call blocks, and the frame is cleared when the call returns.
82
97
  *
83
- * Dismissal returns exactly what `foregroundResult` returns, throw included.
84
- * Detach returns an acknowledgement and leaves the Run executing as `rN`, with
85
- * its completion delivered by the `yaag-run-complete` follow-up. Every path
86
- * closes the view, so `ctx.ui.custom()` cannot outlive the tool call.
98
+ * `esc` aborts the tool call, and an abort detaches rather than stops: the Run
99
+ * becomes a background Run, joins the footer segment, and its completion
100
+ * arrives through the `yaag-run-complete` follow-up. Stopping a Run is `/yaag`
101
+ * `ctrl+q` Stop Run (ADR-0008).
87
102
  */
88
- export async function foregroundInteractive(
89
- options: InteractiveOptions,
90
- ): Promise<ForegroundResult> {
103
+ export async function foregroundInline(options: InlineOptions): Promise<ForegroundResult> {
91
104
  const { run, registry, signal, ctx } = options;
92
- const view = openRunTreeView({
93
- ctx,
94
- state: options.state,
95
- id: run.id,
96
- stop: run.stop,
97
- onDetach: () => options.store.adopt(run.id),
105
+ const frame = createFrame(ctx, run.id, options.state);
106
+ let detached = false;
107
+ // Resolved by the detach itself, so the abort needs no second listener and
108
+ // leaves nothing registered on a signal the tool call outlives.
109
+ let onDetached: () => void = () => {};
110
+ const detaching = new Promise<undefined>((resolve) => {
111
+ onDetached = () => resolve(undefined);
98
112
  });
99
- options.onOpen?.(view);
100
- const abort = (): void => {
101
- run.stop();
102
- view.close("dismissed");
113
+ const detach = (): void => {
114
+ if (detached) return;
115
+ detached = true;
116
+ options.store.adopt(run.id);
117
+ options.announce();
118
+ onDetached();
103
119
  };
104
- signal?.addEventListener("abort", abort, { once: true });
105
- if (signal?.aborted === true) abort();
120
+ signal?.addEventListener("abort", detach, { once: true });
121
+ const unsubscribe = options.store.subscribe((id) => {
122
+ if (id === run.id && !detached) frame.draw();
123
+ });
124
+ frame.draw();
106
125
  try {
107
- // Kept as a settled-shaped promise so a detach — which leaves this promise
108
- // pending cannot surface as an unhandled rejection.
126
+ if (signal?.aborted === true) detach();
127
+ if (detached) return detachedResult(run);
109
128
  const settled = observedSettlement(run.id, run.outcome, registry).then(
110
- (settlement): SettledRace => ({ kind: "settled", settlement }),
111
- (reason): SettledRace => ({ kind: "settled", settlement: { kind: "rejected", reason } }),
129
+ (settlement): RunSettlement => settlement,
130
+ (reason): RunSettlement => ({ kind: "rejected", reason }),
112
131
  );
113
- const first = await Promise.race([
114
- settled,
115
- view.exit.then((exit) => ({ kind: "exit", exit }) as const),
116
- ]);
117
- if (first.kind === "exit" && first.exit === "detached") {
118
- options.announce();
132
+ // The detach resolves this race, so the tool call returns while the Run
133
+ // keeps executing; `settled` stays a handled promise either way.
134
+ const settlement = await Promise.race([settled, detaching]);
135
+ // `detached` is checked beside the settlement: an abort that lands in the
136
+ // same synchronous stretch as the settlement already announced the Run, and
137
+ // returning its value here would report the same Run to the model twice.
138
+ if (settlement === undefined || detached) {
139
+ detach();
119
140
  return detachedResult(run);
120
141
  }
121
- if (first.kind === "settled") view.settle(viewResult(first.settlement));
122
- const exit = await view.exit;
123
- if (exit === "detached") {
124
- options.announce();
125
- return detachedResult(run);
126
- }
127
- if (signal?.aborted === true) throw new Error("yaag_run: cancelled");
128
- return finalResult(run.id, await settled.then(({ settlement }) => settlement));
142
+ return finalResult(run.id, settlement);
129
143
  } finally {
130
- signal?.removeEventListener("abort", abort);
131
- view.close("dismissed");
132
- view.dispose();
144
+ signal?.removeEventListener("abort", detach);
145
+ unsubscribe();
146
+ frame.clear();
133
147
  }
134
148
  }
135
149
 
136
- type SettledRace = { readonly kind: "settled"; readonly settlement: RunSettlement };
150
+ /** One inline frame: a redraw and a clear, both idempotent after the clear. */
151
+ interface InlineFrame {
152
+ draw(): void;
153
+ clear(): void;
154
+ }
155
+
156
+ function createFrame(ctx: ExtensionContext, id: string, state: TreeState): InlineFrame {
157
+ const key = foregroundWidgetKey(id);
158
+ let cleared = false;
159
+ let drawn: readonly string[] | undefined;
160
+ return {
161
+ draw(): void {
162
+ if (cleared) return;
163
+ const lines = renderInlineRun(state, {
164
+ now: Date.now(),
165
+ width: WIDGET_WIDTH,
166
+ label: id,
167
+ maxLines: WIDGET_MAX_LINES,
168
+ });
169
+ if (drawn !== undefined && sameLines(drawn, lines)) return;
170
+ drawn = lines;
171
+ ctx.ui.setWidget(key, [...lines]);
172
+ },
173
+ clear(): void {
174
+ if (cleared) return;
175
+ cleared = true;
176
+ ctx.ui.setWidget(key, undefined);
177
+ },
178
+ };
179
+ }
180
+
181
+ function sameLines(left: readonly string[], right: readonly string[]): boolean {
182
+ return left.length === right.length && left.every((line, index) => line === right[index]);
183
+ }
137
184
 
138
185
  function finalResult(id: string, settlement: RunSettlement): ForegroundResult {
139
186
  if (settlement.kind === "rejected") throw toError(settlement.reason);
@@ -26,7 +26,7 @@ export async function observedSettlement(
26
26
  }
27
27
 
28
28
  /**
29
- * The Result region's content for a settled Run, shared by the foreground view
29
+ * The Result region's content for a settled Run, shared by the `/yaag` view
30
30
  * and `/yaag`'s settled background view.
31
31
  */
32
32
  export function viewResult(settlement: RunSettlement): RunViewResult {
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { BackgroundWidget } from "./background-widget.ts";
3
+ import type { BackgroundStatus } from "./background-status.ts";
4
4
  import { resolveBun } from "./resolve-bun.ts";
5
5
  import { resolveCliEntry } from "./resolve-cli.ts";
6
6
  import type { RunDetails } from "./run-details.ts";
@@ -40,10 +40,10 @@ export async function run(
40
40
  readonly registry?: RunRegistry;
41
41
  readonly sent?: Sent[];
42
42
  readonly start?: (options: import("./spawn-run.ts").StartRunOptions) => RunHandle;
43
- /** A tui-mode context, for the interactive foreground path. */
43
+ /** A tui-mode context, for the inline foreground path. */
44
44
  readonly ctx?: ExtensionContext;
45
45
  readonly store?: RunTreeStore;
46
- readonly widget?: BackgroundWidget;
46
+ readonly status?: BackgroundStatus;
47
47
  } = {},
48
48
  ): Promise<{
49
49
  readonly text: string;
@@ -56,7 +56,7 @@ export async function run(
56
56
  registry: opts.registry ?? new RunRegistry(),
57
57
  ...(opts.start === undefined ? {} : { start: opts.start }),
58
58
  ...(opts.store === undefined ? {} : { store: opts.store }),
59
- ...(opts.widget === undefined ? {} : { widget: opts.widget }),
59
+ ...(opts.status === undefined ? {} : { status: opts.status }),
60
60
  sendMessage: (message, options) =>
61
61
  void opts.sent?.push({
62
62
  content: message.content,
package/src/run-tool.ts CHANGED
@@ -4,9 +4,9 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { initialSummary } from "@yaag/runtime";
6
6
  import { Type } from "typebox";
7
- import { type BackgroundWidget, createBackgroundWidget } from "./background-widget.ts";
7
+ import { type BackgroundStatus, createBackgroundStatus } from "./background-status.ts";
8
8
  import type { RunDetails } from "./run-details.ts";
9
- import { foregroundInteractive, foregroundResult, interactiveAvailable } from "./run-foreground.ts";
9
+ import { foregroundInline, foregroundResult, inlineAvailable } from "./run-foreground.ts";
10
10
  import { RunTreeComponent } from "./run-tree-component.ts";
11
11
  import { RunTreeStore } from "./run-trees.ts";
12
12
 
@@ -50,8 +50,8 @@ export interface RunToolOptions {
50
50
  readonly start?: (options: StartRunOptions) => RunHandle;
51
51
  /** Per-session renderer projections; defaulted so a test may omit it. */
52
52
  readonly store?: RunTreeStore;
53
- /** The combined inline widget; defaulted so a test may omit it. */
54
- readonly widget?: BackgroundWidget;
53
+ /** The background footer segment; defaulted so a test may omit it. */
54
+ readonly status?: BackgroundStatus;
55
55
  }
56
56
 
57
57
  const DESCRIPTION = [
@@ -90,7 +90,7 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
90
90
  const { bun, cli, registry, sendMessage } = deps;
91
91
  const start = deps.start ?? startRun;
92
92
  const store = deps.store ?? new RunTreeStore();
93
- const widget = deps.widget ?? createBackgroundWidget({ registry, store });
93
+ const status = deps.status ?? createBackgroundStatus({ registry, store });
94
94
  return {
95
95
  name: "yaag_run",
96
96
  label: "Run",
@@ -126,11 +126,10 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
126
126
 
127
127
  const id = mintRunId();
128
128
  const background = params.background === true;
129
- const interactive = interactiveAvailable(ctx, background);
129
+ const inline = inlineAvailable(ctx, background);
130
130
  // Attached before start(): the Ask ledger folds from events, so a state
131
- // created when a view opens would miss every early Ask.
131
+ // created when the frame is first drawn would miss every early Ask.
132
132
  const state = store.attach(id, background ? "background" : "foreground");
133
- const tree = interactive ? state : undefined;
134
133
  const run = registeredRun({
135
134
  bun,
136
135
  cli,
@@ -142,33 +141,28 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
142
141
  start,
143
142
  onUpdate,
144
143
  store,
145
- onIngest: () => view?.touch(),
146
144
  });
147
- let view: { touch(): void } | undefined;
148
145
 
149
146
  if (background) {
150
147
  // Attached before returning, so a Run that ends immediately still
151
148
  // announces itself (ADR-0005).
152
- void announce(id, run.outcome, registry, sendMessage, widget);
153
- widget.refresh();
149
+ void announce(id, run.outcome, registry, sendMessage, status);
150
+ status.refresh();
154
151
  return {
155
152
  content: [{ type: "text", text: `Run ${id} started in the background.` }],
156
153
  details: { summary: run.summary, id },
157
154
  };
158
155
  }
159
156
 
160
- if (tree === undefined) return await foregroundResult({ run, registry, signal });
161
- return await foregroundInteractive({
157
+ if (!inline) return await foregroundResult({ run, registry, signal });
158
+ return await foregroundInline({
162
159
  run,
163
160
  registry,
164
161
  signal,
165
162
  ctx,
166
- state: tree,
163
+ state,
167
164
  store,
168
- announce: () => void announce(id, run.outcome, registry, sendMessage, widget),
169
- onOpen: (opened) => {
170
- view = opened;
171
- },
165
+ announce: () => void announce(id, run.outcome, registry, sendMessage, status),
172
166
  });
173
167
  },
174
168
  };
@@ -197,11 +191,11 @@ async function announce(
197
191
  outcome: Promise<RunOutcome>,
198
192
  registry: RunRegistry,
199
193
  sendMessage: SendMessage,
200
- widget: BackgroundWidget,
194
+ status: BackgroundStatus,
201
195
  ): Promise<void> {
202
196
  const settlement = await observedSettlement(id, outcome, registry);
203
- // The Run left `registry.live`, so its widget entry must go with it.
204
- widget.refresh();
197
+ // The Run left `registry.live`, so the footer segment must drop it.
198
+ status.refresh();
205
199
  const content = completionContent(id, settlement);
206
200
  const details = completionDetails(id, settlement, registry);
207
201
  await sendMessage<RunDetails>(
@@ -223,8 +217,6 @@ function registeredRun(options: {
223
217
  readonly onUpdate?: (partial: { readonly content: []; readonly details: RunDetails }) => void;
224
218
  /** The projection store; it ingests each occurrence exactly once. */
225
219
  readonly store: RunTreeStore;
226
- /** Redraw request for the pushed path: fd 3 fold → ingest → requestRender. */
227
- readonly onIngest?: () => void;
228
220
  }): LiveRun {
229
221
  const handle = options.start({
230
222
  bun: options.bun,
@@ -237,7 +229,6 @@ function registeredRun(options: {
237
229
  options.registry.progress(options.id, summary);
238
230
  options.store.ingest(options.id, { summary, event, sequence, id: options.id });
239
231
  options.onUpdate?.({ content: [], details: { summary, event, sequence, id: options.id } });
240
- options.onIngest?.();
241
232
  },
242
233
  });
243
234
  const run: LiveRun = {
@@ -3,7 +3,8 @@
3
3
  * `yaag_run` tool result or its details-only progress frames.
4
4
  *
5
5
  * It is not interactive — the rpc-mode and details-only path has no input focus
6
- * (spec §4). The interactive foreground view is `run-foreground-view.ts`.
6
+ * (architecture §9). The inline frame a blocking Run draws is `run-foreground.ts`, and
7
+ * the interactive view is `/yaag` (`yaag-command.ts`).
7
8
  */
8
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
10
  import { renderTree, TreeState } from "@yaag/tui";
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * The read-only half of `RunTreeViewHost`, adapted from a pi context.
3
3
  *
4
- * Both Run surfaces the blocking foreground view and `/yaag` compose it,
5
- * so the property that a Peek observes and never sends is asserted in one
6
- * place: nothing here can prompt an Agent. The caller adds `stop`, `detach`,
7
- * and `done`.
4
+ * `/yaag` composes it, so the property that a Peek observes and never sends is
5
+ * asserted in one place: nothing here can prompt an Agent. The caller adds
6
+ * `stop` and `done`.
8
7
  */
9
8
  import { readFile } from "node:fs/promises";
10
9
  import { copyToClipboard, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
@@ -22,7 +21,7 @@ export interface RunTreeHostOptions {
22
21
  }
23
22
 
24
23
  /** The read-only capabilities every Run view shares. */
25
- export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "detach" | "done">;
24
+ export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "done">;
26
25
 
27
26
  /** Builds the read-only host; it never writes to an Agent or to the Run. */
28
27
  export function createRunTreeHost(options: RunTreeHostOptions): ReadOnlyRunTreeHost {
package/src/run-trees.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * `RunRegistry` stays the domain record — ids, stop capabilities, outcomes —
5
5
  * and this store holds the bounded renderer-only fold beside it, so the inline
6
- * widget and `/yaag` read the same state the foreground view reads. A
6
+ * frame of a blocking Run and `/yaag` read the same state. A
7
7
  * `TreeState` is bounded on every axis (node cap, Ask-ledger pruning, 2-line
8
8
  * output tails), so keeping one per Run for the session is bounded memory.
9
9
  */
10
10
  import { TreeState, type TreeUpdate } from "@yaag/tui";
11
11
 
12
- /** Which surface owns a Run: the blocking tool call, or the widget. */
12
+ /** Which surface owns a Run: the blocking tool call, or the background. */
13
13
  export type RunKind = "foreground" | "background";
14
14
 
15
15
  interface Entry {
@@ -49,8 +49,8 @@ export class RunTreeStore {
49
49
 
50
50
  /**
51
51
  * Converts a foreground Run to a background Run after a detach, then
52
- * notifies subscribers so the combined inline widget picks the Run up at
53
- * once, without waiting for a later Lifecycle Event.
52
+ * notifies subscribers so the footer segment counts the Run at once, without
53
+ * waiting for a later Lifecycle Event.
54
54
  */
55
55
  adopt(id: string): void {
56
56
  const found = this.#entries.get(id);
@@ -1,5 +1,6 @@
1
1
  /**
2
- * A tui-mode ExtensionContext double for the interactive foreground view.
2
+ * A tui-mode ExtensionContext double for the `/yaag` view and the inline
3
+ * frames of a blocking Run.
3
4
  *
4
5
  * `ui.custom` invokes the factory synchronously against a fake TUI, so a test
5
6
  * can feed key bytes and read rendered frames. `TestExtensionContext` still
@@ -62,6 +63,11 @@ export class TestTuiContext implements ExtensionContext {
62
63
  return this.ui.widgets;
63
64
  }
64
65
 
66
+ /** The latest footer segment text per key. */
67
+ get statuses(): ReadonlyMap<string, string | undefined> {
68
+ return this.ui.statuses;
69
+ }
70
+
65
71
  /** Scripted answers for `ui.select`, consumed in order. */
66
72
  get selections(): string[] {
67
73
  return this.ui.selections;
@@ -1,26 +1,19 @@
1
1
  /**
2
- * The `/yaag` command (spec §4): pick a Run of this session and open the same
3
- * interactive tree the foreground `yaag_run` opens, over the background
4
- * surface.
2
+ * The `/yaag` command (ADR-0008): pick a Run of this session and open the
3
+ * interactive tree over it.
5
4
  *
6
5
  * It replaces `/yaag-peek`'s Run→Agent→menu picker chain: every surviving
7
6
  * action lives in the tree's per-node menu, and the view stays read-only apart
8
- * from the Run's own reap-ladder stop. In a settled view `esc` closes it; in a
9
- * live view `q` opens the three-way prompt, where `Detach (keep running)`
10
- * means "close the view, the Run keeps running".
7
+ * from the Run's own reap-ladder stop. `esc` at tree level closes the view,
8
+ * live or settled, and a live Run keeps executing after that; `ctrl+q` opens
9
+ * the two-way Stop Run prompt.
11
10
  */
12
11
  import type {
13
12
  ExtensionContext,
14
13
  ExtensionUIContext,
15
14
  RegisteredCommand,
16
15
  } from "@earendil-works/pi-coding-agent";
17
- import {
18
- createRunTreeView,
19
- type RunTreeViewHost,
20
- type RunViewExit,
21
- type RunViewResult,
22
- TreeState,
23
- } from "@yaag/tui";
16
+ import { createRunTreeView, type RunTreeViewHost, type RunViewResult, TreeState } from "@yaag/tui";
24
17
  import { type PickableRun, pickableRuns, runFromLabel, runPickerLabel } from "./run-picker.ts";
25
18
  import type { RunRegistry, RunSettlement } from "./run-registry.ts";
26
19
  import { observedSettlement, viewResult } from "./run-settlement.ts";
@@ -102,17 +95,17 @@ async function openRun(
102
95
  // neither the view nor the renderer.
103
96
  let live = true;
104
97
  try {
105
- await ctx.ui.custom<RunViewExit>((tui, _theme, keybindings, done) => {
98
+ await ctx.ui.custom<void>((tui, _theme, keybindings, done) => {
106
99
  const host: RunTreeViewHost = {
107
100
  ...createRunTreeHost({ ui: ctx.ui, tui, keybindings, state }),
108
101
  stop: () => {
109
102
  if (found.state === "live") found.run.stop();
110
103
  },
111
- // The Run is already a background Run: leaving the view is the detach.
112
- detach: () => {},
113
- done,
104
+ // The Run keeps executing after the view closes; there is nothing to
105
+ // hand over, because a Run of this session is already addressable.
106
+ done: () => done(),
114
107
  };
115
- const view = createRunTreeView({ state, host, label: run.id, surface: "background" });
108
+ const view = createRunTreeView({ state, host, label: run.id });
116
109
  if (found.state === "finished")
117
110
  settle(state, view, found.run.outcome, options.registry, run.id);
118
111
  else if (found.state === "live") {
@@ -120,7 +113,7 @@ async function openRun(
120
113
  if (id === run.id && live) view.touch();
121
114
  });
122
115
  // A view opened while live must reach the settled phase itself, or `esc`
123
- // would keep live behavior after the Run ended (spec §4).
116
+ // would keep live behavior after the Run ended (ADR-0008).
124
117
  const onSettled = (settlement: RunSettlement): void => {
125
118
  if (!live) return;
126
119
  settle(state, view, settlement, options.registry, run.id);
@@ -1,107 +0,0 @@
1
- /**
2
- * The single combined inline widget for every live background Run (spec §1).
3
- *
4
- * One pi widget key holds every Run, so a Run starting or ending costs no
5
- * extra scrollback. The frame is a `string[]`, which pi never routes input to,
6
- * so the widget consumes no key by construction.
7
- */
8
- import { renderRunsWidget, type WidgetRun } from "@yaag/tui";
9
- import type { RunRegistry } from "./run-registry.ts";
10
- import type { RunTreeStore } from "./run-trees.ts";
11
-
12
- /** The one pi widget key; every Run shares it. */
13
- export const WIDGET_KEY = "yaag-runs";
14
-
15
- /** The only pi capability the widget needs. */
16
- export interface WidgetSurface {
17
- setWidget(key: string, lines: string[] | undefined): void;
18
- }
19
-
20
- /**
21
- * What the combined widget needs: the Run registry it reads live Runs from,
22
- * the projection store it reads their `TreeState` from, and the render budget.
23
- */
24
- export interface BackgroundWidgetOptions {
25
- readonly registry: RunRegistry;
26
- readonly store: RunTreeStore;
27
- readonly now?: () => number;
28
- readonly width?: number;
29
- readonly maxLines?: number;
30
- }
31
-
32
- /** The widget controller: bind it once, then refresh it on every occurrence. */
33
- export interface BackgroundWidget {
34
- /**
35
- * Binds the pi UI surface, once the Host Session has one, and draws the
36
- * current frame. A `WidgetSurface.setWidget()` that throws propagates here.
37
- */
38
- bind(surface: WidgetSurface): void;
39
- /**
40
- * Redraws the frame; drops the redraw before `bind`, and skips an unchanged
41
- * frame. A bound `WidgetSurface.setWidget()` that throws propagates here.
42
- */
43
- refresh(): void;
44
- /**
45
- * Unsubscribes from the store and clears the widget. A bound
46
- * `WidgetSurface.setWidget()` that throws propagates here.
47
- */
48
- dispose(): void;
49
- }
50
-
51
- /**
52
- * Builds the combined widget over this session's Run registry and projections.
53
- *
54
- * An unbound controller drops every refresh, and a Run without a projection is
55
- * skipped. Construction itself never throws, but `bind()`, `refresh()`, and
56
- * `dispose()` call the caller's `WidgetSurface.setWidget()` and propagate
57
- * whatever it throws; the store subscription also propagates it into the
58
- * `ingest()` that caused the redraw.
59
- */
60
- export function createBackgroundWidget(options: BackgroundWidgetOptions): BackgroundWidget {
61
- const now = options.now ?? Date.now;
62
- const width = options.width ?? 100;
63
- let surface: WidgetSurface | undefined;
64
- let drawn: readonly string[] | undefined;
65
-
66
- const entries = (): readonly WidgetRun[] => {
67
- const runs: WidgetRun[] = [];
68
- for (const run of options.registry.live) {
69
- if (options.store.kind(run.id) !== "background") continue;
70
- const state = options.store.get(run.id);
71
- if (state !== undefined) runs.push({ id: run.id, state });
72
- }
73
- return runs;
74
- };
75
-
76
- const refresh = (): void => {
77
- if (surface === undefined) return;
78
- const lines = renderRunsWidget(entries(), {
79
- now: now(),
80
- width,
81
- ...(options.maxLines === undefined ? {} : { maxLines: options.maxLines }),
82
- });
83
- if (drawn !== undefined && sameLines(drawn, lines)) return;
84
- drawn = lines;
85
- surface.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : [...lines]);
86
- };
87
-
88
- const unsubscribe = options.store.subscribe(() => refresh());
89
-
90
- return {
91
- bind(bound: WidgetSurface): void {
92
- surface = bound;
93
- drawn = undefined;
94
- refresh();
95
- },
96
- refresh,
97
- dispose(): void {
98
- unsubscribe();
99
- surface?.setWidget(WIDGET_KEY, undefined);
100
- surface = undefined;
101
- },
102
- };
103
- }
104
-
105
- function sameLines(left: readonly string[], right: readonly string[]): boolean {
106
- return left.length === right.length && left.every((line, index) => line === right[index]);
107
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * Opens the interactive Run tree for a blocking `yaag_run` in tui mode
3
- * (spec §4), and adapts the Host Session's capabilities to
4
- * `RunTreeViewHost`.
5
- *
6
- * The host surface is read-only except for the Run's own reap-ladder stop and
7
- * the detach request: nothing here can prompt an Agent (ADR — a Peek observes,
8
- * it never sends).
9
- */
10
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import {
12
- createRunTreeView,
13
- type RunTreeView,
14
- type RunTreeViewHost,
15
- type RunViewExit,
16
- type TreeState,
17
- } from "@yaag/tui";
18
- import { createRunTreeHost } from "./run-tree-host.ts";
19
-
20
- /**
21
- * What `openRunTreeView` needs to adapt one Run to the interactive view.
22
- *
23
- * The caller must supply a tui-mode `ctx` with UI; `openRunTreeView` throws
24
- * when `ctx.ui` is absent, because there is no surface to open.
25
- */
26
- export interface OpenRunTreeViewOptions {
27
- readonly ctx: ExtensionContext;
28
- /** Created and ingested from Run start, so no early Ask is missed. */
29
- readonly state: TreeState;
30
- readonly id: string;
31
- /** Runs the reap ladder for this Run (ADR-0008). */
32
- stop(): void;
33
- /** Converts the Run to a background Run. */
34
- onDetach(): void;
35
- }
36
-
37
- /** The opened view: its exit promise, plus the handles the tool drives. */
38
- export interface OpenedRunTreeView {
39
- /** Resolves when the reader dismisses or detaches the view. */
40
- readonly exit: Promise<RunViewExit>;
41
- /** The Run settled; freeze the tree and show the Result region. */
42
- settle(result: Parameters<RunTreeView["settle"]>[0]): void;
43
- /** A pushed fd 3 update landed in the TreeState; redraw. */
44
- touch(): void;
45
- /** Closes the view from the host's side, e.g. on an abort. */
46
- close(exit: RunViewExit): void;
47
- dispose(): void;
48
- }
49
-
50
- /**
51
- * Opens the view through `ctx.ui.custom()`.
52
- *
53
- * The returned `exit` promise always settles: the reader's `q`/detach resolves
54
- * it, and `close` resolves it for a host-side abort, so a tool call can never
55
- * hang on the view.
56
- */
57
- export function openRunTreeView(options: OpenRunTreeViewOptions): OpenedRunTreeView {
58
- const state = options.state;
59
- let view: RunTreeView | undefined;
60
- let finish: ((exit: RunViewExit) => void) | undefined;
61
- let pendingExit: RunViewExit | undefined;
62
- let pendingSettle: Parameters<RunTreeView["settle"]>[0] | undefined;
63
-
64
- const exit = options.ctx.ui.custom<RunViewExit>((tui, _theme, keybindings, done) => {
65
- finish = done;
66
- const host: RunTreeViewHost = {
67
- ...createRunTreeHost({ ui: options.ctx.ui, tui, keybindings, state }),
68
- stop: options.stop,
69
- detach: options.onDetach,
70
- done,
71
- };
72
- view = createRunTreeView({ state, host, label: options.id, surface: "foreground" });
73
- if (pendingSettle !== undefined) view.settle(pendingSettle);
74
- if (pendingExit !== undefined) done(pendingExit);
75
- return view;
76
- });
77
-
78
- return {
79
- exit,
80
- settle(result): void {
81
- if (view === undefined) pendingSettle = result;
82
- else view.settle(result);
83
- },
84
- touch(): void {
85
- view?.touch();
86
- },
87
- close(kind): void {
88
- if (finish === undefined) pendingExit = kind;
89
- else finish(kind);
90
- },
91
- dispose(): void {
92
- view?.dispose();
93
- },
94
- };
95
- }