@vzlentin/harness 0.2.2 → 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 +12 -9
- 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
package/dist/viewer.js
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { apply } from "@earendil-works/chord/delta";
|
|
3
|
-
import { getSelectListTheme, initTheme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Container, Editor, KeybindingsManager, Loader, ScrollView, setKeybindings, Text, TUI_KEYBINDINGS, TuiAltScreen, VStack, } from "@earendil-works/pi-tui";
|
|
5
|
-
import { ChatView } from "./chat.js";
|
|
6
|
-
import { footerText, headerText, Line } from "./chrome.js";
|
|
7
|
-
import { connect } from "./client.js";
|
|
8
|
-
import { agentReporter, viewerTarget } from "./herdr.js";
|
|
9
|
-
import { ModelPicker } from "./picker.js";
|
|
10
|
-
import { childLabel, runDisplayName, } from "./protocol.js";
|
|
11
|
-
import { color } from "./theme.js";
|
|
12
|
-
import { inboxOf, liveOf, queued, statusText } from "./view-state.js";
|
|
13
|
-
/** pi-tui's keys and the viewer's keys, with Pi's ids and defaults where Pi has the same action. */
|
|
14
|
-
const KEYBINDINGS = {
|
|
15
|
-
...TUI_KEYBINDINGS,
|
|
16
|
-
"app.thinking.cycle": { defaultKeys: "shift+tab", description: "Cycle thinking level" },
|
|
17
|
-
"app.model.select": { defaultKeys: "ctrl+l", description: "Select model" },
|
|
18
|
-
"app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" },
|
|
19
|
-
"app.interrupt": { defaultKeys: "escape", description: "Abort the turn" },
|
|
20
|
-
"app.message.followUp": { defaultKeys: "alt+enter", description: "Queue a follow-up message" },
|
|
21
|
-
"app.exit": { defaultKeys: "ctrl+c", description: "Detach" },
|
|
22
|
-
"app.scroll.lineUp": { defaultKeys: "shift+up", description: "Scroll up one line" },
|
|
23
|
-
"app.scroll.lineDown": { defaultKeys: "shift+down", description: "Scroll down one line" },
|
|
24
|
-
"app.scroll.pageUp": { defaultKeys: "shift+pageUp", description: "Scroll up one page" },
|
|
25
|
-
"app.scroll.pageDown": { defaultKeys: "shift+pageDown", description: "Scroll down one page" },
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* Shows the conversation of the child `handle`, or of the project agent for `agent`, in `terminal` and sends
|
|
29
|
-
* what the user types to it. It starts the host when none runs, and rejects before the terminal starts when the handle
|
|
30
|
-
* is unknown. It resolves with the exit code: 0 after Ctrl+C or when the child is stopped, 1 when the host closes the
|
|
31
|
-
* connection. The child keeps running.
|
|
32
|
-
*/
|
|
33
|
-
export async function attach(handle, options) {
|
|
34
|
-
const client = await connect({ start: true });
|
|
35
|
-
if (client === undefined)
|
|
36
|
-
throw new Error("Could not connect to the harness host");
|
|
37
|
-
let watched;
|
|
38
|
-
let models;
|
|
39
|
-
try {
|
|
40
|
-
[watched, models] = await Promise.all([client.watch(handle), client.request({ type: "models" })]);
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
client.close();
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
return new Viewer(handle, options, client, watched, models).start();
|
|
47
|
-
}
|
|
48
|
-
/** The full-screen view of one attached child: header, transcript, status, editor and footer. */
|
|
49
|
-
class Viewer {
|
|
50
|
-
tui;
|
|
51
|
-
chat;
|
|
52
|
-
scroll;
|
|
53
|
-
header = new Line();
|
|
54
|
-
working = new Container();
|
|
55
|
-
queued = new Text("", 1, 0);
|
|
56
|
-
notice = new Text("", 1, 0);
|
|
57
|
-
editor;
|
|
58
|
-
footer = new Line();
|
|
59
|
-
keybindings;
|
|
60
|
-
loader;
|
|
61
|
-
loaderText = "";
|
|
62
|
-
reporter;
|
|
63
|
-
exited;
|
|
64
|
-
#exit;
|
|
65
|
-
handle;
|
|
66
|
-
/** The title of the child and the command that opens this viewer again. */
|
|
67
|
-
target;
|
|
68
|
-
terminal;
|
|
69
|
-
client;
|
|
70
|
-
view;
|
|
71
|
-
lines;
|
|
72
|
-
child;
|
|
73
|
-
models;
|
|
74
|
-
picker;
|
|
75
|
-
done = false;
|
|
76
|
-
/** The viewer's keys. A row with a hint shows in the footer, with its first key. */
|
|
77
|
-
keys = [
|
|
78
|
-
{ id: "app.thinking.cycle", action: () => this.cycleThinking(), hint: "thinking" },
|
|
79
|
-
{ id: "app.model.select", action: () => this.pickModel(), hint: "model" },
|
|
80
|
-
{ id: "app.tools.expand", action: () => this.toggleExpanded(), hint: "expand" },
|
|
81
|
-
{
|
|
82
|
-
id: "app.exit",
|
|
83
|
-
action: () => void this.finish(0, `${this.target.title(this.child)} keeps running. Reattach: harness ${this.target.resume.join(" ")}`),
|
|
84
|
-
hint: "exit",
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
id: "app.interrupt",
|
|
88
|
-
// An idle child has no turn to abort.
|
|
89
|
-
action: () => {
|
|
90
|
-
if (this.child.state === "busy")
|
|
91
|
-
this.abort();
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
{ id: "app.message.followUp", action: () => this.followUp() },
|
|
95
|
-
{ id: "app.scroll.lineUp", action: () => this.scrollBy(-1) },
|
|
96
|
-
{ id: "app.scroll.lineDown", action: () => this.scrollBy(1) },
|
|
97
|
-
{ id: "app.scroll.pageUp", action: () => this.scrollBy(-this.pageSize()) },
|
|
98
|
-
{ id: "app.scroll.pageDown", action: () => this.scrollBy(this.pageSize()) },
|
|
99
|
-
];
|
|
100
|
-
constructor(handle, options, client, watched, models) {
|
|
101
|
-
this.handle = handle;
|
|
102
|
-
this.models = models;
|
|
103
|
-
this.terminal = options.terminal;
|
|
104
|
-
this.client = client;
|
|
105
|
-
this.target = viewerTarget(handle, client.project);
|
|
106
|
-
this.view = watched.view;
|
|
107
|
-
this.child = watched.child;
|
|
108
|
-
this.lines = watched.lines;
|
|
109
|
-
initTheme();
|
|
110
|
-
this.keybindings = new KeybindingsManager(KEYBINDINGS);
|
|
111
|
-
setKeybindings(this.keybindings);
|
|
112
|
-
this.tui = new TuiAltScreen(this.terminal, false);
|
|
113
|
-
this.chat = new ChatView(this.tui, this.child.cwd);
|
|
114
|
-
this.scroll = new ScrollView(this.chat.transcript, { follow: "end", primary: true });
|
|
115
|
-
this.editor = new Editor(this.tui, { borderColor: (text) => color("borderMuted", text), selectList: getSelectListTheme() }, { paddingX: 1 });
|
|
116
|
-
const status = new Container();
|
|
117
|
-
status.addChild(this.working);
|
|
118
|
-
status.addChild(this.queued);
|
|
119
|
-
status.addChild(this.notice);
|
|
120
|
-
this.tui.setLayoutRoot(new VStack([
|
|
121
|
-
{ component: this.header, basis: "auto" },
|
|
122
|
-
{ component: this.scroll, basis: 0, grow: 1, minSize: 3 },
|
|
123
|
-
{ component: status, basis: "auto" },
|
|
124
|
-
{ component: this.editor, basis: "auto", shrink: 1, minSize: 1 },
|
|
125
|
-
{ component: this.footer, basis: "auto" },
|
|
126
|
-
]));
|
|
127
|
-
this.footer.style = (text) => color("dim", text);
|
|
128
|
-
this.footer.hints = this.keys.flatMap(({ id, hint }) => hint === undefined ? [] : [`${this.keybindings.getKeys(id)[0]} ${hint}`]);
|
|
129
|
-
this.tui.setFocus(this.editor);
|
|
130
|
-
this.reporter = agentReporter(options.env, childLabel(this.child), handle, client.project);
|
|
131
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
132
|
-
this.exited = promise;
|
|
133
|
-
this.#exit = resolve;
|
|
134
|
-
this.editor.onSubmit = (text) => this.send(text, false);
|
|
135
|
-
this.tui.addInputListener((data) => this.handleKey(data));
|
|
136
|
-
}
|
|
137
|
-
/** Starts the terminal and the watch, and resolves with the exit code. */
|
|
138
|
-
start() {
|
|
139
|
-
this.tui.start();
|
|
140
|
-
this.render();
|
|
141
|
-
this.reporter.report(this.child.state === "busy");
|
|
142
|
-
void this.follow(this.lines);
|
|
143
|
-
return this.exited;
|
|
144
|
-
}
|
|
145
|
-
render() {
|
|
146
|
-
this.chat.apply(this.view);
|
|
147
|
-
this.header.text = headerText(this.child, this.target.title(this.child));
|
|
148
|
-
this.syncLoader(statusText(liveOf(this.view)));
|
|
149
|
-
this.queued.setText(queued(inboxOf(this.view))
|
|
150
|
-
.map(({ mode, text }) => color("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`))
|
|
151
|
-
.join("\n"));
|
|
152
|
-
this.footer.text = footerText(this.child, this.view, this.modelInfo()?.contextWindow);
|
|
153
|
-
this.tui.requestRender();
|
|
154
|
-
}
|
|
155
|
-
/** Shows Pi's working indicator with `text`, or removes it when `text` is empty. */
|
|
156
|
-
syncLoader(text) {
|
|
157
|
-
if (text === this.loaderText)
|
|
158
|
-
return;
|
|
159
|
-
this.loaderText = text;
|
|
160
|
-
this.loader?.stop();
|
|
161
|
-
this.loader = undefined;
|
|
162
|
-
this.working.clear();
|
|
163
|
-
if (text === "")
|
|
164
|
-
return;
|
|
165
|
-
this.loader = new Loader(this.tui, (value) => color("accent", value), (value) => color("muted", value), text);
|
|
166
|
-
this.working.addChild(this.loader);
|
|
167
|
-
}
|
|
168
|
-
handleKey(data) {
|
|
169
|
-
// The model picker gets every key while it shows.
|
|
170
|
-
if (this.picker !== undefined)
|
|
171
|
-
return undefined;
|
|
172
|
-
const key = this.keys.find(({ id }) => this.keybindings.matches(data, id));
|
|
173
|
-
if (key === undefined)
|
|
174
|
-
return undefined;
|
|
175
|
-
key.action();
|
|
176
|
-
return { consume: true };
|
|
177
|
-
}
|
|
178
|
-
toggleExpanded() {
|
|
179
|
-
this.chat.toggleExpanded();
|
|
180
|
-
this.tui.requestRender();
|
|
181
|
-
}
|
|
182
|
-
/** Alt+Enter: queues the editor's text as a follow-up. */
|
|
183
|
-
followUp() {
|
|
184
|
-
const text = this.editor.getText();
|
|
185
|
-
this.editor.setText("");
|
|
186
|
-
this.send(text, true);
|
|
187
|
-
}
|
|
188
|
-
pageSize() {
|
|
189
|
-
return Math.max(1, this.scroll.viewportHeight - 1);
|
|
190
|
-
}
|
|
191
|
-
scrollBy(lines) {
|
|
192
|
-
this.scroll.scrollBy(lines);
|
|
193
|
-
this.tui.requestRender();
|
|
194
|
-
}
|
|
195
|
-
modelInfo() {
|
|
196
|
-
return this.models.find((model) => model.provider === this.child.provider && model.id === this.child.model);
|
|
197
|
-
}
|
|
198
|
-
/** Shift+Tab: the next thinking level of the child's model, after the last one `off` again. */
|
|
199
|
-
cycleThinking() {
|
|
200
|
-
const levels = this.modelInfo()?.thinkingLevels ?? [];
|
|
201
|
-
const thinking = levels[(levels.indexOf(this.child.thinking) + 1) % levels.length];
|
|
202
|
-
if (levels.length < 2 || thinking === undefined) {
|
|
203
|
-
this.showNotice(`${this.child.provider}/${this.child.model} has no thinking levels`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
this.configure({ thinking });
|
|
207
|
-
}
|
|
208
|
-
/** Ctrl+L: a list of the models with a login, as the host has them now. Typing filters it, Enter picks, Esc closes. */
|
|
209
|
-
pickModel() {
|
|
210
|
-
this.client.request({ type: "models" }).then((models) => {
|
|
211
|
-
this.models = models;
|
|
212
|
-
if (this.picker !== undefined)
|
|
213
|
-
return;
|
|
214
|
-
if (models.length === 0) {
|
|
215
|
-
this.showNotice("The host has no models with a login");
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const picker = new ModelPicker(models, this.modelInfo(), (text) => color("borderMuted", text), (model) => {
|
|
219
|
-
this.closePicker();
|
|
220
|
-
this.configure({ provider: model.provider, model: model.id });
|
|
221
|
-
}, () => this.closePicker());
|
|
222
|
-
this.picker = this.tui.showOverlay(picker, { width: "70%", minWidth: 40, maxHeight: "60%" });
|
|
223
|
-
this.tui.setFocus(picker);
|
|
224
|
-
this.tui.requestRender();
|
|
225
|
-
}, this.showError);
|
|
226
|
-
}
|
|
227
|
-
closePicker() {
|
|
228
|
-
this.picker?.hide();
|
|
229
|
-
this.picker = undefined;
|
|
230
|
-
this.tui.setFocus(this.editor);
|
|
231
|
-
this.tui.requestRender();
|
|
232
|
-
}
|
|
233
|
-
configure(change) {
|
|
234
|
-
this.client.request({ type: "configure", handle: this.handle, ...change }).then((child) => {
|
|
235
|
-
this.child = child;
|
|
236
|
-
this.notice.setText("");
|
|
237
|
-
this.render();
|
|
238
|
-
}, this.showError);
|
|
239
|
-
}
|
|
240
|
-
showNotice(text) {
|
|
241
|
-
this.notice.setText(color("muted", text));
|
|
242
|
-
this.tui.requestRender();
|
|
243
|
-
}
|
|
244
|
-
/** Aborts the turn. The host withdraws the queued messages, so they go back to the editor, steers first. */
|
|
245
|
-
abort() {
|
|
246
|
-
const inputs = queued(inboxOf(this.view));
|
|
247
|
-
const texts = [
|
|
248
|
-
...inputs.filter((input) => input.mode === "steer"),
|
|
249
|
-
...inputs.filter((input) => input.mode === "followUp"),
|
|
250
|
-
].map((input) => input.text);
|
|
251
|
-
this.client.request({ type: "abort", handle: this.handle }).then(() => {
|
|
252
|
-
this.notice.setText("");
|
|
253
|
-
const text = [...texts, this.editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
|
|
254
|
-
if (text !== this.editor.getText())
|
|
255
|
-
this.editor.setText(text);
|
|
256
|
-
this.tui.requestRender();
|
|
257
|
-
}, this.showError);
|
|
258
|
-
}
|
|
259
|
-
send(message, followUp) {
|
|
260
|
-
if (message.trim() === "")
|
|
261
|
-
return;
|
|
262
|
-
this.editor.addToHistory(message);
|
|
263
|
-
const request = {
|
|
264
|
-
type: "input",
|
|
265
|
-
requestId: randomUUID(),
|
|
266
|
-
handle: this.handle,
|
|
267
|
-
message,
|
|
268
|
-
followUp,
|
|
269
|
-
};
|
|
270
|
-
this.client.request(request).then(() => this.notice.setText(""), this.showError);
|
|
271
|
-
}
|
|
272
|
-
/** Shows each event of the watch, and finishes when the connection closes. */
|
|
273
|
-
async follow(lines) {
|
|
274
|
-
for await (const line of lines)
|
|
275
|
-
this.onLine(line);
|
|
276
|
-
await this.finish(1, "The harness host closed the connection");
|
|
277
|
-
}
|
|
278
|
-
onLine(line) {
|
|
279
|
-
if (this.done)
|
|
280
|
-
return;
|
|
281
|
-
if (line.stopped) {
|
|
282
|
-
void this.finish(0, `${runDisplayName(this.child)} was stopped`);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
this.view = apply(this.view, line.ops);
|
|
286
|
-
this.child = line.child;
|
|
287
|
-
this.render();
|
|
288
|
-
this.reporter.report(this.child.state === "busy");
|
|
289
|
-
}
|
|
290
|
-
async finish(code, message) {
|
|
291
|
-
if (this.done)
|
|
292
|
-
return;
|
|
293
|
-
this.done = true;
|
|
294
|
-
this.loader?.stop();
|
|
295
|
-
this.chat.stopTimers();
|
|
296
|
-
this.tui.stop();
|
|
297
|
-
if (message !== undefined)
|
|
298
|
-
this.terminal.write(`${message}\n`);
|
|
299
|
-
this.client.close();
|
|
300
|
-
await this.reporter.release();
|
|
301
|
-
this.#exit(code);
|
|
302
|
-
}
|
|
303
|
-
showError = (cause) => {
|
|
304
|
-
this.notice.setText(color("error", `Error: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
305
|
-
this.tui.requestRender();
|
|
306
|
-
};
|
|
307
|
-
}
|