@yaag/extension 0.8.3 → 0.10.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/authoring.md +24 -0
- package/docs/cli.md +47 -0
- package/docs/examples/06-lineage.ts +33 -0
- package/docs/examples.md +17 -0
- package/package.json +4 -4
- package/src/docs/docs-root.ts +2 -1
- package/src/record/index.ts +1 -0
- package/src/record/run-program-param.ts +24 -11
- package/src/record/run-summary-parse.ts +4 -0
- package/src/testing/fake-extension-ui.ts +21 -5
- package/src/testing/fake-theme.ts +9 -4
- package/src/testing/index.ts +9 -1
- package/src/testing/run-call-render.ts +134 -17
- package/src/tool/run-tool.ts +59 -9
- package/src/view/index.ts +7 -0
- package/src/view/run-call-body.ts +27 -20
- package/src/view/run-call-params.ts +36 -0
- package/src/view/run-details.ts +29 -3
- package/src/view/run-foreground.ts +24 -4
- package/src/view/run-result-block.ts +117 -0
- package/src/view/run-status-line.ts +76 -0
- package/src/view/theme-styler.ts +34 -0
- package/src/view/yaag-command.ts +29 -8
package/docs/authoring.md
CHANGED
|
@@ -113,3 +113,27 @@ rules. ADR-0022 plans the human-in-the-loop verbs; they are not available yet.
|
|
|
113
113
|
program again and replays the recorded Asks. A resume needs the program too,
|
|
114
114
|
because a Cassette holds the history of a Run and never the program. See
|
|
115
115
|
[Examples](examples.md#05--record-and-resume).
|
|
116
|
+
|
|
117
|
+
## Lineage
|
|
118
|
+
|
|
119
|
+
`parent` names the Agent a spawn belongs under. It is data only: it sets the
|
|
120
|
+
Agent's place in the Run tree, so the TUI and the Run Summary draw the child
|
|
121
|
+
under its parent. It opens no channel between the two Agents, and it ends no
|
|
122
|
+
lifetime — every Agent still dies with the Run.
|
|
123
|
+
|
|
124
|
+
`parent` takes a Handle a previous spawn in this Run returned, and it works the
|
|
125
|
+
same way as an override on a definition: `ctx.spawn(reviewer, { parent })`. A
|
|
126
|
+
parent that already exited stays a valid parent.
|
|
127
|
+
|
|
128
|
+
An Agent cannot spawn. It can *ask* for a helper through `outputSchema`, and
|
|
129
|
+
the program decides:
|
|
130
|
+
|
|
131
|
+
<!-- embed: docs/examples/06-lineage.ts -->
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
for (const helper of wish.helpers) {
|
|
135
|
+
if (!ALLOWED_ROLES.has(helper.role)) continue;
|
|
136
|
+
const agent = await ctx.spawn({ name: helper.role, parent: implementer });
|
|
137
|
+
reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
|
|
138
|
+
}
|
|
139
|
+
```
|
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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mediated autonomy. The implementer reports which helpers it wants; the
|
|
3
|
+
* program is the gate that decides, spawns them, and places them in the tree
|
|
4
|
+
* under the implementer with `parent`.
|
|
5
|
+
*/
|
|
6
|
+
import { defineRun, prompt } from "@yaag/runtime";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
|
|
9
|
+
const ALLOWED_ROLES = new Set(["reviewer", "summarizer"]);
|
|
10
|
+
|
|
11
|
+
const Wish = Type.Object({
|
|
12
|
+
report: Type.String(),
|
|
13
|
+
helpers: Type.Array(Type.Object({ role: Type.String(), reason: Type.String() })),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export default defineRun({
|
|
17
|
+
name: "lineage",
|
|
18
|
+
description: "Spawns the helpers an implementer asks for, under the implementer.",
|
|
19
|
+
async run(ctx) {
|
|
20
|
+
const implementer = await ctx.spawn({ name: "implementer" });
|
|
21
|
+
const wish = await implementer.ask(
|
|
22
|
+
prompt`Report one paragraph about this repository, and the helper roles you want.`,
|
|
23
|
+
{ outputSchema: Wish },
|
|
24
|
+
);
|
|
25
|
+
const reports: string[] = [];
|
|
26
|
+
for (const helper of wish.helpers) {
|
|
27
|
+
if (!ALLOWED_ROLES.has(helper.role)) continue;
|
|
28
|
+
const agent = await ctx.spawn({ name: helper.role, parent: implementer });
|
|
29
|
+
reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
|
|
30
|
+
}
|
|
31
|
+
return [wish.report, ...reports].join("\n\n");
|
|
32
|
+
},
|
|
33
|
+
});
|
package/docs/examples.md
CHANGED
|
@@ -100,3 +100,20 @@ holds it.
|
|
|
100
100
|
Full file: [`examples/05-record-resume.ts`](examples/05-record-resume.ts). Run
|
|
101
101
|
it with `yaag run examples/05-record-resume.ts --record run.json`, then resume
|
|
102
102
|
it with `yaag run examples/05-record-resume.ts --resume run.json`.
|
|
103
|
+
|
|
104
|
+
## 06 — lineage
|
|
105
|
+
|
|
106
|
+
`parent` places an Agent under another Agent in the Run tree. Here the
|
|
107
|
+
implementer reports the helper roles it wants, and the program decides which of
|
|
108
|
+
them to spawn. The helpers become a subtree of the implementer, and no Agent
|
|
109
|
+
ever spawns anything itself.
|
|
110
|
+
|
|
111
|
+
<!-- embed: docs/examples/06-lineage.ts -->
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const agent = await ctx.spawn({ name: helper.role, parent: implementer });
|
|
115
|
+
reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Full file: [`examples/06-lineage.ts`](examples/06-lineage.ts). Run it with
|
|
119
|
+
`yaag run examples/06-lineage.ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.
|
|
29
|
-
"@yaag/runtime": "0.
|
|
30
|
-
"@yaag/tui": "0.
|
|
28
|
+
"@yaag/cli": "0.10.0",
|
|
29
|
+
"@yaag/runtime": "0.10.0",
|
|
30
|
+
"@yaag/tui": "0.10.0",
|
|
31
31
|
"nanoid": "^6.0.1"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
package/src/docs/docs-root.ts
CHANGED
|
@@ -18,13 +18,14 @@ export const DOC_PAGES = [
|
|
|
18
18
|
"troubleshooting.md",
|
|
19
19
|
] as const;
|
|
20
20
|
|
|
21
|
-
/** The
|
|
21
|
+
/** The shipped example programs, in reading order. */
|
|
22
22
|
export const EXAMPLE_FILES = [
|
|
23
23
|
"01-minimal.ts",
|
|
24
24
|
"02-args.ts",
|
|
25
25
|
"03-fan-out.ts",
|
|
26
26
|
"04-controlled-ask.ts",
|
|
27
27
|
"05-record-resume.ts",
|
|
28
|
+
"06-lineage.ts",
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
31
|
/** Absolute path of one shipped doc file, named relative to the docs root. */
|
package/src/record/index.ts
CHANGED
|
@@ -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.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
*
|
|
111
|
+
* Every caller-controlled branch — a file path and a program name — is
|
|
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.
|
|
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
|
-
|
|
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,
|
|
117
|
-
|
|
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
|
}
|
|
@@ -150,6 +150,10 @@ function parseAgent(value: unknown): AgentInfo | null {
|
|
|
150
150
|
nodes,
|
|
151
151
|
finishedNodesPruned: stored.finishedNodesPruned,
|
|
152
152
|
modelFallbacks: fallbacks,
|
|
153
|
+
// Lineage is tolerant for the same reason as the counters: a record written
|
|
154
|
+
// before the Parent Link carries neither field.
|
|
155
|
+
parent: isNullableString(stored.parent) ? stored.parent : null,
|
|
156
|
+
origin: stored.origin === "fork" ? ("fork" as const) : ("spawn" as const),
|
|
153
157
|
// Tolerant for the same reason as the Run-level counter.
|
|
154
158
|
modelFallbacksPruned:
|
|
155
159
|
typeof stored.modelFallbacksPruned === "number" ? stored.modelFallbacksPruned : 0,
|
|
@@ -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 =
|
|
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
|
|
79
|
-
if (
|
|
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) =>
|
|
85
|
-
input: (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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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:
|
|
35
|
-
state:
|
|
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
|
-
|
|
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
|
|
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
|
|
55
|
-
|
|
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
|
|
58
|
-
|
|
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
|
}
|
package/src/tool/run-tool.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
foregroundInline,
|
|
12
12
|
foregroundResult,
|
|
13
13
|
inlineAvailable,
|
|
14
|
-
|
|
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
|
-
|
|
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,
|
|
175
|
+
renderResult(result, options, _theme, context) {
|
|
168
176
|
const details = result.details;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
context.lastComponent instanceof RunTreeComponent
|
|
177
|
+
const block =
|
|
178
|
+
context.lastComponent instanceof RunBlockComponent
|
|
172
179
|
? context.lastComponent
|
|
173
|
-
: new
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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:
|
|
15
|
+
readonly params: RunCallParams;
|
|
12
16
|
readonly theme: Theme;
|
|
13
|
-
/** True while pi shows the
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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")) +
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/view/run-details.ts
CHANGED
|
@@ -82,6 +82,8 @@ function normalizeAgents(value: RunSummary): RunSummary {
|
|
|
82
82
|
finishedNodesPruned: agent.finishedNodesPruned ?? 0,
|
|
83
83
|
modelFallbacks: agent.modelFallbacks ?? [],
|
|
84
84
|
modelFallbacksPruned: agent.modelFallbacksPruned ?? 0,
|
|
85
|
+
parent: agent.parent ?? null,
|
|
86
|
+
origin: normalizeOrigin(agent.origin),
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
89
|
return { ...value, agents, modelFallbacks: value.modelFallbacks ?? 0 };
|
|
@@ -166,10 +168,23 @@ function isAgentBase(value: Record<string, unknown>): boolean {
|
|
|
166
168
|
optionalNodes(value.nodes) &&
|
|
167
169
|
(value.finishedNodesPruned === undefined || natural(value.finishedNodesPruned)) &&
|
|
168
170
|
optionalFallbacks(value.modelFallbacks) &&
|
|
169
|
-
(value.modelFallbacksPruned === undefined || natural(value.modelFallbacksPruned))
|
|
171
|
+
(value.modelFallbacksPruned === undefined || natural(value.modelFallbacksPruned)) &&
|
|
172
|
+
// Lineage is tolerated as absent: a details blob persisted before the
|
|
173
|
+
// Parent Link carries neither field.
|
|
174
|
+
(value.parent === undefined || nullableString(value.parent)) &&
|
|
175
|
+
(value.origin === undefined || typeof value.origin === "string")
|
|
170
176
|
);
|
|
171
177
|
}
|
|
172
178
|
|
|
179
|
+
/**
|
|
180
|
+
* An origin a newer CLI may not have shipped yet reads as "spawn", the same way
|
|
181
|
+
* a stored Run record treats it. A value outside the contract must never drop
|
|
182
|
+
* the whole details blob and blank the Run view.
|
|
183
|
+
*/
|
|
184
|
+
function normalizeOrigin(value: unknown): "spawn" | "fork" {
|
|
185
|
+
return value === "fork" ? "fork" : "spawn";
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
function optionalEvent(value: unknown): value is LifecycleEvent | undefined {
|
|
174
189
|
return value === undefined || isEvent(value);
|
|
175
190
|
}
|
|
@@ -183,7 +198,9 @@ function isEvent(value: unknown): value is LifecycleEvent {
|
|
|
183
198
|
return (
|
|
184
199
|
strings(value.agent, value.model, value.cwd) &&
|
|
185
200
|
optionalString(value.branch) &&
|
|
186
|
-
optionalString(value.sessionFile)
|
|
201
|
+
optionalString(value.sessionFile) &&
|
|
202
|
+
optionalString(value.parent) &&
|
|
203
|
+
(value.origin === undefined || typeof value.origin === "string")
|
|
187
204
|
);
|
|
188
205
|
case "ask_start":
|
|
189
206
|
return (
|
|
@@ -221,6 +238,8 @@ function isEvent(value: unknown): value is LifecycleEvent {
|
|
|
221
238
|
natural(value.attempt) &&
|
|
222
239
|
isReason(value.reason)
|
|
223
240
|
);
|
|
241
|
+
case "agent_model":
|
|
242
|
+
return strings(value.agent, value.model);
|
|
224
243
|
case "agent_usage":
|
|
225
244
|
return strings(value.agent) && isTokens(value.tokens) && number(value.cost);
|
|
226
245
|
case "agent_exit":
|
|
@@ -285,8 +304,15 @@ function isTokens(value: unknown): value is TokenBreakdown {
|
|
|
285
304
|
);
|
|
286
305
|
}
|
|
287
306
|
|
|
307
|
+
/** Every member of `RunOutcome`; `interrupted` marks a Run that died mid-flight. */
|
|
288
308
|
function isOutcome(value: unknown): boolean {
|
|
289
|
-
return
|
|
309
|
+
return (
|
|
310
|
+
value === "completed" ||
|
|
311
|
+
value === "failed" ||
|
|
312
|
+
value === "stopped" ||
|
|
313
|
+
value === "paused" ||
|
|
314
|
+
value === "interrupted"
|
|
315
|
+
);
|
|
290
316
|
}
|
|
291
317
|
|
|
292
318
|
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(
|
|
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:
|
|
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
|
+
}
|
package/src/view/yaag-command.ts
CHANGED
|
@@ -13,13 +13,21 @@ import type {
|
|
|
13
13
|
ExtensionUIContext,
|
|
14
14
|
RegisteredCommand,
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import {
|
|
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,
|
|
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
|
|
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
|
-
|
|
139
|
+
view = opened;
|
|
140
|
+
if (found.state === "restored") opened.settle(restoredViewResult(found.run.record));
|
|
122
141
|
else if (found.state === "finished")
|
|
123
|
-
settle(state,
|
|
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)
|
|
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,
|
|
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
|
|
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
|
|