@wrongstack/tui 0.2.0 → 0.3.2

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/dist/index.js CHANGED
@@ -1,117 +1,14 @@
1
1
  import { render, useApp, Box, useStdout, Static, Text, useInput, useStdin } from 'ink';
2
2
  import React, { useState, useReducer, useRef, useEffect, useMemo } from 'react';
3
- import * as fs from 'fs/promises';
4
- import * as path3 from 'path';
3
+ import * as fs2 from 'fs/promises';
4
+ import * as path2 from 'path';
5
5
  import { InputBuilder, formatTodosList } from '@wrongstack/core';
6
- import { spawn } from 'child_process';
7
- import * as os from 'os';
6
+ import { routeImagesForModel } from '@wrongstack/runtime/vision';
7
+ import { readClipboardImage } from '@wrongstack/runtime/clipboard';
8
8
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
+ import { spawn } from 'child_process';
9
10
 
10
11
  // src/run-tui.ts
11
- var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
12
- async function readClipboardImage() {
13
- const platform = process.platform;
14
- if (platform === "win32") return readWindows();
15
- if (platform === "darwin") return readDarwin();
16
- if (platform === "linux") return readLinux();
17
- return null;
18
- }
19
- async function readWindows() {
20
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
21
- const ps = [
22
- "Add-Type -AssemblyName System.Windows.Forms",
23
- "Add-Type -AssemblyName System.Drawing",
24
- "$img = [System.Windows.Forms.Clipboard]::GetImage()",
25
- 'if ($img -eq $null) { Write-Output "NO_IMAGE"; exit 0 }',
26
- `$img.Save('${tmp.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
27
- 'Write-Output "OK"'
28
- ].join("; ");
29
- const out = await runCmd("powershell", ["-NoProfile", "-Command", ps]);
30
- if (!out || out.trim() === "NO_IMAGE") return null;
31
- if (!out.includes("OK")) return null;
32
- return readPngFile(tmp);
33
- }
34
- async function readDarwin() {
35
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
36
- const script = [
37
- "try",
38
- ` set the_file to (open for access POSIX file "${tmp}" with write permission)`,
39
- " write (the clipboard as \xABclass PNGf\xBB) to the_file",
40
- " close access the_file",
41
- "on error",
42
- " try",
43
- ' close access POSIX file "' + tmp + '"',
44
- " end try",
45
- ' return "NO_IMAGE"',
46
- "end try",
47
- 'return "OK"'
48
- ].join("\n");
49
- const out = await runCmd("osascript", ["-e", script]);
50
- if (!out || out.trim() !== "OK") return null;
51
- return readPngFile(tmp);
52
- }
53
- async function readLinux() {
54
- const tmp = path3.join(os.tmpdir(), `wstack-clip-${Date.now()}.png`);
55
- const tries = [
56
- ["wl-paste", ["--type", "image/png"]],
57
- ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
58
- ];
59
- for (const [cmd, args] of tries) {
60
- const ok = await runCmdToFile(cmd, args, tmp).catch(() => false);
61
- if (ok) return readPngFile(tmp);
62
- }
63
- return null;
64
- }
65
- async function readPngFile(p) {
66
- try {
67
- const buf = await fs.readFile(p);
68
- if (buf.length === 0) {
69
- await fs.unlink(p).catch(() => void 0);
70
- return null;
71
- }
72
- if (buf.length > MAX_IMAGE_BYTES) {
73
- await fs.unlink(p).catch(() => void 0);
74
- throw new Error(`Clipboard image exceeds ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit`);
75
- }
76
- if (buf[0] !== 137 || buf[1] !== 80 || buf[2] !== 78 || buf[3] !== 71) {
77
- await fs.unlink(p).catch(() => void 0);
78
- return null;
79
- }
80
- await fs.unlink(p).catch(() => void 0);
81
- return { base64: buf.toString("base64"), mediaType: "image/png", bytes: buf.length };
82
- } catch (err) {
83
- if (err.code === "ENOENT") return null;
84
- throw err;
85
- }
86
- }
87
- function runCmd(cmd, args) {
88
- return new Promise((resolve) => {
89
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
90
- let out = "";
91
- child.stdout.on("data", (c) => {
92
- out += String(c);
93
- });
94
- child.on("error", () => resolve(null));
95
- child.on("exit", (code) => resolve(code === 0 ? out : null));
96
- });
97
- }
98
- function runCmdToFile(cmd, args, outPath) {
99
- return new Promise((resolve) => {
100
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
101
- const chunks = [];
102
- child.stdout.on("data", (c) => chunks.push(c));
103
- child.on("error", () => resolve(false));
104
- child.on("exit", async (code) => {
105
- if (code !== 0 || chunks.length === 0) return resolve(false);
106
- try {
107
- await fs.writeFile(outPath, Buffer.concat(chunks));
108
- resolve(true);
109
- } catch {
110
- resolve(false);
111
- }
112
- });
113
- });
114
- }
115
12
  function stringifyInput(input) {
116
13
  if (!input || typeof input !== "object") return "";
117
14
  const obj = input;
@@ -214,8 +111,8 @@ function FilePicker({ query, matches, selected }) {
214
111
  ] }, m))
215
112
  ] });
216
113
  }
217
- function highlight(path4, query) {
218
- return path4;
114
+ function highlight(path3, query) {
115
+ return path3;
219
116
  }
220
117
  var STATUS_ICON = {
221
118
  idle: { icon: "\u25CB", color: "gray" },
@@ -233,6 +130,27 @@ function fmtCount(n) {
233
130
  if (n === 0) return "\u2014";
234
131
  return String(n);
235
132
  }
133
+ function fmtDuration(ms) {
134
+ if (ms < 1e3) return `${ms}ms`;
135
+ return `${(ms / 1e3).toFixed(1)}s`;
136
+ }
137
+ function fmtBytes(n) {
138
+ if (n < 1024) return `${n}B`;
139
+ return `${(n / 1024).toFixed(1)}KB`;
140
+ }
141
+ function fmtRecentTool(tool) {
142
+ const status = tool.ok === false ? "fail" : "ok";
143
+ const name = tool.name.length > 24 ? `${tool.name.slice(0, 23)}...` : tool.name;
144
+ const parts = [status, name];
145
+ if (typeof tool.durationMs === "number") parts.push(fmtDuration(tool.durationMs));
146
+ if (typeof tool.outputBytes === "number" && tool.outputBytes > 0) parts.push(fmtBytes(tool.outputBytes));
147
+ if (typeof tool.outputLines === "number" && tool.outputLines > 0) parts.push(`${tool.outputLines}L`);
148
+ return parts.join(" ");
149
+ }
150
+ function fmtRecentMessage(message) {
151
+ const text = message.text.replace(/\s+/g, " ");
152
+ return text.length > 80 ? `${text.slice(0, 79)}...` : text;
153
+ }
236
154
  function fmtModel(provider, model) {
237
155
  if (!provider && !model) return "";
238
156
  const p = provider ?? "";
@@ -282,6 +200,8 @@ function FleetPanel({ entries, totalCost, roster }) {
282
200
  const si = STATUS_ICON[entry.status];
283
201
  const modelTag = fmtModel(entry.provider, entry.model);
284
202
  const name = resolveName(entry, roster);
203
+ const recentTools = (entry.recentTools ?? []).slice(-2).map(fmtRecentTool).join(" | ");
204
+ const recentMessages = (entry.recentMessages ?? []).slice(-2).map(fmtRecentMessage);
285
205
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
286
206
  /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 1, children: [
287
207
  /* @__PURE__ */ jsx(Text, { color: si.color, children: si.icon }),
@@ -313,6 +233,14 @@ function FleetPanel({ entries, totalCost, roster }) {
313
233
  "ms)"
314
234
  ] })
315
235
  ] }) : null,
236
+ recentTools ? /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
237
+ "tools: ",
238
+ recentTools
239
+ ] }) }) : null,
240
+ recentMessages.map((message, index) => /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
241
+ "msg: ",
242
+ message
243
+ ] }) }, `${entry.id}-msg-${index}-${message}`)),
316
244
  entry.status === "running" && entry.streamingText ? /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
317
245
  ">",
318
246
  " ",
@@ -335,6 +263,23 @@ function fmtElapsed(ms) {
335
263
  const s = Math.floor(ms % 6e4 / 1e3);
336
264
  return `${m}m${s.toString().padStart(2, "0")}s`;
337
265
  }
266
+ function fmtBytes2(n) {
267
+ if (n < 1024) return `${n}B`;
268
+ return `${(n / 1024).toFixed(1)}KB`;
269
+ }
270
+ function fmtRecentTool2(tool) {
271
+ const status = tool.ok === false ? "fail" : "ok";
272
+ const name = tool.name.length > 18 ? `${tool.name.slice(0, 17)}...` : tool.name;
273
+ const parts = [status, name];
274
+ if (typeof tool.durationMs === "number") parts.push(fmtElapsed(tool.durationMs));
275
+ if (typeof tool.outputBytes === "number" && tool.outputBytes > 0) parts.push(fmtBytes2(tool.outputBytes));
276
+ if (typeof tool.outputLines === "number" && tool.outputLines > 0) parts.push(`${tool.outputLines}L`);
277
+ return parts.join(" ");
278
+ }
279
+ function fmtRecentMessage2(message) {
280
+ const text = message.text.replace(/\s+/g, " ");
281
+ return text.length > 48 ? `${text.slice(0, 47)}...` : text;
282
+ }
338
283
  function LiveActivityStrip({
339
284
  entries,
340
285
  nowTick,
@@ -348,6 +293,8 @@ function LiveActivityStrip({
348
293
  const toolElapsed = e.currentTool ? now - e.currentTool.startedAt : 0;
349
294
  const taskElapsed = now - e.startedAt;
350
295
  const toolSeg = e.currentTool ? `\u2192 ${e.currentTool.name} (${fmtElapsed(toolElapsed)})` : "idle between tools";
296
+ const recentTools = (e.recentTools ?? []).slice(-2).map(fmtRecentTool2).join(" | ");
297
+ const messageText = e.streamingText.trim() || (e.recentMessages ?? []).slice(-1).map(fmtRecentMessage2).join("");
351
298
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 1, children: [
352
299
  /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u25CF" }),
353
300
  /* @__PURE__ */ jsx(Text, { children: e.name.slice(0, 14).padEnd(14) }),
@@ -360,7 +307,21 @@ function LiveActivityStrip({
360
307
  e.toolCalls,
361
308
  "tc \xB7 ",
362
309
  fmtElapsed(taskElapsed)
363
- ] })
310
+ ] }),
311
+ recentTools ? /* @__PURE__ */ jsxs(Fragment, { children: [
312
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "|" }),
313
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
314
+ "last: ",
315
+ recentTools
316
+ ] })
317
+ ] }) : null,
318
+ messageText ? /* @__PURE__ */ jsxs(Fragment, { children: [
319
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "|" }),
320
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
321
+ "msg: ",
322
+ fmtRecentMessage2({ text: messageText})
323
+ ] })
324
+ ] }) : null
364
325
  ] }, e.id);
365
326
  }),
366
327
  Object.values(entries).filter((e) => e.status === "running").length > maxRows ? /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
@@ -594,7 +555,7 @@ function ToolStreamBox({
594
555
  /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
595
556
  /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25C6 " }),
596
557
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: name }),
597
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u23F1 ${fmtDuration(elapsedMs)}` }),
558
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u23F1 ${fmtDuration2(elapsedMs)}` }),
598
559
  hidden > 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` (${totalLines} lines, showing last ${MAX_STREAM_LINES})` }) : null
599
560
  ] }),
600
561
  /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [
@@ -617,6 +578,15 @@ function tailForDisplay(text, maxChars) {
617
578
  return `\u2026 ${text.slice(cut)}`;
618
579
  }
619
580
  function DiffBlock({ rows, hidden }) {
581
+ let gutterWidth = 1;
582
+ for (const r of rows) {
583
+ const n = r.kind === "del" ? r.oldLine : r.newLine;
584
+ if (typeof n === "number") {
585
+ const w = String(n).length;
586
+ if (w > gutterWidth) gutterWidth = w;
587
+ }
588
+ }
589
+ const blank = " ".repeat(gutterWidth);
620
590
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginLeft: 4, marginTop: 0, children: [
621
591
  rows.map((row, i) => {
622
592
  const key = i;
@@ -624,16 +594,20 @@ function DiffBlock({ rows, hidden }) {
624
594
  return /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: row.text }, key);
625
595
  }
626
596
  if (row.kind === "meta") {
627
- return /* @__PURE__ */ jsx(Text, { dimColor: true, children: row.text }, key);
597
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${blank} ${row.text}` }, key);
628
598
  }
599
+ const lnNumber = row.kind === "del" ? row.oldLine : row.newLine;
600
+ const lnText = typeof lnNumber === "number" ? String(lnNumber).padStart(gutterWidth, " ") : blank;
629
601
  if (row.kind === "ctx") {
630
- return /* @__PURE__ */ jsx(Text, { dimColor: true, children: row.text }, key);
602
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${lnText} ${row.text}` }, key);
631
603
  }
632
- const bg = row.kind === "add" ? "green" : "red";
633
- const fg = row.kind === "add" ? "black" : "white";
634
- return /* @__PURE__ */ jsx(Text, { backgroundColor: bg, color: fg, children: row.text }, key);
604
+ const bg = row.kind === "add" ? "greenBright" : "redBright";
605
+ return /* @__PURE__ */ jsxs(Text, { children: [
606
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${lnText} ` }),
607
+ /* @__PURE__ */ jsx(Text, { backgroundColor: bg, color: "black", children: row.text })
608
+ ] }, key);
635
609
  }),
636
- hidden > 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, italic: true, children: ` \u2026 ${hidden} more line${hidden === 1 ? "" : "s"}` }) : null
610
+ hidden > 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, italic: true, children: `${blank} \u2026 ${hidden} more line${hidden === 1 ? "" : "s"}` }) : null
637
611
  ] });
638
612
  }
639
613
  function Entry({
@@ -667,7 +641,7 @@ function Entry({
667
641
  parts.push(`${entry.outputLines} L`);
668
642
  }
669
643
  if (entry.outputBytes && entry.outputBytes > 0) {
670
- parts.push(fmtBytes(entry.outputBytes));
644
+ parts.push(fmtBytes3(entry.outputBytes));
671
645
  }
672
646
  if (entry.outputTokens && entry.outputTokens > 0) {
673
647
  parts.push(`\u2248${fmtTok(entry.outputTokens)} tok`);
@@ -683,7 +657,7 @@ function Entry({
683
657
  /* @__PURE__ */ jsx(Text, { children: " " }),
684
658
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: argSummary })
685
659
  ] }) : null,
686
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \xB7 ${fmtDuration(entry.durationMs)}` }),
660
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \xB7 ${fmtDuration2(entry.durationMs)}` }),
687
661
  sizeChip ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \xB7 ${sizeChip}` }) : null
688
662
  ] }),
689
663
  outLines.map((line, i) => (
@@ -823,7 +797,7 @@ function fmtTok(n) {
823
797
  if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
824
798
  return String(n);
825
799
  }
826
- function fmtDuration(ms) {
800
+ function fmtDuration2(ms) {
827
801
  if (ms < 1e3) return `${ms}ms`;
828
802
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
829
803
  const totalSec = Math.floor(ms / 1e3);
@@ -951,7 +925,7 @@ function formatToolOutput(toolName, output, ok, _outputBytes, outputLines) {
951
925
  const bytes = numOf(o["bytes_written"]) ?? numOf(o["bytes"]);
952
926
  const created = o["created"] === true;
953
927
  const tag = created ? "created" : "updated";
954
- if (bytes !== void 0) return [`${tag} \xB7 ${fmtBytes(bytes)}`];
928
+ if (bytes !== void 0) return [`${tag} \xB7 ${fmtBytes3(bytes)}`];
955
929
  return [tag];
956
930
  }
957
931
  }
@@ -1016,17 +990,17 @@ function formatToolOutput(toolName, output, ok, _outputBytes, outputLines) {
1016
990
  if (json && typeof json === "object") {
1017
991
  const o = json;
1018
992
  const bytes = numOf(o["bytes"]);
1019
- if (bytes !== void 0) return [`${fmtBytes(bytes)} read`];
993
+ if (bytes !== void 0) return [`${fmtBytes3(bytes)} read`];
1020
994
  }
1021
995
  const range = scanNumberedRange(text);
1022
996
  if (range.count > 0 && range.first !== void 0 && range.last !== void 0) {
1023
997
  if (range.first === range.last) {
1024
- return [`L${range.first} \xB7 ${fmtBytes(text.length)}`];
998
+ return [`L${range.first} \xB7 ${fmtBytes3(text.length)}`];
1025
999
  }
1026
1000
  const contiguous = range.count === range.last - range.first + 1;
1027
1001
  const head = `L${range.first}\u2013${range.last}`;
1028
1002
  const tail = contiguous ? `${range.count} line${range.count === 1 ? "" : "s"}` : `${range.count} lines (gaps)`;
1029
- return [`${head} \xB7 ${tail} \xB7 ${fmtBytes(text.length)}`];
1003
+ return [`${head} \xB7 ${tail} \xB7 ${fmtBytes3(text.length)}`];
1030
1004
  }
1031
1005
  }
1032
1006
  if (toolName === "grep" || toolName === "glob") {
@@ -1084,7 +1058,7 @@ function formatToolOutput(toolName, output, ok, _outputBytes, outputLines) {
1084
1058
  const head = [];
1085
1059
  if (status !== void 0) head.push(`HTTP ${status}`);
1086
1060
  if (ct) head.push(ct.split(";")[0] ?? ct);
1087
- if (content) head.push(fmtBytes(Buffer.byteLength(content, "utf8")));
1061
+ if (content) head.push(fmtBytes3(Buffer.byteLength(content, "utf8")));
1088
1062
  const lines = [];
1089
1063
  if (head.length > 0) lines.push(head.join(" \xB7 "));
1090
1064
  if (url && status !== void 0 && (status < 200 || status >= 400)) {
@@ -1175,7 +1149,7 @@ function formatToolOutput(toolName, output, ok, _outputBytes, outputLines) {
1175
1149
  if (runner && runner !== "none") head.push(runner);
1176
1150
  head.push(`${passed}/${total} passed`);
1177
1151
  if (failed > 0) head.push(`${failed} failed`);
1178
- if (duration !== void 0) head.push(fmtDuration(duration));
1152
+ if (duration !== void 0) head.push(fmtDuration2(duration));
1179
1153
  return [head.join(" \xB7 ")];
1180
1154
  }
1181
1155
  }
@@ -1386,20 +1360,29 @@ function extractDiffPreview(toolName, output) {
1386
1360
  }
1387
1361
  function parseUnifiedDiff(diff, maxLines) {
1388
1362
  const all = [];
1363
+ let oldLn = 0;
1364
+ let newLn = 0;
1389
1365
  for (const raw of diff.split("\n")) {
1390
1366
  const line = raw.replace(/\r$/, "");
1391
1367
  if (line.startsWith("+++") || line.startsWith("---")) continue;
1392
1368
  if (line.startsWith("diff --git") || line.startsWith("index ")) continue;
1393
1369
  if (line.startsWith("@@")) {
1370
+ const m = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
1371
+ if (m) {
1372
+ oldLn = Number.parseInt(m[1] ?? "0", 10) || 0;
1373
+ newLn = Number.parseInt(m[2] ?? "0", 10) || 0;
1374
+ }
1394
1375
  all.push({ kind: "hunk", text: truncMid(line, 60) });
1395
1376
  continue;
1396
1377
  }
1397
1378
  if (line.startsWith("+")) {
1398
- all.push({ kind: "add", text: truncMid(line, 100) });
1379
+ all.push({ kind: "add", text: truncMid(line, 100), newLine: newLn });
1380
+ newLn++;
1399
1381
  continue;
1400
1382
  }
1401
1383
  if (line.startsWith("-")) {
1402
- all.push({ kind: "del", text: truncMid(line, 100) });
1384
+ all.push({ kind: "del", text: truncMid(line, 100), oldLine: oldLn });
1385
+ oldLn++;
1403
1386
  continue;
1404
1387
  }
1405
1388
  if (line.startsWith("\\ No newline")) {
@@ -1407,7 +1390,9 @@ function parseUnifiedDiff(diff, maxLines) {
1407
1390
  continue;
1408
1391
  }
1409
1392
  if (line.length === 0) continue;
1410
- all.push({ kind: "ctx", text: truncMid(line, 100) });
1393
+ all.push({ kind: "ctx", text: truncMid(line, 100), oldLine: oldLn, newLine: newLn });
1394
+ oldLn++;
1395
+ newLn++;
1411
1396
  }
1412
1397
  if (all.length === 0) return { rows: [], hidden: 0 };
1413
1398
  if (all.length <= maxLines) return { rows: all, hidden: 0 };
@@ -1449,7 +1434,7 @@ function countLines(text) {
1449
1434
  if (!text) return 0;
1450
1435
  return text.replace(/\n$/, "").split("\n").length;
1451
1436
  }
1452
- function fmtBytes(n) {
1437
+ function fmtBytes3(n) {
1453
1438
  if (n < 1024) return `${n}B`;
1454
1439
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
1455
1440
  return `${(n / (1024 * 1024)).toFixed(1)}MB`;
@@ -1886,10 +1871,10 @@ async function loadIndex(root) {
1886
1871
  async function walk(root, rel, depth, out) {
1887
1872
  if (out.length >= MAX_FILES_INDEXED) return;
1888
1873
  if (depth > MAX_DEPTH) return;
1889
- const dir = rel ? path3.join(root, rel) : root;
1874
+ const dir = rel ? path2.join(root, rel) : root;
1890
1875
  let entries;
1891
1876
  try {
1892
- entries = await fs.readdir(dir, { withFileTypes: true });
1877
+ entries = await fs2.readdir(dir, { withFileTypes: true });
1893
1878
  } catch {
1894
1879
  return;
1895
1880
  }
@@ -2308,7 +2293,13 @@ function reducer(state, action) {
2308
2293
  // --- Fleet ---
2309
2294
  case "fleetSeed": {
2310
2295
  const seeded = {};
2311
- for (const e of action.entries) seeded[e.id] = e;
2296
+ for (const e of action.entries) {
2297
+ seeded[e.id] = {
2298
+ ...e,
2299
+ recentTools: e.recentTools ?? [],
2300
+ recentMessages: e.recentMessages ?? []
2301
+ };
2302
+ }
2312
2303
  return { ...state, fleet: seeded, fleetCost: action.cost };
2313
2304
  }
2314
2305
  case "fleetSpawn": {
@@ -2322,6 +2313,8 @@ function reducer(state, action) {
2322
2313
  streamingText: "",
2323
2314
  iterations: 0,
2324
2315
  toolCalls: 0,
2316
+ recentTools: [],
2317
+ recentMessages: [],
2325
2318
  cost: 0,
2326
2319
  startedAt: Date.now(),
2327
2320
  lastEventAt: Date.now(),
@@ -2383,14 +2376,45 @@ function reducer(state, action) {
2383
2376
  }
2384
2377
  };
2385
2378
  }
2379
+ case "fleetMessage": {
2380
+ const cur = state.fleet[action.id];
2381
+ const text = action.text.trim().replace(/\s+/g, " ");
2382
+ if (!cur || !text) return state;
2383
+ const now = Date.now();
2384
+ const recentMessages = [...cur.recentMessages ?? [], { text, at: now }].slice(-2);
2385
+ return {
2386
+ ...state,
2387
+ fleet: {
2388
+ ...state.fleet,
2389
+ [action.id]: { ...cur, recentMessages, lastEventAt: now }
2390
+ }
2391
+ };
2392
+ }
2386
2393
  case "fleetTool": {
2387
2394
  const cur = state.fleet[action.id];
2388
2395
  if (!cur) return state;
2396
+ const now = Date.now();
2397
+ const recentTools = action.name !== void 0 ? [
2398
+ ...cur.recentTools ?? [],
2399
+ {
2400
+ name: action.name,
2401
+ ok: action.ok,
2402
+ durationMs: action.durationMs,
2403
+ outputBytes: action.outputBytes,
2404
+ outputLines: action.outputLines,
2405
+ at: now
2406
+ }
2407
+ ].slice(-2) : cur.recentTools ?? [];
2389
2408
  return {
2390
2409
  ...state,
2391
2410
  fleet: {
2392
2411
  ...state.fleet,
2393
- [action.id]: { ...cur, toolCalls: cur.toolCalls + 1, lastEventAt: Date.now() }
2412
+ [action.id]: {
2413
+ ...cur,
2414
+ toolCalls: cur.toolCalls + 1,
2415
+ recentTools,
2416
+ lastEventAt: now
2417
+ }
2394
2418
  }
2395
2419
  };
2396
2420
  }
@@ -2416,6 +2440,7 @@ function reducer(state, action) {
2416
2440
  iterations: action.iterations,
2417
2441
  toolCalls: action.toolCalls,
2418
2442
  streamingText: "",
2443
+ currentTool: void 0,
2419
2444
  lastEventAt: Date.now()
2420
2445
  }
2421
2446
  }
@@ -2539,6 +2564,8 @@ function App({
2539
2564
  attachments,
2540
2565
  events,
2541
2566
  tokenCounter,
2567
+ visionAdapters = [],
2568
+ supportsVision,
2542
2569
  model,
2543
2570
  banner = true,
2544
2571
  queueStore,
@@ -2614,8 +2641,8 @@ function App({
2614
2641
  const lastEnterAtRef = useRef(0);
2615
2642
  const projectRoot = agent.ctx.projectRoot;
2616
2643
  const projectName = React.useMemo(() => {
2617
- const base = path3.basename(projectRoot);
2618
- return base && base !== path3.sep ? base : void 0;
2644
+ const base = path2.basename(projectRoot);
2645
+ return base && base !== path2.sep ? base : void 0;
2619
2646
  }, [projectRoot]);
2620
2647
  const streamingTextRef = useRef("");
2621
2648
  const pendingDeltaRef = useRef("");
@@ -2725,7 +2752,7 @@ function App({
2725
2752
  let cancelled = false;
2726
2753
  const poll = async () => {
2727
2754
  try {
2728
- const data = await fs.readFile(planPath, "utf8");
2755
+ const data = await fs2.readFile(planPath, "utf8");
2729
2756
  const parsed = JSON.parse(data);
2730
2757
  if (cancelled) return;
2731
2758
  if (!Array.isArray(parsed.items)) {
@@ -2857,9 +2884,9 @@ function App({
2857
2884
  dispatch({ type: "pickerClose" });
2858
2885
  return;
2859
2886
  }
2860
- const absPath = path3.isAbsolute(picked) ? picked : path3.join(projectRoot, picked);
2887
+ const absPath = path2.isAbsolute(picked) ? picked : path2.join(projectRoot, picked);
2861
2888
  try {
2862
- const data = await fs.readFile(absPath, "utf8");
2889
+ const data = await fs2.readFile(absPath, "utf8");
2863
2890
  const placeholder = await builder.appendFile({
2864
2891
  kind: "file",
2865
2892
  data,
@@ -3255,21 +3282,16 @@ function App({
3255
3282
  });
3256
3283
  });
3257
3284
  const offTool = events.on("subagent.tool_executed", (e) => {
3258
- const lbl = labelFor(e.subagentId);
3259
- dispatch({ type: "fleetTool", id: e.subagentId });
3260
- dispatch({ type: "fleetToolEnd", id: e.subagentId });
3261
- const bytesTag = typeof e.outputBytes === "number" && e.outputBytes > 0 ? ` \xB7 ${e.outputBytes < 1024 ? `${e.outputBytes}B` : `${(e.outputBytes / 1024).toFixed(1)}KB`}` : "";
3285
+ if (director) return;
3262
3286
  dispatch({
3263
- type: "addEntry",
3264
- entry: {
3265
- kind: "subagent",
3266
- agentLabel: lbl.label,
3267
- agentColor: lbl.color,
3268
- icon: e.ok === false ? "\u2717" : "\u25CF",
3269
- text: e.name,
3270
- detail: `${e.durationMs}ms${bytesTag}`
3271
- }
3287
+ type: "fleetTool",
3288
+ id: e.subagentId,
3289
+ name: e.name,
3290
+ ok: e.ok,
3291
+ durationMs: e.durationMs,
3292
+ outputBytes: e.outputBytes
3272
3293
  });
3294
+ dispatch({ type: "fleetToolEnd", id: e.subagentId });
3273
3295
  });
3274
3296
  return () => {
3275
3297
  offSpawned();
@@ -3277,7 +3299,7 @@ function App({
3277
3299
  offCompleted();
3278
3300
  offTool();
3279
3301
  };
3280
- }, [events]);
3302
+ }, [events, director]);
3281
3303
  useEffect(() => {
3282
3304
  if (!fleetStreamController) return;
3283
3305
  fleetStreamController.enabled = state.streamFleet;
@@ -3301,18 +3323,22 @@ function App({
3301
3323
  let streamFlushTimer = null;
3302
3324
  const flushStreamBufs = () => {
3303
3325
  for (const [id, text] of streamBuf) {
3304
- if (!text.trim()) continue;
3326
+ const trimmed = text.trim();
3327
+ if (!trimmed) continue;
3305
3328
  const lbl = labelFor(id);
3306
- dispatch({
3307
- type: "addEntry",
3308
- entry: {
3309
- kind: "subagent",
3310
- agentLabel: lbl.label,
3311
- agentColor: lbl.color,
3312
- icon: "\u{1F4AC}",
3313
- text: text.trim()
3314
- }
3315
- });
3329
+ dispatch({ type: "fleetMessage", id, text: trimmed });
3330
+ if (streamFleetRef.current) {
3331
+ dispatch({
3332
+ type: "addEntry",
3333
+ entry: {
3334
+ kind: "subagent",
3335
+ agentLabel: lbl.label,
3336
+ agentColor: lbl.color,
3337
+ icon: "\u{1F4AC}",
3338
+ text: trimmed
3339
+ }
3340
+ });
3341
+ }
3316
3342
  }
3317
3343
  streamBuf.clear();
3318
3344
  streamFlushTimer = null;
@@ -3377,10 +3403,9 @@ function App({
3377
3403
  const cur = pending.get(e.subagentId) ?? "";
3378
3404
  pending.set(e.subagentId, cur + p.text);
3379
3405
  if (!flushTimer) flushTimer = setTimeout(doFlush, FLUSH_MS);
3380
- if (streamFleetRef.current) {
3381
- streamBuf.set(e.subagentId, (streamBuf.get(e.subagentId) ?? "") + p.text);
3382
- if (!streamFlushTimer) streamFlushTimer = setTimeout(flushStreamBufs, FLUSH_MS * 4);
3383
- }
3406
+ streamBuf.set(e.subagentId, (streamBuf.get(e.subagentId) ?? "") + p.text);
3407
+ if (streamFlushTimer) clearTimeout(streamFlushTimer);
3408
+ streamFlushTimer = setTimeout(flushStreamBufs, FLUSH_MS * 4);
3384
3409
  }
3385
3410
  break;
3386
3411
  }
@@ -3392,28 +3417,17 @@ function App({
3392
3417
  break;
3393
3418
  }
3394
3419
  case "tool.executed": {
3395
- dispatch({ type: "fleetTool", id: e.subagentId });
3420
+ const p = e.payload;
3421
+ dispatch({
3422
+ type: "fleetTool",
3423
+ id: e.subagentId,
3424
+ name: p?.name,
3425
+ ok: p?.ok,
3426
+ durationMs: p?.durationMs,
3427
+ outputBytes: p?.outputBytes,
3428
+ outputLines: p?.outputLines
3429
+ });
3396
3430
  dispatch({ type: "fleetToolEnd", id: e.subagentId });
3397
- if (streamFleetRef.current) {
3398
- if (streamFlushTimer) {
3399
- clearTimeout(streamFlushTimer);
3400
- flushStreamBufs();
3401
- }
3402
- const p = e.payload;
3403
- const args = p?.input ? formatToolArgs(p.name ?? "", p.input) : "";
3404
- const lbl = labelFor(e.subagentId);
3405
- dispatch({
3406
- type: "addEntry",
3407
- entry: {
3408
- kind: "subagent",
3409
- agentLabel: lbl.label,
3410
- agentColor: lbl.color,
3411
- icon: p?.ok === false ? "\u2717" : "\u25CF",
3412
- text: args ? `${p?.name ?? "tool"} ${args}` : p?.name ?? "tool",
3413
- detail: typeof p?.durationMs === "number" ? `${p.durationMs}ms` : void 0
3414
- }
3415
- });
3416
- }
3417
3431
  break;
3418
3432
  }
3419
3433
  case "provider.response": {
@@ -3431,7 +3445,7 @@ function App({
3431
3445
  toolCalls: payload.result.toolCalls
3432
3446
  });
3433
3447
  dispatch({ type: "fleetCost", cost: d.snapshot().total.cost });
3434
- if (streamFleetRef.current && streamFlushTimer) {
3448
+ if (streamFlushTimer) {
3435
3449
  clearTimeout(streamFlushTimer);
3436
3450
  flushStreamBufs();
3437
3451
  }
@@ -3785,7 +3799,24 @@ function App({
3785
3799
  const startedAt = Date.now();
3786
3800
  const before = tokenCounter?.total();
3787
3801
  const costBefore = tokenCounter?.estimateCost().total ?? 0;
3788
- const result = await agent.run(blocks, { signal: ctrl.signal });
3802
+ const routed = blocks.some((block) => block.type === "image") ? await routeImagesForModel(blocks, {
3803
+ supportsVision: supportsVision ? await supportsVision() : agent.ctx.provider.capabilities.vision,
3804
+ adapters: visionAdapters,
3805
+ ctx: agent.ctx,
3806
+ signal: ctrl.signal,
3807
+ providerId: agent.ctx.provider.id,
3808
+ model: agent.ctx.model
3809
+ }) : { blocks, route: "none", convertedImages: 0 };
3810
+ if (routed.route === "adapter") {
3811
+ dispatch({
3812
+ type: "addEntry",
3813
+ entry: {
3814
+ kind: "info",
3815
+ text: `Image input analyzed via ${routed.adapterName ?? "vision adapter"} (${routed.convertedImages} image${routed.convertedImages === 1 ? "" : "s"}).`
3816
+ }
3817
+ });
3818
+ }
3819
+ const result = await agent.run(routed.blocks, { signal: ctrl.signal });
3789
3820
  const lingering = streamingTextRef.current;
3790
3821
  if (lingering.trim()) {
3791
3822
  dispatch({ type: "addEntry", entry: { kind: "assistant", text: lingering } });
@@ -3838,11 +3869,19 @@ function App({
3838
3869
  await runBlocks(head.blocks);
3839
3870
  }
3840
3871
  };
3872
+ const runBlocksRef = useRef(runBlocks);
3873
+ runBlocksRef.current = runBlocks;
3841
3874
  const submit = async () => {
3842
3875
  const raw = state.buffer;
3843
3876
  const trimmed = raw.trim();
3844
3877
  if (!trimmed && state.placeholders.length === 0) return;
3845
3878
  dispatch({ type: "resetInterrupts" });
3879
+ if (trimmed === "/image" || trimmed === "/paste-image") {
3880
+ dispatch({ type: "clearInput" });
3881
+ await pasteClipboardImage();
3882
+ if (state.historyIndex > 0) dispatch({ type: "historyPush", text: trimmed });
3883
+ return;
3884
+ }
3846
3885
  if (trimmed.startsWith("/")) {
3847
3886
  dispatch({ type: "addEntry", entry: { kind: "user", text: trimmed } });
3848
3887
  if (state.historyIndex > 0) dispatch({ type: "historyPush", text: trimmed });
@@ -3935,9 +3974,9 @@ function App({
3935
3974
  b.appendText(ask);
3936
3975
  }
3937
3976
  const blocks = await b.submit();
3938
- await runBlocks(blocks);
3977
+ await runBlocksRef.current(blocks);
3939
3978
  })();
3940
- }, []);
3979
+ }, [initialAsk, initialGoal]);
3941
3980
  const inputHint = useMemo(() => {
3942
3981
  if (state.status !== "idle") return "";
3943
3982
  if (state.buffer.startsWith("/")) return "slash command \u2014 Enter to dispatch";
@@ -4138,6 +4177,8 @@ async function runTui(opts) {
4138
4177
  attachments: opts.attachments,
4139
4178
  events: opts.events,
4140
4179
  tokenCounter: opts.tokenCounter,
4180
+ visionAdapters: opts.visionAdapters,
4181
+ supportsVision: opts.supportsVision,
4141
4182
  model: opts.model,
4142
4183
  banner: opts.banner ?? true,
4143
4184
  queueStore: opts.queueStore,