@vzlentin/harness 0.2.3 → 0.3.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/README.md +11 -8
- package/dist/answer.js +2 -2
- package/dist/children.d.ts +5 -0
- package/dist/children.js +14 -4
- package/dist/cli.js +38 -16
- package/dist/client.d.ts +11 -7
- package/dist/client.js +14 -9
- package/dist/context.js +2 -1
- package/dist/herdr.d.ts +25 -23
- package/dist/herdr.js +93 -38
- package/dist/host.d.ts +1 -1
- package/dist/host.js +42 -219
- package/dist/load-entries.d.ts +18 -0
- package/dist/load-entries.js +91 -0
- package/dist/mirror.d.ts +95 -0
- package/dist/mirror.js +291 -0
- package/dist/protocol.d.ts +70 -44
- package/dist/protocol.js +35 -11
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +3 -2
- package/dist/targets.d.ts +67 -0
- package/dist/targets.js +140 -0
- package/dist/tool.js +1 -1
- package/dist/view-change.d.ts +38 -0
- package/dist/view-change.js +55 -0
- package/dist/view-state.d.ts +22 -6
- package/dist/view-state.js +45 -7
- package/package.json +2 -1
- package/pi/attachment.ts +303 -0
- package/pi/chat.ts +286 -0
- package/pi/index.ts +10 -8
- package/pi/mirror.ts +218 -0
- package/pi/model-sync.ts +102 -0
- package/pi/package.json +3 -2
- package/pi/program-status.ts +66 -0
- package/pi/usage-line.ts +59 -0
- package/dist/chat.d.ts +0 -48
- package/dist/chat.js +0 -208
- package/dist/chrome.d.ts +0 -18
- package/dist/chrome.js +0 -74
- package/dist/picker.d.ts +0 -22
- package/dist/picker.js +0 -70
- package/dist/theme.d.ts +0 -3
- package/dist/theme.js +0 -19
- package/dist/viewer.d.ts +0 -20
- package/dist/viewer.js +0 -307
package/pi/mirror.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror sessions: a Pi session that shows a child or the agent of a harness project in Pi's own chat, and sends what
|
|
3
|
+
* the user types to it. The agent runs in the host of the project; the session keeps only its target, in one custom
|
|
4
|
+
* entry. `/harness [project]` opens one in a new session, and `pi --harness <project> [--harness-child <handle> |
|
|
5
|
+
* --harness-conversation <id>]` in the session that Pi starts with.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionCommandContext,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
SessionEntry,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
16
|
+
import * as tui from "@earendil-works/pi-tui";
|
|
17
|
+
import { isMirrorTarget, MIRROR_ENTRY, type MirrorTarget, PI_FLAGS } from "../dist/mirror.js";
|
|
18
|
+
import { checkProject, MAIN_PROJECT, projectDir, readProjects, resolveProject } from "../dist/projects.js";
|
|
19
|
+
import { AGENT_HANDLE, errorMessage } from "../dist/protocol.js";
|
|
20
|
+
import { Attachment } from "./attachment.ts";
|
|
21
|
+
|
|
22
|
+
/** The mirror entry of a session: its ID and its target. */
|
|
23
|
+
type MirrorEntry = { readonly id: string; readonly target: MirrorTarget };
|
|
24
|
+
|
|
25
|
+
/** Registers `/harness`, `--harness` and the handlers that turn a session with a mirror entry into a mirror session. */
|
|
26
|
+
export function registerMirror(pi: ExtensionAPI): void {
|
|
27
|
+
// The frame of each mirror entry in Pi's chat. Pi can draw the entry before or after `session_start`.
|
|
28
|
+
const frames = new Map<string, tui.Container>();
|
|
29
|
+
let attached: Attachment | undefined;
|
|
30
|
+
|
|
31
|
+
const frameOf = (entryId: string): tui.Container => {
|
|
32
|
+
const frame = frames.get(entryId) ?? new tui.Container();
|
|
33
|
+
frames.set(entryId, frame);
|
|
34
|
+
|
|
35
|
+
return frame;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
pi.registerFlag(PI_FLAGS.project, {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Show the agent of this harness project (main or a project name) in the new session",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerFlag(PI_FLAGS.child, {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Show this subagent of the harness project in the new session, in place of the project agent",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.registerFlag(PI_FLAGS.conversation, {
|
|
49
|
+
type: "string",
|
|
50
|
+
description:
|
|
51
|
+
"Show this conversation of the children library of the harness project in the new session, in place of the project agent",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.registerEntryRenderer(MIRROR_ENTRY, (entry, options, theme) => {
|
|
55
|
+
if (attached?.entryId === entry.id) attached.restyle(theme, options.expanded);
|
|
56
|
+
|
|
57
|
+
return frameOf(entry.id);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.registerCommand("harness", {
|
|
61
|
+
description: "Show the agent of a harness project in a new session: /harness [project]",
|
|
62
|
+
getArgumentCompletions: projectItems,
|
|
63
|
+
handler: async (args, ctx) => {
|
|
64
|
+
let project: string | undefined;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
project = args.trim() === "" ? await pickProject(ctx) : checkProject(args.trim());
|
|
68
|
+
} catch (error) {
|
|
69
|
+
ctx.ui.notify(`harness: ${errorMessage(error)}`, "error");
|
|
70
|
+
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (project === undefined) return;
|
|
75
|
+
const target: MirrorTarget = { project, handle: AGENT_HANDLE };
|
|
76
|
+
await ctx.newSession({
|
|
77
|
+
setup: async (sessionManager) => {
|
|
78
|
+
sessionManager.appendCustomEntry(MIRROR_ENTRY, target);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("session_start", (event, ctx) => {
|
|
85
|
+
if (ctx.mode !== "tui") return;
|
|
86
|
+
const branch = ctx.sessionManager.getBranch();
|
|
87
|
+
|
|
88
|
+
// Pi draws only the entries of this branch from now on. A /reload keeps the session, and its frames.
|
|
89
|
+
for (const id of frames.keys()) {
|
|
90
|
+
if (!branch.some((entry) => entry.id === id)) frames.delete(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entry = mirrorEntry(branch) ?? (event.reason === "startup" ? flagEntry(pi, ctx) : undefined);
|
|
94
|
+
|
|
95
|
+
if (entry !== undefined) attached = new Attachment(pi, ctx, entry.id, entry.target, frameOf(entry.id));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", () => {
|
|
99
|
+
attached?.close();
|
|
100
|
+
attached = undefined;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The agent of the host answers in a mirror session, so the input goes there and not to Pi's own agent.
|
|
104
|
+
pi.on("input", (event, ctx) => {
|
|
105
|
+
if (attached === undefined) return undefined;
|
|
106
|
+
|
|
107
|
+
if (event.images !== undefined && event.images.length > 0) {
|
|
108
|
+
ctx.ui.notify("harness: the images are not sent; the agent gets only the text", "warning");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
attached.send(event.text, false);
|
|
112
|
+
|
|
113
|
+
return { action: "handled" };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// A restored model is the one of the Pi session, not a choice of the user.
|
|
117
|
+
pi.on("model_select", (event) => {
|
|
118
|
+
if (event.source !== "restore") attached?.models.piSelected(event.model.provider, event.model.id);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The newest mirror entry of `entries` with a valid target. */
|
|
123
|
+
function mirrorEntry(entries: readonly SessionEntry[]): MirrorEntry | undefined {
|
|
124
|
+
for (const entry of entries.toReversed()) {
|
|
125
|
+
if (entry.type === "custom" && entry.customType === MIRROR_ENTRY && isMirrorTarget(entry.data)) {
|
|
126
|
+
return { id: entry.id, target: entry.data };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The mirror entry for `--harness <project>`, `--harness-child <handle>` and `--harness-conversation <id>`, written in
|
|
135
|
+
* the session that Pi starts with. Without a project, it is the project of `HARNESS_PROJECT` and Pi's working
|
|
136
|
+
* directory; without a child or a conversation, the project agent. It refuses both a child and a conversation, and a
|
|
137
|
+
* session with messages, such as one of `--continue`, because Pi's own agent answered there.
|
|
138
|
+
*/
|
|
139
|
+
function flagEntry(pi: ExtensionAPI, ctx: ExtensionContext): MirrorEntry | undefined {
|
|
140
|
+
const project = stringFlag(pi, PI_FLAGS.project);
|
|
141
|
+
const child = stringFlag(pi, PI_FLAGS.child);
|
|
142
|
+
const conversation = stringFlag(pi, PI_FLAGS.conversation);
|
|
143
|
+
|
|
144
|
+
if (project === undefined && child === undefined && conversation === undefined) return undefined;
|
|
145
|
+
|
|
146
|
+
if (child && conversation) {
|
|
147
|
+
ctx.ui.notify("harness: use --harness-child or --harness-conversation, not both", "error");
|
|
148
|
+
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ctx.sessionManager.getBranch().some((entry) => entry.type === "message")) {
|
|
153
|
+
ctx.ui.notify("harness: --harness needs a new session, not one with messages", "error");
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let target: MirrorTarget;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const name = project ? checkProject(project) : resolveProject(process.env, ctx.cwd);
|
|
162
|
+
target = conversation ? { project: name, conversation } : { project: name, handle: child || AGENT_HANDLE };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
ctx.ui.notify(`harness: ${errorMessage(error)}`, "error");
|
|
165
|
+
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `appendEntry` gives no entry ID, so the entry comes back from the branch.
|
|
170
|
+
pi.appendEntry(MIRROR_ENTRY, target);
|
|
171
|
+
|
|
172
|
+
return mirrorEntry(ctx.sessionManager.getBranch());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The value of the string flag `name`, or undefined when it is not given. */
|
|
176
|
+
function stringFlag(pi: ExtensionAPI, name: string): string | undefined {
|
|
177
|
+
const flag = pi.getFlag(name);
|
|
178
|
+
|
|
179
|
+
return flag === undefined || flag === true || flag === false ? undefined : flag;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The project that the user selects for `/harness` without a project: the project of Pi's working directory first,
|
|
184
|
+
* then the other projects and `main`. It is undefined when the user cancels.
|
|
185
|
+
*/
|
|
186
|
+
async function pickProject(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
187
|
+
const projects = readProjects();
|
|
188
|
+
const current = resolveProject(process.env, ctx.cwd, projects);
|
|
189
|
+
const names = [current, ...[...Object.keys(projects), MAIN_PROJECT].filter((name) => name !== current)];
|
|
190
|
+
|
|
191
|
+
if (names.length === 1) return current;
|
|
192
|
+
const labels = names.map((name) => `${name} ${tildePath(projectDir(name, projects))}`);
|
|
193
|
+
const label = await ctx.ui.select("Harness project", labels);
|
|
194
|
+
|
|
195
|
+
return label === undefined ? undefined : names[labels.indexOf(label)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `path` with the home directory as `~`. */
|
|
199
|
+
function tildePath(path: string): string {
|
|
200
|
+
const home = homedir();
|
|
201
|
+
|
|
202
|
+
return home !== "" && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The projects whose names start with `prefix`, for the argument of `/harness`. */
|
|
206
|
+
function projectItems(prefix: string): AutocompleteItem[] | null {
|
|
207
|
+
let names: string[];
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
names = [...Object.keys(readProjects()), MAIN_PROJECT];
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const items = names.flatMap((name) => (name.startsWith(prefix) ? [{ value: name, label: name }] : []));
|
|
216
|
+
|
|
217
|
+
return items.length > 0 ? items : null;
|
|
218
|
+
}
|
package/pi/model-sync.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model and the thinking level of a mirror session. The agent in the host is the truth: the Pi session shows the
|
|
3
|
+
* agent's model and level, so Pi's footer and `/model` show them, and a change that the user makes in Pi is a request
|
|
4
|
+
* to the host. When the host refuses it, Pi goes back to the agent's model and level.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { Mirror, MirrorConfigure } from "../dist/mirror.js";
|
|
8
|
+
import type { ChildInfo } from "../dist/protocol.js";
|
|
9
|
+
|
|
10
|
+
/** What the sync uses of Pi's extension API. */
|
|
11
|
+
export type PiModels = Pick<ExtensionAPI, "setModel" | "getThinkingLevel" | "setThinkingLevel">;
|
|
12
|
+
|
|
13
|
+
/** What the sync uses of the context of the Pi session. */
|
|
14
|
+
export type PiSession = Pick<ExtensionContext, "model"> & {
|
|
15
|
+
readonly modelRegistry: Pick<ExtensionContext["modelRegistry"], "find">;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What the sync uses of the mirror: the agent, and the requests that change it. */
|
|
19
|
+
export type MirroredAgent = Pick<Mirror, "child" | "configure">;
|
|
20
|
+
|
|
21
|
+
export class ModelSync {
|
|
22
|
+
// After a session switch, `#pi` and `#ctx` are stale and throw, so nothing uses them after `close()`.
|
|
23
|
+
readonly #pi: PiModels;
|
|
24
|
+
readonly #ctx: PiSession;
|
|
25
|
+
readonly #mirror: MirroredAgent;
|
|
26
|
+
readonly #notify: (cause: unknown) => void;
|
|
27
|
+
/** True while Pi sets the agent's model: `setModel` is async, and the agent changes often while it works. */
|
|
28
|
+
#settingModel = false;
|
|
29
|
+
#closed = false;
|
|
30
|
+
|
|
31
|
+
constructor(pi: PiModels, ctx: PiSession, mirror: MirroredAgent, notify: (cause: unknown) => void) {
|
|
32
|
+
this.#pi = pi;
|
|
33
|
+
this.#ctx = ctx;
|
|
34
|
+
this.#mirror = mirror;
|
|
35
|
+
this.#notify = notify;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Gives the Pi session the model and the thinking level of `child`, the agent as the host gives it. */
|
|
39
|
+
fromAgent(child: ChildInfo): void {
|
|
40
|
+
if (this.#closed) return;
|
|
41
|
+
const model = this.#ctx.modelRegistry.find(child.provider, child.model);
|
|
42
|
+
|
|
43
|
+
if (model !== undefined && !this.#piHasModel(child) && !this.#settingModel) {
|
|
44
|
+
this.#settingModel = true;
|
|
45
|
+
// Pi sets its own thinking level when it takes a model, so the agent's level follows once Pi has the model.
|
|
46
|
+
void this.#pi.setModel(model).finally(() => {
|
|
47
|
+
this.#settingModel = false;
|
|
48
|
+
const current = this.#mirror.child;
|
|
49
|
+
|
|
50
|
+
if (current !== undefined) this.#thinkingFromAgent(current);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.#thinkingFromAgent(child);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Asks the host for the model that the user selected in Pi. */
|
|
58
|
+
piSelected(provider: string, model: string): void {
|
|
59
|
+
const child = this.#mirror.child;
|
|
60
|
+
|
|
61
|
+
if (child === undefined || (child.provider === provider && child.model === model)) return;
|
|
62
|
+
this.#configure({ provider, model });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Asks the host for the thinking level that the user cycled to in Pi, once Pi has the agent's model. Pi also changes
|
|
67
|
+
* its level when it takes a model, so only the cycle key calls this, not each change of Pi's level.
|
|
68
|
+
*/
|
|
69
|
+
piCycled(): void {
|
|
70
|
+
const child = this.#mirror.child;
|
|
71
|
+
|
|
72
|
+
if (this.#closed || child === undefined || !this.#piHasModel(child)) return;
|
|
73
|
+
const thinking = this.#pi.getThinkingLevel();
|
|
74
|
+
|
|
75
|
+
if (child.thinking !== thinking) this.#configure({ thinking });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
close(): void {
|
|
79
|
+
this.#closed = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#configure(change: MirrorConfigure): void {
|
|
83
|
+
this.#mirror.configure(change).catch((cause: unknown) => {
|
|
84
|
+
this.#notify(cause);
|
|
85
|
+
const child = this.#mirror.child;
|
|
86
|
+
|
|
87
|
+
if (child !== undefined) this.fromAgent(child);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#thinkingFromAgent(child: ChildInfo): void {
|
|
92
|
+
if (!this.#closed && this.#piHasModel(child) && this.#pi.getThinkingLevel() !== child.thinking) {
|
|
93
|
+
this.#pi.setThinkingLevel(child.thinking);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#piHasModel(child: ChildInfo): boolean {
|
|
98
|
+
const model = this.#ctx.model;
|
|
99
|
+
|
|
100
|
+
return model?.provider === child.provider && model.id === child.model;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/pi/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"private": true,
|
|
3
|
-
"description": "The Pi extension of harness: the subagent tool, the /subagent command and the
|
|
3
|
+
"description": "The Pi extension of harness: the subagent tool, the /subagent command, the subagents widget and the /harness mirror sessions",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"pi": {
|
|
6
6
|
"extensions": [
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
11
|
"@earendil-works/pi-ai": "*",
|
|
12
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
13
|
+
"@earendil-works/pi-tui": "*"
|
|
13
14
|
}
|
|
14
15
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state of the agent of a mirror session for the terminal, with the Program Status Protocol (OSC 7501): `working`
|
|
3
|
+
* while it works, `error` when its last request failed, and `idle` in all other cases.
|
|
4
|
+
*/
|
|
5
|
+
import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui";
|
|
6
|
+
import { type Mirror, targetTitle } from "../dist/mirror.js";
|
|
7
|
+
import { childTitle } from "../dist/protocol.js";
|
|
8
|
+
|
|
9
|
+
/** The program name of each report. */
|
|
10
|
+
const APP = "harness";
|
|
11
|
+
|
|
12
|
+
type Mirrored = Pick<Mirror, "target" | "status" | "child" | "view" | "busy">;
|
|
13
|
+
|
|
14
|
+
/** The title of the agent of `mirror`. */
|
|
15
|
+
function title({ target, child }: Pick<Mirrored, "target" | "child">): string {
|
|
16
|
+
return child === undefined ? targetTitle(target) : childTitle(child);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The first line of the error of the last failed request, or `Error` when there is none. */
|
|
20
|
+
function errorLine({ view }: Pick<Mirrored, "view">): string {
|
|
21
|
+
const failed = view?.entries.findLast((entry) => {
|
|
22
|
+
const message = entry.model?.[0];
|
|
23
|
+
|
|
24
|
+
return entry.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error";
|
|
25
|
+
})?.model?.[0];
|
|
26
|
+
|
|
27
|
+
const text = failed?.role === "assistant" ? failed.errorMessage : undefined;
|
|
28
|
+
|
|
29
|
+
return text?.split(/\r?\n/, 1)[0]?.trim() || "Error";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The state of the agent of `mirror`. */
|
|
33
|
+
function programStatus(mirror: Mirrored): ProgramStatus {
|
|
34
|
+
if (mirror.busy) return { state: "working", app: APP, message: title(mirror) };
|
|
35
|
+
|
|
36
|
+
if (mirror.status.kind === "open" && mirror.child?.state === "error") {
|
|
37
|
+
return { state: "error", app: APP, message: errorLine(mirror) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { state: "idle", app: APP, message: title(mirror) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ProgramStatusReport {
|
|
44
|
+
readonly #terminal: Pick<Terminal, "setProgramStatus">;
|
|
45
|
+
/** The state and the message of the last report. */
|
|
46
|
+
#last: string | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(terminal: Pick<Terminal, "setProgramStatus">) {
|
|
49
|
+
this.#terminal = terminal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reports the state of the agent of `mirror` when its state or its message changed since the last report. */
|
|
53
|
+
update(mirror: Mirrored): void {
|
|
54
|
+
const report = programStatus(mirror);
|
|
55
|
+
const key = `${report.state}\n${report.message}`;
|
|
56
|
+
|
|
57
|
+
if (key === this.#last) return;
|
|
58
|
+
this.#last = key;
|
|
59
|
+
this.#terminal.setProgramStatus(report);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reports `idle` one more time, when Pi leaves the session of `mirror`. */
|
|
63
|
+
leave(mirror: Pick<Mirrored, "target" | "child">): void {
|
|
64
|
+
this.#terminal.setProgramStatus({ state: "idle", app: APP, message: title(mirror) });
|
|
65
|
+
}
|
|
66
|
+
}
|
package/pi/usage-line.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The usage line of a mirror session, above the editor, as Pi's footer shows the usage of its own session. */
|
|
2
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import * as coding from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ConversationView } from "@earendil-works/pi-durable";
|
|
5
|
+
import { type ChildInfo, childTitle } from "../dist/protocol.js";
|
|
6
|
+
import { lastAnswerUsage, usageTotals } from "../dist/view-state.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The title of the agent, the tokens and the cost of its model requests and tool results, and the context of its last
|
|
10
|
+
* answer that did not abort or fail, against the context window of its model, as Pi's footer shows them for its own
|
|
11
|
+
* session.
|
|
12
|
+
*/
|
|
13
|
+
export function usageLine(
|
|
14
|
+
theme: Theme,
|
|
15
|
+
models: ExtensionContext["modelRegistry"],
|
|
16
|
+
child: ChildInfo,
|
|
17
|
+
view: ConversationView,
|
|
18
|
+
): string {
|
|
19
|
+
const totals = usageTotals(view);
|
|
20
|
+
const last = lastAnswerUsage(view);
|
|
21
|
+
const model = models.find(child.provider, child.model);
|
|
22
|
+
const subscription = model !== undefined && models.isUsingOAuth(model);
|
|
23
|
+
const usage: string[] = [];
|
|
24
|
+
|
|
25
|
+
if (totals.input) usage.push(`↑${formatTokens(totals.input)}`);
|
|
26
|
+
|
|
27
|
+
if (totals.output) usage.push(`↓${formatTokens(totals.output)}`);
|
|
28
|
+
|
|
29
|
+
if (totals.cacheRead) usage.push(`R${formatTokens(totals.cacheRead)}`);
|
|
30
|
+
|
|
31
|
+
if (totals.cacheWrite) usage.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
32
|
+
|
|
33
|
+
if (totals.cost || subscription) usage.push(`$${totals.cost.toFixed(3)}${subscription ? " (sub)" : ""}`);
|
|
34
|
+
const parts = [childTitle(child)];
|
|
35
|
+
|
|
36
|
+
if (usage.length > 0) parts.push(usage.join(" "));
|
|
37
|
+
const contextWindow = model?.contextWindow ?? 0;
|
|
38
|
+
|
|
39
|
+
if (contextWindow > 0) {
|
|
40
|
+
const percent = last === undefined ? 0 : (coding.calculateContextTokens(last) / contextWindow) * 100;
|
|
41
|
+
const context = `${percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
|
|
42
|
+
parts.push(percent > 90 ? theme.fg("error", context) : percent > 70 ? theme.fg("warning", context) : context);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return theme.fg("dim", parts.join(" • "));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A token count as Pi's footer shows it: `950`, `1.2k`, `45k`, `1.5M`. */
|
|
49
|
+
function formatTokens(count: number): string {
|
|
50
|
+
if (count < 1000) return count.toString();
|
|
51
|
+
|
|
52
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
53
|
+
|
|
54
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
55
|
+
|
|
56
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
57
|
+
|
|
58
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
59
|
+
}
|
package/dist/chat.d.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A durable conversation drawn with Pi's own message and tool components.
|
|
3
|
-
*
|
|
4
|
-
* Adapted from `ChatView` in mavam/pi-agents, src/ui/attach.ts. MIT License, Copyright (c) 2026 Matthias Vallentin.
|
|
5
|
-
*/
|
|
6
|
-
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
7
|
-
import type { ConversationView } from "@earendil-works/pi-durable";
|
|
8
|
-
import { Container, type TUI } from "@earendil-works/pi-tui";
|
|
9
|
-
/**
|
|
10
|
-
* A tool result as Pi shows its own. pi-durable adds the diagnostics of a call for the model as a `<harness>` block, such
|
|
11
|
-
* as `Command exited with code 3`, and the card shows them as plain lines.
|
|
12
|
-
*/
|
|
13
|
-
export declare function displayedResult(result: ToolResultMessage): ToolResultMessage;
|
|
14
|
-
/** The transcript of one conversation view, updated in place as the view changes. */
|
|
15
|
-
export declare class ChatView {
|
|
16
|
-
readonly transcript: Container;
|
|
17
|
-
/** The newest card for each call ID: providers can use a call ID again in a later turn. */
|
|
18
|
-
private readonly tools;
|
|
19
|
-
/** Every card shown, also the older ones whose call ID a later turn used again. */
|
|
20
|
-
private readonly cards;
|
|
21
|
-
/** The cards that have no final result yet. */
|
|
22
|
-
private readonly running;
|
|
23
|
-
/** The call IDs whose cards the streamed answer made; its entry takes them over. */
|
|
24
|
-
private readonly streamingCalls;
|
|
25
|
-
private readonly renderers;
|
|
26
|
-
private renderedEntryIds;
|
|
27
|
-
private streaming;
|
|
28
|
-
/** Ctrl+O shows tool output in full, as in Pi. */
|
|
29
|
-
private expanded;
|
|
30
|
-
private readonly ui;
|
|
31
|
-
private readonly cwd;
|
|
32
|
-
constructor(ui: TUI, cwd: string);
|
|
33
|
-
apply(view: ConversationView): void;
|
|
34
|
-
/** Shows tool output in full, or collapses it again. */
|
|
35
|
-
toggleExpanded(): void;
|
|
36
|
-
/** Finishes the running cards: a running bash card keeps a timer until it gets a final result. */
|
|
37
|
-
stopTimers(): void;
|
|
38
|
-
private syncTranscript;
|
|
39
|
-
private rebuild;
|
|
40
|
-
private addEntry;
|
|
41
|
-
private syncStreaming;
|
|
42
|
-
private renderer;
|
|
43
|
-
/** The newest card of a call, or a new card when the call has none. */
|
|
44
|
-
private card;
|
|
45
|
-
/** A new card of a call, also for a call ID that an earlier turn used. */
|
|
46
|
-
private newCard;
|
|
47
|
-
private addText;
|
|
48
|
-
}
|