@vzlentin/harness 0.2.3 → 0.3.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/README.md +10 -7
- package/dist/answer.js +2 -2
- package/dist/cli.js +29 -14
- package/dist/client.d.ts +7 -3
- package/dist/client.js +9 -5
- package/dist/context.js +2 -1
- package/dist/herdr.d.ts +11 -23
- package/dist/herdr.js +30 -28
- package/dist/host.d.ts +1 -1
- package/dist/host.js +28 -111
- package/dist/load-entries.d.ts +18 -0
- package/dist/load-entries.js +91 -0
- package/dist/mirror.d.ts +80 -0
- package/dist/mirror.js +263 -0
- package/dist/protocol.d.ts +18 -27
- package/dist/protocol.js +9 -2
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +3 -2
- 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 +296 -0
- package/pi/chat.ts +286 -0
- package/pi/index.ts +10 -8
- package/pi/mirror.ts +206 -0
- package/pi/model-sync.ts +102 -0
- package/pi/package.json +3 -2
- 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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change of a watched conversation view that a watch line carries, and its two ends: the host makes it from the
|
|
3
|
+
* chord ops of a commit, and a client applies it, so the client needs no chord. It imports only types, so the Pi
|
|
4
|
+
* extension can load it.
|
|
5
|
+
*
|
|
6
|
+
* The entries of a change are the entries from one index to the end. This is correct because no op moves the entries
|
|
7
|
+
* before the index where it starts: a splice starts at its index, a change in an entry at that entry, and a reorder or
|
|
8
|
+
* a new array at the first entry.
|
|
9
|
+
*/
|
|
10
|
+
import type { Op } from "@earendil-works/chord/delta";
|
|
11
|
+
import type { ConversationRecord, ConversationView, EntryRecord, JsonObject } from "@earendil-works/pi-durable";
|
|
12
|
+
/**
|
|
13
|
+
* The parts of a watched view that changed, as whole values: the entries from index `from` on, the documents that
|
|
14
|
+
* changed by kind (`null` for a removed document), and the conversation record. A part that did not change is absent.
|
|
15
|
+
*/
|
|
16
|
+
export type ViewParts = {
|
|
17
|
+
readonly entries?: {
|
|
18
|
+
readonly from: number;
|
|
19
|
+
readonly items: readonly EntryRecord[];
|
|
20
|
+
};
|
|
21
|
+
readonly docs?: Readonly<Record<string, JsonObject | null>>;
|
|
22
|
+
readonly conversation?: ConversationRecord;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* What one commit changed in a watched view, so a client keeps the view without chord: some parts, or the whole view,
|
|
26
|
+
* as after a watch that fell behind.
|
|
27
|
+
*/
|
|
28
|
+
export type ViewChange = ViewParts | {
|
|
29
|
+
readonly view: ConversationView;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* What `ops` changed in a watched view, as whole values of `view`, the view after them: the entries from the first one
|
|
33
|
+
* that an op reached, each document that an op reached, and the conversation record. An op on the root, or on a part
|
|
34
|
+
* that this does not know, gives the whole view.
|
|
35
|
+
*/
|
|
36
|
+
export declare function viewChange(view: ConversationView, ops: readonly Op[]): ViewChange;
|
|
37
|
+
/** `view` after `change`. A client of a watch keeps its view up to date with it. */
|
|
38
|
+
export declare function applyChange(view: ConversationView, change: ViewChange): ConversationView;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `ops` changed in a watched view, as whole values of `view`, the view after them: the entries from the first one
|
|
3
|
+
* that an op reached, each document that an op reached, and the conversation record. An op on the root, or on a part
|
|
4
|
+
* that this does not know, gives the whole view.
|
|
5
|
+
*/
|
|
6
|
+
export function viewChange(view, ops) {
|
|
7
|
+
let from;
|
|
8
|
+
const kinds = new Set();
|
|
9
|
+
let conversation = false;
|
|
10
|
+
for (const op of ops) {
|
|
11
|
+
if (op[0] === "r")
|
|
12
|
+
return { view };
|
|
13
|
+
const [part, next] = op[1];
|
|
14
|
+
if (part === "entries") {
|
|
15
|
+
const index = next !== undefined ? Number(next) : op[0] === "p" ? op[2] : 0;
|
|
16
|
+
from = Math.min(from ?? index, index);
|
|
17
|
+
}
|
|
18
|
+
else if (part === "docs" && next !== undefined) {
|
|
19
|
+
kinds.add(String(next));
|
|
20
|
+
}
|
|
21
|
+
else if (part === "conversation") {
|
|
22
|
+
conversation = true;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
return { view };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const parts = {};
|
|
29
|
+
if (from !== undefined)
|
|
30
|
+
parts.entries = { from, items: view.entries.slice(from) };
|
|
31
|
+
if (kinds.size > 0) {
|
|
32
|
+
parts.docs = Object.fromEntries([...kinds].map((kind) => [kind, view.docs[kind] ?? null]));
|
|
33
|
+
}
|
|
34
|
+
if (conversation)
|
|
35
|
+
parts.conversation = view.conversation;
|
|
36
|
+
return parts;
|
|
37
|
+
}
|
|
38
|
+
/** `view` after `change`. A client of a watch keeps its view up to date with it. */
|
|
39
|
+
export function applyChange(view, change) {
|
|
40
|
+
if ("view" in change)
|
|
41
|
+
return change.view;
|
|
42
|
+
const { entries, docs = {}, conversation } = change;
|
|
43
|
+
const changedDocs = { ...view.docs };
|
|
44
|
+
for (const [kind, doc] of Object.entries(docs)) {
|
|
45
|
+
if (doc === null)
|
|
46
|
+
delete changedDocs[kind];
|
|
47
|
+
else
|
|
48
|
+
changedDocs[kind] = doc;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
conversation: conversation ?? view.conversation,
|
|
52
|
+
entries: entries === undefined ? view.entries : [...view.entries.slice(0, entries.from), ...entries.items],
|
|
53
|
+
docs: changedDocs,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/view-state.d.ts
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the
|
|
3
|
-
* its user messages.
|
|
2
|
+
* What the Pi extension reads from a durable conversation view: its live state, its inbox, its usage and
|
|
3
|
+
* the texts of its user messages. It imports only types, so the Pi extension can load it.
|
|
4
4
|
*/
|
|
5
5
|
import type { JsonRepresentation } from "@earendil-works/chord";
|
|
6
|
-
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
7
|
-
import
|
|
8
|
-
/** The built-in document of `token` in `view`, if the conversation has it. */
|
|
9
|
-
export declare function docOf<T extends JsonObject>(view: ConversationView, token: ConversationDocToken<T>): T | undefined;
|
|
6
|
+
import type { Usage, UserMessage } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ConversationView, InboxState, LiveState, UsageState } from "@earendil-works/pi-durable";
|
|
10
8
|
export declare function liveOf(view: ConversationView): LiveState;
|
|
11
9
|
export declare function inboxOf(view: ConversationView): InboxState;
|
|
10
|
+
export declare function usageOf(view: ConversationView): UsageState | undefined;
|
|
11
|
+
/** The tokens and the cost of a conversation. */
|
|
12
|
+
export type UsageTotals = {
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
cost: number;
|
|
18
|
+
};
|
|
19
|
+
/** The sums of the usage of the model requests and of the tool results of the conversation, as Pi's footer counts. */
|
|
20
|
+
export declare function usageTotals(view: ConversationView): UsageTotals;
|
|
21
|
+
/**
|
|
22
|
+
* The usage of the last answer of the active transcript, which tells how full the context is. An aborted answer and an
|
|
23
|
+
* answer that failed do not count.
|
|
24
|
+
*/
|
|
25
|
+
export declare function lastAnswerUsage(view: ConversationView): Usage | undefined;
|
|
12
26
|
export declare function userText(content: JsonRepresentation<UserMessage["content"]>): string;
|
|
13
27
|
/** The queued steers and follow-ups, in inbox order. */
|
|
14
28
|
export declare function queued(inbox: InboxState): {
|
|
15
29
|
mode: "steer" | "followUp";
|
|
16
30
|
text: string;
|
|
17
31
|
}[];
|
|
32
|
+
/** The texts of the queued messages that an abort withdraws, steers first, as they go back to the editor. */
|
|
33
|
+
export declare function withdrawnTexts(inbox: InboxState): string[];
|
|
18
34
|
/** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
|
|
19
35
|
export declare function statusText(live: LiveState): string;
|
package/dist/view-state.js
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
return view.docs[
|
|
1
|
+
/** The built-in document of the kind `kind` in `view`, if the conversation has it. */
|
|
2
|
+
function docOf(view, kind) {
|
|
3
|
+
// SAFETY: pi-durable keeps each built-in conversation document in `docs` under its kind: `pi.live` holds a
|
|
4
|
+
// `LiveState`, `pi.inbox` an `InboxState` and `pi.usage` a `UsageState`. Each caller passes the kind of its type.
|
|
5
|
+
return view.docs[kind];
|
|
6
6
|
}
|
|
7
7
|
export function liveOf(view) {
|
|
8
|
-
return docOf(view,
|
|
8
|
+
return docOf(view, "pi.live") ?? {};
|
|
9
9
|
}
|
|
10
10
|
export function inboxOf(view) {
|
|
11
|
-
return docOf(view,
|
|
11
|
+
return docOf(view, "pi.inbox") ?? { items: [] };
|
|
12
|
+
}
|
|
13
|
+
export function usageOf(view) {
|
|
14
|
+
return docOf(view, "pi.usage");
|
|
15
|
+
}
|
|
16
|
+
/** The sums of the usage of the model requests and of the tool results of the conversation, as Pi's footer counts. */
|
|
17
|
+
export function usageTotals(view) {
|
|
18
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
19
|
+
const usage = usageOf(view);
|
|
20
|
+
for (const item of [...Object.values(usage?.models ?? {}), ...Object.values(usage?.tools ?? {})]) {
|
|
21
|
+
totals.input += item.input;
|
|
22
|
+
totals.output += item.output;
|
|
23
|
+
totals.cacheRead += item.cacheRead;
|
|
24
|
+
totals.cacheWrite += item.cacheWrite;
|
|
25
|
+
totals.cost += item.cost.total;
|
|
26
|
+
}
|
|
27
|
+
return totals;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The usage of the last answer of the active transcript, which tells how full the context is. An aborted answer and an
|
|
31
|
+
* answer that failed do not count.
|
|
32
|
+
*/
|
|
33
|
+
export function lastAnswerUsage(view) {
|
|
34
|
+
for (const entry of view.entries.toReversed()) {
|
|
35
|
+
const message = entry.model?.[0];
|
|
36
|
+
if (entry.kind !== "pi.assistant" || message?.role !== "assistant")
|
|
37
|
+
continue;
|
|
38
|
+
if (message.stopReason !== "aborted" && message.stopReason !== "error")
|
|
39
|
+
return message.usage;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
12
42
|
}
|
|
13
43
|
export function userText(content) {
|
|
14
44
|
if (!Array.isArray(content))
|
|
@@ -22,6 +52,14 @@ export function userText(content) {
|
|
|
22
52
|
export function queued(inbox) {
|
|
23
53
|
return inbox.items.flatMap((item) => item.mode === "write" ? [] : [{ mode: item.mode, text: userText(item.content) }]);
|
|
24
54
|
}
|
|
55
|
+
/** The texts of the queued messages that an abort withdraws, steers first, as they go back to the editor. */
|
|
56
|
+
export function withdrawnTexts(inbox) {
|
|
57
|
+
const inputs = queued(inbox);
|
|
58
|
+
return [
|
|
59
|
+
...inputs.filter((input) => input.mode === "steer"),
|
|
60
|
+
...inputs.filter((input) => input.mode === "followUp"),
|
|
61
|
+
].map((input) => input.text);
|
|
62
|
+
}
|
|
25
63
|
/** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
|
|
26
64
|
export function statusText(live) {
|
|
27
65
|
const generation = live.generation;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzlentin/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A durable agent harness on Pi, starting with observable, persistent subagents",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@earendil-works/chord": "1.3.0",
|
|
37
37
|
"@earendil-works/pi-ai": "1.3.0",
|
|
38
|
+
"@earendil-works/pi-codemode": "1.3.0",
|
|
38
39
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
39
40
|
"@earendil-works/pi-durable": "1.3.0",
|
|
40
41
|
"@earendil-works/pi-tui": "1.3.0",
|
package/pi/attachment.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mirror of one Pi session: the frame of its entry in Pi's chat, which holds the transcript, the status above the
|
|
3
|
+
* editor, and an editor whose interrupt and follow-up keys act on the agent while it works. The connection to the host
|
|
4
|
+
* is the `Mirror`, and the model and thinking level of the session are the `ModelSync`.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
KeybindingsManager,
|
|
10
|
+
Theme,
|
|
11
|
+
ThemeColor,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import * as coding from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { ConversationView } from "@earendil-works/pi-durable";
|
|
15
|
+
import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
16
|
+
import * as tui from "@earendil-works/pi-tui";
|
|
17
|
+
import { type AgentReporter, agentReporter } from "../dist/herdr.js";
|
|
18
|
+
import { Mirror, type MirrorStatus, type MirrorTarget, targetTitle } from "../dist/mirror.js";
|
|
19
|
+
import { type ChildInfo, childLabel, childTitle, errorMessage } from "../dist/protocol.js";
|
|
20
|
+
import { inboxOf, liveOf, queued, statusText } from "../dist/view-state.js";
|
|
21
|
+
import { ChatView } from "./chat.ts";
|
|
22
|
+
import { ModelSync } from "./model-sync.ts";
|
|
23
|
+
import { usageLine } from "./usage-line.ts";
|
|
24
|
+
|
|
25
|
+
/** The key of the status widget above the editor. */
|
|
26
|
+
const WIDGET = "harness-mirror";
|
|
27
|
+
|
|
28
|
+
export class Attachment {
|
|
29
|
+
readonly entryId: string;
|
|
30
|
+
readonly models: ModelSync;
|
|
31
|
+
// After a session switch, `#pi` and `#ctx` are stale and throw, so only code that checks `#closed` uses them. The
|
|
32
|
+
// components that Pi draws use `#theme` instead.
|
|
33
|
+
readonly #pi: ExtensionAPI;
|
|
34
|
+
readonly #ctx: ExtensionContext;
|
|
35
|
+
#theme: Theme;
|
|
36
|
+
readonly #frame: tui.Container;
|
|
37
|
+
readonly #status = new StatusArea();
|
|
38
|
+
readonly #mirror: Mirror;
|
|
39
|
+
#ui: TUI | undefined;
|
|
40
|
+
#chat: ChatView | undefined;
|
|
41
|
+
/** The state of the agent for Herdr, made when the agent is first known. */
|
|
42
|
+
#reporter: AgentReporter | undefined;
|
|
43
|
+
#expanded = false;
|
|
44
|
+
#closed = false;
|
|
45
|
+
|
|
46
|
+
constructor(pi: ExtensionAPI, ctx: ExtensionContext, entryId: string, target: MirrorTarget, frame: tui.Container) {
|
|
47
|
+
this.entryId = entryId;
|
|
48
|
+
this.#pi = pi;
|
|
49
|
+
this.#ctx = ctx;
|
|
50
|
+
this.#theme = ctx.ui.theme;
|
|
51
|
+
this.#frame = frame;
|
|
52
|
+
this.#frame.clear();
|
|
53
|
+
this.#frame.addChild(this.#line("muted", () => `Connecting to the ${targetTitle(target)}...`));
|
|
54
|
+
// Pi calls the factory at once, which gives the TUI that the tool cards need.
|
|
55
|
+
ctx.ui.setWidget(
|
|
56
|
+
WIDGET,
|
|
57
|
+
(ui, theme) => {
|
|
58
|
+
this.#ui = ui;
|
|
59
|
+
this.#theme = theme;
|
|
60
|
+
|
|
61
|
+
return this.#status;
|
|
62
|
+
},
|
|
63
|
+
{ placement: "aboveEditor" },
|
|
64
|
+
);
|
|
65
|
+
ctx.ui.setEditorComponent((ui, theme, keybindings) => new MirrorEditor(ui, theme, keybindings, this));
|
|
66
|
+
this.#mirror = new Mirror(target, (previous) => this.#update(previous));
|
|
67
|
+
this.models = new ModelSync(pi, ctx, this.#mirror, (cause) => this.#notify(cause));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get busy(): boolean {
|
|
71
|
+
return this.#mirror.busy;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sends `text` to the agent: a new turn when it is idle, else a steer, or a follow-up with `followUp`. A text that
|
|
76
|
+
* does not reach the agent goes back to the editor, after the text there, so it is not lost.
|
|
77
|
+
*/
|
|
78
|
+
send(text: string, followUp: boolean): void {
|
|
79
|
+
this.#mirror.send(text, followUp).catch((cause: unknown) => {
|
|
80
|
+
if (this.#closed) return;
|
|
81
|
+
const editor = this.#ctx.ui.getEditorText();
|
|
82
|
+
this.#ctx.ui.setEditorText([editor, text].filter((part) => part.trim() !== "").join("\n\n"));
|
|
83
|
+
this.#ctx.ui.notify(`harness: not sent: ${errorMessage(cause)}`, "error");
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Aborts the agent's turn. The queued messages that the host withdraws go back to `editor`, steers first. */
|
|
88
|
+
abort(editor: Pick<tui.Editor, "getText" | "setText">): void {
|
|
89
|
+
this.#mirror.abort().then(
|
|
90
|
+
(texts) => {
|
|
91
|
+
if (this.#closed) return;
|
|
92
|
+
const text = [...texts, editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
|
|
93
|
+
|
|
94
|
+
if (text !== editor.getText()) editor.setText(text);
|
|
95
|
+
},
|
|
96
|
+
(cause: unknown) => this.#notify(cause),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Takes the theme and the expanded state of tool output that Pi gives when it draws the entry again. */
|
|
101
|
+
restyle(theme: Theme, expanded: boolean): void {
|
|
102
|
+
this.#theme = theme;
|
|
103
|
+
this.#expanded = expanded;
|
|
104
|
+
this.#chat?.setExpanded(expanded);
|
|
105
|
+
this.showThinkingSetting();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Shows or hides the thinking of the answers, as Pi's `hideThinkingBlock` setting says. */
|
|
109
|
+
showThinkingSetting(): void {
|
|
110
|
+
if (!this.#closed) this.#chat?.setHideThinking(this.#pi.getSettings().hideThinkingBlock ?? false);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Closes the connection and gives Pi back its editor. The agent keeps running in the host. */
|
|
114
|
+
close(): void {
|
|
115
|
+
if (this.#closed) return;
|
|
116
|
+
this.#closed = true;
|
|
117
|
+
this.models.close();
|
|
118
|
+
this.#mirror.close();
|
|
119
|
+
this.#chat?.stopTimers();
|
|
120
|
+
this.#status.dispose();
|
|
121
|
+
this.#ctx.ui.setWidget(WIDGET, undefined);
|
|
122
|
+
this.#ctx.ui.setEditorComponent(undefined);
|
|
123
|
+
void this.#reporter?.release();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Shows each change of the mirror, whose status was `previous` before it. */
|
|
127
|
+
#update(previous: MirrorStatus): void {
|
|
128
|
+
const ui = this.#ui;
|
|
129
|
+
|
|
130
|
+
if (ui === undefined || this.#closed) return;
|
|
131
|
+
const { status, view, child } = this.#mirror;
|
|
132
|
+
|
|
133
|
+
if (view !== undefined && child !== undefined) this.#showTranscript(ui, view, child, previous);
|
|
134
|
+
this.#showStatus(ui, view, child);
|
|
135
|
+
|
|
136
|
+
if (status.kind === "failed" && previous.kind !== "failed") this.#showFailure(status.reason);
|
|
137
|
+
this.#reporter?.report(this.#mirror.busy);
|
|
138
|
+
|
|
139
|
+
if (child !== undefined) this.#followAgent(child);
|
|
140
|
+
ui.requestRender();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Shows the transcript under its header, and makes the Herdr reporter at the first connection. It applies `view` only
|
|
145
|
+
* on an open connection.
|
|
146
|
+
*/
|
|
147
|
+
#showTranscript(ui: TUI, view: ConversationView, child: ChildInfo, previous: MirrorStatus): void {
|
|
148
|
+
const chat = this.#chat ?? this.#startTranscript(ui, child);
|
|
149
|
+
|
|
150
|
+
if (this.#mirror.status.kind === "open") chat.apply(view);
|
|
151
|
+
// A running bash card keeps its timer until it gets a final result, and no connection gives one now.
|
|
152
|
+
else if (previous.kind === "open") chat.stopTimers();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#startTranscript(ui: TUI, first: ChildInfo): ChatView {
|
|
156
|
+
const chat = new ChatView(ui, first.cwd, (name, text) => this.#theme.fg(name, text));
|
|
157
|
+
this.#chat = chat;
|
|
158
|
+
chat.setExpanded(this.#expanded);
|
|
159
|
+
this.showThinkingSetting();
|
|
160
|
+
this.#frame.clear();
|
|
161
|
+
this.#frame.addChild(this.#line("muted", () => this.#header(this.#mirror.child ?? first)));
|
|
162
|
+
this.#frame.addChild(chat.transcript);
|
|
163
|
+
this.#reporter = agentReporter(process.env, childLabel(first), this.#mirror.target);
|
|
164
|
+
|
|
165
|
+
return chat;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Shows above the editor what the agent does, its queued messages and its usage line. */
|
|
169
|
+
#showStatus(ui: TUI, view: ConversationView | undefined, child: ChildInfo | undefined): void {
|
|
170
|
+
const { status, busy } = this.#mirror;
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
|
|
173
|
+
if (view !== undefined && status.kind === "open") {
|
|
174
|
+
for (const { mode, text } of queued(inboxOf(view))) {
|
|
175
|
+
lines.push(this.#theme.fg("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (view !== undefined && child !== undefined) {
|
|
180
|
+
lines.push(usageLine(this.#theme, this.#ctx.modelRegistry, child, view));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const working =
|
|
184
|
+
status.kind === "reconnecting"
|
|
185
|
+
? "Connecting again to the harness host..."
|
|
186
|
+
: busy && view !== undefined
|
|
187
|
+
? statusText(liveOf(view)) || "Working..."
|
|
188
|
+
: "";
|
|
189
|
+
|
|
190
|
+
this.#status.show(ui, this.#theme, working, lines);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#showFailure(reason: string): void {
|
|
194
|
+
// Without a transcript, the error takes the place of the connecting line.
|
|
195
|
+
if (this.#chat === undefined) this.#frame.clear();
|
|
196
|
+
this.#frame.addChild(this.#line("error", () => reason));
|
|
197
|
+
this.#ctx.ui.notify(`harness: ${reason}`, "error");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Gives Pi the agent's title, and on an open connection its model and thinking level. */
|
|
201
|
+
#followAgent(child: ChildInfo): void {
|
|
202
|
+
if (this.#mirror.status.kind === "open") this.models.fromAgent(child);
|
|
203
|
+
const title = childTitle(child);
|
|
204
|
+
|
|
205
|
+
if (this.#pi.getSessionName() !== title) this.#pi.setSessionName(title);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#notify(cause: unknown): void {
|
|
209
|
+
if (!this.#closed) this.#ctx.ui.notify(`harness: ${errorMessage(cause)}`, "error");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#header(child: ChildInfo): string {
|
|
213
|
+
const theme = this.#theme;
|
|
214
|
+
|
|
215
|
+
return [theme.bold(theme.fg("accent", "harness")), theme.fg("text", childTitle(child)), child.cwd].join(" │ ");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One line in the theme color `name`, colored at each render so that it follows a change of theme. */
|
|
219
|
+
#line(name: ThemeColor, text: () => string): Component {
|
|
220
|
+
return {
|
|
221
|
+
render: (width) => [tui.truncateToWidth(` ${this.#theme.fg(name, text())}`, width)],
|
|
222
|
+
invalidate: () => {},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The editor of a mirror session. While the agent works, the interrupt key (Esc) aborts its turn and the follow-up key
|
|
229
|
+
* (Alt+Enter) queues the text as a follow-up. Pi's editor handles every other key. After the key that hides thinking
|
|
230
|
+
* (Ctrl+T), which Pi applies only to its own messages, the transcript of the agent follows the setting. After the key
|
|
231
|
+
* that cycles the thinking level (Shift+Tab), the agent gets the level of Pi.
|
|
232
|
+
*/
|
|
233
|
+
class MirrorEditor extends coding.CustomEditor {
|
|
234
|
+
readonly #keys: KeybindingsManager;
|
|
235
|
+
readonly #attachment: Attachment;
|
|
236
|
+
|
|
237
|
+
constructor(ui: TUI, theme: EditorTheme, keys: KeybindingsManager, attachment: Attachment) {
|
|
238
|
+
super(ui, theme, keys);
|
|
239
|
+
this.#keys = keys;
|
|
240
|
+
this.#attachment = attachment;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
handleInput(data: string): void {
|
|
244
|
+
if (this.#attachment.busy && this.#keys.matches(data, "app.interrupt") && !this.isShowingAutocomplete()) {
|
|
245
|
+
this.#attachment.abort(this);
|
|
246
|
+
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (this.#attachment.busy && this.#keys.matches(data, "app.message.followUp")) {
|
|
251
|
+
const text = this.getExpandedText().trim();
|
|
252
|
+
|
|
253
|
+
if (text !== "") {
|
|
254
|
+
this.addToHistory(text);
|
|
255
|
+
this.setText("");
|
|
256
|
+
this.#attachment.send(text, true);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
super.handleInput(data);
|
|
263
|
+
|
|
264
|
+
if (this.#keys.matches(data, "app.thinking.toggle")) this.#attachment.showThinkingSetting();
|
|
265
|
+
|
|
266
|
+
if (this.#keys.matches(data, "app.thinking.cycle")) this.#attachment.models.piCycled();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The status of the agent above the editor: Pi's working indicator while it works, then some lines. */
|
|
271
|
+
class StatusArea extends tui.Container {
|
|
272
|
+
#loader: tui.Loader | undefined;
|
|
273
|
+
|
|
274
|
+
/** Shows `status` with a spinner, or nothing when it is empty, then each of `lines`, cut to the width. */
|
|
275
|
+
show(ui: TUI, theme: Theme, status: string, lines: readonly string[]): void {
|
|
276
|
+
if (status === "") {
|
|
277
|
+
this.dispose();
|
|
278
|
+
} else if (this.#loader === undefined) {
|
|
279
|
+
const spinner = (text: string) => theme.fg("accent", text);
|
|
280
|
+
this.#loader = new tui.Loader(ui, spinner, (text) => theme.fg("muted", text), status);
|
|
281
|
+
} else {
|
|
282
|
+
this.#loader.setMessage(status);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.clear();
|
|
286
|
+
|
|
287
|
+
if (this.#loader !== undefined) this.addChild(this.#loader);
|
|
288
|
+
|
|
289
|
+
for (const line of lines) this.addChild(new tui.TruncatedText(line, 1, 0));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
dispose(): void {
|
|
293
|
+
this.#loader?.stop();
|
|
294
|
+
this.#loader = undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|