@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.
- package/LICENSE +21 -0
- package/README.md +180 -2
- package/bin/pine.js +27 -0
- package/docs/SKILL.md +189 -0
- package/docs/references/agents.md +368 -0
- package/docs/references/coding.md +126 -0
- package/docs/references/configuration.md +229 -0
- package/docs/references/events.md +263 -0
- package/docs/references/mcp.md +257 -0
- package/docs/references/operations.md +280 -0
- package/docs/references/overview.md +186 -0
- package/docs/references/security.md +489 -0
- package/docs/references/setup.md +181 -0
- package/docs/references/sources.md +768 -0
- package/docs/references/stack.md +105 -0
- package/docs/references/tui.md +348 -0
- package/examples/home/.env.example +5 -0
- package/examples/home/USER.md +4 -0
- package/examples/home/agents/main/agent.json +4 -0
- package/examples/home/agents/main/workspace/AGENTS.md +14 -0
- package/examples/home/agents/main/workspace/MEMORY.md +4 -0
- package/examples/home/agents/main/workspace/SOUL.md +4 -0
- package/examples/home/models.json +62 -0
- package/examples/home/pine.json +30 -0
- package/examples/home/skills/README.txt +7 -0
- package/examples/launchd/com.pine.daemon.plist +32 -0
- package/mcp/browser.d.ts +34 -0
- package/mcp/browser.js +228 -0
- package/mcp/commands.d.ts +20 -0
- package/mcp/commands.js +88 -0
- package/mcp/lib.d.ts +13 -0
- package/mcp/lib.js +50 -0
- package/mcp/web.d.ts +6 -0
- package/mcp/web.js +140 -0
- package/package.json +32 -4
- package/src/agent-admin.d.ts +24 -0
- package/src/agent-admin.js +55 -0
- package/src/agents.d.ts +343 -0
- package/src/agents.js +1587 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +526 -0
- package/src/config.d.ts +351 -0
- package/src/config.js +514 -0
- package/src/dev/claude-model.d.ts +28 -0
- package/src/dev/claude-model.js +174 -0
- package/src/env-file.d.ts +4 -0
- package/src/env-file.js +40 -0
- package/src/http.d.ts +46 -0
- package/src/http.js +407 -0
- package/src/limiter.d.ts +32 -0
- package/src/limiter.js +84 -0
- package/src/login.d.ts +21 -0
- package/src/login.js +126 -0
- package/src/manual.d.ts +61 -0
- package/src/manual.js +425 -0
- package/src/mcp-config.d.ts +40 -0
- package/src/mcp-config.js +132 -0
- package/src/net.d.ts +36 -0
- package/src/net.js +122 -0
- package/src/pine.d.ts +32 -0
- package/src/pine.js +142 -0
- package/src/router.d.ts +56 -0
- package/src/router.js +164 -0
- package/src/sandbox-fs.d.ts +27 -0
- package/src/sandbox-fs.js +172 -0
- package/src/security.d.ts +121 -0
- package/src/security.js +363 -0
- package/src/skills.d.ts +67 -0
- package/src/skills.js +163 -0
- package/src/source-types.d.ts +61 -0
- package/src/source-types.js +1 -0
- package/src/sources/command.d.ts +4 -0
- package/src/sources/command.js +77 -0
- package/src/sources/folder.d.ts +6 -0
- package/src/sources/folder.js +116 -0
- package/src/sources/ftp.d.ts +4 -0
- package/src/sources/ftp.js +307 -0
- package/src/sources/http.d.ts +4 -0
- package/src/sources/http.js +201 -0
- package/src/sources/mqtt.d.ts +18 -0
- package/src/sources/mqtt.js +97 -0
- package/src/sources/telegram.d.ts +22 -0
- package/src/sources/telegram.js +269 -0
- package/src/sources/webhook.d.ts +4 -0
- package/src/sources/webhook.js +52 -0
- package/src/sources.d.ts +339 -0
- package/src/sources.js +955 -0
- package/src/store.d.ts +89 -0
- package/src/store.js +251 -0
- package/src/timers.d.ts +83 -0
- package/src/timers.js +318 -0
- package/src/tools.d.ts +6 -0
- package/src/tools.js +777 -0
- package/src/tui/app.d.ts +209 -0
- package/src/tui/app.js +2223 -0
- package/src/tui/client.d.ts +178 -0
- package/src/tui/client.js +116 -0
- package/src/tui/code-pane.d.ts +51 -0
- package/src/tui/code-pane.js +398 -0
- package/src/tui/highlight.d.ts +8 -0
- package/src/tui/highlight.js +86 -0
- package/src/tui/picture.d.ts +141 -0
- package/src/tui/picture.js +571 -0
- package/src/tui/run.d.ts +8 -0
- package/src/tui/run.js +97 -0
- package/src/tui/theme.d.ts +32 -0
- package/src/tui/theme.js +128 -0
- package/src/types.d.ts +124 -0
- package/src/types.js +1 -0
- package/src/update.d.ts +25 -0
- package/src/update.js +110 -0
- package/src/util.d.ts +32 -0
- package/src/util.js +116 -0
- package/src/watching.d.ts +20 -0
- package/src/watching.js +68 -0
- package/src/workspace-view.d.ts +38 -0
- package/src/workspace-view.js +160 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ScrollView, truncateToWidth, visibleWidth, VStack, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { errText } from "../util.js";
|
|
5
|
+
import { highlightCode } from "./highlight.js";
|
|
6
|
+
import { halfBlocks, isImageFile, prepare } from "./picture.js";
|
|
7
|
+
import { c } from "./theme.js";
|
|
8
|
+
export const PANE_VIEWS = ["changes", "files", "output"];
|
|
9
|
+
const tilde = (p) => (p === os.homedir() ? "~" : p.startsWith(`${os.homedir()}/`) ? `~${p.slice(os.homedir().length)}` : p);
|
|
10
|
+
const plain = (s) => s.replace(/\t/g, " ").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
11
|
+
const size = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
12
|
+
const took = (ms) => (ms === undefined ? "" : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`);
|
|
13
|
+
/** The lines of a diff as git prints it, in colours, without the lines that only name the file. */
|
|
14
|
+
export function diffLines(diff) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const raw of diff.replace(/\n$/, "").split("\n")) {
|
|
17
|
+
const line = plain(raw);
|
|
18
|
+
if (/^(diff --git |index |--- |\+\+\+ |new file mode|deleted file mode|old mode|new mode|similarity index)/.test(line))
|
|
19
|
+
continue;
|
|
20
|
+
if (line.startsWith("@@"))
|
|
21
|
+
out.push(c.cyan(line));
|
|
22
|
+
else if (line.startsWith("+"))
|
|
23
|
+
out.push(c.green(line));
|
|
24
|
+
else if (line.startsWith("-"))
|
|
25
|
+
out.push(c.red(line));
|
|
26
|
+
else if (/^(rename |Binary files |\\ )/.test(line))
|
|
27
|
+
out.push(c.gray(line));
|
|
28
|
+
else
|
|
29
|
+
out.push(line);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Lines that react to a click, each cut to the width or, where it says so, wrapped. */
|
|
34
|
+
class Rows {
|
|
35
|
+
rows = [];
|
|
36
|
+
owner = [];
|
|
37
|
+
cache;
|
|
38
|
+
invalidate() {
|
|
39
|
+
this.cache = undefined;
|
|
40
|
+
}
|
|
41
|
+
handleMouse(e) {
|
|
42
|
+
const row = this.rows[this.owner[e.y]];
|
|
43
|
+
if (!row?.run || e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
|
|
44
|
+
return undefined;
|
|
45
|
+
if (e.type === "click")
|
|
46
|
+
row.run();
|
|
47
|
+
return { handled: true, render: true };
|
|
48
|
+
}
|
|
49
|
+
render(width) {
|
|
50
|
+
if (this.cache?.width === width && this.cache.rows === this.rows)
|
|
51
|
+
return this.cache.lines;
|
|
52
|
+
const lines = [];
|
|
53
|
+
this.owner = [];
|
|
54
|
+
this.rows.forEach((row, i) => {
|
|
55
|
+
const gutter = row.gutter ?? "";
|
|
56
|
+
const room = Math.max(8, width - 1 - visibleWidth(gutter));
|
|
57
|
+
const parts = row.wrap && visibleWidth(row.text) > room ? wrapTextWithAnsi(row.text, room) : [truncateToWidth(row.text, room)];
|
|
58
|
+
parts.forEach((part, n) => {
|
|
59
|
+
lines.push(` ${n === 0 ? gutter : " ".repeat(visibleWidth(gutter))}${part}`);
|
|
60
|
+
this.owner.push(i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
this.cache = { width, rows: this.rows, lines };
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export class CodePane {
|
|
68
|
+
/** What goes into the layout. */
|
|
69
|
+
component;
|
|
70
|
+
client;
|
|
71
|
+
redraw;
|
|
72
|
+
close;
|
|
73
|
+
target;
|
|
74
|
+
states = new Map();
|
|
75
|
+
rows = new Rows();
|
|
76
|
+
scroll;
|
|
77
|
+
hits = [];
|
|
78
|
+
timer;
|
|
79
|
+
width = 60;
|
|
80
|
+
/** Counts loads, so that an answer that arrives late does not overwrite a newer one. */
|
|
81
|
+
turn = 0;
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.client = opts.client;
|
|
84
|
+
this.redraw = opts.redraw;
|
|
85
|
+
this.close = opts.close;
|
|
86
|
+
this.target = opts.target;
|
|
87
|
+
this.scroll = new ScrollView(this.rows, { follow: "none", overscroll: "contain", scrollbar: "auto" });
|
|
88
|
+
const head = {
|
|
89
|
+
invalidate() { },
|
|
90
|
+
render: (w) => this.renderHead(w),
|
|
91
|
+
handleMouse: (e) => {
|
|
92
|
+
if (e.y !== 0 || e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
|
|
93
|
+
return undefined;
|
|
94
|
+
const hit = this.hits.find((h) => e.x >= h.from && e.x < h.to);
|
|
95
|
+
if (!hit)
|
|
96
|
+
return undefined;
|
|
97
|
+
if (e.type === "click")
|
|
98
|
+
hit.run();
|
|
99
|
+
return { handled: true, render: true };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
this.component = new VStack([
|
|
103
|
+
{ component: head, basis: "auto", grow: 0, shrink: 0 },
|
|
104
|
+
{ component: this.scroll, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
get state() {
|
|
108
|
+
const t = this.target();
|
|
109
|
+
if (!t)
|
|
110
|
+
return undefined;
|
|
111
|
+
const key = `${t.agent}:${t.session}`;
|
|
112
|
+
let s = this.states.get(key);
|
|
113
|
+
if (!s) {
|
|
114
|
+
if (this.states.size > 40)
|
|
115
|
+
this.states.clear();
|
|
116
|
+
s = { view: "changes", dirs: new Map(), expanded: new Set([""]) };
|
|
117
|
+
this.states.set(key, s);
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
121
|
+
get view() {
|
|
122
|
+
return this.state?.view ?? "changes";
|
|
123
|
+
}
|
|
124
|
+
/** Show one of the views, from its top level. Without a view: whatever was shown last, read again. */
|
|
125
|
+
show(view) {
|
|
126
|
+
const s = this.state;
|
|
127
|
+
if (!s)
|
|
128
|
+
return this.build();
|
|
129
|
+
if (view) {
|
|
130
|
+
s.view = view;
|
|
131
|
+
s.diff = undefined;
|
|
132
|
+
s.file = undefined;
|
|
133
|
+
s.call = undefined;
|
|
134
|
+
}
|
|
135
|
+
this.scroll.scrollToStart();
|
|
136
|
+
void this.load(true);
|
|
137
|
+
}
|
|
138
|
+
/** The conversation shown changed its folder: what was read from the old one is not shown any more. */
|
|
139
|
+
forget(target) {
|
|
140
|
+
const view = this.states.get(`${target.agent}:${target.session}`)?.view;
|
|
141
|
+
this.states.delete(`${target.agent}:${target.session}`);
|
|
142
|
+
if (view && this.state)
|
|
143
|
+
this.state.view = view;
|
|
144
|
+
this.show();
|
|
145
|
+
}
|
|
146
|
+
/** Something happened that may have changed what is shown (a tool ran, a task ended): read it again in a moment. */
|
|
147
|
+
touch() {
|
|
148
|
+
clearTimeout(this.timer);
|
|
149
|
+
this.timer = setTimeout(() => void this.load(false), 350);
|
|
150
|
+
this.timer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
stop() {
|
|
153
|
+
clearTimeout(this.timer);
|
|
154
|
+
}
|
|
155
|
+
// ---- reading ---------------------------------------------------------------------------------
|
|
156
|
+
async load(visibly) {
|
|
157
|
+
const t = this.target();
|
|
158
|
+
const s = this.state;
|
|
159
|
+
if (!t || !s)
|
|
160
|
+
return this.build();
|
|
161
|
+
const turn = ++this.turn;
|
|
162
|
+
if (visibly) {
|
|
163
|
+
s.loading = "reading …";
|
|
164
|
+
this.build();
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
if (s.view === "changes") {
|
|
168
|
+
const changes = await this.client.changes(t.agent, t.session);
|
|
169
|
+
const diff = s.diff ? await this.client.diff(t.agent, t.session, s.diff.file).catch(() => undefined) : undefined;
|
|
170
|
+
if (turn !== this.turn)
|
|
171
|
+
return;
|
|
172
|
+
s.changes = changes;
|
|
173
|
+
s.root = changes.root;
|
|
174
|
+
if (s.diff)
|
|
175
|
+
Object.assign(s.diff, { lines: diff ? diffLines(diff.diff) : [], truncated: diff?.truncated });
|
|
176
|
+
}
|
|
177
|
+
else if (s.view === "files") {
|
|
178
|
+
const dirs = new Map();
|
|
179
|
+
for (const dir of [...s.expanded].sort()) {
|
|
180
|
+
const got = await this.client.tree(t.agent, t.session, dir).catch(() => undefined);
|
|
181
|
+
if (got)
|
|
182
|
+
dirs.set(dir, got.entries);
|
|
183
|
+
}
|
|
184
|
+
const root = s.root ?? (await this.client.folder(t.agent, t.session).then((f) => f.folder ?? f.workspace).catch(() => undefined));
|
|
185
|
+
const file = s.file ? await this.fileRows(t, s.file.path) : undefined;
|
|
186
|
+
if (turn !== this.turn)
|
|
187
|
+
return;
|
|
188
|
+
s.dirs = dirs;
|
|
189
|
+
s.root = root;
|
|
190
|
+
if (s.file)
|
|
191
|
+
s.file.rows = file;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const tasks = (await this.client.history(t.agent, t.session, 8)).filter((x) => x.depth === 0).reverse();
|
|
195
|
+
const calls = [];
|
|
196
|
+
for (const task of tasks) {
|
|
197
|
+
if (task.meta?.stats && !task.meta.stats.tools.length)
|
|
198
|
+
continue;
|
|
199
|
+
const got = await this.client.output(task.id).catch(() => undefined);
|
|
200
|
+
let n = 0;
|
|
201
|
+
for (const entry of got?.entries ?? [])
|
|
202
|
+
if (entry.kind === "tool")
|
|
203
|
+
calls.push({ task: task.id, n: n++, entry });
|
|
204
|
+
}
|
|
205
|
+
if (turn !== this.turn)
|
|
206
|
+
return;
|
|
207
|
+
s.calls = calls;
|
|
208
|
+
}
|
|
209
|
+
s.error = undefined;
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
if (turn !== this.turn)
|
|
213
|
+
return;
|
|
214
|
+
s.error = errText(e);
|
|
215
|
+
}
|
|
216
|
+
s.loading = undefined;
|
|
217
|
+
this.build();
|
|
218
|
+
}
|
|
219
|
+
async fileRows(t, file) {
|
|
220
|
+
if (isImageFile(file)) {
|
|
221
|
+
const got = await this.client.workspaceImage(t.agent, file, t.session).catch(() => undefined);
|
|
222
|
+
if (!got)
|
|
223
|
+
return [{ text: c.gray("this picture cannot be shown") }];
|
|
224
|
+
const p = await prepare(got.bytes, got.mime, path.basename(file));
|
|
225
|
+
// As half blocks: a real picture can only be drawn at the left edge of the screen.
|
|
226
|
+
const blocks = p.pixels ? halfBlocks(p.pixels, Math.max(8, Math.min(this.width - 3, 80)), 30) : [];
|
|
227
|
+
return [...blocks.map((text) => ({ text })), { text: c.gray(`${p.widthPx}×${p.heightPx}`) }];
|
|
228
|
+
}
|
|
229
|
+
const got = await this.client.file(t.agent, t.session, file);
|
|
230
|
+
if (got.binary || got.text === undefined)
|
|
231
|
+
return [{ text: c.gray(`a binary file, ${size(got.size)}`) }];
|
|
232
|
+
const lines = highlightCode(plain(got.text.replace(/\n$/, "")), path.extname(file).slice(1) || path.basename(file).toLowerCase());
|
|
233
|
+
const digits = String(lines.length).length;
|
|
234
|
+
const rows = lines.map((text, i) => ({ text, gutter: c.gray(`${String(i + 1).padStart(digits)} `), wrap: true }));
|
|
235
|
+
if (got.truncated)
|
|
236
|
+
rows.push({ text: c.gray(`… the first 256 KB of ${size(got.size)}`) });
|
|
237
|
+
return rows;
|
|
238
|
+
}
|
|
239
|
+
// ---- drawing ---------------------------------------------------------------------------------
|
|
240
|
+
go(change, top = true) {
|
|
241
|
+
const s = this.state;
|
|
242
|
+
if (!s)
|
|
243
|
+
return;
|
|
244
|
+
change(s);
|
|
245
|
+
if (top)
|
|
246
|
+
this.scroll.scrollToStart();
|
|
247
|
+
void this.load(false);
|
|
248
|
+
this.build();
|
|
249
|
+
}
|
|
250
|
+
back(label, undo) {
|
|
251
|
+
return { text: `${c.accent("‹ back")} ${c.gray("·")} ${c.bold(label)}`, run: () => this.go(undo) };
|
|
252
|
+
}
|
|
253
|
+
build() {
|
|
254
|
+
const s = this.state;
|
|
255
|
+
const rows = [];
|
|
256
|
+
if (!s)
|
|
257
|
+
rows.push({ text: c.gray("Switch to a conversation tab: this pane shows the folder a conversation works in."), wrap: true });
|
|
258
|
+
else if (s.error)
|
|
259
|
+
rows.push({ text: c.red(s.error), wrap: true });
|
|
260
|
+
else if (s.view === "changes")
|
|
261
|
+
this.changesRows(s, rows);
|
|
262
|
+
else if (s.view === "files")
|
|
263
|
+
this.filesRows(s, rows);
|
|
264
|
+
else
|
|
265
|
+
this.outputRows(s, rows);
|
|
266
|
+
this.rows.rows = rows;
|
|
267
|
+
this.redraw();
|
|
268
|
+
}
|
|
269
|
+
changesRows(s, rows) {
|
|
270
|
+
const ch = s.changes;
|
|
271
|
+
if (!ch)
|
|
272
|
+
return void rows.push({ text: c.gray(s.loading ?? "") });
|
|
273
|
+
if (!ch.repo)
|
|
274
|
+
return void rows.push({ text: c.gray("This folder is not in a git repository, so there are no changes to show. Files lists what is in it."), wrap: true });
|
|
275
|
+
if (s.diff) {
|
|
276
|
+
const file = s.diff.file;
|
|
277
|
+
const entry = ch.files.find((f) => f.path === file);
|
|
278
|
+
rows.push(this.back(file, (x) => (x.diff = undefined)));
|
|
279
|
+
if (entry)
|
|
280
|
+
rows.push({ text: c.gray(`${STATUS[entry.status] ?? entry.status}${counts(entry) ? ` · ${counts(entry)}` : ""}`) });
|
|
281
|
+
rows.push({ text: "" });
|
|
282
|
+
if (!s.diff.lines)
|
|
283
|
+
rows.push({ text: c.gray("reading …") });
|
|
284
|
+
else if (!s.diff.lines.length)
|
|
285
|
+
rows.push({ text: c.gray(entry ? "nothing to show for this file (a folder, or a binary file)" : "no difference to the last commit any more") });
|
|
286
|
+
else
|
|
287
|
+
for (const text of s.diff.lines)
|
|
288
|
+
rows.push({ text, wrap: true });
|
|
289
|
+
if (s.diff.truncated)
|
|
290
|
+
rows.push({ text: c.gray("… the diff goes on; this is its first 200 000 characters") });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!ch.files.length)
|
|
294
|
+
return void rows.push({ text: c.gray("Nothing has changed since the last commit.") });
|
|
295
|
+
const letter = { M: c.yellow, A: c.green, "?": c.green, D: c.red, R: c.cyan };
|
|
296
|
+
for (const f of ch.files) {
|
|
297
|
+
const n = counts(f);
|
|
298
|
+
rows.push({ text: `${(letter[f.status] ?? c.gray)(f.status === "?" ? "+" : f.status)} ${plain(f.path)}${n ? ` ${n}` : ""}`, run: () => this.go((x) => (x.diff = { file: f.path })) });
|
|
299
|
+
}
|
|
300
|
+
if (ch.more)
|
|
301
|
+
rows.push({ text: c.gray("… and more; the first 300 are listed") });
|
|
302
|
+
}
|
|
303
|
+
filesRows(s, rows) {
|
|
304
|
+
if (s.file) {
|
|
305
|
+
rows.push(this.back(s.file.path, (x) => (x.file = undefined)));
|
|
306
|
+
rows.push({ text: "" });
|
|
307
|
+
rows.push(...(s.file.rows ?? [{ text: c.gray("reading …") }]));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!s.dirs.has(""))
|
|
311
|
+
return void rows.push({ text: c.gray(s.loading ?? "this folder cannot be read") });
|
|
312
|
+
const walk = (dir, depth) => {
|
|
313
|
+
for (const e of s.dirs.get(dir) ?? []) {
|
|
314
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
315
|
+
const pad = " ".repeat(depth);
|
|
316
|
+
if (e.dir) {
|
|
317
|
+
const open = s.expanded.has(rel);
|
|
318
|
+
rows.push({ text: `${pad}${c.accent(open ? "▾" : "▸")} ${c.bold(plain(e.name))}/`, run: () => this.go((x) => void (open ? x.expanded.delete(rel) : x.expanded.add(rel)), false) });
|
|
319
|
+
if (open)
|
|
320
|
+
walk(rel, depth + 1);
|
|
321
|
+
}
|
|
322
|
+
else
|
|
323
|
+
rows.push({ text: `${pad} ${plain(e.name)} ${c.gray(size(e.size))}`, run: () => this.go((x) => (x.file = { path: rel })) });
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
walk("", 0);
|
|
327
|
+
if (!rows.length)
|
|
328
|
+
rows.push({ text: c.gray("This folder is empty.") });
|
|
329
|
+
}
|
|
330
|
+
outputRows(s, rows) {
|
|
331
|
+
if (!s.calls)
|
|
332
|
+
return void rows.push({ text: c.gray(s.loading ?? "") });
|
|
333
|
+
const mark = (e) => (e.ok === undefined ? c.yellow("●") : e.ok ? c.green("✓") : c.red("✗"));
|
|
334
|
+
const open = s.call && s.calls.find((x) => x.task === s.call.task && x.n === s.call.n);
|
|
335
|
+
if (open) {
|
|
336
|
+
const e = open.entry;
|
|
337
|
+
rows.push(this.back(e.tool ?? "tool", (x) => (x.call = undefined)));
|
|
338
|
+
rows.push({ text: `${mark(e)} ${c.gray(`task #${open.task}${e.ms !== undefined ? ` · ${took(e.ms)}` : ""}${e.ok === undefined ? " · still running" : ""}`)}` });
|
|
339
|
+
if (e.subject)
|
|
340
|
+
rows.push({ text: c.yellow(plain(e.subject).replace(/\n/g, " ")), wrap: true });
|
|
341
|
+
rows.push({ text: "" });
|
|
342
|
+
const text = plain(e.result ?? "");
|
|
343
|
+
const body = /^diff --git /m.test(text) ? diffLines(text) : text.split("\n");
|
|
344
|
+
if (!text.trim())
|
|
345
|
+
rows.push({ text: c.gray(e.ok === undefined ? "no output yet" : "(no output)") });
|
|
346
|
+
else
|
|
347
|
+
for (const line of body.slice(0, 4000))
|
|
348
|
+
rows.push({ text: e.ok === false ? c.red(line) : line, wrap: true });
|
|
349
|
+
if (body.length > 4000)
|
|
350
|
+
rows.push({ text: c.gray(`… ${body.length - 4000} more lines`) });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!s.calls.length)
|
|
354
|
+
return void rows.push({ text: c.gray("No tool has run in this conversation yet. What the agent's commands and other tools print is listed here."), wrap: true });
|
|
355
|
+
// The newest on top: that is the one looked for.
|
|
356
|
+
[...s.calls].reverse().forEach((call) => {
|
|
357
|
+
const e = call.entry;
|
|
358
|
+
rows.push({ text: `${mark(e)} ${c.bold(e.tool ?? "")} ${c.gray(plain(e.subject ?? "").replace(/\s+/g, " "))}${e.ms !== undefined ? c.gray(` ${took(e.ms)}`) : ""}`, run: () => this.go((x) => (x.call = { task: call.task, n: call.n })) });
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
renderHead(width) {
|
|
362
|
+
this.width = width;
|
|
363
|
+
const s = this.state;
|
|
364
|
+
let text = " ";
|
|
365
|
+
let at = 1;
|
|
366
|
+
this.hits = [];
|
|
367
|
+
const add = (styled, run) => {
|
|
368
|
+
const w = visibleWidth(styled);
|
|
369
|
+
if (run)
|
|
370
|
+
this.hits.push({ from: at, to: at + w, run });
|
|
371
|
+
text += styled;
|
|
372
|
+
at += w;
|
|
373
|
+
};
|
|
374
|
+
for (const view of PANE_VIEWS) {
|
|
375
|
+
const label = ` ${view[0].toUpperCase()}${view.slice(1)} `;
|
|
376
|
+
add(s?.view === view ? c.bgAccent(label) : c.gray(label), () => this.show(view));
|
|
377
|
+
add(" ");
|
|
378
|
+
}
|
|
379
|
+
const right = [
|
|
380
|
+
{ styled: c.gray(" ↻ "), run: () => this.show() },
|
|
381
|
+
{ styled: c.gray(" × "), run: () => this.close() },
|
|
382
|
+
];
|
|
383
|
+
const rightWidth = right.reduce((n, r) => n + visibleWidth(r.styled), 0);
|
|
384
|
+
text += " ".repeat(Math.max(0, width - at - rightWidth));
|
|
385
|
+
at = Math.max(at, width - rightWidth);
|
|
386
|
+
for (const r of right)
|
|
387
|
+
add(r.styled, r.run);
|
|
388
|
+
const where = s?.root ? tilde(s.root) : "";
|
|
389
|
+
const ch = s?.view === "changes" ? s.changes : undefined;
|
|
390
|
+
// What is asked for first comes first; the folder's name is what gets cut when the pane is narrow.
|
|
391
|
+
const info = [...(ch?.repo ? [`${ch.branch || "no branch"}`, ch.files.length ? `${ch.files.length}${ch.more ? "+" : ""} changed` : "clean"] : []), ...(s?.view === "output" && s.calls ? [`${s.calls.length} tool call${s.calls.length === 1 ? "" : "s"}`] : []), where].filter(Boolean).join(" · ");
|
|
392
|
+
return [truncateToWidth(text, width), truncateToWidth(` ${c.gray(info)}`, width), c.gray("─".repeat(width))];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const STATUS = { M: "changed", A: "added", D: "deleted", R: "renamed", "?": "new, not tracked by git yet" };
|
|
396
|
+
function counts(f) {
|
|
397
|
+
return [f.added ? c.green(`+${f.added}`) : "", f.removed ? c.red(`−${f.removed}`) : ""].filter(Boolean).join(" ");
|
|
398
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The language highlight.js knows for the word after a code block's opening fence, if any. */
|
|
2
|
+
export declare function languageOf(lang: string | undefined): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* A code block as lines, coloured when its language is known. A block without a language, or in
|
|
5
|
+
* one highlight.js does not know, comes back as it is: guessing the language colours plain text
|
|
6
|
+
* at random.
|
|
7
|
+
*/
|
|
8
|
+
export declare function highlightCode(code: string, lang?: string): string[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// Colours for code in an answer. highlight.js knows the languages and says what each piece of a
|
|
3
|
+
// text is (a keyword, a string, a removed line of a diff); here that becomes one of the terminal's
|
|
4
|
+
// own sixteen colours, so code follows the terminal's palette like the rest of the UI.
|
|
5
|
+
/** What highlight.js calls a piece of code -> the colour it gets (an SGR foreground code). */
|
|
6
|
+
const COLOUR = {
|
|
7
|
+
keyword: "35", literal: "35", "selector-tag": "35", "meta-keyword": "35", doctag: "35",
|
|
8
|
+
built_in: "36", type: "36", "builtin-name": "36", attr: "36", attribute: "36", variable: "36", "template-variable": "36", "selector-class": "36", "selector-id": "36", "selector-attr": "36", "selector-pseudo": "36",
|
|
9
|
+
string: "32", regexp: "32", "meta-string": "32", addition: "32",
|
|
10
|
+
number: "33", symbol: "33", bullet: "33", link: "33",
|
|
11
|
+
title: "34", section: "34", name: "34",
|
|
12
|
+
comment: "90", meta: "90", quote: "90",
|
|
13
|
+
deletion: "31",
|
|
14
|
+
};
|
|
15
|
+
/** Names a code block may carry that highlight.js knows under another name. */
|
|
16
|
+
const ALIAS = { tsx: "typescript", mts: "typescript", cts: "typescript", jsx: "javascript", mjs: "javascript", cjs: "javascript", jsonc: "json", json5: "json", jsonl: "json", vue: "xml", svelte: "xml", mdx: "markdown", fish: "bash", shellscript: "bash", terminal: "shell", "objective-c": "objectivec", "c++": "cpp", "c#": "csharp", tf: "ini", hcl: "ini", env: "ini", dotenv: "ini" };
|
|
17
|
+
const ENTITY = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'" };
|
|
18
|
+
let lib;
|
|
19
|
+
function hljs() {
|
|
20
|
+
if (lib === undefined) {
|
|
21
|
+
try {
|
|
22
|
+
lib = createRequire(import.meta.url)("highlight.js");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
lib = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return lib;
|
|
29
|
+
}
|
|
30
|
+
/** highlight.js's answer (text with <span class="hljs-..."> around the pieces) as coloured lines. */
|
|
31
|
+
function toLines(html) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
const open = [];
|
|
34
|
+
const colour = () => open.findLast((x) => x !== undefined);
|
|
35
|
+
let line = "";
|
|
36
|
+
for (const m of html.matchAll(/<span class="([^"]*)">|<\/span>|&(?:amp|lt|gt|quot|#x27|#39);|\n|[^<&\n]+|[<&]/g)) {
|
|
37
|
+
const piece = m[0];
|
|
38
|
+
if (m[1] !== undefined) {
|
|
39
|
+
const now = m[1].split(/\s+/).map((name) => COLOUR[name.replace(/^hljs-/, "")]).find(Boolean);
|
|
40
|
+
open.push(now);
|
|
41
|
+
if (now)
|
|
42
|
+
line += `\x1b[${now}m`;
|
|
43
|
+
}
|
|
44
|
+
else if (piece === "</span>") {
|
|
45
|
+
const was = open.pop();
|
|
46
|
+
if (was)
|
|
47
|
+
line += `\x1b[${colour() ?? "39"}m`;
|
|
48
|
+
}
|
|
49
|
+
else if (piece === "\n") {
|
|
50
|
+
// A colour that runs over the end of a line (a long comment, a string) ends with the line and starts again on the next.
|
|
51
|
+
lines.push(colour() ? `${line}\x1b[39m` : line);
|
|
52
|
+
line = colour() ? `\x1b[${colour()}m` : "";
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
line += ENTITY[piece] ?? piece;
|
|
56
|
+
}
|
|
57
|
+
lines.push(colour() ? `${line}\x1b[39m` : line);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
/** The language highlight.js knows for the word after a code block's opening fence, if any. */
|
|
61
|
+
export function languageOf(lang) {
|
|
62
|
+
const word = (lang ?? "").trim().split(/[\s,{:]/)[0].toLowerCase().replace(/^\./, "");
|
|
63
|
+
if (!word)
|
|
64
|
+
return undefined;
|
|
65
|
+
const name = ALIAS[word] ?? word;
|
|
66
|
+
return hljs()?.getLanguage(name) ? name : undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A code block as lines, coloured when its language is known. A block without a language, or in
|
|
70
|
+
* one highlight.js does not know, comes back as it is: guessing the language colours plain text
|
|
71
|
+
* at random.
|
|
72
|
+
*/
|
|
73
|
+
export function highlightCode(code, lang) {
|
|
74
|
+
const plain = () => code.split("\n");
|
|
75
|
+
if (process.env.NO_COLOR || code.length > 200_000)
|
|
76
|
+
return plain();
|
|
77
|
+
const language = languageOf(lang);
|
|
78
|
+
if (!language)
|
|
79
|
+
return plain();
|
|
80
|
+
try {
|
|
81
|
+
return toLines(hljs().highlight(code, { language, ignoreIllegals: true }).value);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return plain();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
/**
|
|
3
|
+
* Pictures in the conversation. Display only: nothing here is sent to a model.
|
|
4
|
+
*
|
|
5
|
+
* Two ways to draw one. A terminal with the Kitty graphics protocol (kitty, Ghostty, WezTerm) gets
|
|
6
|
+
* the real picture. Every other terminal gets coloured half blocks: each character cell shows two
|
|
7
|
+
* pixels, one as the colour of "▀" and one as its background. For that the picture has to be
|
|
8
|
+
* decoded and scaled, which macOS's own `sips` does; it also turns jpg, gif and webp into the png
|
|
9
|
+
* the Kitty protocol wants.
|
|
10
|
+
*/
|
|
11
|
+
export interface Pixels {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
/** width * height * 3 bytes, rows from the top. */
|
|
15
|
+
rgb: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
export interface Prepared {
|
|
18
|
+
name: string;
|
|
19
|
+
widthPx: number;
|
|
20
|
+
heightPx: number;
|
|
21
|
+
/** Base64 png, scaled down to at most 1200 pixels on its longer side. */
|
|
22
|
+
png?: string;
|
|
23
|
+
/** A small copy for the half-block drawing. */
|
|
24
|
+
pixels?: Pixels;
|
|
25
|
+
}
|
|
26
|
+
/** Read an uncompressed 24 or 32 bit BMP, the simplest format sips writes. Transparent parts are shown on dark grey. */
|
|
27
|
+
export declare function parseBmp(b: Buffer): Pixels | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Make a picture ready to draw. Returns what could be made: without sips (not macOS) a png still
|
|
30
|
+
* shows on a Kitty-protocol terminal, and anything else is shown as a line of text.
|
|
31
|
+
*/
|
|
32
|
+
export declare function prepare(bytes: Buffer, mime: string, name: string): Promise<Prepared>;
|
|
33
|
+
/** Draw a picture with half blocks, at most `maxCols` cells wide and `maxRows` lines high. */
|
|
34
|
+
export declare function halfBlocks(p: Pixels, maxCols: number, maxRows: number, trueColor?: boolean): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The lines of a screen, with every real picture that `covered` says something lies over drawn as
|
|
37
|
+
* text instead. `covered` gets the picture's place: its first line and column, its width and height.
|
|
38
|
+
* Returns the same array when no picture is covered.
|
|
39
|
+
*/
|
|
40
|
+
export declare function picturesAsText(lines: string[], covered: (row: number, col: number, width: number, height: number) => boolean): string[];
|
|
41
|
+
/** One picture in the conversation: a thumbnail that a click enlarges and a second click shrinks again. */
|
|
42
|
+
export declare class Picture implements Component {
|
|
43
|
+
private p;
|
|
44
|
+
private large;
|
|
45
|
+
private images;
|
|
46
|
+
private cache?;
|
|
47
|
+
constructor(prepared: Prepared);
|
|
48
|
+
invalidate(): void;
|
|
49
|
+
handleMouse(e: any): {
|
|
50
|
+
handled: boolean;
|
|
51
|
+
render: boolean;
|
|
52
|
+
} | undefined;
|
|
53
|
+
/** The drawing itself, without a caption: the real picture where the terminal can show one, half blocks otherwise, nothing if neither works. */
|
|
54
|
+
body(width: number, large?: boolean): string[];
|
|
55
|
+
blocks(id: number, cols: number): string[];
|
|
56
|
+
render(width: number): string[];
|
|
57
|
+
}
|
|
58
|
+
/** A place in the conversation that is filled in later, so a picture lands where it belongs however long it takes to load. */
|
|
59
|
+
export declare class Slot implements Component {
|
|
60
|
+
inner?: Component;
|
|
61
|
+
invalidate(): void;
|
|
62
|
+
handleMouse(e: any): import("@earendil-works/pi-tui").TuiMouseEventResult | undefined;
|
|
63
|
+
render(width: number): string[];
|
|
64
|
+
}
|
|
65
|
+
/** Whether videos can be played in the conversation (ffmpeg is installed). */
|
|
66
|
+
export declare const canPlayVideo: boolean;
|
|
67
|
+
export interface PreparedVideo {
|
|
68
|
+
file: string;
|
|
69
|
+
name: string;
|
|
70
|
+
seconds?: number;
|
|
71
|
+
poster: Prepared;
|
|
72
|
+
}
|
|
73
|
+
/** Get a video file ready to show: a still from its start, and how long it runs. */
|
|
74
|
+
export declare function prepareVideo(file: string, name: string): Promise<PreparedVideo>;
|
|
75
|
+
/** A png from raw pixels (3 bytes each, rows from the top): what the Kitty graphics protocol takes. Fast, lightly compressed. */
|
|
76
|
+
export declare function pngFromRgb(rgb: Uint8Array, width: number, height: number): Buffer;
|
|
77
|
+
/**
|
|
78
|
+
* One video in the conversation. It plays at the size it is shown in. The picture itself is the
|
|
79
|
+
* size switch (click to enlarge, click to shrink), the first word of the caption is play and pause.
|
|
80
|
+
*
|
|
81
|
+
* Frames are drawn the way pictures are: as real images where the terminal has the Kitty graphics
|
|
82
|
+
* protocol (each frame replaces the last under one image id), as half blocks everywhere else.
|
|
83
|
+
*/
|
|
84
|
+
export declare class Video implements Component {
|
|
85
|
+
private v;
|
|
86
|
+
private still;
|
|
87
|
+
private redraw;
|
|
88
|
+
private open;
|
|
89
|
+
private child?;
|
|
90
|
+
private ticker?;
|
|
91
|
+
private frame?;
|
|
92
|
+
/** The current frame as a real image, on terminals that can show one. */
|
|
93
|
+
private shown?;
|
|
94
|
+
private imageId;
|
|
95
|
+
private paused;
|
|
96
|
+
private large;
|
|
97
|
+
/** Seconds of the video shown so far. */
|
|
98
|
+
private position;
|
|
99
|
+
private width;
|
|
100
|
+
private captionRow;
|
|
101
|
+
/** The lines the frame takes on screen. */
|
|
102
|
+
private shownRows;
|
|
103
|
+
private playTo;
|
|
104
|
+
private openFrom;
|
|
105
|
+
constructor(video: PreparedVideo, redraw: () => void, open: (file: string) => void);
|
|
106
|
+
invalidate(): void;
|
|
107
|
+
get state(): "idle" | "playing" | "paused";
|
|
108
|
+
/** The cells a frame fills: the same room the still takes at the current size, in the video's own shape. */
|
|
109
|
+
private size;
|
|
110
|
+
/**
|
|
111
|
+
* The pixels to decode a frame at. For half blocks: one pixel per half cell. For real images: as
|
|
112
|
+
* many pixels as those cells have on screen, up to a width that a terminal takes ten times a second.
|
|
113
|
+
*/
|
|
114
|
+
private pixels;
|
|
115
|
+
/** Play, or go on after a pause. At the end it starts over. */
|
|
116
|
+
play(): void;
|
|
117
|
+
pause(): void;
|
|
118
|
+
/** Decode from `from` seconds on, at the current size. `hold`: show one frame and stay paused there. */
|
|
119
|
+
private start;
|
|
120
|
+
/** Back to the still. */
|
|
121
|
+
stop(): void;
|
|
122
|
+
private resize;
|
|
123
|
+
handleMouse(e: any): {
|
|
124
|
+
handled: boolean;
|
|
125
|
+
render: boolean;
|
|
126
|
+
} | undefined;
|
|
127
|
+
blocks(_id: number, cols: number): string[];
|
|
128
|
+
render(width: number): string[];
|
|
129
|
+
}
|
|
130
|
+
/** Stop whatever video is playing, for example when the UI closes. */
|
|
131
|
+
export declare function stopVideos(): void;
|
|
132
|
+
export declare const isVideoFile: (name: string) => boolean;
|
|
133
|
+
export declare const isImageFile: (name: string) => boolean;
|
|
134
|
+
export declare const mimeOf: (name: string) => "" | "image/gif" | "image/jpeg" | "image/png" | "image/webp";
|
|
135
|
+
export interface PictureRef {
|
|
136
|
+
caption: string;
|
|
137
|
+
target: string;
|
|
138
|
+
remote: boolean;
|
|
139
|
+
}
|
|
140
|
+
/** The pictures an answer points at with . Code blocks are left alone. */
|
|
141
|
+
export declare function picturesIn(markdown: string, max?: number): PictureRef[];
|