@yaag/extension 0.1.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A tui-mode ExtensionContext double for the interactive foreground view.
3
+ *
4
+ * `ui.custom` invokes the factory synchronously against a fake TUI, so a test
5
+ * can feed key bytes and read rendered frames. `TestExtensionContext` still
6
+ * covers the rpc/json path, where every `ui` access must throw.
7
+ */
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ FakeExtensionUi,
11
+ type Notice,
12
+ type OpenedComponent,
13
+ type OpenedEditor,
14
+ } from "./fake-extension-ui.ts";
15
+
16
+ export type { OpenedComponent } from "./fake-extension-ui.ts";
17
+
18
+ export interface TestTuiContextOptions {
19
+ /** Terminal rows the fake TUI reports. */
20
+ readonly rows?: number;
21
+ }
22
+
23
+ /**
24
+ * A tui context whose UI is `FakeExtensionUi`: `custom`, `notify`, and
25
+ * `editor` work, and every other capability throws on access, so an
26
+ * accidental dependency fails loudly.
27
+ */
28
+ export class TestTuiContext implements ExtensionContext {
29
+ readonly mode: ExtensionContext["mode"] = "tui";
30
+ readonly hasUI = true;
31
+ readonly model: ExtensionContext["model"] = undefined;
32
+ readonly scopedModels: ExtensionContext["scopedModels"] = [];
33
+ readonly signal: ExtensionContext["signal"] = undefined;
34
+ readonly ui: FakeExtensionUi;
35
+
36
+ constructor(
37
+ readonly cwd: string,
38
+ options: TestTuiContextOptions = {},
39
+ ) {
40
+ this.ui = new FakeExtensionUi({
41
+ ...(options.rows === undefined ? {} : { rows: options.rows }),
42
+ });
43
+ }
44
+
45
+ /** The component `ui.custom` opened, once a Run has opened one. */
46
+ get opened(): OpenedComponent | undefined {
47
+ return this.ui.opened;
48
+ }
49
+
50
+ /** Notifications the view sent to the Host Session. */
51
+ get notices(): readonly Notice[] {
52
+ return this.ui.notices;
53
+ }
54
+
55
+ /** Editors the view opened. */
56
+ get edits(): readonly OpenedEditor[] {
57
+ return this.ui.edits;
58
+ }
59
+
60
+ /** The latest inline widget frame per key. */
61
+ get widgets(): ReadonlyMap<string, string[] | undefined> {
62
+ return this.ui.widgets;
63
+ }
64
+
65
+ /** Scripted answers for `ui.select`, consumed in order. */
66
+ get selections(): string[] {
67
+ return this.ui.selections;
68
+ }
69
+
70
+ get sessionManager(): ExtensionContext["sessionManager"] {
71
+ return unavailable("sessionManager");
72
+ }
73
+
74
+ get modelRegistry(): ExtensionContext["modelRegistry"] {
75
+ return unavailable("modelRegistry");
76
+ }
77
+
78
+ isIdle(): boolean {
79
+ return true;
80
+ }
81
+
82
+ isProjectTrusted(): boolean {
83
+ return true;
84
+ }
85
+
86
+ abort(): void {}
87
+
88
+ hasPendingMessages(): boolean {
89
+ return false;
90
+ }
91
+
92
+ shutdown(): void {}
93
+
94
+ getContextUsage(): ReturnType<ExtensionContext["getContextUsage"]> {
95
+ return undefined;
96
+ }
97
+
98
+ compact(): void {}
99
+
100
+ getSystemPrompt(): string {
101
+ return "";
102
+ }
103
+ }
104
+
105
+ function unavailable<T>(dependency: string): T {
106
+ throw new Error(`unexpected test context dependency: ${dependency}`);
107
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import type { RunSummary } from "@yaag/runtime";
3
+
4
+ /** pi's nested-usage shape, taken from the tool result so no extra dependency is needed. */
5
+ type Usage = NonNullable<AgentToolResult<unknown>["usage"]>;
6
+
7
+ /**
8
+ * Maps a Run Summary onto pi's nested-usage channel, so the Host Session's own
9
+ * accounting absorbs what the Run spent (ADR-0006).
10
+ *
11
+ * Undefined when the Summary's token total is unknown: pi would read a partial
12
+ * sum as a total, and an undercount is worse than a gap (ADR-0012). The Run's
13
+ * cost is only known in total, so it lands on `cost.total` alone.
14
+ */
15
+ export function toUsage(summary: RunSummary): Usage | undefined {
16
+ const tokens = summary.tokens;
17
+ if (tokens === null) return undefined;
18
+ return {
19
+ input: tokens.input,
20
+ output: tokens.output,
21
+ cacheRead: tokens.cacheRead,
22
+ cacheWrite: tokens.cacheWrite,
23
+ totalTokens: tokens.total,
24
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: summary.cost },
25
+ };
26
+ }
@@ -0,0 +1,163 @@
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.
5
+ *
6
+ * It replaces `/yaag-peek`'s Run→Agent→menu picker chain: every surviving
7
+ * 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".
11
+ */
12
+ import type {
13
+ ExtensionContext,
14
+ ExtensionUIContext,
15
+ RegisteredCommand,
16
+ } 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";
24
+ import { type PickableRun, pickableRuns, runFromLabel, runPickerLabel } from "./run-picker.ts";
25
+ import type { RunRegistry, RunSettlement } from "./run-registry.ts";
26
+ import { observedSettlement, viewResult } from "./run-settlement.ts";
27
+ import { createRunTreeHost } from "./run-tree-host.ts";
28
+ import type { RunTreeStore } from "./run-trees.ts";
29
+
30
+ type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
31
+
32
+ const DESCRIPTION = "Open the interactive Run tree for a Run of this session";
33
+
34
+ /** The per-session state `/yaag` reads: Run records plus their projections. */
35
+ export interface YaagCommandOptions {
36
+ readonly registry: RunRegistry;
37
+ readonly store: RunTreeStore;
38
+ }
39
+
40
+ /** Everything `/yaag` needs from its pi context, and nothing more. */
41
+ export interface YaagContext {
42
+ readonly mode: ExtensionContext["mode"];
43
+ readonly ui: Pick<ExtensionUIContext, "notify" | "select" | "custom" | "editor">;
44
+ }
45
+
46
+ /** Builds the `/yaag` command bound to this session's registry and projections. */
47
+ export function createYaagCommand(options: YaagCommandOptions): CommandOptions {
48
+ return {
49
+ description: DESCRIPTION,
50
+ handler: async (_args, ctx) => {
51
+ await runYaagCommand(ctx, options);
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Lists this session's Runs and opens the interactive tree for the chosen one.
58
+ *
59
+ * Resolves without opening anything when there is no Run, when the client is
60
+ * not the TUI, or when the reader cancels the picker.
61
+ */
62
+ export async function runYaagCommand(ctx: YaagContext, options: YaagCommandOptions): Promise<void> {
63
+ const runs = pickableRuns(options.registry.runs);
64
+ if (runs.length === 0) {
65
+ ctx.ui.notify("yaag: no Runs started this session.", "info");
66
+ return;
67
+ }
68
+ if (ctx.mode !== "tui") {
69
+ ctx.ui.notify("yaag: the Run tree needs the TUI; use yaag_status instead.", "warning");
70
+ return;
71
+ }
72
+ const run = await pickRun(ctx, runs);
73
+ if (run === undefined) return;
74
+ await openRun(ctx, run, options);
75
+ }
76
+
77
+ async function pickRun(
78
+ ctx: YaagContext,
79
+ runs: readonly PickableRun[],
80
+ ): Promise<PickableRun | undefined> {
81
+ const only = runs[0];
82
+ if (runs.length === 1 && only !== undefined) return only;
83
+ return runFromLabel(runs, await ctx.ui.select("yaag — pick a Run", runs.map(runPickerLabel)));
84
+ }
85
+
86
+ /**
87
+ * Opens the tree for one Run and resolves when the reader leaves it.
88
+ *
89
+ * A live Run keeps executing whatever the reader does here; a settled Run
90
+ * opens already frozen, with its Result region filled in.
91
+ */
92
+ async function openRun(
93
+ ctx: YaagContext,
94
+ run: PickableRun,
95
+ options: YaagCommandOptions,
96
+ ): Promise<void> {
97
+ const state = options.store.get(run.id) ?? TreeState.fromSummary(run.summary);
98
+ const found = options.registry.lookup(run.id);
99
+ let unsubscribe: (() => void) | undefined;
100
+ // The observer below outlives no more than the `ctx.ui.custom()` call: once
101
+ // the view exits or the UI call rejects, a later settlement must touch
102
+ // neither the view nor the renderer.
103
+ let live = true;
104
+ try {
105
+ await ctx.ui.custom<RunViewExit>((tui, _theme, keybindings, done) => {
106
+ const host: RunTreeViewHost = {
107
+ ...createRunTreeHost({ ui: ctx.ui, tui, keybindings, state }),
108
+ stop: () => {
109
+ if (found.state === "live") found.run.stop();
110
+ },
111
+ // The Run is already a background Run: leaving the view is the detach.
112
+ detach: () => {},
113
+ done,
114
+ };
115
+ const view = createRunTreeView({ state, host, label: run.id, surface: "background" });
116
+ if (found.state === "finished")
117
+ settle(state, view, found.run.outcome, options.registry, run.id);
118
+ else if (found.state === "live") {
119
+ unsubscribe = options.store.subscribe((id) => {
120
+ if (id === run.id && live) view.touch();
121
+ });
122
+ // A view opened while live must reach the settled phase itself, or `esc`
123
+ // would keep live behavior after the Run ended (spec §4).
124
+ const onSettled = (settlement: RunSettlement): void => {
125
+ if (!live) return;
126
+ settle(state, view, settlement, options.registry, run.id);
127
+ };
128
+ void observedSettlement(run.id, found.run.outcome, options.registry)
129
+ .then(onSettled, (reason: unknown) => {
130
+ onSettled({ kind: "rejected", reason });
131
+ })
132
+ // A throwing view or renderer must not become an unhandled rejection.
133
+ .catch(() => {});
134
+ }
135
+ return view;
136
+ });
137
+ } finally {
138
+ live = false;
139
+ unsubscribe?.();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Freezes the view on the Run's retained settlement, after topping the
145
+ * projection up with the registry's retained final Summary.
146
+ *
147
+ * The top-up is unconditional: an already-ended projection can still hold a
148
+ * successful Summary that the registry later converted to failed accounting,
149
+ * for example when the outcome promise rejects after fd 3 delivered `run_end`.
150
+ * Skipping the top-up there would render a `completed` header beside a failed
151
+ * Result.
152
+ */
153
+ function settle(
154
+ state: TreeState,
155
+ view: { settle(result: RunViewResult): void },
156
+ settlement: RunSettlement,
157
+ registry: RunRegistry,
158
+ id: string,
159
+ ): void {
160
+ const found = registry.lookup(id);
161
+ if (found.state === "finished") state.ingest({ summary: found.run.summary });
162
+ view.settle(viewResult(settlement));
163
+ }