@pmndrs/pine 0.0.0-stage → 0.1.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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/bin/pine.js +27 -0
  4. package/docs/SKILL.md +189 -0
  5. package/docs/references/agents.md +368 -0
  6. package/docs/references/coding.md +126 -0
  7. package/docs/references/configuration.md +229 -0
  8. package/docs/references/events.md +263 -0
  9. package/docs/references/mcp.md +257 -0
  10. package/docs/references/operations.md +280 -0
  11. package/docs/references/overview.md +186 -0
  12. package/docs/references/security.md +489 -0
  13. package/docs/references/setup.md +181 -0
  14. package/docs/references/sources.md +768 -0
  15. package/docs/references/stack.md +105 -0
  16. package/docs/references/tui.md +348 -0
  17. package/examples/home/.env.example +5 -0
  18. package/examples/home/USER.md +4 -0
  19. package/examples/home/agents/main/agent.json +4 -0
  20. package/examples/home/agents/main/workspace/AGENTS.md +14 -0
  21. package/examples/home/agents/main/workspace/MEMORY.md +4 -0
  22. package/examples/home/agents/main/workspace/SOUL.md +4 -0
  23. package/examples/home/models.json +62 -0
  24. package/examples/home/pine.json +30 -0
  25. package/examples/home/skills/README.txt +7 -0
  26. package/examples/launchd/com.pine.daemon.plist +32 -0
  27. package/mcp/browser.d.ts +34 -0
  28. package/mcp/browser.js +228 -0
  29. package/mcp/commands.d.ts +20 -0
  30. package/mcp/commands.js +88 -0
  31. package/mcp/lib.d.ts +13 -0
  32. package/mcp/lib.js +50 -0
  33. package/mcp/web.d.ts +6 -0
  34. package/mcp/web.js +140 -0
  35. package/package.json +32 -4
  36. package/src/agent-admin.d.ts +24 -0
  37. package/src/agent-admin.js +55 -0
  38. package/src/agents.d.ts +343 -0
  39. package/src/agents.js +1587 -0
  40. package/src/cli.d.ts +2 -0
  41. package/src/cli.js +526 -0
  42. package/src/config.d.ts +351 -0
  43. package/src/config.js +514 -0
  44. package/src/dev/claude-model.d.ts +28 -0
  45. package/src/dev/claude-model.js +174 -0
  46. package/src/env-file.d.ts +4 -0
  47. package/src/env-file.js +40 -0
  48. package/src/http.d.ts +46 -0
  49. package/src/http.js +407 -0
  50. package/src/limiter.d.ts +32 -0
  51. package/src/limiter.js +84 -0
  52. package/src/login.d.ts +21 -0
  53. package/src/login.js +126 -0
  54. package/src/manual.d.ts +61 -0
  55. package/src/manual.js +425 -0
  56. package/src/mcp-config.d.ts +40 -0
  57. package/src/mcp-config.js +132 -0
  58. package/src/net.d.ts +36 -0
  59. package/src/net.js +122 -0
  60. package/src/pine.d.ts +32 -0
  61. package/src/pine.js +142 -0
  62. package/src/router.d.ts +56 -0
  63. package/src/router.js +164 -0
  64. package/src/sandbox-fs.d.ts +27 -0
  65. package/src/sandbox-fs.js +172 -0
  66. package/src/security.d.ts +121 -0
  67. package/src/security.js +363 -0
  68. package/src/skills.d.ts +67 -0
  69. package/src/skills.js +163 -0
  70. package/src/source-types.d.ts +61 -0
  71. package/src/source-types.js +1 -0
  72. package/src/sources/command.d.ts +4 -0
  73. package/src/sources/command.js +77 -0
  74. package/src/sources/folder.d.ts +6 -0
  75. package/src/sources/folder.js +116 -0
  76. package/src/sources/ftp.d.ts +4 -0
  77. package/src/sources/ftp.js +307 -0
  78. package/src/sources/http.d.ts +4 -0
  79. package/src/sources/http.js +201 -0
  80. package/src/sources/mqtt.d.ts +18 -0
  81. package/src/sources/mqtt.js +97 -0
  82. package/src/sources/telegram.d.ts +22 -0
  83. package/src/sources/telegram.js +269 -0
  84. package/src/sources/webhook.d.ts +4 -0
  85. package/src/sources/webhook.js +52 -0
  86. package/src/sources.d.ts +339 -0
  87. package/src/sources.js +955 -0
  88. package/src/store.d.ts +89 -0
  89. package/src/store.js +251 -0
  90. package/src/timers.d.ts +83 -0
  91. package/src/timers.js +318 -0
  92. package/src/tools.d.ts +6 -0
  93. package/src/tools.js +777 -0
  94. package/src/tui/app.d.ts +209 -0
  95. package/src/tui/app.js +2223 -0
  96. package/src/tui/client.d.ts +178 -0
  97. package/src/tui/client.js +116 -0
  98. package/src/tui/code-pane.d.ts +51 -0
  99. package/src/tui/code-pane.js +398 -0
  100. package/src/tui/highlight.d.ts +8 -0
  101. package/src/tui/highlight.js +86 -0
  102. package/src/tui/picture.d.ts +141 -0
  103. package/src/tui/picture.js +571 -0
  104. package/src/tui/run.d.ts +8 -0
  105. package/src/tui/run.js +97 -0
  106. package/src/tui/theme.d.ts +32 -0
  107. package/src/tui/theme.js +128 -0
  108. package/src/types.d.ts +124 -0
  109. package/src/types.js +1 -0
  110. package/src/update.d.ts +25 -0
  111. package/src/update.js +110 -0
  112. package/src/util.d.ts +32 -0
  113. package/src/util.js +116 -0
  114. package/src/watching.d.ts +20 -0
  115. package/src/watching.js +68 -0
  116. package/src/workspace-view.d.ts +38 -0
  117. package/src/workspace-view.js +160 -0
package/src/tui/app.js ADDED
@@ -0,0 +1,2223 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { CombinedAutocompleteProvider, Container, Editor, isKeyRelease, isKeyRepeat, matchesKey, fuzzyFilter, getCapabilities, HStack, hyperlink, ScrollView, setCapabilities, sliceByColumn, SelectList, Spacer, stripTerminalSequences, Text, truncateToWidth, TuiAltScreen, visibleWidth, VStack, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
6
+ import { errText, expandHome } from "../util.js";
7
+ import { CodePane, PANE_VIEWS } from "./code-pane.js";
8
+ import { isImageFile, isVideoFile, mimeOf, Picture, picturesAsText, picturesIn, prepare, prepareVideo, Slot, stopVideos, Video } from "./picture.js";
9
+ import { Answer, c, editorTheme, selectTheme, SPINNER } from "./theme.js";
10
+ /** Below this many columns the code pane takes the whole width instead of half of it. */
11
+ const SIDE_BY_SIDE = 100;
12
+ const RULE = Array.from({ length: 400 }, () => c.gray("│"));
13
+ const tilde = (p) => (p === os.homedir() ? "~" : p.startsWith(`${os.homedir()}/`) ? `~${p.slice(os.homedir().length)}` : p);
14
+ /** The library's name for "this component lays out children". */
15
+ const LAYOUT = Symbol.for("@earendil-works/pi-tui/layout-node");
16
+ /**
17
+ * A conversation with the code pane beside it. The pane gets half the width (the library's own
18
+ * sharing of free room is not an even split), or all of it where two halves would be too narrow.
19
+ */
20
+ class Split extends HStack {
21
+ side;
22
+ columns;
23
+ constructor(main, rule, side, open, columns) {
24
+ super([
25
+ { component: main, basis: 0, grow: 1, shrink: 1, minSize: 1, visible: (v) => !(open() && v.width < SIDE_BY_SIDE) },
26
+ { component: rule, basis: 1, grow: 0, shrink: 0, visible: (v) => open() && v.width >= SIDE_BY_SIDE },
27
+ { component: side, basis: 0, grow: 1, shrink: 1, minSize: 1, visible: () => open() },
28
+ ]);
29
+ this.side = side;
30
+ this.columns = columns;
31
+ }
32
+ [LAYOUT]() {
33
+ const node = HStack.prototype[LAYOUT].call(this);
34
+ const width = this.columns();
35
+ if (width < SIDE_BY_SIDE)
36
+ return node;
37
+ const side = Math.max(44, Math.min(96, Math.floor(width / 2)));
38
+ return { ...node, entries: node.entries.map((e) => (e.component === this.side ? { ...e, basis: side, grow: 0, shrink: 0 } : e)) };
39
+ }
40
+ }
41
+ /**
42
+ * What a conversation shows. A real picture can only be drawn at the left edge of a full line;
43
+ * with a pane beside the conversation it would lie across that pane, so pictures are drawn as
44
+ * text for as long as the pane is there.
45
+ */
46
+ class Doc extends Container {
47
+ asText = () => false;
48
+ render(width) {
49
+ const lines = super.render(width);
50
+ return this.asText() ? picturesAsText(lines, () => true) : lines;
51
+ }
52
+ }
53
+ /** A one-line (or few-line) piece of chrome whose first line has clickable stretches. */
54
+ function clickable(render, hits, row = 0) {
55
+ return {
56
+ invalidate() { },
57
+ render,
58
+ handleMouse(e) {
59
+ if (e.y !== row || e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
60
+ return undefined;
61
+ const hit = hits().find((h) => e.x >= h.from && e.x < h.to);
62
+ if (!hit)
63
+ return undefined; // leave everything else to text selection
64
+ if (e.type === "click")
65
+ hit.run();
66
+ return { handled: true, render: true };
67
+ },
68
+ };
69
+ }
70
+ /** Builds a line from pieces and remembers where the clickable ones ended up. */
71
+ class Line {
72
+ text = "";
73
+ width = 0;
74
+ hits = [];
75
+ add(styled, run) {
76
+ const w = visibleWidth(styled);
77
+ if (run)
78
+ this.hits.push({ from: this.width, to: this.width + w, run });
79
+ this.text += styled;
80
+ this.width += w;
81
+ return this;
82
+ }
83
+ }
84
+ const MAX_ATTACHMENT = 20 * 1024 * 1024;
85
+ const URL_CHARS = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]/;
86
+ /**
87
+ * Web addresses in a block of plain screen lines, with the cells each one covers. A long address
88
+ * is wrapped over several lines; a line that runs to the right edge is read together with the next,
89
+ * and every piece of the address points to the whole of it.
90
+ */
91
+ export function findLinks(lines, width) {
92
+ const full = (l) => visibleWidth(l.replace(/\s+$/, "")) >= width - 2;
93
+ const out = [];
94
+ for (let first = 0; first < lines.length;) {
95
+ // One logical run: `first` and the lines it wraps into.
96
+ const parts = [];
97
+ let text = "";
98
+ let i = first;
99
+ for (; i < lines.length; i++) {
100
+ const line = lines[i].replace(/\s+$/, "");
101
+ const pad = i === first ? 0 : line.length - line.replace(/^ +/, "").length;
102
+ parts.push({ line: i, start: text.length, pad, text: line.slice(pad) });
103
+ text += line.slice(pad);
104
+ if (!full(lines[i]))
105
+ break;
106
+ }
107
+ for (const m of text.matchAll(/https?:\/\/[^\s<>"`]+/g)) {
108
+ let url = m[0];
109
+ while (url.length && (!URL_CHARS.test(url.at(-1)) || /[.,;:!?'")\]]/.test(url.at(-1)))) {
110
+ // Closing punctuation belongs to the sentence, unless the address itself opened the bracket.
111
+ if (url.at(-1) === ")" && url.split("(").length > url.split(")").length - 1)
112
+ break;
113
+ url = url.slice(0, -1);
114
+ }
115
+ if (url.length < 10)
116
+ continue;
117
+ const end = m.index + url.length;
118
+ for (const part of parts) {
119
+ const a = Math.max(m.index, part.start);
120
+ const z = Math.min(end, part.start + part.text.length);
121
+ if (a >= z)
122
+ continue;
123
+ const before = " ".repeat(part.pad) + part.text.slice(0, a - part.start);
124
+ out.push({ line: part.line, from: visibleWidth(before), to: visibleWidth(before) + visibleWidth(part.text.slice(a - part.start, z - part.start)), url });
125
+ }
126
+ }
127
+ first = Math.min(i, lines.length - 1) + 1;
128
+ }
129
+ return out;
130
+ }
131
+ /** The address under a cell, if any. */
132
+ export function urlAt(lines, y, x, width) {
133
+ return findLinks(lines, width).find((l) => l.line === y && x >= l.from && x < l.to)?.url;
134
+ }
135
+ /**
136
+ * Makes the web addresses in a component clickable. The text is not changed: an address is
137
+ * always shown in full, and what is shown is what a click opens.
138
+ */
139
+ class Linked {
140
+ inner;
141
+ constructor(inner) {
142
+ this.inner = inner;
143
+ }
144
+ invalidate() {
145
+ this.inner.invalidate();
146
+ }
147
+ handleMouse(e) {
148
+ return this.inner.handleMouse?.(e);
149
+ }
150
+ render(width) {
151
+ const lines = this.inner.render(width);
152
+ const spans = findLinks(lines.map((l) => stripTerminalSequences(l)), width);
153
+ if (!spans.length)
154
+ return lines;
155
+ const out = [...lines];
156
+ // Right to left, so earlier columns stay valid while a line is being rebuilt.
157
+ for (const sp of spans.sort((p, q) => q.line - p.line || q.from - p.from)) {
158
+ const line = out[sp.line];
159
+ const total = visibleWidth(line);
160
+ out[sp.line] = `${sliceByColumn(line, 0, sp.from)}${hyperlink(sliceByColumn(line, sp.from, sp.to - sp.from), sp.url)}${sliceByColumn(line, sp.to, Math.max(0, total - sp.to))}`;
161
+ }
162
+ return out;
163
+ }
164
+ }
165
+ /**
166
+ * Text that a terminal pastes when files are dropped on it: one or more paths, with spaces
167
+ * escaped or the path in quotes, sometimes as file:// URLs. Returns the files (with `folders`: the folders) only when every
168
+ * piece of the text is an existing one; otherwise the text is ordinary input.
169
+ */
170
+ export function droppedFiles(text, folders = false) {
171
+ const tokens = [];
172
+ let cur = "";
173
+ let quote = "";
174
+ for (let i = 0; i < text.length; i++) {
175
+ const ch = text[i];
176
+ if (quote) {
177
+ if (ch === quote)
178
+ quote = "";
179
+ else
180
+ cur += ch;
181
+ }
182
+ else if (ch === "'" || ch === '"')
183
+ quote = ch;
184
+ else if (ch === "\\" && i + 1 < text.length)
185
+ cur += text[++i];
186
+ else if (/\s/.test(ch)) {
187
+ if (cur)
188
+ tokens.push(cur);
189
+ cur = "";
190
+ }
191
+ else
192
+ cur += ch;
193
+ }
194
+ if (cur)
195
+ tokens.push(cur);
196
+ if (!tokens.length)
197
+ return null;
198
+ const files = [];
199
+ for (const t of tokens) {
200
+ let file = t;
201
+ try {
202
+ if (t.startsWith("file://"))
203
+ file = decodeURIComponent(new URL(t).pathname);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ file = expandHome(file);
209
+ try {
210
+ const stat = path.isAbsolute(file) ? fs.statSync(file) : undefined;
211
+ if (!stat || !(folders ? stat.isDirectory() : stat.isFile()))
212
+ return null;
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ files.push(file);
218
+ }
219
+ return files;
220
+ }
221
+ /**
222
+ * Entries that match what was typed: every word must occur somewhere in the name or description,
223
+ * in any position ("dee" finds opencode-go/deepseek-v4.1-flash). If nothing matches that way,
224
+ * fall back to loose matching so a typo still finds something.
225
+ */
226
+ export function searchItems(items, query) {
227
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
228
+ if (!words.length)
229
+ return items;
230
+ const text = (x) => `${x.label} ${x.description ?? ""}`.toLowerCase();
231
+ const hits = items.filter((x) => words.every((w) => text(x).includes(w)));
232
+ if (!hits.length)
233
+ return fuzzyFilter(items, query, (x) => x.label);
234
+ // Names that start with the query, or have it right after a separator, come first.
235
+ const rank = (x) => (x.label.toLowerCase().startsWith(words[0]) ? 0 : new RegExp(`[/._ -]${words[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(x.label.toLowerCase()) ? 1 : 2);
236
+ return hits.map((x, i) => ({ x, i })).sort((p, q) => rank(p.x) - rank(q.x) || p.i - q.i).map((p) => p.x);
237
+ }
238
+ /** A line of the conversation that does something when it is clicked. */
239
+ class Button {
240
+ text;
241
+ run;
242
+ constructor(text, run) {
243
+ this.text = text;
244
+ this.run = run;
245
+ }
246
+ setText(text) {
247
+ this.text.setText(text);
248
+ }
249
+ invalidate() {
250
+ this.text.invalidate();
251
+ }
252
+ render(width) {
253
+ return this.text.render(width);
254
+ }
255
+ handleMouse(e) {
256
+ if (e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
257
+ return undefined;
258
+ if (e.type === "click")
259
+ this.run();
260
+ return { handled: true, render: true };
261
+ }
262
+ }
263
+ const SCOPES = ["local", "agent", "global"];
264
+ const SCOPE_HELP = { local: "only this conversation, until it is closed", agent: "the agent, in its main conversation", global: "every agent" };
265
+ const PANE_HELP = { changes: "what git says changed in the folder, with the diff of each file", files: "the files of the folder", output: "what the agent's tool calls printed", off: "close the pane" };
266
+ const childKind = (kind) => (kind === "subagent" ? "sub-agent" : kind === "delegate" ? "delegated task" : kind);
267
+ /** A list to choose from, opened over the conversation. Type to narrow it down. */
268
+ class Picker {
269
+ title;
270
+ items;
271
+ list;
272
+ filter = "";
273
+ done;
274
+ constructor(title, items, done, current) {
275
+ this.title = title;
276
+ this.items = items;
277
+ this.done = done;
278
+ this.build(current);
279
+ }
280
+ /** The list component only matches from the start of a value, so the narrowing is done here. */
281
+ build(current) {
282
+ const shown = searchItems(this.items, this.filter);
283
+ // Give the names the room they need, so descriptions are not cut short by an empty column.
284
+ const primary = Math.min(44, Math.max(10, ...this.items.map((x) => visibleWidth(x.label))) + 2);
285
+ this.list = new SelectList(shown, 12, selectTheme, { minPrimaryColumnWidth: primary, maxPrimaryColumnWidth: primary });
286
+ const i = shown.findIndex((x) => x.value === current);
287
+ if (i >= 0)
288
+ this.list.setSelectedIndex(i);
289
+ this.list.onSelect = (item) => this.done(item.value);
290
+ this.list.onCancel = () => this.done(undefined);
291
+ }
292
+ invalidate() {
293
+ this.list.invalidate();
294
+ }
295
+ handleInput(data) {
296
+ if (matchesKey(data, "escape"))
297
+ return this.done(undefined);
298
+ if (matchesKey(data, "backspace"))
299
+ this.filter = this.filter.slice(0, -1);
300
+ else if (data.length === 1 && data >= " " && data !== "\x7f")
301
+ this.filter += data;
302
+ else
303
+ return this.list.handleInput(data);
304
+ this.build();
305
+ }
306
+ handleMouse(e) {
307
+ // The list starts below the top border, the title and the filter line, one column in.
308
+ const r = this.list.handleMouse({ ...e, x: e.x - 2, y: e.y - 3 });
309
+ return r ?? { handled: true };
310
+ }
311
+ render(width) {
312
+ const inner = Math.max(20, width - 4);
313
+ const pad = (l) => `${c.gray("│")} ${truncateToWidth(l, inner)}${" ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(l, inner))))} ${c.gray("│")}`;
314
+ return [
315
+ c.gray(`╭${"─".repeat(inner + 2)}╮`),
316
+ pad(c.bold(this.title)),
317
+ pad(`${c.accent(">")} ${this.filter}${c.gray(this.filter ? "" : "type to search · enter picks · esc closes")}`),
318
+ ...this.list.render(inner).map(pad),
319
+ c.gray(`╰${"─".repeat(inner + 2)}╯`),
320
+ ];
321
+ }
322
+ }
323
+ const since = (ms) => {
324
+ const min = Math.max(0, Math.round((Date.now() - ms) / 60_000));
325
+ return min < 1 ? "just now" : min < 60 ? `${min} min ago` : min < 48 * 60 ? `${Math.round(min / 60)} h ago` : `${Math.round(min / 1440)} days ago`;
326
+ };
327
+ const until = (ms) => {
328
+ const hours = Math.round((ms - Date.now()) / 3_600_000);
329
+ return hours < 1 ? "goes soon" : hours < 48 ? `goes in ${hours} h` : `goes in ${Math.round(hours / 24)} days`;
330
+ };
331
+ /**
332
+ * An agent's conversations, opened over the screen: open one, pin it so that it stays, or remove
333
+ * it with the × at its end (asked twice, because it cannot be undone). Type to narrow the list.
334
+ */
335
+ class ConversationList {
336
+ title;
337
+ items;
338
+ act;
339
+ filter = "";
340
+ /** 0 is "+ new conversation", the conversations follow. */
341
+ selected = 0;
342
+ top = 0;
343
+ /** The conversation whose × was clicked once. */
344
+ asked;
345
+ /** Where the words "pin" and "×" of each row on screen are: columns from, to (inclusive). */
346
+ hits = new Map();
347
+ static ROWS = 12;
348
+ constructor(title, items, current, act) {
349
+ this.title = title;
350
+ this.items = items;
351
+ this.act = act;
352
+ this.selected = Math.max(0, items.findIndex((x) => x.session === current) + 1);
353
+ }
354
+ invalidate() { }
355
+ shown() {
356
+ const f = this.filter.toLowerCase();
357
+ return f ? this.items.filter((x) => x.session.toLowerCase().includes(f)) : this.items;
358
+ }
359
+ get conv() {
360
+ return this.shown()[this.selected - 1];
361
+ }
362
+ async pin(conv) {
363
+ if (conv.session === "main")
364
+ return;
365
+ conv.pinned = await this.act.pin(conv);
366
+ this.act.redraw();
367
+ }
368
+ async remove(conv) {
369
+ if (conv.session === "main")
370
+ return;
371
+ if (this.asked !== conv.session) {
372
+ this.asked = conv.session;
373
+ return this.act.redraw();
374
+ }
375
+ this.asked = undefined;
376
+ if (await this.act.remove(conv)) {
377
+ this.items = this.items.filter((x) => x !== conv);
378
+ this.selected = Math.min(this.selected, this.shown().length);
379
+ }
380
+ this.act.redraw();
381
+ }
382
+ handleInput(data) {
383
+ const count = this.shown().length + 1;
384
+ if (matchesKey(data, "escape"))
385
+ return this.act.close();
386
+ if (matchesKey(data, "up"))
387
+ this.selected = (this.selected - 1 + count) % count;
388
+ else if (matchesKey(data, "down"))
389
+ this.selected = (this.selected + 1) % count;
390
+ else if (matchesKey(data, "enter"))
391
+ return this.selected === 0 ? this.act.fresh() : this.conv && this.act.open(this.conv.session);
392
+ else if (matchesKey(data, "ctrl+p"))
393
+ return void (this.conv && this.pin(this.conv));
394
+ else if (matchesKey(data, "ctrl+x"))
395
+ return void (this.conv && this.remove(this.conv));
396
+ else if (matchesKey(data, "backspace"))
397
+ (this.filter = this.filter.slice(0, -1)), (this.selected = 0);
398
+ else if (data.length === 1 && data >= " " && data !== "\x7f")
399
+ (this.filter += data), (this.selected = this.shown().length ? 1 : 0);
400
+ else
401
+ return;
402
+ this.asked = undefined;
403
+ this.act.redraw();
404
+ }
405
+ handleMouse(e) {
406
+ const count = this.shown().length + 1;
407
+ if (e.type === "wheel" && e.wheelDelta) {
408
+ this.selected = Math.max(0, Math.min(count - 1, this.selected + (e.wheelDelta < 0 ? -1 : 1)));
409
+ return { handled: true, render: true };
410
+ }
411
+ if (e.button !== "left" || e.type !== "click")
412
+ return { handled: true };
413
+ // Rows start below the border, the title and the filter line; their text starts two columns in.
414
+ const index = this.top + e.y - 3;
415
+ if (e.y < 3 || index < 0 || index >= count || e.y - 3 >= ConversationList.ROWS)
416
+ return { handled: true };
417
+ this.selected = index;
418
+ const conv = this.conv;
419
+ const hit = this.hits.get(index);
420
+ const within = (zone) => zone !== undefined && e.x >= zone[0] && e.x <= zone[1];
421
+ if (!conv)
422
+ this.act.fresh();
423
+ else if (within(hit?.x))
424
+ void this.remove(conv);
425
+ else if (within(hit?.pin))
426
+ void this.pin(conv);
427
+ else
428
+ this.act.open(conv.session);
429
+ return { handled: true, render: true };
430
+ }
431
+ render(width) {
432
+ const inner = Math.max(20, width - 4);
433
+ this.hits.clear();
434
+ const pad = (l) => `${c.gray("│")} ${truncateToWidth(l, inner)}${" ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(l, inner))))} ${c.gray("│")}`;
435
+ const shown = this.shown();
436
+ const rows = ConversationList.ROWS;
437
+ this.top = Math.max(0, Math.min(this.selected - Math.floor(rows / 2), shown.length + 1 - rows));
438
+ const name = Math.min(28, Math.max(10, ...shown.map((x) => visibleWidth(x.session)))) + 1;
439
+ const lines = [];
440
+ for (let i = this.top; i < Math.min(shown.length + 1, this.top + rows); i++) {
441
+ const on = i === this.selected;
442
+ if (i === 0) {
443
+ lines.push(pad(on ? c.accent(" + new conversation") : " + new conversation"));
444
+ continue;
445
+ }
446
+ const conv = shown[i - 1];
447
+ const main = conv.session === "main";
448
+ const state = main ? "the agent's own, always kept" : conv.pinned ? "pinned" : conv.open ? "open in a tab" : conv.expires ? until(conv.expires) : "kept";
449
+ const mark = conv.pinned ? c.accent("★") : " ";
450
+ const label = short(conv.session, name - 1).padEnd(name);
451
+ const left = `${mark} ${on ? c.accent(label) : label} ${c.gray(`${conv.tasks} message${conv.tasks === 1 ? "" : "s"} · ${since(conv.last)} · ${state}`)}`;
452
+ // At the end of the row, the two things that can be done to it, as words to click.
453
+ const pin = main ? "" : conv.pinned ? "unpin" : "pin";
454
+ const x = main ? "" : this.asked === conv.session ? "remove? ×" : "×";
455
+ const right = main ? "" : `${c.accent(pin)} ${this.asked === conv.session ? c.red(x) : c.gray(x)}`;
456
+ const room = inner - visibleWidth(right) - 2;
457
+ const cut = truncateToWidth(left, room);
458
+ lines.push(pad(`${cut}${" ".repeat(Math.max(0, room - visibleWidth(cut)))} ${right}`));
459
+ // Columns as a click reports them: the border and a space come first. A click just beside a word counts too.
460
+ const end = 2 + inner - 1;
461
+ if (!main)
462
+ this.hits.set(i, { x: [end - x.length, end], pin: [end - x.length - 3 - pin.length, end - x.length - 2] });
463
+ }
464
+ return [
465
+ c.gray(`╭${"─".repeat(inner + 2)}╮`),
466
+ pad(c.bold(this.title)),
467
+ pad(`${c.accent(">")} ${this.filter}${c.gray(this.filter ? "" : "type to search · enter opens · ctrl+p pins · ctrl+x removes · esc closes")}`),
468
+ ...lines,
469
+ pad(c.gray(`pin keeps a conversation for good · × removes it now${shown.length + 1 > rows ? ` · ${shown.length} in all` : ""}`)),
470
+ c.gray(`╰${"─".repeat(inner + 2)}╯`),
471
+ ];
472
+ }
473
+ }
474
+ const REASONING_ITEMS = [
475
+ { value: "off", label: "off", description: "answer straight away" },
476
+ { value: "minimal", label: "minimal" },
477
+ { value: "low", label: "low" },
478
+ { value: "medium", label: "medium" },
479
+ { value: "high", label: "high", description: "think longer before answering" },
480
+ { value: "xhigh", label: "xhigh" },
481
+ { value: "max", label: "max", description: "as much as the model allows" },
482
+ ];
483
+ const MODE_ITEMS = [
484
+ { value: "auto", label: "auto", description: "works inside its sandbox, asks for the rest" },
485
+ { value: "manual", label: "manual", description: "asks before every action" },
486
+ { value: "bypass", label: "bypass", description: "never asks; reaches your whole account" },
487
+ ];
488
+ const short = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
489
+ const oneLine = (s) => s.replace(/\s+/g, " ").trim();
490
+ /** Modal question for one approval request. */
491
+ class ApprovalDialog {
492
+ req;
493
+ done;
494
+ choice = 0;
495
+ options;
496
+ buttons = [];
497
+ /**
498
+ * Values the request needs from the owner and that no model may see: a password, a token. They
499
+ * are typed here without being shown, and go from this dialog straight to the daemon.
500
+ */
501
+ fields;
502
+ field = 0;
503
+ constructor(req, done) {
504
+ this.req = req;
505
+ this.done = done;
506
+ this.fields = (Array.isArray(req.inputs) ? req.inputs : []).map((i) => ({ name: String(i.name), label: String(i.label ?? i.name), value: "", secret: i.secret !== false }));
507
+ this.options = this.fields.length
508
+ ? [
509
+ { answer: "allow", label: "Save and allow (enter)" },
510
+ { answer: "deny", label: "Deny" },
511
+ { answer: "later", label: "Later (esc)" },
512
+ ]
513
+ : [
514
+ { answer: "allow", label: "Allow once (a)" },
515
+ // Only where the daemon offers it: not for actions that are configured to ask every time.
516
+ ...(req.remember ? [{ answer: "always", label: "Always here (s)" }] : []),
517
+ { answer: "deny", label: "Deny (d)" },
518
+ { answer: "later", label: "Later (esc)" },
519
+ ];
520
+ }
521
+ answer(answer) {
522
+ if (answer !== "allow" || !this.fields.length)
523
+ return this.done(answer);
524
+ // A yes needs every value: go to the first one that is still empty.
525
+ const empty = this.fields.findIndex((f) => !f.value.trim());
526
+ if (empty >= 0)
527
+ return void (this.field = empty);
528
+ this.done("allow", Object.fromEntries(this.fields.map((f) => [f.name, f.value.trim()])));
529
+ }
530
+ invalidate() { }
531
+ handleMouse(e) {
532
+ const b = this.buttons.find((x) => e.y === x.row && e.x >= x.from && e.x < x.to);
533
+ if (b && e.type === "click" && e.button === "left")
534
+ this.answer(b.answer);
535
+ return { handled: true, render: true };
536
+ }
537
+ handleInput(data) {
538
+ if (this.fields.length)
539
+ return this.typed(data);
540
+ const n = this.options.length;
541
+ if (data === "a" || data === "y")
542
+ return this.done("allow");
543
+ if (data === "s" && this.req.remember)
544
+ return this.done("always");
545
+ if (data === "d" || data === "n")
546
+ return this.done("deny");
547
+ if (matchesKey(data, "escape"))
548
+ return this.done("later");
549
+ if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab"))
550
+ this.choice = (this.choice + n - 1) % n;
551
+ else if (matchesKey(data, "right") || matchesKey(data, "down") || matchesKey(data, "tab"))
552
+ this.choice = (this.choice + 1) % n;
553
+ else if (matchesKey(data, "enter"))
554
+ return this.done(this.options[this.choice].answer);
555
+ }
556
+ /** Keys while a value is being typed: every printable key is part of the value, so there are no letter shortcuts. */
557
+ typed(data) {
558
+ const f = this.fields[this.field];
559
+ const paste = data.match(/\x1b\[200~([\s\S]*?)\x1b\[201~/);
560
+ if (paste)
561
+ return void (f.value += paste[1].replace(/[\r\n\t]/g, "").trim());
562
+ if (matchesKey(data, "escape"))
563
+ return this.done("later");
564
+ if (matchesKey(data, "enter"))
565
+ return this.answer("allow");
566
+ if (matchesKey(data, "tab") || matchesKey(data, "down"))
567
+ this.field = (this.field + 1) % this.fields.length;
568
+ else if (matchesKey(data, "shift+tab") || matchesKey(data, "up"))
569
+ this.field = (this.field + this.fields.length - 1) % this.fields.length;
570
+ else if (matchesKey(data, "backspace"))
571
+ f.value = f.value.slice(0, -1);
572
+ else if (matchesKey(data, "ctrl+u"))
573
+ f.value = "";
574
+ else if (data.length === 1 && data >= " " && data !== "\x7f")
575
+ f.value += data;
576
+ }
577
+ render(width) {
578
+ const inner = Math.max(20, width - 4);
579
+ const inputs = this.fields.flatMap((f, i) => [
580
+ ...wrapTextWithAnsi(c.bold(f.label), inner),
581
+ // A secret is never shown: a dot for each character, so that you see it arrive.
582
+ `${i === this.field ? c.accent(">") : " "} ${c.yellow(f.secret ? "•".repeat(Math.min(f.value.length, inner - 6)) : f.value.slice(-(inner - 6)))}${i === this.field ? c.inverse(" ") : ""}`,
583
+ ]);
584
+ const body = [
585
+ c.bold(c.yellow(this.fields.length ? "Approval needed, and something only you have" : "Approval needed")),
586
+ "",
587
+ `${c.gray("agent ")} ${this.req.agent}${this.req.task ? ` ${c.gray(`(task #${this.req.task})`)}` : ""}`,
588
+ `${c.gray("action")} ${c.bold(String(this.req.tool))}`,
589
+ ...String(this.req.summary ?? "").split("\n").slice(0, 8).flatMap((l) => wrapTextWithAnsi(` ${l}`, inner)).slice(0, 12),
590
+ "",
591
+ ...wrapTextWithAnsi(c.gray(`Why you are asked: ${this.req.why}`), inner),
592
+ ...(this.req.remember ? wrapTextWithAnsi(c.gray(`"Always here" stops asking about ${this.req.remember} in this conversation.`), inner) : []),
593
+ ...(inputs.length ? ["", ...inputs, ...wrapTextWithAnsi(c.gray(this.fields.some((f) => f.secret) ? "Type or paste it here. It is not shown, goes into pine's .env file, and never reaches the agent or its model." : "Type it here and press enter. Without it, this cannot be allowed."), inner)] : []),
594
+ "",
595
+ ];
596
+ // The buttons, wrapped onto a second line in a narrow window. Their places are kept for clicks:
597
+ // two columns of border and padding, then the labels.
598
+ const rows = [""];
599
+ this.buttons = [];
600
+ let col = 0;
601
+ this.options.forEach((o, i) => {
602
+ const w = o.label.length + 2;
603
+ if (col > 0 && col + w > inner) {
604
+ rows.push("");
605
+ col = 0;
606
+ }
607
+ rows[rows.length - 1] += `${i === this.choice ? c.bgAccent(` ${o.label} `) : ` ${o.label} `} `;
608
+ this.buttons.push({ row: 1 + body.length + rows.length - 1, from: 2 + col, to: 2 + col + w, answer: o.answer });
609
+ col += w + 2;
610
+ });
611
+ body.push(...rows);
612
+ const pad = (l) => `${c.yellow("│")} ${l}${" ".repeat(Math.max(0, inner - visibleWidth(l)))} ${c.yellow("│")}`;
613
+ return [c.yellow(`╭${"─".repeat(inner + 2)}╮`), ...body.map((l) => pad(truncateToWidth(l, inner))), c.yellow(`╰${"─".repeat(inner + 2)}╯`)];
614
+ }
615
+ }
616
+ /**
617
+ * The screen, with one addition: while a menu or question is open, a click next to it goes to the
618
+ * app (which closes the menu) instead of to whatever lies underneath.
619
+ */
620
+ class Screen extends TuiAltScreen {
621
+ onOutside;
622
+ dispatchMouseToOverlay(event) {
623
+ const r = super.dispatchMouseToOverlay(event);
624
+ return !r.hit && this.onOutside?.(event) ? { hit: true } : r;
625
+ }
626
+ /**
627
+ * A real picture is drawn above all text, so it would lie across a menu opened over it. Pictures
628
+ * that a menu or dialog covers are drawn as text for as long as it is there.
629
+ */
630
+ compositeOverlays(lines, width, height) {
631
+ const out = super.compositeOverlays(lines, width, height);
632
+ const boxes = this.renderedOverlayLayouts ?? [];
633
+ if (!boxes.length)
634
+ return out;
635
+ // Overlays are placed from the top of the visible screen; `lines` may be longer than that.
636
+ const top = Math.max(0, out.length - height);
637
+ const asText = picturesAsText(lines, (row, col, w, h) => boxes.some((b) => row < top + b.row + b.height && top + b.row < row + h && col < b.col + b.width && b.col < col + w));
638
+ return asText === lines ? out : super.compositeOverlays(asText, width, height);
639
+ }
640
+ }
641
+ /** The terminal UI: one tab per conversation, a live event tab, slash commands, approval prompts. */
642
+ export class PineTui {
643
+ client;
644
+ tui;
645
+ editor;
646
+ header;
647
+ status;
648
+ footer;
649
+ dock;
650
+ tabs = [];
651
+ active;
652
+ nextTabId = 1;
653
+ agents = [];
654
+ commands = [];
655
+ connected = false;
656
+ notice = "";
657
+ noticeTimer;
658
+ spinner;
659
+ frame = 0;
660
+ unsubscribe;
661
+ dialog;
662
+ /** When a menu or question last closed. A key that closed it is not also a key for what lies under it. */
663
+ dialogClosedAt = 0;
664
+ /** Videos fetched from the daemon, as files for ffmpeg and the player. Removed when the UI closes. */
665
+ mediaDir;
666
+ mediaCount = 0;
667
+ headerHits = [];
668
+ footerHits = [];
669
+ attachHits = [];
670
+ attach;
671
+ sessionModes = new Map();
672
+ lastCtrlC = 0;
673
+ taints = new Map();
674
+ bypassWhenTainted = false;
675
+ opts;
676
+ stopped = false;
677
+ /** While the tabs of last time are being opened again. */
678
+ restoring = false;
679
+ /** The pane beside a conversation: changes, files, tool output. */
680
+ code;
681
+ codeOpen = false;
682
+ codeRule = { invalidate() { }, render: () => RULE };
683
+ constructor(opts) {
684
+ this.opts = opts;
685
+ this.client = opts.client;
686
+ this.tui = new Screen(opts.terminal, false, undefined, {
687
+ mouse: true,
688
+ openUrl: (url) => this.open(url),
689
+ // With the mouse in use by the UI, selecting text is the UI's job too: what you drag over is copied.
690
+ copyOnSelect: true,
691
+ copySelection: (text) => new Promise((resolve) => {
692
+ const child = spawn(process.platform === "darwin" ? "pbcopy" : "xclip", process.platform === "darwin" ? [] : ["-selection", "clipboard"], { stdio: ["pipe", "ignore", "ignore"] });
693
+ child.on("error", (e) => resolve(e.message));
694
+ child.on("close", (code) => resolve(code === 0 ? true : `copy failed (${code})`));
695
+ child.stdin.end(text);
696
+ }),
697
+ });
698
+ this.editor = new Editor(this.tui, editorTheme, { paddingX: 1, autocompleteMaxVisible: 8 });
699
+ this.editor.onSubmit = (text) => void this.submit(text);
700
+ this.header = clickable((w) => this.renderHeader(w), () => this.headerHits);
701
+ this.status = { invalidate: () => { }, render: (w) => this.renderStatus(w) };
702
+ this.attach = clickable((w) => this.renderAttachments(w), () => this.attachHits);
703
+ this.footer = clickable((w) => this.renderFooter(w), () => this.footerHits);
704
+ this.dock = new VStack([
705
+ { component: this.status, shrink: 1, minSize: 0 },
706
+ { component: this.attach, shrink: 1, minSize: 0 },
707
+ { component: this.editor, shrink: 1, minSize: 3 },
708
+ { component: this.footer, shrink: 1, minSize: 0 },
709
+ ]);
710
+ this.code = new CodePane({
711
+ client: this.client,
712
+ redraw: () => this.tui.requestRender(),
713
+ close: () => this.showCode(false),
714
+ target: () => (this.active?.kind === "chat" ? { agent: this.active.agent, session: this.active.session } : undefined),
715
+ });
716
+ this.defineCommands();
717
+ this.editor.setAutocompleteProvider(new CombinedAutocompleteProvider(this.commands, process.cwd(), null));
718
+ this.tui.addInputListener((data) => (this.onKey(data) ? { consume: true } : undefined));
719
+ // A menu or question is modal: a click beside it closes it (a question stays open for later,
720
+ // as with esc) and does not reach the tabs and the bar underneath. The wheel still scrolls.
721
+ this.tui.onOutside = (e) => {
722
+ if (!this.dialog || e.type === "wheel")
723
+ return false;
724
+ if (e.type === "click" && e.button === "left")
725
+ this.dialog.dismiss();
726
+ return true;
727
+ };
728
+ // Addresses are always printed in full, so what is clicked is what is opened; a link whose text
729
+ // hides its target would be an easy trick for a web page to play through an agent's answer.
730
+ setCapabilities({ ...getCapabilities(), hyperlinks: false });
731
+ }
732
+ async start() {
733
+ this.agents = await this.client.agents();
734
+ if (!this.agents.length)
735
+ throw new Error("the daemon has no agents");
736
+ const first = this.agents.find((a) => a.main) ?? this.agents[0];
737
+ this.unsubscribe = this.client.subscribe((e) => this.onEvent(e), (up) => {
738
+ this.connected = up;
739
+ this.tui.requestRender();
740
+ });
741
+ this.tui.start();
742
+ this.spinner = setInterval(() => {
743
+ if (!this.tabs.some((t) => t.running.size))
744
+ return;
745
+ this.frame++;
746
+ this.tui.requestRender();
747
+ }, 120);
748
+ this.spinner.unref?.();
749
+ // The same tabs as last time, in the same order.
750
+ const saved = await this.client.tabs().catch(() => ({ tabs: [], active: 0, code: false }));
751
+ const again = saved.tabs.filter((t) => this.agents.some((a) => a.id === t.agent));
752
+ this.restoring = true;
753
+ try {
754
+ if (!again.length)
755
+ await this.openTab(first.id, "main");
756
+ for (const t of again)
757
+ await this.openTab(t.agent, t.session);
758
+ const chats = this.tabs.filter((t) => t.kind === "chat");
759
+ if (again.length && chats[saved.active])
760
+ this.activate(chats[saved.active]);
761
+ }
762
+ finally {
763
+ this.restoring = false;
764
+ }
765
+ if (saved.code)
766
+ this.showCode(true);
767
+ this.saveTabs();
768
+ void this.refreshTaints();
769
+ }
770
+ /** Remember which conversations are open, so that the next start opens the same tabs. */
771
+ saveTabs() {
772
+ if (this.restoring || this.stopped)
773
+ return;
774
+ const chats = this.tabs.filter((t) => t.kind === "chat");
775
+ void this.client.saveTabs(chats.map((t) => ({ agent: t.agent, session: t.session })), Math.max(0, chats.indexOf(this.active)), this.codeOpen).catch(() => { });
776
+ }
777
+ async stop() {
778
+ if (this.stopped)
779
+ return;
780
+ this.stopped = true;
781
+ clearInterval(this.spinner);
782
+ clearTimeout(this.noticeTimer);
783
+ // Leaving keeps every conversation as it is: the same tabs open again next time.
784
+ this.unsubscribe?.();
785
+ this.code.stop();
786
+ stopVideos();
787
+ if (this.mediaDir)
788
+ fs.rmSync(this.mediaDir, { recursive: true, force: true });
789
+ this.tui.stop();
790
+ await this.opts.onQuit?.();
791
+ }
792
+ /** What is on screen, without colours. For tests and debugging. */
793
+ screen() {
794
+ this.tui.renderNow(true);
795
+ return this.tui.getScreenLines().map((l) => stripTerminalSequences(l).replace(/\s+$/, ""));
796
+ }
797
+ // ---- tabs ------------------------------------------------------------------------------------
798
+ newTab(kind, agent, session) {
799
+ const doc = new Doc();
800
+ const scroll = new ScrollView(doc, { follow: "end", primary: true, overscroll: "chain", scrollbar: "auto" });
801
+ // A conversation can have the code pane beside it: half the width each, or the pane alone where that is too narrow.
802
+ const beside = kind === "chat";
803
+ doc.asText = () => beside && this.codeOpen;
804
+ const body = beside ? new Split(scroll, this.codeRule, this.code.component, () => this.codeOpen, () => this.opts.terminal.columns) : scroll;
805
+ const tab = {
806
+ id: this.nextTabId++, kind, agent, session, doc, scroll,
807
+ layout: new VStack([
808
+ { component: this.header, basis: "auto", grow: 0, shrink: 0 },
809
+ { component: body, basis: 0, grow: 1, shrink: 1, minSize: 1 },
810
+ { component: this.dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
811
+ ]),
812
+ roots: new Set(), running: new Set(), awaiting: [], blocks: new Map(), tools: new Map(), streamed: new Map(), approvals: new Map(), baseDepth: 0, children: new Map(),
813
+ unread: false, startedAt: 0, toolCalls: 0, tokens: { input: 0, output: 0, cached: 0 }, thinking: false, lines: [], attachments: [], pauseNoted: false,
814
+ };
815
+ this.tabs.push(tab);
816
+ return tab;
817
+ }
818
+ async openTab(agent, session) {
819
+ const existing = this.tabs.find((t) => t.kind === "chat" && t.agent === agent && t.session === session);
820
+ if (existing)
821
+ return this.activate(existing);
822
+ if (!this.agents.some((a) => a.id === agent))
823
+ this.agents = await this.client.agents().catch(() => this.agents);
824
+ if (!this.agents.some((a) => a.id === agent))
825
+ return this.flash(`there is no agent called ${agent}`);
826
+ const tab = this.newTab("chat", agent, session);
827
+ this.activate(tab);
828
+ this.saveTabs();
829
+ const info = this.agents.find((a) => a.id === agent);
830
+ tab.folder = (await this.client.folder(agent, session).catch(() => undefined))?.folder;
831
+ this.say(tab, c.gray(`${c.bold(agent)}${info?.description ? ` · ${info.description}` : ""}\nsession ${session} · model ${info?.model ?? "?"}${tab.folder ? ` · works in ${tilde(tab.folder)}` : ""} · type a message, / for commands, or click the bar below`));
832
+ try {
833
+ const history = (await this.client.history(agent, session)).filter((t) => t.depth === 0).reverse();
834
+ if (history.length)
835
+ this.say(tab, c.gray(`── earlier in this session (${history.length}) ──`));
836
+ for (const t of history)
837
+ this.renderPast(tab, t);
838
+ this.takeStats(tab, history.at(-1)?.meta?.stats);
839
+ if (history.length)
840
+ this.say(tab, c.gray("── now ──"));
841
+ }
842
+ catch (e) {
843
+ this.say(tab, c.red(`could not load history: ${errText(e)}`));
844
+ }
845
+ this.tui.requestRender();
846
+ }
847
+ /**
848
+ * Open a tab on one task, typically a sub-agent: what it was given, what it thought and wrote,
849
+ * every tool call with the start of its result. A running task is followed live.
850
+ */
851
+ async openTask(id) {
852
+ const existing = this.tabs.find((t) => t.kind === "task" && t.task === id);
853
+ if (existing)
854
+ return this.activate(existing);
855
+ let data;
856
+ try {
857
+ data = await this.client.transcript(id);
858
+ }
859
+ catch (e) {
860
+ return this.flash(`task #${id}: ${errText(e)}`);
861
+ }
862
+ const { task, entries } = data;
863
+ const tab = this.newTab("task", task.agent, task.session);
864
+ tab.task = id;
865
+ tab.baseDepth = task.depth;
866
+ tab.roots.add(id);
867
+ this.activate(tab);
868
+ const who = task.kind === "subagent" ? `sub-agent of ${task.agent}` : task.kind === "delegate" ? `${task.agent}, on a task from ${task.meta.chain?.at(-1) ?? "another agent"}` : task.agent;
869
+ const live = task.status === "running" || task.status === "pending";
870
+ this.say(tab, c.gray(`${c.bold(who)} · task #${id}${task.parent !== undefined ? ` · started by task #${task.parent}` : ""} · ${task.status}\nsession ${task.session} · this tab shows the work and takes no messages${live ? " · esc stops this task" : ""}`));
871
+ if (entries.length)
872
+ for (const e of entries)
873
+ this.renderEntry(tab, e);
874
+ else {
875
+ // No session file (not started yet, or cleared): what the task list knows.
876
+ this.user(tab, task.prompt, "task");
877
+ if (task.result)
878
+ this.add(tab, new Answer(task.result));
879
+ }
880
+ if (live) {
881
+ tab.running.add(id);
882
+ tab.startedAt = task.started ?? Date.now();
883
+ }
884
+ else
885
+ this.say(tab, c.gray(this.taskSummary(task.status, task.meta.stats, task.error)));
886
+ tab.scroll.scrollToEnd();
887
+ this.tui.requestRender();
888
+ }
889
+ taskSummary(status, stats, error) {
890
+ const parts = [status, ...(stats ? [`${Math.round(stats.ms / 100) / 10} s`, `${stats.tools.length} tool call${stats.tools.length === 1 ? "" : "s"}`, `${stats.input + (stats.cached ?? 0)}↑ ${stats.output}↓`] : []), ...(error ? [error] : [])];
891
+ return `── ${parts.join(" · ")} ──`;
892
+ }
893
+ renderEntry(tab, e) {
894
+ if (e.kind === "user")
895
+ return this.user(tab, e.text ?? "", "task");
896
+ if (e.kind === "text") {
897
+ this.add(tab, new Answer(e.text ?? ""));
898
+ return this.answerPictures(tab, tab.agent, e.text, false);
899
+ }
900
+ if (e.kind === "thinking")
901
+ return void this.say(tab, c.gray(`thinking: ${short(oneLine(e.text ?? ""), 400)}`));
902
+ const mark = e.ok === undefined ? c.yellow("●") : e.ok ? c.green("✓") : c.red("✗");
903
+ const took = e.ms !== undefined ? c.gray(` ${e.ms < 1000 ? `${Math.round(e.ms)} ms` : `${(e.ms / 1000).toFixed(1)} s`}`) : "";
904
+ const result = e.result ? `\n ${(e.ok === false ? c.red : c.gray)(short(oneLine(e.result), 240))}` : "";
905
+ this.say(tab, `${mark} ${c.bold(e.tool ?? "")}${e.subject ? ` ${c.gray(short(oneLine(e.subject), 100))}` : ""}${took}${result}`, false);
906
+ for (const id of e.images ?? [])
907
+ this.picture(tab, `from ${e.tool}`, () => this.client.toolImage(id));
908
+ }
909
+ openEvents() {
910
+ let tab = this.tabs.find((t) => t.kind === "events");
911
+ if (!tab) {
912
+ tab = this.newTab("events", "events", "");
913
+ tab.log = new Text(c.gray("Live events. Agent text deltas are left out."), 1, 0);
914
+ tab.doc.addChild(tab.log);
915
+ }
916
+ this.activate(tab);
917
+ }
918
+ activate(tab) {
919
+ this.active = tab;
920
+ tab.unread = false;
921
+ this.tui.setLayoutRoot(tab.layout);
922
+ this.tui.setFocus(this.editor);
923
+ tab.scroll.scrollToEnd();
924
+ if (this.codeOpen)
925
+ this.code.show();
926
+ this.tui.requestRender();
927
+ this.saveTabs();
928
+ const waiting = [...tab.approvals.values()][0];
929
+ if (waiting && !this.dialog)
930
+ this.ask(tab, waiting.req);
931
+ }
932
+ cycle(by) {
933
+ const i = this.tabs.indexOf(this.active);
934
+ this.activate(this.tabs[(i + by + this.tabs.length) % this.tabs.length]);
935
+ }
936
+ /**
937
+ * The tab of a conversation other than an agent's main one is closed: what it was subscribed to
938
+ * on its own ends, the conversation itself stays in the agent's list. (The main conversation is
939
+ * the agent; its subscriptions stay.)
940
+ */
941
+ async endLocal(tab, say = true) {
942
+ if (tab.kind !== "chat" || tab.session === "main" || this.tabs.some((t) => t !== tab && t.kind === "chat" && t.agent === tab.agent && t.session === tab.session))
943
+ return;
944
+ const ended = (await this.client.closeSession(tab.agent, tab.session).catch(() => ({ unsubscribed: [] }))).unsubscribed;
945
+ if (ended.length && say)
946
+ this.flash(`closed ${tab.agent}·${tab.session}: its subscription${ended.length === 1 ? "" : "s"} to ${ended.join(", ")} ended with it`, 6000);
947
+ }
948
+ closeTab(tab) {
949
+ if (this.tabs.length === 1)
950
+ return this.flash("the last tab stays open; /quit leaves");
951
+ const i = this.tabs.indexOf(tab);
952
+ this.tabs.splice(i, 1);
953
+ void this.endLocal(tab);
954
+ if (this.active === tab)
955
+ this.activate(this.tabs[Math.max(0, i - 1)]);
956
+ else
957
+ this.tui.requestRender();
958
+ this.saveTabs();
959
+ }
960
+ // ---- the code pane and the folder of a conversation -------------------------------------------
961
+ /** Open or close the pane beside the conversation, optionally on one of its views. */
962
+ showCode(open, view) {
963
+ this.codeOpen = open;
964
+ if (open)
965
+ this.code.show(view);
966
+ // Pictures change between real ones and text with the pane; what was drawn is drawn anew.
967
+ for (const t of this.tabs)
968
+ t.doc.invalidate();
969
+ this.tui.requestRender(true);
970
+ this.saveTabs();
971
+ }
972
+ /** Give a conversation a folder to work in, or with `null` take it back to the agent's own workspace. */
973
+ async setFolder(tab, folder) {
974
+ if (tab.kind !== "chat")
975
+ return;
976
+ let now;
977
+ try {
978
+ now = (await this.client.setFolder(tab.agent, tab.session, folder ? path.resolve(expandHome(folder)) : null)).folder;
979
+ }
980
+ catch (e) {
981
+ return void this.say(tab, c.red(`the folder was not changed: ${errText(e)}`));
982
+ }
983
+ for (const t of this.tabs)
984
+ if (t.kind === "chat" && t.agent === tab.agent && t.session === tab.session)
985
+ t.folder = now;
986
+ this.say(tab, now
987
+ ? `${c.gray("this conversation now works in")} ${c.bold(tilde(now))}${c.gray(`: ${tab.agent}'s tools start there and may read and change what is in it, from your next message on.`)}`
988
+ : c.gray(`this conversation works in ${tab.agent}'s own workspace again; the folder is closed to it from your next message on.`));
989
+ this.code.forget({ agent: tab.agent, session: tab.session });
990
+ this.tui.requestRender();
991
+ }
992
+ async pickFolder() {
993
+ const t = this.active;
994
+ if (t.kind !== "chat")
995
+ return;
996
+ const here = path.resolve(this.opts.cwd ?? process.cwd());
997
+ const used = await this.client.folders(t.agent).catch(() => []);
998
+ const items = [{ value: "none", label: "No folder", description: `${t.agent}'s own workspace` }];
999
+ if (t.folder)
1000
+ items.push({ value: t.folder, label: tilde(t.folder), description: "this conversation's folder" });
1001
+ // A home folder holds pine's own files and is refused anyway, so it is not offered.
1002
+ if (here !== t.folder && here !== os.homedir() && here !== "/")
1003
+ items.push({ value: here, label: tilde(here), description: "where pine was started" });
1004
+ for (const u of used)
1005
+ if (!items.some((i) => i.value === u.folder) && items.length < 9)
1006
+ items.push({ value: u.folder, label: tilde(u.folder), description: `also open in ${u.session}` });
1007
+ items.push({ value: "pick", label: "Pick a folder …", description: "choose one, or type /folder <path>" });
1008
+ const chosen = await this.pick(`Folder for ${t.agent}·${t.session}`, items, t.folder ?? "none");
1009
+ if (!chosen || chosen === (t.folder ?? "none"))
1010
+ return;
1011
+ if (chosen !== "pick")
1012
+ return this.setFolder(t, chosen === "none" ? null : chosen);
1013
+ const picked = await (this.opts.chooseFolder ?? chooseFolder)();
1014
+ // The window of the chooser took the keyboard; the screen is drawn anew when it is back.
1015
+ this.tui.requestRender(true);
1016
+ if (picked)
1017
+ await this.setFolder(t, picked);
1018
+ else if (process.platform !== "darwin" && !this.opts.chooseFolder)
1019
+ this.flash("type /folder <path> to open a folder", 6000);
1020
+ }
1021
+ /**
1022
+ * `pine <folder>`: a tab on the conversation that works in that folder. The one that already
1023
+ * does is opened again; otherwise the main agent gets a new conversation named after the folder.
1024
+ */
1025
+ async openFolder(dir) {
1026
+ const agent = (this.agents.find((a) => a.main) ?? this.agents[0]).id;
1027
+ let full = path.resolve(expandHome(dir));
1028
+ try {
1029
+ full = fs.realpathSync(full);
1030
+ }
1031
+ catch {
1032
+ return void this.say(this.active, c.red(`${dir} is not a folder`));
1033
+ }
1034
+ const used = await this.client.folders(agent).catch(() => []);
1035
+ const have = used.find((u) => u.folder === full);
1036
+ if (have)
1037
+ return this.openTab(agent, have.session);
1038
+ const taken = new Set(["main", ...used.map((u) => u.session), ...(await this.client.sessions(agent).catch(() => [])).map((x) => x.session)]);
1039
+ const base = (path.basename(full).replace(/[^A-Za-z0-9.-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "folder").slice(0, 24);
1040
+ let name = base;
1041
+ for (let n = 2; taken.has(name); n++)
1042
+ name = `${base}-${n}`;
1043
+ try {
1044
+ await this.client.setFolder(agent, name, full);
1045
+ }
1046
+ catch (e) {
1047
+ return void this.say(this.active, c.red(`${tilde(full)} was not opened: ${errText(e)}`));
1048
+ }
1049
+ await this.openTab(agent, name);
1050
+ }
1051
+ // ---- transcript ------------------------------------------------------------------------------
1052
+ add(tab, component, gap = true) {
1053
+ if (gap && tab.doc.children.length)
1054
+ tab.doc.addChild(new Spacer(1));
1055
+ tab.doc.addChild(new Linked(component));
1056
+ if (tab !== this.active)
1057
+ tab.unread = true;
1058
+ this.tui.requestRender();
1059
+ }
1060
+ say(tab, text, gap = true) {
1061
+ const t = new Text(text, 1, 0);
1062
+ this.add(tab, t, gap);
1063
+ return t;
1064
+ }
1065
+ /** A line that opens something on a click. */
1066
+ button(tab, text, run, gap = false) {
1067
+ const b = new Button(new Text(text, 1, 0), run);
1068
+ this.add(tab, b, gap);
1069
+ return b;
1070
+ }
1071
+ /**
1072
+ * Reserve a place for a picture and fill it once the picture is fetched and prepared, so it lands
1073
+ * where it belongs however long that takes. Display only: nothing here reaches a model.
1074
+ */
1075
+ picture(tab, name, load, missing, live = true) {
1076
+ const slot = new Slot();
1077
+ // Not through add(): picture lines are not text, and are not searched for web addresses.
1078
+ tab.doc.addChild(slot);
1079
+ // A video that arrives while you are looking starts by itself; one from earlier in the conversation waits for a click.
1080
+ void this.fill(slot, name, load, missing, () => live && this.active === tab);
1081
+ return slot;
1082
+ }
1083
+ async fill(slot, name, load, missing, autoplay = () => true) {
1084
+ try {
1085
+ const got = await load();
1086
+ if (got?.mime.startsWith("video/")) {
1087
+ const video = await this.video(this.keepVideo(got.bytes, got.mime, name), name);
1088
+ slot.inner = video;
1089
+ if (autoplay())
1090
+ video.play();
1091
+ }
1092
+ else if (got)
1093
+ slot.inner = new Picture(await prepare(got.bytes, got.mime, name));
1094
+ else
1095
+ slot.inner = missing ? new Text(c.gray(missing), 1, 0) : undefined;
1096
+ }
1097
+ catch (e) {
1098
+ slot.inner = new Text(c.gray(`(picture ${name}: ${errText(e)})`), 1, 0);
1099
+ }
1100
+ this.tui.requestRender();
1101
+ }
1102
+ /** A video that came over the API needs to be a file for ffmpeg and for the player. Kept until the UI closes. */
1103
+ keepVideo(bytes, mime, name) {
1104
+ this.mediaDir ??= fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "pine-media-"));
1105
+ const ext = mime === "video/webm" ? ".webm" : mime === "video/quicktime" ? ".mov" : ".mp4";
1106
+ const file = path.join(this.mediaDir, `${this.mediaCount++}-${name.replace(/[^A-Za-z0-9._-]+/g, "_").slice(-60) || "video"}${/\.\w{2,5}$/.test(name) ? "" : ext}`);
1107
+ fs.writeFileSync(file, bytes, { mode: 0o600 });
1108
+ return file;
1109
+ }
1110
+ async video(file, name) {
1111
+ return new Video(await prepareVideo(file, name), () => this.tui.requestRender(), (f) => (this.opts.openFile ?? ((x) => void spawn("open", [x], { stdio: "ignore", detached: true }).on("error", () => { })))(f));
1112
+ }
1113
+ /**
1114
+ * The pictures an answer points at with ![caption](target). A file in the agent's workspace is
1115
+ * shown at once. A web address is not fetched until it is clicked: an address can carry
1116
+ * information out, and an answer may repeat what a web page told the agent to write.
1117
+ */
1118
+ answerPictures(tab, agent, text, live = true) {
1119
+ for (const ref of picturesIn(text ?? "")) {
1120
+ const name = ref.caption || path.basename(ref.target.split("?")[0]) || "picture";
1121
+ if (!ref.remote) {
1122
+ this.picture(tab, name, () => this.client.workspaceImage(agent, ref.target, tab.kind === "chat" ? tab.session : undefined), `(no picture at ${ref.target} in the ${tab.folder ? "folder or the " : ""}workspace)`, live);
1123
+ continue;
1124
+ }
1125
+ let slot;
1126
+ this.button(tab, `${c.accent("▸ load picture")} ${c.gray(ref.target)}`, () => {
1127
+ if (!slot)
1128
+ return;
1129
+ slot.inner = new Text(c.gray("loading …"), 1, 0);
1130
+ this.tui.requestRender();
1131
+ void this.fill(slot, name, () => this.client.fetchImage(ref.target), "(that address is not a picture)");
1132
+ });
1133
+ slot = new Slot();
1134
+ tab.doc.addChild(slot);
1135
+ }
1136
+ }
1137
+ /** An event that woke the agent, shown as what it is and not as something you typed: the source, and the start of what it reported. */
1138
+ eventLine(tab, label, prompt) {
1139
+ const body = prompt.replace(/^\[(?:You are subscribed to|Your owner wrote|Somebody wrote|It is time for your schedule)[\s\S]*?\]\n\n/, "").replace(/^\[event on [^\n]*\]\n/, "").replace(/^Event [^\n]*:\n/, "");
1140
+ // Paths under the user's home are shown the short way, as one would type them.
1141
+ this.say(tab, `${c.yellow("⚡")} ${c.bold(label)}\n${c.gray(` ${short(oneLine(body).replaceAll(os.homedir(), "~"), 300)}`)}`);
1142
+ }
1143
+ user(tab, text, from) {
1144
+ this.add(tab, new Text(`${from ? c.gray(`[${from}] `) : ""}${text}`, 1, 0, c.bgUser));
1145
+ }
1146
+ /** Context and cache figures of a finished task, for the bottom bar. */
1147
+ takeStats(tab, stats) {
1148
+ if (!stats)
1149
+ return;
1150
+ if (stats.context?.percent !== undefined)
1151
+ tab.context = stats.context.percent;
1152
+ const total = stats.input + (stats.cached ?? 0);
1153
+ // Shown once the server has reported cached input at least once; many servers report nothing,
1154
+ // and a permanent "0%" would say something that is not known.
1155
+ if (total > 0 && (stats.cached || tab.cacheShare !== undefined))
1156
+ tab.cacheShare = Math.round((100 * (stats.cached ?? 0)) / total);
1157
+ }
1158
+ renderPast(tab, t) {
1159
+ if (t.kind === "event" && t.meta?.label)
1160
+ this.eventLine(tab, t.meta.label, t.prompt);
1161
+ else
1162
+ this.user(tab, t.prompt, t.kind === "prompt" ? undefined : t.kind);
1163
+ (t.meta?.images ?? []).forEach((_, i) => this.picture(tab, `attached picture ${i + 1}`, () => this.client.taskImage(t.id, i)));
1164
+ if (t.status === "done") {
1165
+ this.add(tab, new Answer(t.result || "(no answer)"));
1166
+ this.answerPictures(tab, t.agent, t.result, false);
1167
+ }
1168
+ else if (t.status === "failed" || t.status === "cancelled")
1169
+ this.say(tab, c.red(`✗ ${t.status}${t.error ? `: ${t.error}` : ""}`));
1170
+ else
1171
+ this.say(tab, c.gray(`… ${t.status}`));
1172
+ }
1173
+ flash(text, ms = 4000) {
1174
+ this.notice = text;
1175
+ clearTimeout(this.noticeTimer);
1176
+ this.noticeTimer = setTimeout(() => {
1177
+ this.notice = "";
1178
+ this.tui.requestRender();
1179
+ }, ms);
1180
+ this.noticeTimer.unref?.();
1181
+ this.tui.requestRender();
1182
+ }
1183
+ // ---- input -----------------------------------------------------------------------------------
1184
+ /** Keys the app handles before the editor sees them. Returns true when the key is used up. */
1185
+ onKey(data) {
1186
+ if (this.dialog)
1187
+ return false;
1188
+ // Terminals that report key releases send one for the key that just closed a menu, and repeats
1189
+ // while it is held. Neither is a new key press: esc that closes a menu must not also stop the task.
1190
+ if (isKeyRelease(data))
1191
+ return false;
1192
+ if (matchesKey(data, "escape") && (isKeyRepeat(data) || Date.now() - this.dialogClosedAt < 250))
1193
+ return true;
1194
+ // Files dropped on the terminal arrive as pasted paths. Take them as attachments.
1195
+ const paste = data.match(/\x1b\[200~([\s\S]*?)\x1b\[201~/);
1196
+ if (paste && this.active.kind === "chat") {
1197
+ // One folder dropped on the terminal: that is the folder this conversation is to work in.
1198
+ const folder = droppedFiles(paste[1], true);
1199
+ if (folder?.length === 1)
1200
+ return void this.setFolder(this.active, folder[0]), true;
1201
+ const files = droppedFiles(paste[1]);
1202
+ if (files)
1203
+ return files.forEach((f) => this.attachFile(f)), true;
1204
+ }
1205
+ const empty = this.editor.getText().length === 0;
1206
+ if (matchesKey(data, "ctrl+c")) {
1207
+ if (!empty) {
1208
+ this.editor.setText("");
1209
+ this.tui.requestRender();
1210
+ return true;
1211
+ }
1212
+ if (Date.now() - this.lastCtrlC < 1500)
1213
+ return void this.stop(), true;
1214
+ this.lastCtrlC = Date.now();
1215
+ this.flash("press ctrl+c again to quit", 1500);
1216
+ return true;
1217
+ }
1218
+ if (matchesKey(data, "ctrl+d") && empty)
1219
+ return void this.stop(), true;
1220
+ if (this.editor.isShowingAutocomplete()) {
1221
+ // A command typed in full and without arguments runs on enter, even if the suggestion list
1222
+ // has not caught up with fast typing. With arguments, enter belongs to the list: it picks
1223
+ // the highlighted suggestion.
1224
+ const text = this.editor.getText();
1225
+ if (matchesKey(data, "enter") && /^\/\S+$/.test(text) && this.commands.some((x) => x.name === text.slice(1)))
1226
+ return void this.submit(text), true;
1227
+ // Picking an argument (a model, an agent, a session) also runs the command: one enter, not two.
1228
+ if (matchesKey(data, "enter") && /^\/\S+\s/.test(text)) {
1229
+ setImmediate(() => {
1230
+ const picked = this.editor.getText();
1231
+ // If the editor already sent the line, it is empty by now and nothing happens here.
1232
+ if (!this.editor.isShowingAutocomplete() && picked.startsWith("/"))
1233
+ void this.submit(picked);
1234
+ });
1235
+ }
1236
+ return false;
1237
+ }
1238
+ if (matchesKey(data, "escape") && this.active.running.size)
1239
+ return void this.stopRunning(this.active), true;
1240
+ if (matchesKey(data, "tab") && empty)
1241
+ return this.cycle(1), true;
1242
+ // As in Claude Code and opencode: shift+tab steps through the modes.
1243
+ if (matchesKey(data, "shift+tab") && empty && this.active.kind === "chat") {
1244
+ const info = this.agents.find((a) => a.id === this.active.agent);
1245
+ const order = ["auto", "manual", "bypass"];
1246
+ return void this.setMode(this.active, order[(order.indexOf(info?.mode ?? "auto") + 1) % order.length]).catch((e) => this.flash(errText(e))), true;
1247
+ }
1248
+ if (matchesKey(data, "ctrl+t"))
1249
+ return void this.run("new"), true;
1250
+ if (matchesKey(data, "ctrl+g"))
1251
+ return this.showCode(!this.codeOpen), true;
1252
+ if (matchesKey(data, "ctrl+w"))
1253
+ return this.closeTab(this.active), true;
1254
+ for (let n = 1; n <= 9; n++) {
1255
+ if (matchesKey(data, `alt+${n}`) && this.tabs[n - 1])
1256
+ return this.activate(this.tabs[n - 1]), true;
1257
+ }
1258
+ if (matchesKey(data, "pageUp"))
1259
+ return void this.active.scroll.scrollBy(-Math.max(1, this.active.scroll.viewportHeight - 2)), this.tui.requestRender(), true;
1260
+ if (matchesKey(data, "pageDown"))
1261
+ return void this.active.scroll.scrollBy(Math.max(1, this.active.scroll.viewportHeight - 2)), this.tui.requestRender(), true;
1262
+ return false;
1263
+ }
1264
+ /** A click on a web address. Only the web: nothing an answer contains may launch a program or open a file. */
1265
+ open(url) {
1266
+ if (!/^https?:\/\//i.test(url))
1267
+ return;
1268
+ (this.opts.openUrl ?? ((u) => void spawn("open", [u], { stdio: "ignore", detached: true }).on("error", () => { })))(url);
1269
+ this.flash(`opened ${short(url, 90)}`, 2500);
1270
+ }
1271
+ async submit(raw) {
1272
+ const text = raw.trim();
1273
+ if (!text)
1274
+ return;
1275
+ this.editor.addToHistory(text);
1276
+ this.editor.setText("");
1277
+ if (text.startsWith("/"))
1278
+ return this.run(text.slice(1));
1279
+ const tab = this.active;
1280
+ if (tab.kind !== "chat")
1281
+ return this.flash(tab.kind === "task" ? "this tab shows one task's work and takes no messages; write in the conversation tab" : "this tab only shows events; switch to a conversation tab to write");
1282
+ const files = tab.attachments.splice(0);
1283
+ this.user(tab, files.length ? `${text}\n${c.gray(`attached: ${files.map((f) => f.name).join(", ")}`)}` : text);
1284
+ for (const f of files.filter((x) => isImageFile(x.name)))
1285
+ this.picture(tab, f.name, async () => ({ mime: mimeOf(f.name), bytes: await fs.promises.readFile(f.path) }));
1286
+ for (const f of files.filter((x) => isVideoFile(x.name))) {
1287
+ // Played from where it lies; nothing is copied.
1288
+ const slot = new Slot();
1289
+ tab.doc.addChild(slot);
1290
+ void this.video(f.path, f.name).then((v) => ((slot.inner = v), this.active === tab && v.play(), this.tui.requestRender())).catch(() => { });
1291
+ }
1292
+ tab.awaiting.push(text);
1293
+ tab.scroll.scrollToEnd();
1294
+ try {
1295
+ const attachments = files.map((f) => ({ name: f.name, data: fs.readFileSync(f.path).toString("base64") }));
1296
+ const { task } = await this.client.prompt(tab.agent, text, tab.session, attachments.length ? attachments : undefined);
1297
+ this.adopt(tab, task);
1298
+ }
1299
+ catch (e) {
1300
+ tab.awaiting = tab.awaiting.filter((p) => p !== text);
1301
+ tab.attachments.unshift(...files);
1302
+ this.say(tab, c.red(`✗ not sent: ${errText(e)}`));
1303
+ }
1304
+ }
1305
+ attachFile(file) {
1306
+ const tab = this.active;
1307
+ if (tab.kind !== "chat")
1308
+ return this.flash("switch to a conversation tab to attach files");
1309
+ try {
1310
+ const full = path.resolve(expandHome(file));
1311
+ const stat = fs.statSync(full);
1312
+ if (!stat.isFile())
1313
+ return this.flash(`${file} is not a file`);
1314
+ if (stat.size > MAX_ATTACHMENT)
1315
+ return this.flash(`${path.basename(full)} is larger than 20 MB`);
1316
+ if (tab.attachments.length >= 10)
1317
+ return this.flash("at most 10 files per message");
1318
+ if (!tab.attachments.some((a) => a.path === full))
1319
+ tab.attachments.push({ path: full, name: path.basename(full), size: stat.size });
1320
+ this.tui.requestRender();
1321
+ }
1322
+ catch {
1323
+ this.flash(`cannot read ${file}`);
1324
+ }
1325
+ }
1326
+ /** Open a list over the conversation and resolve with what was chosen. */
1327
+ pick(title, items, current) {
1328
+ if (this.dialog)
1329
+ return Promise.resolve(undefined);
1330
+ return new Promise((resolve) => {
1331
+ const picker = new Picker(title, items, (value) => {
1332
+ this.closeDialog();
1333
+ resolve(value);
1334
+ }, current);
1335
+ const handle = this.tui.showOverlay(picker, { anchor: "center", width: "64%", minWidth: 44, maxHeight: "80%" });
1336
+ handle.focus();
1337
+ this.dialog = { id: `pick:${title}`, handle, dismiss: () => (this.closeDialog(), resolve(undefined)) };
1338
+ this.tui.requestRender();
1339
+ });
1340
+ }
1341
+ async pickAgent() {
1342
+ // Below the agents: what this conversation has started, so a sub-agent can be found and looked at.
1343
+ const helpers = [...this.active.children].reverse().slice(0, 8).map(([id, x]) => ({ value: `task:${id}`, label: `↳ #${id} ${childKind(x.kind)}`, description: `${x.running ? "running" : "finished"} · ${x.agent} · ${x.label}` }));
1344
+ // Asked anew each time: the main agent can create agents while pine runs.
1345
+ this.agents = await this.client.agents().catch(() => this.agents);
1346
+ const id = await this.pick("Agent", [...this.agents.map((a) => ({ value: a.id, label: a.id, description: a.description || a.model })), ...helpers], this.active.agent);
1347
+ if (id?.startsWith("task:"))
1348
+ await this.openTask(Number(id.slice(5)));
1349
+ else if (id)
1350
+ await this.openTab(id, "main");
1351
+ }
1352
+ async pickSession() {
1353
+ const t = this.active;
1354
+ if (t.kind !== "chat")
1355
+ return;
1356
+ if (this.dialog)
1357
+ return;
1358
+ const agent = t.agent;
1359
+ const isOpen = (session) => this.tabs.some((x) => x.kind === "chat" && x.agent === agent && x.session === session);
1360
+ const list = (await this.client.sessions(agent).catch(() => [])).map((s) => ({ ...s, open: isOpen(s.session) }));
1361
+ const close = () => this.closeDialog();
1362
+ const view = new ConversationList(`Conversations of ${agent}`, list, t.session, {
1363
+ open: (session) => (close(), void this.openTab(agent, session)),
1364
+ fresh: () => (close(), void this.run("new")),
1365
+ pin: async (conv) => {
1366
+ try {
1367
+ return (await this.client.pinSession(agent, conv.session, !conv.pinned)).pinned;
1368
+ }
1369
+ catch (e) {
1370
+ this.flash(errText(e));
1371
+ return conv.pinned;
1372
+ }
1373
+ },
1374
+ remove: async (conv) => {
1375
+ let gone;
1376
+ try {
1377
+ gone = await this.client.removeSession(agent, conv.session);
1378
+ }
1379
+ catch (e) {
1380
+ this.flash(errText(e), 6000);
1381
+ return false;
1382
+ }
1383
+ // A tab that showed it has nothing left to show.
1384
+ const showing = this.tabs.filter((x) => x.kind === "chat" && x.agent === agent && x.session === conv.session);
1385
+ for (const tab of showing)
1386
+ this.tabs.splice(this.tabs.indexOf(tab), 1);
1387
+ if (showing.includes(this.active)) {
1388
+ close();
1389
+ if (this.tabs.length)
1390
+ this.activate(this.tabs[0]);
1391
+ else
1392
+ await this.openTab(agent, "main");
1393
+ }
1394
+ this.saveTabs();
1395
+ // What was set up for it alone went with it; say so, since it is gone for good.
1396
+ const with_ = [...(gone.sources.length ? [`its event source${gone.sources.length === 1 ? "" : "s"} ${gone.sources.join(", ")}`] : gone.unsubscribed.length ? [`its subscription${gone.unsubscribed.length === 1 ? "" : "s"} to ${gone.unsubscribed.join(", ")}`] : []), ...(gone.servers.length ? [`its MCP server${gone.servers.length === 1 ? "" : "s"} ${gone.servers.join(", ")}`] : [])];
1397
+ this.flash(`removed the conversation ${conv.session} of ${agent}${with_.length ? `, with ${with_.join(" and ")}` : ""}`, with_.length ? 7000 : 4000);
1398
+ return true;
1399
+ },
1400
+ close,
1401
+ redraw: () => this.tui.requestRender(),
1402
+ });
1403
+ const handle = this.tui.showOverlay(view, { anchor: "center", width: "72%", minWidth: 50, maxHeight: "80%" });
1404
+ handle.focus();
1405
+ this.dialog = { id: "conversations", handle, dismiss: close };
1406
+ this.tui.requestRender();
1407
+ }
1408
+ async pickModel() {
1409
+ const t = this.active;
1410
+ if (t.kind !== "chat")
1411
+ return;
1412
+ const info = this.agents.find((a) => a.id === t.agent);
1413
+ const models = await this.client.models();
1414
+ const model = await this.pick(`Model for ${t.agent}`, models.map((m) => ({ value: m, label: m })), info.model);
1415
+ if (model)
1416
+ await this.run(`model ${model}`);
1417
+ }
1418
+ async pickReasoning() {
1419
+ const t = this.active;
1420
+ if (t.kind !== "chat")
1421
+ return;
1422
+ const info = this.agents.find((a) => a.id === t.agent);
1423
+ const level = await this.pick(`Reasoning for ${t.agent}`, REASONING_ITEMS, info.thinking);
1424
+ if (level)
1425
+ await this.run(`reasoning ${level}`);
1426
+ }
1427
+ async pickMode() {
1428
+ const t = this.active;
1429
+ if (t.kind !== "chat")
1430
+ return;
1431
+ const info = this.agents.find((a) => a.id === t.agent);
1432
+ const mode = await this.pick(`Mode for ${t.agent}`, MODE_ITEMS, info.mode);
1433
+ if (mode)
1434
+ await this.setMode(t, mode);
1435
+ }
1436
+ isTainted(t) {
1437
+ return this.taints.has(`${t.agent}:${t.session}`);
1438
+ }
1439
+ async setMode(t, mode) {
1440
+ const info = this.agents.find((a) => a.id === t.agent);
1441
+ if (!MODE_ITEMS.some((m) => m.value === mode))
1442
+ return this.flash("modes: manual, auto, bypass");
1443
+ let anyway;
1444
+ await this.refreshTaints();
1445
+ if (mode === "bypass" && this.isTainted(t) && !this.bypassWhenTainted) {
1446
+ // Bypass normally stops applying once a conversation has read outside content. Make that a choice, not a surprise.
1447
+ const choice = await this.pick("This conversation has read outside content", [
1448
+ { value: "new", label: "Bypass in a new, clean conversation", description: "recommended" },
1449
+ { value: "anyway", label: "Bypass here anyway", description: "what it read could steer an agent with your whole account" },
1450
+ { value: "cancel", label: "Cancel" },
1451
+ ]);
1452
+ if (!choice || choice === "cancel")
1453
+ return;
1454
+ if (choice === "anyway")
1455
+ anyway = t.session;
1456
+ else {
1457
+ await this.client.setMode(t.agent, mode);
1458
+ info.mode = "bypass";
1459
+ await this.openTab(t.agent, `s${Date.now().toString(36)}`);
1460
+ return void this.say(this.active, `${c.gray(`${t.agent} is in`)} ${c.bold("bypass")} ${c.gray("mode in this new conversation.")}${c.yellow(" Nothing waits for approval, and its shell and file tools reach your whole account and the network.")}`);
1461
+ }
1462
+ }
1463
+ await this.client.setMode(t.agent, mode, anyway);
1464
+ info.mode = mode;
1465
+ t.pauseNoted = false;
1466
+ const note = mode === "bypass" ? c.yellow(" Nothing waits for approval now, and its shell and file tools reach your whole account and the network.") : "";
1467
+ this.say(t, `${c.gray(`${t.agent} is now in`)} ${c.bold(mode)} ${c.gray("mode (until the daemon restarts; set \"mode\" in agent.json to keep it).")}${note}`);
1468
+ await this.refreshTaints();
1469
+ }
1470
+ adopt(tab, task) {
1471
+ if (tab.roots.has(task))
1472
+ return;
1473
+ tab.roots.add(task);
1474
+ }
1475
+ async stopRunning(tab) {
1476
+ for (const id of tab.running)
1477
+ await this.client.cancel(id).catch(() => { });
1478
+ this.flash("stopping…", 1500);
1479
+ }
1480
+ // ---- commands --------------------------------------------------------------------------------
1481
+ defineCommands() {
1482
+ const agentIds = (prefix) => this.agents.filter((a) => a.id.startsWith(prefix)).map((a) => ({ value: a.id, label: a.id, description: a.description }));
1483
+ const chat = () => (this.active.kind === "chat" ? this.active : (this.flash("switch to a conversation tab first"), undefined));
1484
+ this.commands = [
1485
+ { name: "help", description: "Commands and keys", run: () => this.help() },
1486
+ {
1487
+ name: "new", description: "New conversation with this agent in a new tab", argumentHint: "[name]",
1488
+ run: (args) => this.openTab(this.active.kind === "chat" ? this.active.agent : this.agents[0].id, args.trim() || `s${Date.now().toString(36)}`),
1489
+ },
1490
+ {
1491
+ name: "agent", description: "Open a tab with another agent", argumentHint: "<id> [session]", getArgumentCompletions: agentIds,
1492
+ run: (args) => {
1493
+ const [id, session] = args.trim().split(/\s+/);
1494
+ if (!id)
1495
+ return this.pickAgent();
1496
+ if (!this.agents.some((a) => a.id === id))
1497
+ return this.flash(`no agent "${id}"`);
1498
+ return this.openTab(id, session || "main");
1499
+ },
1500
+ },
1501
+ {
1502
+ name: "sessions", description: "List this agent's conversations, or open one", argumentHint: "[name]",
1503
+ getArgumentCompletions: async (prefix) => {
1504
+ const t = this.active;
1505
+ if (t.kind !== "chat")
1506
+ return null;
1507
+ return (await this.client.sessions(t.agent).catch(() => [])).filter((s) => s.session.startsWith(prefix)).map((s) => ({ value: s.session, label: s.session, description: `${s.tasks} messages` }));
1508
+ },
1509
+ run: async (args) => {
1510
+ const t = chat();
1511
+ if (!t)
1512
+ return;
1513
+ if (args.trim())
1514
+ return this.openTab(t.agent, args.trim());
1515
+ return this.pickSession();
1516
+ },
1517
+ },
1518
+ { name: "tab", description: "Go to tab number", argumentHint: "<n>", run: (args) => (this.tabs[Number(args) - 1] ? this.activate(this.tabs[Number(args) - 1]) : this.flash("no such tab")) },
1519
+ { name: "close", description: "Close this tab (the conversation stays in the list)", run: () => this.closeTab(this.active) },
1520
+ {
1521
+ name: "rename", description: "Give this conversation, and its tab, another name", argumentHint: "<name>",
1522
+ run: async (args) => {
1523
+ const t = chat();
1524
+ if (!t)
1525
+ return;
1526
+ if (!args.trim())
1527
+ return this.flash("usage: /rename <name>");
1528
+ const was = t.session;
1529
+ const now = (await this.client.renameSession(t.agent, was, args.trim())).session;
1530
+ for (const tab of this.tabs)
1531
+ if (tab.kind === "chat" && tab.agent === t.agent && tab.session === was)
1532
+ tab.session = now;
1533
+ this.saveTabs();
1534
+ await this.refreshTaints();
1535
+ this.say(t, c.gray(`this conversation is now called ${c.bold(now)}`));
1536
+ },
1537
+ },
1538
+ {
1539
+ name: "pin", description: "Keep this conversation however long it is not used; /unpin takes that back",
1540
+ run: async () => {
1541
+ const t = chat();
1542
+ if (!t)
1543
+ return;
1544
+ if (t.session === "main")
1545
+ return this.flash("the main conversation always stays");
1546
+ await this.client.pinSession(t.agent, t.session, true);
1547
+ this.say(t, c.gray("pinned: this conversation stays until you remove it"));
1548
+ },
1549
+ },
1550
+ {
1551
+ name: "unpin", description: "Let this conversation go when it has not been used for a while",
1552
+ run: async () => {
1553
+ const t = chat();
1554
+ if (!t)
1555
+ return;
1556
+ await this.client.pinSession(t.agent, t.session, false);
1557
+ this.say(t, c.gray("not pinned any more: it is removed by itself once it is neither open nor used for some days"));
1558
+ },
1559
+ },
1560
+ {
1561
+ name: "reset", description: "Forget this conversation and start fresh",
1562
+ run: async () => {
1563
+ const t = chat();
1564
+ if (!t)
1565
+ return;
1566
+ await this.client.reset(t.agent, t.session);
1567
+ t.doc.clear();
1568
+ t.roots.clear();
1569
+ t.context = undefined;
1570
+ t.cacheShare = undefined;
1571
+ this.taints.delete(`${t.agent}:${t.session}`);
1572
+ this.say(t, c.gray("fresh conversation"));
1573
+ },
1574
+ },
1575
+ {
1576
+ name: "folder", description: "The folder this conversation works in: open one, or none", argumentHint: "[path|none]",
1577
+ run: async (args) => {
1578
+ const t = chat();
1579
+ if (!t)
1580
+ return;
1581
+ const wanted = args.trim();
1582
+ if (!wanted)
1583
+ return this.pickFolder();
1584
+ return this.setFolder(t, /^(none|off|-)$/i.test(wanted) ? null : (droppedFiles(wanted, true)?.[0] ?? wanted));
1585
+ },
1586
+ },
1587
+ {
1588
+ name: "code", description: "The pane beside the conversation: changes (git), files, tool output", argumentHint: "[changes|files|output|off]",
1589
+ getArgumentCompletions: (prefix) => [...PANE_VIEWS, "off"].filter((v) => v.startsWith(prefix.trim())).map((v) => ({ value: v, label: v, description: PANE_HELP[v] })),
1590
+ run: (args) => {
1591
+ const word = args.trim().toLowerCase();
1592
+ if (word && word !== "off" && !PANE_VIEWS.includes(word))
1593
+ return this.flash("usage: /code [changes|files|output|off]");
1594
+ if (this.active.kind !== "chat")
1595
+ return this.flash("switch to a conversation tab first");
1596
+ // Without a word it opens and closes, like the button and ctrl+g.
1597
+ this.showCode(word ? word !== "off" : !this.codeOpen, word && word !== "off" ? word : undefined);
1598
+ },
1599
+ },
1600
+ { name: "stop", description: "Stop what the agent is doing (also: esc)", run: () => (this.active.running.size ? this.stopRunning(this.active) : this.flash("nothing is running")) },
1601
+ {
1602
+ name: "model", description: "Show or switch this agent's model", argumentHint: "[provider/id]",
1603
+ getArgumentCompletions: async (prefix) => (await this.client.models().catch(() => [])).filter((m) => m.includes(prefix)).slice(0, 50).map((m) => ({ value: m, label: m })),
1604
+ run: async (args) => {
1605
+ const t = chat();
1606
+ if (!t)
1607
+ return;
1608
+ const info = this.agents.find((a) => a.id === t.agent);
1609
+ if (!args.trim())
1610
+ return this.pickModel();
1611
+ await this.client.setModel(t.agent, args.trim());
1612
+ info.model = args.trim();
1613
+ this.say(t, c.gray(`${t.agent} now uses ${c.bold(info.model)} (saved as its model)`));
1614
+ },
1615
+ },
1616
+ {
1617
+ name: "reasoning", description: "How much this agent's model thinks before it answers", argumentHint: "[off|minimal|low|medium|high|xhigh|max]",
1618
+ getArgumentCompletions: (prefix) => REASONING_ITEMS.filter((x) => x.value.startsWith(prefix.trim())),
1619
+ run: async (args) => {
1620
+ const t = chat();
1621
+ if (!t)
1622
+ return;
1623
+ const level = args.trim();
1624
+ if (!level)
1625
+ return this.pickReasoning();
1626
+ await this.client.setThinking(t.agent, level);
1627
+ this.agents.find((a) => a.id === t.agent).thinking = level;
1628
+ this.say(t, c.gray(`${t.agent} now reasons at level ${c.bold(level)} (saved; a model that cannot reason ignores it)`));
1629
+ },
1630
+ },
1631
+ {
1632
+ name: "mode", description: "How much this agent may do without asking", argumentHint: "[manual|auto|bypass]",
1633
+ getArgumentCompletions: (prefix) => [
1634
+ { value: "manual", label: "manual", description: "ask before every action" },
1635
+ { value: "auto", label: "auto", description: "work inside the sandbox, ask for the rest" },
1636
+ { value: "bypass", label: "bypass", description: "never ask; shell and files reach your whole account" },
1637
+ ].filter((m) => m.value.startsWith(prefix.trim())),
1638
+ run: async (args) => {
1639
+ const t = chat();
1640
+ if (!t)
1641
+ return;
1642
+ return args.trim() ? this.setMode(t, args.trim()) : this.pickMode();
1643
+ },
1644
+ },
1645
+ {
1646
+ name: "attach", description: "Attach a file to your next message (or drop it on the terminal)", argumentHint: "<path>",
1647
+ run: (args) => (args.trim() ? (droppedFiles(args.trim()) ?? [args.trim()]).forEach((f) => this.attachFile(f)) : this.flash("usage: /attach <path>, or drag a file onto the terminal")),
1648
+ },
1649
+ {
1650
+ name: "detach", description: "Remove attached files", argumentHint: "[n]",
1651
+ run: (args) => {
1652
+ const t = this.active;
1653
+ if (args.trim())
1654
+ t.attachments.splice(Number(args) - 1, 1);
1655
+ else
1656
+ t.attachments = [];
1657
+ this.tui.requestRender();
1658
+ },
1659
+ },
1660
+ {
1661
+ name: "status", description: "Running tasks and live sessions",
1662
+ run: async () => {
1663
+ const s = await this.client.status();
1664
+ const lines = [
1665
+ `${c.gray("model slots")} ${s.limiter.active}/${s.limiter.max} busy, ${s.limiter.waiting} waiting`,
1666
+ ...s.running.map((r) => `${c.yellow("●")} task #${r.task} ${r.agent}/${r.session} ${c.gray(r.kind)}`),
1667
+ ...s.sessions.map((x) => `${c.gray("session")} ${x.agent}/${x.session}${x.busy ? c.yellow(" busy") : ""}${x.tainted ? c.yellow(` tainted: ${x.tainted}`) : ""}`),
1668
+ ];
1669
+ this.say(this.active, lines.join("\n"));
1670
+ },
1671
+ },
1672
+ {
1673
+ name: "sources", description: "Event sources: what can wake an agent, and who is subscribed",
1674
+ run: async () => {
1675
+ const list = await this.client.sources();
1676
+ const t = this.active;
1677
+ if (!list.length)
1678
+ return void this.say(t, c.gray(`no event sources configured. They go into "sources" in pine.json (for every agent) or an agent.json; docs/references/sources.md has the types.`));
1679
+ this.say(t, list.map((x) => `${c.bold(x.name)}${x.scope === "global" ? "" : c.gray(` (of ${x.scope})`)} ${c.gray(x.type)} ${x.error ? c.red(`not running: ${x.error}`) : x.running ? c.green("running") : c.gray("idle")}${x.description ? c.gray(` · ${x.description}`) : ""}${x.status ? `\n ${x.status}` : ""}\n${c.gray(` subscribers: ${x.subscribers.join(", ") || "none"} · ${x.last ? `last event ${new Date(x.last.ts).toLocaleTimeString("sv-SE")}, ${x.events} since start` : "no events since start"}`)}`).join("\n"));
1680
+ this.say(t, c.gray("/subscribe <source> [what to do] wakes this tab's agent on its events · /unsubscribe <source> stops that"), false);
1681
+ },
1682
+ },
1683
+ {
1684
+ name: "subscribe", description: "Wake on an event source: this conversation (local), the agent, or all agents (global)", argumentHint: "[local|agent|global] <source> [what to do]",
1685
+ getArgumentCompletions: async (prefix) => {
1686
+ const words = prefix.split(/\s+/);
1687
+ const scoped = SCOPES.includes(words[0]);
1688
+ if (words.length > (scoped ? 2 : 1))
1689
+ return null;
1690
+ const typed = scoped ? (words[1] ?? "") : words[0];
1691
+ const lead = scoped ? `${words[0]} ` : "";
1692
+ const sources = (await this.client.sources().catch(() => [])).filter((x) => String(x.name).startsWith(typed)).map((x) => ({ value: `${lead}${x.name} `, label: x.name, description: `${x.type}${x.description ? ` · ${x.description}` : ""}` }));
1693
+ return scoped ? sources : [...SCOPES.filter((x) => x.startsWith(typed)).map((x) => ({ value: `${x} `, label: x, description: SCOPE_HELP[x] })), ...sources];
1694
+ },
1695
+ run: async (args) => {
1696
+ const t = chat();
1697
+ if (!t)
1698
+ return;
1699
+ const words = args.trim().split(/\s+/).filter(Boolean);
1700
+ // Said without a scope, it means here: this conversation. In an agent's main conversation that is the agent.
1701
+ const scope = SCOPES.includes(words[0]) ? words.shift() : "local";
1702
+ const [source, ...note] = words;
1703
+ if (!source)
1704
+ return this.flash("usage: /subscribe [local|agent|global] <source> [what the agent should do] (see /sources)");
1705
+ const details = { note: note.join(" ") || undefined };
1706
+ if (scope === "global") {
1707
+ const info = await this.client.subscribeAll(source, details);
1708
+ return void this.say(t, `${c.green("✓")} ${c.gray(`all agents are subscribed to ${source} (${info.running ? "running" : "not running"}). Each event wakes ${info.agents.join(", ") || "nobody yet"}, each in its main conversation.`)}`);
1709
+ }
1710
+ const local = scope === "local" && t.session !== "main";
1711
+ const info = await this.client.subscribeTo(t.agent, source, { ...details, session: local ? t.session : "main" });
1712
+ this.say(t, `${c.green("✓")} ${c.bold(t.agent)} ${c.gray(`is subscribed to ${source} (${info.running ? "running" : "not running"}). ${local ? "Each event arrives here, in this conversation, and wakes it. Closing this conversation ends the subscription." : `Each event arrives in its main conversation${t.session === "main" ? ", here," : ""} and wakes it.`}`)}`);
1713
+ },
1714
+ },
1715
+ {
1716
+ name: "unsubscribe", description: "End a subscription of this conversation, the agent, or all agents", argumentHint: "[local|agent|global] <source>",
1717
+ getArgumentCompletions: async (prefix) => (prefix.trim().includes(" ") ? null : (await this.client.sources().catch(() => [])).filter((x) => String(x.name).startsWith(prefix.trim()) && x.subscribers.length).map((x) => ({ value: x.name, label: x.name, description: `${x.type} · subscribed: ${x.subscribers.join(", ")}` }))),
1718
+ run: async (args) => {
1719
+ const t = chat();
1720
+ if (!t)
1721
+ return;
1722
+ const words = args.trim().split(/\s+/).filter(Boolean);
1723
+ const scope = SCOPES.includes(words[0]) ? words.shift() : t.session === "main" ? "agent" : "local";
1724
+ const source = words[0];
1725
+ if (!source)
1726
+ return this.flash("usage: /unsubscribe [local|agent|global] <source>");
1727
+ if (scope === "global")
1728
+ return void this.say(t, c.gray((await this.client.unsubscribeAll(source)).unsubscribed ? `all agents: no longer subscribed to ${source}` : `there was no subscription of all agents to ${source}`));
1729
+ const session = scope === "agent" ? "main" : t.session;
1730
+ const r = await this.client.unsubscribeFrom(t.agent, source, session);
1731
+ const what = session === "main" ? t.agent : "this conversation";
1732
+ this.say(t, c.gray(r.unsubscribed ? `${what} is no longer subscribed to ${source}` : `${what} was not subscribed to ${source} on its own (see /sources for who is)`));
1733
+ },
1734
+ },
1735
+ {
1736
+ name: "mcp", description: "MCP servers: list them, or sign in to one", argumentHint: "[login [name]]",
1737
+ getArgumentCompletions: async (prefix) => {
1738
+ if (!prefix.startsWith("login "))
1739
+ return [{ value: "login ", label: "login", description: "sign in to a remote server through the browser" }].filter((x) => x.value.startsWith(prefix));
1740
+ // Only the names here: asking every server for its state would take seconds.
1741
+ const servers = (await this.opts.mcp?.list(false).catch(() => [])) ?? [];
1742
+ const typed = prefix.slice(6).trim();
1743
+ return servers.filter((sv) => /^https?:/.test(sv.target) && sv.name.includes(typed)).map((sv) => ({ value: `login ${sv.name}`, label: sv.name, description: sv.target }));
1744
+ },
1745
+ run: async (args) => {
1746
+ const t = this.active;
1747
+ const mcp = this.opts.mcp;
1748
+ if (!mcp)
1749
+ return this.flash("not available in this UI; use pine mcp in a shell");
1750
+ const [sub, given] = args.trim().split(/\s+/);
1751
+ const table = (servers, checking) => servers.length
1752
+ ? servers.map((sv) => `${c.bold(sv.name.padEnd(14))} ${(/^connected/.test(sv.state ?? "") ? c.green : /sign-in/.test(sv.state ?? "") ? c.yellow : c.gray)((sv.state ?? (checking ? "checking…" : "")).padEnd(16))} ${c.gray(sv.scope === "global" ? `all agents: ${sv.agents.join(", ") || "nobody"}` : `only ${sv.scope}`)}\n${" ".repeat(15)}${c.gray(sv.target)}`).join("\n")
1753
+ : c.gray("no MCP servers configured");
1754
+ if (!sub) {
1755
+ // Show what is configured at once; whether each server connects arrives a moment later.
1756
+ const line = this.say(t, table(await mcp.list(false), true));
1757
+ void mcp.list(true).then((servers) => (line.setText(table(servers, false)), this.tui.requestRender())).catch(() => { });
1758
+ return;
1759
+ }
1760
+ if (sub !== "login")
1761
+ return this.flash("usage: /mcp, or /mcp login [name]");
1762
+ let name = given;
1763
+ if (!name) {
1764
+ const remote = (await mcp.list(false)).filter((sv) => /^https?:/.test(sv.target));
1765
+ if (!remote.length)
1766
+ return this.flash("no remote MCP server is configured");
1767
+ name = remote.length === 1 ? remote[0].name : await this.pick("Sign in to which server?", remote.map((sv) => ({ value: sv.name, label: sv.name, description: sv.target })));
1768
+ if (!name)
1769
+ return;
1770
+ }
1771
+ const line = this.say(t, c.gray(`signing in to ${name}… your browser opens the sign-in page`));
1772
+ let shown = "";
1773
+ const code = await mcp.login(name, (text) => {
1774
+ shown += text;
1775
+ line.setText(c.gray(shown.trim()));
1776
+ this.tui.requestRender();
1777
+ });
1778
+ if (code !== 0)
1779
+ return void this.say(t, c.red(`sign-in to ${name} did not complete`), false);
1780
+ await this.client.post("/mcp/refresh").catch(() => { });
1781
+ this.say(t, `${c.green("✓")} signed in to ${c.bold(name)}${c.gray(". Its tools are available from your next message.")}`, false);
1782
+ },
1783
+ },
1784
+ { name: "events", description: "Open the live event log", run: () => this.openEvents() },
1785
+ {
1786
+ name: "tasks", description: "Recent tasks of this agent",
1787
+ run: async () => {
1788
+ const t = this.active;
1789
+ const tasks = await this.client.get(`/tasks?limit=15${t.kind === "chat" ? `&agent=${t.agent}` : ""}`);
1790
+ this.say(t, tasks.reverse().map((x) => `${c.gray(`#${String(x.id).padEnd(4)}`)} ${(x.status === "done" ? c.green : x.status === "failed" ? c.red : c.yellow)(x.status.padEnd(9))} ${`${x.agent}/${x.session}`.padEnd(20)} ${c.gray(short(oneLine(x.meta?.label ?? x.prompt), 60))}`).join("\n") || c.gray("no tasks"));
1791
+ this.say(t, c.gray("/task <id> opens one and shows what it did"), false);
1792
+ },
1793
+ },
1794
+ {
1795
+ name: "task", description: "Show what one task did, step by step (a sub-agent, for example)", argumentHint: "<id>",
1796
+ getArgumentCompletions: async (prefix) => {
1797
+ const tasks = await this.client.get("/tasks?limit=40").catch(() => []);
1798
+ return tasks.filter((x) => String(x.id).startsWith(prefix.trim())).map((x) => ({ value: String(x.id), label: `#${x.id}`, description: `${x.depth > 0 ? childKind(x.kind) : x.kind} · ${x.agent} · ${x.status} · ${short(oneLine(x.meta?.label ?? x.prompt), 50)}` }));
1799
+ },
1800
+ run: (args) => (/^\d+$/.test(args.trim()) ? this.openTask(Number(args.trim())) : this.flash("usage: /task <id> (the ids are in /tasks)")),
1801
+ },
1802
+ { name: "approve", description: "Allow the action that waits for approval", argumentHint: "[id] [always]", run: (args) => this.answer(args.trim(), true) },
1803
+ { name: "deny", description: "Refuse the action that waits for approval", argumentHint: "[id]", run: (args) => this.answer(args.trim(), false) },
1804
+ {
1805
+ name: "state", description: "Shared state", argumentHint: "[key pattern]",
1806
+ run: async (args) => {
1807
+ const rows = await this.client.state(args.trim() || "#");
1808
+ this.say(this.active, rows.map((r) => `${c.bold(r.key)} = ${JSON.stringify(r.value)}`).join("\n") || c.gray("nothing stored"));
1809
+ },
1810
+ },
1811
+ {
1812
+ name: "emit", description: "Publish an event as yourself", argumentHint: "<topic> [json]",
1813
+ run: async (args) => {
1814
+ const m = args.trim().match(/^(\S+)\s*([\s\S]*)$/);
1815
+ if (!m)
1816
+ return this.flash("usage: /emit <topic> [json]");
1817
+ const r = await this.client.emit(m[1], m[2] ? JSON.parse(m[2]) : null);
1818
+ this.flash(`event #${r.id} published on ${m[1]}`);
1819
+ },
1820
+ },
1821
+ { name: "clear", description: "Clear this tab's view (the conversation is kept)", run: () => (this.active.doc.clear(), this.tui.requestRender()) },
1822
+ { name: "quit", description: "Leave", run: () => this.stop() },
1823
+ { name: "exit", description: "Leave", run: () => this.stop() },
1824
+ ];
1825
+ }
1826
+ async run(input) {
1827
+ const m = input.match(/^(\S+)\s*([\s\S]*)$/);
1828
+ const cmd = m && this.commands.find((x) => x.name === m[1]);
1829
+ if (!cmd)
1830
+ return this.flash(`unknown command /${m?.[1] ?? ""} · /help lists them`);
1831
+ try {
1832
+ await cmd.run(m[2] ?? "");
1833
+ }
1834
+ catch (e) {
1835
+ this.say(this.active, c.red(`/${cmd.name}: ${errText(e)}`));
1836
+ }
1837
+ }
1838
+ help() {
1839
+ const cmds = this.commands.filter((x) => x.name !== "exit").map((x) => ` ${c.accent(`/${x.name}`)}${x.argumentHint ? c.gray(` ${x.argumentHint}`) : ""}${" ".repeat(Math.max(1, 24 - x.name.length - (x.argumentHint?.length ?? -1) - 2))}${x.description}`);
1840
+ const keys = [
1841
+ ["enter", "send"], ["shift+enter", "new line"], ["esc", "stop the agent"], ["tab", "next tab (when the input is empty)"], ["shift+tab", "next mode: auto, manual, bypass (when the input is empty)"],
1842
+ ["alt+1..9", "go to tab"], ["ctrl+t", "new conversation"], ["ctrl+w", "close tab"], ["ctrl+g", "open or close the code pane (changes, files, tool output)"], ["pgup / pgdn", "scroll"], ["mouse", "click a tab, its ×, +, ▤ code, a web address, or agent / conversation / folder / model / reasoning / mode in the bottom bar; drop files to attach"], ["ctrl+c twice, ctrl+d", "quit"],
1843
+ ].map(([k, d]) => ` ${c.accent(k.padEnd(22))} ${d}`);
1844
+ this.say(this.active, [c.bold("Commands"), ...cmds, "", c.bold("Keys"), ...keys].join("\n"));
1845
+ }
1846
+ // ---- approvals -------------------------------------------------------------------------------
1847
+ ask(tab, req) {
1848
+ if (this.dialog || tab !== this.active)
1849
+ return;
1850
+ const dialog = new ApprovalDialog(req, (answer, values) => {
1851
+ this.closeDialog();
1852
+ if (answer === "later")
1853
+ return; // the status line keeps saying that an approval is open
1854
+ void this.client.decide(req.id, answer !== "deny", answer === "always", values).catch((e) => this.flash(errText(e)));
1855
+ });
1856
+ const handle = this.tui.showOverlay(dialog, { anchor: "center", width: "76%", minWidth: 40 });
1857
+ handle.focus();
1858
+ this.dialog = { id: req.id, handle, dismiss: () => this.closeDialog() };
1859
+ this.tui.requestRender();
1860
+ }
1861
+ closeDialog() {
1862
+ this.dialog?.handle.hide();
1863
+ this.dialog = undefined;
1864
+ this.dialogClosedAt = Date.now();
1865
+ this.tui.setFocus(this.editor);
1866
+ this.tui.requestRender();
1867
+ }
1868
+ async answer(args, allow) {
1869
+ const words = args.split(/\s+/).filter(Boolean);
1870
+ const always = allow && words.includes("always");
1871
+ const id = words.find((w) => w !== "always") ?? "";
1872
+ const pending = await this.client.approvals();
1873
+ const target = id || (pending.length === 1 ? pending[0].id : "");
1874
+ if (!target)
1875
+ return this.flash(pending.length ? `several requests wait: ${pending.map((p) => p.id).join(", ")}` : "nothing waits for approval");
1876
+ // One that needs a value from you is answered in its dialog, where the value is typed without being shown.
1877
+ const req = pending.find((p) => p.id === target);
1878
+ if (allow && req?.inputs?.length)
1879
+ return this.ask(this.active, req);
1880
+ const r = await this.client.decide(target, allow, always);
1881
+ if (!r.decided)
1882
+ this.flash("that request was already answered");
1883
+ }
1884
+ // ---- events ----------------------------------------------------------------------------------
1885
+ tabFor(p) {
1886
+ if (p?.root !== undefined) {
1887
+ const byRoot = this.tabs.find((t) => t.kind === "chat" && t.roots.has(p.root));
1888
+ if (byRoot)
1889
+ return byRoot;
1890
+ }
1891
+ return undefined;
1892
+ }
1893
+ onEvent(e) {
1894
+ const p = (e.payload ?? {});
1895
+ const [ns, , kind] = e.topic.split("/");
1896
+ this.logEvent(e);
1897
+ if (ns === "approval") {
1898
+ if (e.topic === "approval/requested") {
1899
+ const tab = this.tabFor(p) ?? this.active;
1900
+ const card = this.say(tab, `${c.yellow("⚠ approval needed")} ${c.bold(p.tool)} ${c.gray(short(oneLine(String(p.summary ?? "")), 120))}\n${c.gray(` ${p.why} · ${p.inputs?.length ? "it needs something only you have: /approve opens the dialog to enter it" : `/approve${p.remember ? ", /approve always" : ""}`} or /deny`)}`);
1901
+ tab.approvals.set(p.id, { card, req: p });
1902
+ this.ask(tab, p);
1903
+ }
1904
+ else if (e.topic === "approval/decided") {
1905
+ for (const tab of this.tabs) {
1906
+ const a = tab.approvals.get(p.id);
1907
+ if (!a)
1908
+ continue;
1909
+ a.card.setText(`${p.allow ? c.green("✓ allowed") : c.red("✗ denied")} ${c.bold(a.req.tool)} ${c.gray(`(${p.by})${p.always ? ` · ${p.remember} will not ask again in this conversation` : ""}`)}`);
1910
+ tab.approvals.delete(p.id);
1911
+ }
1912
+ if (this.dialog?.id === p.id)
1913
+ this.closeDialog();
1914
+ this.tui.requestRender();
1915
+ }
1916
+ return;
1917
+ }
1918
+ if (ns !== "agent")
1919
+ return;
1920
+ // A tab on one task follows that task and whatever it starts itself.
1921
+ for (const t of this.tabs) {
1922
+ if (t.kind !== "task")
1923
+ continue;
1924
+ if (kind === "started" && p.parent !== undefined && t.roots.has(p.parent))
1925
+ t.roots.add(p.task);
1926
+ if (t.roots.has(p.task))
1927
+ this.live(t, kind, p, (p.depth ?? 0) - t.baseDepth);
1928
+ }
1929
+ if (kind === "started" && p.depth === 0) {
1930
+ // A top-level task: ours (sent from a tab) or someone else's in a session a tab shows.
1931
+ let tab = this.tabFor(p) ?? this.tabs.find((t) => t.kind === "chat" && t.agent === p.agent && t.session === p.session);
1932
+ if (!tab) {
1933
+ // An event woke the agent in a conversation nobody is looking at: say so where that agent is on screen.
1934
+ const home = p.kind === "event" ? this.tabs.find((t) => t.kind === "chat" && t.agent === p.agent) : undefined;
1935
+ if (home)
1936
+ this.button(home, `${c.yellow("⚡")} ${c.gray(`an event woke ${p.agent} in the conversation`)} ${c.bold(String(p.session))} ${c.accent("▸ open")}`, () => void this.openTab(p.agent, String(p.session)), true);
1937
+ return;
1938
+ }
1939
+ const i = tab.awaiting.findIndex((a) => String(p.prompt ?? "").startsWith(a));
1940
+ if (i >= 0)
1941
+ tab.awaiting.splice(i, 1);
1942
+ else if (!tab.roots.has(p.task)) {
1943
+ if (p.kind === "event" && p.label)
1944
+ this.eventLine(tab, String(p.label), String(p.prompt ?? ""));
1945
+ else
1946
+ this.user(tab, String(p.prompt ?? ""), p.kind === "prompt" ? "elsewhere" : p.kind);
1947
+ // What came with it and was not typed here: a photo sent on Telegram, a picture a camera saved.
1948
+ for (let n = 0; n < Number(p.images ?? 0); n++)
1949
+ this.picture(tab, `picture ${n + 1}`, () => this.client.taskImage(p.task, n));
1950
+ }
1951
+ tab.roots.add(p.task);
1952
+ tab.running.add(p.task);
1953
+ if (tab.running.size === 1) {
1954
+ tab.startedAt = Date.now();
1955
+ tab.toolCalls = 0;
1956
+ tab.tokens = { input: 0, output: 0, cached: 0 };
1957
+ }
1958
+ this.tui.requestRender();
1959
+ return;
1960
+ }
1961
+ const tab = this.tabFor(p);
1962
+ if (tab)
1963
+ this.live(tab, kind, p, p.depth ?? 0);
1964
+ }
1965
+ /** Show one live event of a task in a tab. `depth` is counted from what the tab shows: 0 is its own task. */
1966
+ live(tab, kind, p, depth) {
1967
+ const indent = " ".repeat(Math.max(0, depth));
1968
+ const open = c.accent("▸ open");
1969
+ switch (kind) {
1970
+ case "started":
1971
+ if (depth <= 0) {
1972
+ tab.running.add(p.task);
1973
+ tab.startedAt = Date.now();
1974
+ break;
1975
+ }
1976
+ tab.children.set(p.task, { agent: p.agent, kind: p.kind, label: short(oneLine(String(p.label ?? p.prompt ?? "")), 60), running: true });
1977
+ this.button(tab, `${indent}${c.magenta("↳")} ${c.bold(p.agent)} ${c.gray(`${childKind(p.kind)} #${p.task}: ${short(oneLine(String(p.label ?? p.prompt ?? "")), 80)}`)} ${open}`, () => void this.openTask(p.task));
1978
+ break;
1979
+ case "thinking":
1980
+ tab.thinking = true;
1981
+ break;
1982
+ case "delta": {
1983
+ tab.thinking = false;
1984
+ if (depth > 0)
1985
+ break; // a child's prose stays with the child (its own tab shows it); its answer reaches the parent as a tool result
1986
+ let block = tab.blocks.get(p.task);
1987
+ if (!block) {
1988
+ block = { md: new Answer(""), text: "" };
1989
+ tab.blocks.set(p.task, block);
1990
+ this.add(tab, block.md);
1991
+ }
1992
+ block.text += p.text;
1993
+ block.md.setText(block.text);
1994
+ break;
1995
+ }
1996
+ case "message":
1997
+ if (tab.blocks.has(p.task)) {
1998
+ tab.streamed.set(p.task, tab.blocks.get(p.task).text);
1999
+ this.answerPictures(tab, p.agent, tab.blocks.get(p.task).text);
2000
+ }
2001
+ tab.blocks.delete(p.task);
2002
+ if (depth === 0) {
2003
+ tab.tokens = { input: p.input ?? 0, output: p.output ?? 0, cached: p.cached ?? 0 };
2004
+ if (p.context?.percent !== undefined)
2005
+ tab.context = p.context.percent;
2006
+ }
2007
+ break;
2008
+ case "tool": {
2009
+ tab.thinking = false;
2010
+ tab.blocks.delete(p.task);
2011
+ tab.streamed.delete(p.task);
2012
+ const key = `${p.task}:${p.call}`;
2013
+ const label = `${c.bold(p.tool)}${p.subject ? ` ${c.gray(short(oneLine(p.subject), 100))}` : ""}`;
2014
+ if (p.phase === "start") {
2015
+ tab.toolCalls++;
2016
+ tab.tools.set(key, { line: this.say(tab, `${indent}${c.yellow("●")} ${label}`, depth === 0 && !tab.tools.size), label });
2017
+ }
2018
+ else {
2019
+ const t = tab.tools.get(key);
2020
+ const took = p.ms !== undefined ? c.gray(` ${p.ms < 1000 ? `${Math.round(p.ms)} ms` : `${(p.ms / 1000).toFixed(1)} s`}`) : "";
2021
+ const failed = p.ok ? "" : `\n${indent} ${c.red(short(oneLine(String(p.preview ?? "")), 160))}`;
2022
+ const text = `${indent}${p.ok ? c.green("✓") : c.red("✗")} ${t?.label ?? label}${took}${failed}`;
2023
+ // A call that began before this tab was opened has no line of its own yet.
2024
+ if (t)
2025
+ t.line.setText(text);
2026
+ else
2027
+ this.say(tab, text, false);
2028
+ tab.tools.delete(key);
2029
+ for (const id of (p.images ?? []))
2030
+ this.picture(tab, `from ${p.tool}`, () => this.client.toolImage(id));
2031
+ }
2032
+ break;
2033
+ }
2034
+ case "done":
2035
+ case "failed": {
2036
+ const block = tab.blocks.get(p.task);
2037
+ tab.blocks.delete(p.task);
2038
+ if (depth > 0) {
2039
+ const child = tab.children.get(p.task);
2040
+ if (child)
2041
+ child.running = false;
2042
+ const n = p.stats?.tools?.length ?? 0;
2043
+ const how = p.status === "done" ? `${childKind(p.kind ?? child?.kind ?? "task")} #${p.task} answered (${Math.round((p.stats?.ms ?? 0) / 100) / 10} s${n ? `, ${n} tool call${n === 1 ? "" : "s"}` : ""})` : `${childKind(p.kind ?? child?.kind ?? "task")} #${p.task} ${p.status}: ${p.error ?? ""}`;
2044
+ this.button(tab, `${indent}${p.status === "done" ? c.green("✓") : c.red("✗")} ${c.bold(p.agent)} ${c.gray(how)} ${open}`, () => void this.openTask(p.task));
2045
+ break;
2046
+ }
2047
+ tab.running.delete(p.task);
2048
+ tab.thinking = false;
2049
+ this.takeStats(tab, p.stats);
2050
+ if (p.status === "done") {
2051
+ // Streaming normally showed the answer already; this covers a missed stream.
2052
+ if (!block && !tab.streamed.get(p.task) && p.text) {
2053
+ this.add(tab, new Answer(p.text));
2054
+ this.answerPictures(tab, p.agent, p.text);
2055
+ }
2056
+ }
2057
+ else
2058
+ this.say(tab, c.red(`✗ ${p.status}${p.error ? `: ${p.error}` : ""}`));
2059
+ tab.streamed.delete(p.task);
2060
+ if (tab.kind === "task")
2061
+ this.say(tab, c.gray(this.taskSummary(p.status, p.stats)));
2062
+ else
2063
+ void this.refreshTaints();
2064
+ break;
2065
+ }
2066
+ }
2067
+ // A tool that finished may have changed files, and a finished task has new output to show.
2068
+ if (this.codeOpen && tab === this.active && ((kind === "tool" && p.phase !== "start") || kind === "done" || kind === "failed"))
2069
+ this.code.touch();
2070
+ if (tab !== this.active)
2071
+ tab.unread = true;
2072
+ this.tui.requestRender();
2073
+ }
2074
+ logEvent(e) {
2075
+ const tab = this.tabs.find((t) => t.kind === "events");
2076
+ if (!tab?.log || /\/(delta|thinking|message)$/.test(e.topic))
2077
+ return;
2078
+ const time = new Date(e.ts).toLocaleTimeString("sv-SE");
2079
+ tab.lines.push(`${c.gray(time)} ${(e.trust === "owner" ? c.green : c.gray)(e.trust === "owner" ? "owner" : "other")} ${c.bold(e.topic)} ${c.gray(short(JSON.stringify(e.payload ?? null), 160))}`);
2080
+ if (tab.lines.length > 400)
2081
+ tab.lines.splice(0, tab.lines.length - 400);
2082
+ tab.log.setText(tab.lines.join("\n"));
2083
+ if (tab === this.active)
2084
+ this.tui.requestRender();
2085
+ }
2086
+ async refreshTaints() {
2087
+ try {
2088
+ const s = await this.client.status();
2089
+ this.taints.clear();
2090
+ this.sessionModes.clear();
2091
+ this.bypassWhenTainted = s.bypassWhenTainted === true;
2092
+ for (const x of s.sessions) {
2093
+ const open = this.tabs.find((t) => t.kind === "chat" && t.agent === x.agent && t.session === x.session);
2094
+ if (open && x.context?.percent !== undefined)
2095
+ open.context = x.context.percent;
2096
+ if (x.tainted)
2097
+ this.taints.set(`${x.agent}:${x.session}`, x.tainted);
2098
+ this.sessionModes.set(`${x.agent}:${x.session}`, x.mode);
2099
+ }
2100
+ for (const [id, mode] of Object.entries(s.modes ?? {})) {
2101
+ const info = this.agents.find((a) => a.id === id);
2102
+ if (info)
2103
+ info.mode = mode;
2104
+ }
2105
+ // Bypass that stopped applying must not go unnoticed.
2106
+ for (const t of this.tabs) {
2107
+ if (t.kind !== "chat" || t.pauseNoted || !this.bypassPaused(t))
2108
+ continue;
2109
+ t.pauseNoted = true;
2110
+ this.say(t, c.yellow(`bypass is paused in this conversation: it has read outside content (${this.taints.get(`${t.agent}:${t.session}`)}).\n`) + c.gray("The agent is back inside its sandbox and asks before sending things out. /mode bypass offers a clean conversation or bypass here anyway; /reset starts over."));
2111
+ }
2112
+ this.tui.requestRender();
2113
+ }
2114
+ catch {
2115
+ // The footer just keeps its last state.
2116
+ }
2117
+ }
2118
+ bypassPaused(t) {
2119
+ const info = this.agents.find((a) => a.id === t.agent);
2120
+ return info?.mode === "bypass" && this.sessionModes.get(`${t.agent}:${t.session}`) === "auto";
2121
+ }
2122
+ // ---- chrome ----------------------------------------------------------------------------------
2123
+ renderHeader(width) {
2124
+ const line = new Line().add(c.bold(c.accent(" pine ")));
2125
+ this.tabs.forEach((t, i) => {
2126
+ const name = t.kind === "events" ? "events" : t.kind === "task" ? `${t.agent}↳#${t.task}` : t.session === "main" ? t.agent : `${t.agent}·${t.session}`;
2127
+ const mark = t.running.size ? c.yellow(SPINNER[this.frame % SPINNER.length]) : t.approvals.size ? c.yellow("!") : t.unread ? c.accent("•") : " ";
2128
+ const label = ` ${i + 1} ${short(name, 22)} `;
2129
+ line.add(t === this.active ? c.bgAccent(label) : c.gray(label), () => this.activate(t));
2130
+ line.add(t === this.active ? c.bgAccent("×") : c.gray("×"), () => this.closeTab(t));
2131
+ line.add(`${mark} `);
2132
+ });
2133
+ line.add(c.gray(" + "), () => void this.run("new"));
2134
+ // At the right edge, and always in view however many tabs there are: the button of the code pane.
2135
+ const right = new Line().add(this.connected ? "" : c.red(" reconnecting… "));
2136
+ if (this.active.kind === "chat")
2137
+ right.add(this.codeOpen ? c.bgAccent(" ▤ code ") : c.gray(" ▤ code "), () => this.showCode(!this.codeOpen));
2138
+ const room = Math.max(0, width - right.width);
2139
+ const at = Math.max(0, width - right.width);
2140
+ this.headerHits = [...line.hits.filter((h) => h.from < room).map((h) => ({ ...h, to: Math.min(h.to, room) })), ...right.hits.map((h) => ({ ...h, from: h.from + at, to: h.to + at }))];
2141
+ const left = truncateToWidth(line.text, room);
2142
+ return [truncateToWidth(left + " ".repeat(Math.max(0, room - visibleWidth(left))) + right.text, width), c.gray("─".repeat(width))];
2143
+ }
2144
+ renderAttachments(width) {
2145
+ const t = this.active;
2146
+ if (!t.attachments.length)
2147
+ return (this.attachHits = []), [];
2148
+ const kb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
2149
+ const line = new Line().add(c.gray(" attached: "));
2150
+ t.attachments.forEach((a, i) => {
2151
+ line.add(`${c.bold(short(a.name, 28))} ${c.gray(kb(a.size))} `);
2152
+ line.add(c.gray("× "), () => {
2153
+ t.attachments.splice(i, 1);
2154
+ this.tui.requestRender();
2155
+ });
2156
+ line.add(" ");
2157
+ });
2158
+ this.attachHits = line.hits;
2159
+ return [truncateToWidth(line.text, width)];
2160
+ }
2161
+ renderStatus(width) {
2162
+ const t = this.active;
2163
+ let line = "";
2164
+ if (this.notice)
2165
+ line = c.yellow(this.notice);
2166
+ else if (t.approvals.size)
2167
+ line = c.yellow("waiting for your approval · /approve or /deny");
2168
+ else if (t.running.size) {
2169
+ const secs = Math.max(0, Math.round((Date.now() - t.startedAt) / 1000));
2170
+ const helpers = [...t.children.values()].filter((x) => x.running).length;
2171
+ const parts = [t.thinking ? "thinking" : "working", `${secs}s`, ...(t.toolCalls ? [`${t.toolCalls} tool call${t.toolCalls === 1 ? "" : "s"}`] : []), ...(helpers ? [`${helpers} sub-task${helpers === 1 ? "" : "s"} running`] : []), ...(t.tokens.output ? [`${t.tokens.input + t.tokens.cached}↑${t.tokens.cached ? ` (${Math.round((100 * t.tokens.cached) / (t.tokens.input + t.tokens.cached))}% cached)` : ""} ${t.tokens.output}↓`] : []), "esc to stop"];
2172
+ line = `${c.yellow(SPINNER[this.frame % SPINNER.length])} ${c.gray(parts.join(" · "))}`;
2173
+ }
2174
+ return [truncateToWidth(` ${line}`, width)];
2175
+ }
2176
+ renderFooter(width) {
2177
+ const t = this.active;
2178
+ if (t.kind !== "chat")
2179
+ return (this.footerHits = []), [truncateToWidth(c.gray(t.kind === "task" ? ` task #${t.task} of ${t.agent} · read-only · ctrl+w closes this tab` : " live events"), width)];
2180
+ const info = this.agents.find((a) => a.id === t.agent);
2181
+ const taint = this.taints.get(`${t.agent}:${t.session}`);
2182
+ const mode = info?.mode ?? "auto";
2183
+ const modeText = `${mode}${this.bypassPaused(t) ? " (paused)" : ""}`;
2184
+ // Each part opens its list on a click: agent, conversation, model, mode.
2185
+ const line = new Line()
2186
+ .add(" ")
2187
+ .add(c.bold(t.agent), () => void this.pickAgent())
2188
+ .add(c.gray(" · "))
2189
+ .add(c.gray(t.session), () => void this.pickSession())
2190
+ .add(c.gray(" · "))
2191
+ .add(t.folder ? c.cyan(short(tilde(t.folder), 34)) : c.gray("no folder"), () => void this.pickFolder())
2192
+ .add(c.gray(" · "))
2193
+ .add(c.gray(info?.model ?? ""), () => void this.pickModel())
2194
+ .add(c.gray(" · "))
2195
+ .add(c.gray(`reasoning ${info?.thinking ?? "off"}`), () => void this.pickReasoning())
2196
+ .add(c.gray(" · "))
2197
+ .add(mode === "bypass" ? c.red(modeText) : mode === "manual" ? c.cyan(modeText) : c.gray(modeText), () => void this.pickMode());
2198
+ // How full the context window is. pi compacts the conversation by itself when it gets close to full.
2199
+ if (t.context !== undefined)
2200
+ line.add(c.gray(" · ")).add((t.context >= 85 ? c.red : t.context >= 65 ? c.yellow : c.gray)(`context ${t.context}%`));
2201
+ // How much of the last request the model server took from its cache. Low figures on a local model mean slow turns.
2202
+ if (t.cacheShare !== undefined)
2203
+ line.add(c.gray(" · ")).add((t.cacheShare < 50 ? c.yellow : c.gray)(`cache ${t.cacheShare}%`));
2204
+ if (taint)
2205
+ line.add(c.yellow(" · outside content"));
2206
+ this.footerHits = line.hits;
2207
+ const right = c.gray("/help ");
2208
+ const gap = Math.max(1, width - line.width - visibleWidth(right));
2209
+ return [truncateToWidth(line.text + " ".repeat(gap) + right, width)];
2210
+ }
2211
+ }
2212
+ /** The system's own window for choosing a folder. Resolves with nothing when it is closed without a choice, or where there is none. */
2213
+ function chooseFolder() {
2214
+ if (process.platform !== "darwin")
2215
+ return Promise.resolve(undefined);
2216
+ return new Promise((resolve) => {
2217
+ const child = spawn("/usr/bin/osascript", ["-e", 'POSIX path of (choose folder with prompt "A folder for this conversation")'], { stdio: ["ignore", "pipe", "ignore"] });
2218
+ let out = "";
2219
+ child.stdout.on("data", (d) => (out += d));
2220
+ child.on("error", () => resolve(undefined));
2221
+ child.on("close", (code) => resolve(code === 0 && out.trim() ? out.trim() : undefined));
2222
+ });
2223
+ }