kafox-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Subagentes: um agente dentro do agente.
3
+ *
4
+ * O modelo chama `spawn_subagent` com um objetivo; cada tarefa roda em contexto
5
+ * proprio (nao polui a conversa principal) e devolve apenas o resultado final.
6
+ * Várias tarefas rodam em paralelo, o que serve para investigar pontos
7
+ * diferentes do projeto ao mesmo tempo (auditoria, testes, levantamento).
8
+ */
9
+ const SUBAGENT_TOOL_NAME = "spawn_subagent";
10
+ const MAX_TASKS = 3;
11
+ const MAX_DEPTH = 1;
12
+ // Subagente tambem nao tem teto por padrao: roda ate entregar o objetivo.
13
+ const DEFAULT_MAX_STEPS = Infinity;
14
+ const READ_ONLY_TOOLS = ["read_file", "list_dir"];
15
+ const ALL_LOCAL_TOOLS = ["read_file", "list_dir", "write_file", "edit_file", "run_command"];
16
+
17
+ const SUBAGENT_TOOL = {
18
+ type: "function",
19
+ function: {
20
+ name: SUBAGENT_TOOL_NAME,
21
+ description:
22
+ "Lanca um ou mais subagentes com objetivo proprio. Cada subagente roda em contexto separado, usa as ferramentas liberadas (padrao: read_file e list_dir) e devolve so o resultado final. Use para dividir trabalho, investigar varios pontos em paralelo (auditoria, testes, busca de arquivos) ou tarefas longas que nao precisam sujar esta conversa.",
23
+ parameters: {
24
+ type: "object",
25
+ properties: {
26
+ tasks: {
27
+ type: "array",
28
+ description: `Lista de tarefas (1 a ${MAX_TASKS}). Independentes rodam em paralelo.`,
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ objective: {
33
+ type: "string",
34
+ description: "O que o subagente deve entregar, com o criterio de pronto",
35
+ },
36
+ context: {
37
+ type: "string",
38
+ description: "Contexto extra: arquivos, caminhos, comandos, restricoes",
39
+ },
40
+ tools: {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ description:
44
+ "Ferramentas liberadas para esta tarefa (read_file, list_dir, write_file, edit_file, run_command). Padrao: read_file e list_dir.",
45
+ },
46
+ max_steps: {
47
+ type: "integer",
48
+ description: "Limite de passos (opcional; sem ele o subagente roda ate terminar)",
49
+ },
50
+ },
51
+ required: ["objective"],
52
+ },
53
+ },
54
+ objective: {
55
+ type: "string",
56
+ description: "Atalho para uma unica tarefa (equivalente a tasks: [{objective}])",
57
+ },
58
+ context: { type: "string", description: "Contexto extra para a tarefa unica" },
59
+ model: { type: "string", description: "Modelo dos subagentes (padrao: o da sessao)" },
60
+ },
61
+ },
62
+ },
63
+ };
64
+
65
+ function normalizeTasks(args = {}) {
66
+ const list = [];
67
+ if (Array.isArray(args.tasks)) {
68
+ for (const item of args.tasks) {
69
+ if (!item) continue;
70
+ if (typeof item === "string") list.push({ objective: item });
71
+ else if (item.objective) list.push(item);
72
+ }
73
+ } else if (args.objective) {
74
+ list.push({ objective: args.objective, context: args.context, tools: args.tools });
75
+ }
76
+ return list.slice(0, MAX_TASKS).map((task) => ({
77
+ objective: String(task.objective).trim(),
78
+ context: task.context ? String(task.context) : "",
79
+ tools: allowedTools(task.tools),
80
+ maxSteps: Number(task.max_steps || task.maxSteps) > 0 ? Number(task.max_steps || task.maxSteps) : Infinity,
81
+ }));
82
+ }
83
+
84
+ function allowedTools(requested) {
85
+ if (!Array.isArray(requested) || !requested.length) return [...READ_ONLY_TOOLS];
86
+ const chosen = requested
87
+ .map((name) => String(name).trim())
88
+ .filter((name) => ALL_LOCAL_TOOLS.includes(name));
89
+ return chosen.length ? [...new Set(chosen)] : [...READ_ONLY_TOOLS];
90
+ }
91
+
92
+ function subagentPrompt({ objective, context, cwd, depth }) {
93
+ return [
94
+ "Voce e um subagente do kafox CLI, rodando em contexto separado do agente principal.",
95
+ `Diretorio de trabalho: ${cwd}`,
96
+ `Objetivo: ${objective}`,
97
+ context ? `Contexto: ${context}` : "",
98
+ depth >= MAX_DEPTH ? "Nao lance outros subagentes." : "",
99
+ "Trabalhe so no objetivo recebido, use as ferramentas disponiveis e pare quando tiver a resposta.",
100
+ "Devolva um relatorio curto e direto em Markdown: o que encontrou, evidencias (arquivo:linha, saida de comando) e o que ficou em duvida.",
101
+ "Nao invente nada: se nao conseguiu verificar, diga isso explicitamente.",
102
+ ]
103
+ .filter(Boolean)
104
+ .join("\n");
105
+ }
106
+
107
+ function describeSubagentCall(args = {}) {
108
+ const tasks = normalizeTasks(args);
109
+ if (!tasks.length) return "(sem objetivo)";
110
+ if (tasks.length === 1) return tasks[0].objective.slice(0, 120);
111
+ return `${tasks.length} tarefas: ${tasks.map((task) => task.objective.slice(0, 40)).join(" | ")}`;
112
+ }
113
+
114
+ /* ------------------------- historico da sessao ------------------------- */
115
+ // O que os subagentes desta sessao fizeram: alimenta o `/agents`.
116
+ const SESSION_RUNS = [];
117
+ const MAX_RUNS = 50;
118
+
119
+ function recordRun({ objective, tools = [], ok, ms = 0, steps = 0, report = "" }) {
120
+ SESSION_RUNS.push({
121
+ objective: String(objective || "(sem objetivo)"),
122
+ tools: [...tools],
123
+ ok: Boolean(ok),
124
+ ms: Number(ms) || 0,
125
+ steps: Number(steps) || 0,
126
+ report: String(report || ""),
127
+ at: new Date().toISOString(),
128
+ });
129
+ if (SESSION_RUNS.length > MAX_RUNS) SESSION_RUNS.splice(0, SESSION_RUNS.length - MAX_RUNS);
130
+ return SESSION_RUNS[SESSION_RUNS.length - 1];
131
+ }
132
+
133
+ function listRuns() {
134
+ return SESSION_RUNS.map((run) => ({ ...run }));
135
+ }
136
+
137
+ function clearRuns() {
138
+ SESSION_RUNS.length = 0;
139
+ }
140
+
141
+ module.exports = {
142
+ SUBAGENT_TOOL,
143
+ SUBAGENT_TOOL_NAME,
144
+ MAX_TASKS,
145
+ MAX_DEPTH,
146
+ DEFAULT_MAX_STEPS,
147
+ READ_ONLY_TOOLS,
148
+ ALL_LOCAL_TOOLS,
149
+ normalizeTasks,
150
+ allowedTools,
151
+ subagentPrompt,
152
+ describeSubagentCall,
153
+ recordRun,
154
+ listRuns,
155
+ clearRuns,
156
+ };
package/src/tools.js CHANGED
@@ -102,6 +102,10 @@ const TOOL_NAMES = LOCAL_TOOL_NAMES;
102
102
  const APPROVAL_REQUIRED = new Set(["write_file", "edit_file", "run_command"]);
103
103
 
104
104
  const TOOL_META = {
105
+ // A ferramenta de subagente e montada no agent.js (nao vai para TOOL_SPECS
106
+ // porque só aparece enquanto a profundidade permite), mas o card da TUI
107
+ // precisa do mesmo rótulo das outras.
108
+ spawn_subagent: { kind: "agent", label: "Subagente" },
105
109
  read_file: { kind: "read", label: "Read" },
106
110
  list_dir: { kind: "list", label: "List" },
107
111
  write_file: { kind: "write", label: "Write" },
@@ -292,6 +296,14 @@ function previewToolChange(name, args = {}, context = {}) {
292
296
 
293
297
  /** Resumo curto da chamada para mostrar na conversa. */
294
298
  function describeToolCall(name, args = {}) {
299
+ if (name === "spawn_subagent") {
300
+ const tasks = Array.isArray(args.tasks) ? args.tasks : args.objective ? [args] : [];
301
+ if (!tasks.length) return "(sem objetivo)";
302
+ if (tasks.length === 1) return String(tasks[0].objective || "(objetivo)").slice(0, 120);
303
+ return `${tasks.length} subagentes: ${tasks
304
+ .map((task) => String(task.objective || "").slice(0, 40))
305
+ .join(" | ")}`;
306
+ }
295
307
  if (name === "read_file") return args.path || "(arquivo)";
296
308
  if (name === "write_file") return `${args.path || "(arquivo)"} (${String(args.content || "").length} chars)`;
297
309
  if (name === "edit_file") return `${args.path || "(arquivo)"}`;
package/src/tui.js CHANGED
@@ -23,6 +23,7 @@ const { logoLines, LOGO_WIDTH, LOGO_HEIGHT } = require("./logo");
23
23
  const { diffBlocks } = require("./diff");
24
24
  const { toolMeta, parseCommandOutput } = require("./tools");
25
25
  const { McpHub } = require("./mcp");
26
+ const sessions = require("./sessions");
26
27
 
27
28
  const ANSI = /\x1b\[[0-9;]*m/g;
28
29
  const stripAnsi = (value) => String(value).replace(ANSI, "");
@@ -508,6 +509,9 @@ function initialState() {
508
509
  scroll: 0,
509
510
  pageSize: 10,
510
511
  mouse: true,
512
+ retry: null,
513
+ sessionId: null,
514
+ sessionStartedAt: null,
511
515
  model: cfg.model || null,
512
516
  theme: cfg.theme || "default",
513
517
  suspended: false,
@@ -851,7 +855,7 @@ function messageLines(state, width) {
851
855
 
852
856
  if (state.busy && !streaming?.text) {
853
857
  lines.push("");
854
- lines.push(indent(2) + chalk.dim("pensando" + ".".repeat(state.frame % 4)));
858
+ lines.push(indent(2) + chalk.dim("trabalhando" + ".".repeat(state.frame % 4)));
855
859
  }
856
860
  return lines;
857
861
  }
@@ -962,6 +966,23 @@ function queueLines(state, width) {
962
966
  return rows;
963
967
  }
964
968
 
969
+ function modelLine(state, width) {
970
+ const model = state.model || "auto";
971
+ // O aviso do momento tem prioridade sobre a versao: com nome de modelo longo
972
+ // a linha trunca e a mensagem util precisa aparecer primeiro.
973
+ let label = `${model} · kafox v${state.version}`;
974
+ if (state.escArmed) {
975
+ label = `${model} · Esc de novo para parar`;
976
+ } else if (state.retry) {
977
+ const left = Math.max(1, Math.ceil(((state.retry.endsAt || 0) - Date.now()) / 1000));
978
+ label = `${model} · reconectando em ${left}s (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`;
979
+ }
980
+ return paintPanel(
981
+ state.theme,
982
+ padRight(indent(2) + chalk.dim(truncate(label, width - 2)), width)
983
+ );
984
+ }
985
+
965
986
  function inputBlock(state, width, { hero = false, tight = false } = {}) {
966
987
  const available = width - 4;
967
988
  const start =
@@ -975,36 +996,23 @@ function inputBlock(state, width, { hero = false, tight = false } = {}) {
975
996
  if (state.input) {
976
997
  body = prompt + shown;
977
998
  } else {
978
- const placeholder = hero
979
- ? "Pergunte alguma coisa... ex: \"explica esse repo\""
980
- : "Pergunte alguma coisa...";
999
+ // Enquanto a IA trabalha os pontinhos correm; parada, a caixa fica limpa.
1000
+ const placeholder = state.busy ? `trabalhando${".".repeat(state.frame % 4)}` : "";
981
1001
  body = prompt + chalk.dim(truncate(placeholder, available));
982
1002
  }
983
1003
 
984
- const lines = [rule(state, width), paintPanel(state.theme, padRight(body, width))];
985
- let cursorRow = 1;
986
-
987
- if (!tight) {
988
- const model = state.model || "auto";
989
- const status = state.escArmed
990
- ? " Esc de novo para parar a IA"
991
- : state.busy
992
- ? ` pensando${".".repeat(state.frame % 4)} · Esc 2× para parar`
993
- : state.autoApprove
994
- ? " rodando tudo"
995
- : "";
996
- // Com o Esc armado a versao sai de cena: o aviso precisa caber na linha.
997
- const label = state.escArmed
998
- ? `${model}${status}`
999
- : `${model} · kafox v${state.version}${status}`;
1000
- lines.push(paintPanel(state.theme, padRight(indent(2) + chalk.dim(truncate(label, width - 2)), width)));
1001
- lines.push(rule(state, width));
1002
- }
1004
+ const lines = [
1005
+ rule(state, width),
1006
+ paintPanel(state.theme, padRight(body, width)),
1007
+ rule(state, width),
1008
+ ];
1003
1009
 
1004
1010
  return {
1005
1011
  lines,
1006
- cursorRow,
1012
+ cursorRow: 1,
1007
1013
  cursorCol: 4 + (state.cursor - start),
1014
+ // O modelo fica fora da caixa de texto, logo abaixo dela.
1015
+ footer: tight ? null : modelLine(state, width),
1008
1016
  };
1009
1017
  }
1010
1018
 
@@ -1132,13 +1140,20 @@ function buildOverlay(state, cols, rows) {
1132
1140
  } else if (state.picker) {
1133
1141
  const picker = state.picker;
1134
1142
  const themePicker = picker.kind === "theme";
1135
- title = themePicker ? `Tema (${picker.index + 1}/${picker.items.length})` : "Escolha o modelo";
1136
- hint = "↑/↓ ou clique · Enter aplica · Esc cancela";
1143
+ const chatPicker = picker.kind === "chat";
1144
+ title = themePicker
1145
+ ? `Tema (${picker.index + 1}/${picker.items.length})`
1146
+ : chatPicker
1147
+ ? `Conversas (${picker.index + 1}/${picker.items.length})`
1148
+ : "Escolha o modelo";
1149
+ hint = chatPicker
1150
+ ? "↑/↓ ou clique · Enter retoma · Esc cancela"
1151
+ : "↑/↓ ou clique · Enter aplica · Esc cancela";
1137
1152
 
1138
1153
  if (picker.loading) {
1139
1154
  body = [chalk.dim("carregando modelos...")];
1140
1155
  } else {
1141
- const height = Math.min(themePicker ? 9 : 8, picker.items.length);
1156
+ const height = Math.min(chatPicker ? 10 : themePicker ? 9 : 8, picker.items.length);
1142
1157
  const start = clamp(
1143
1158
  picker.index - Math.floor(height / 2),
1144
1159
  0,
@@ -1152,10 +1167,16 @@ function buildOverlay(state, cols, rows) {
1152
1167
  let line;
1153
1168
  if (themePicker) {
1154
1169
  line = padRight(` ${caret} ${padRight(item.id, 15)} ` + themeSwatch(item.id), width - 4);
1170
+ } else if (chatPicker) {
1171
+ const when = String(item.updatedAt || "").slice(0, 10);
1172
+ const right = chalk.dim(`${when} ${item.id}`);
1173
+ const space = width - 6;
1174
+ const room = Math.max(12, space - visible(right) - 2);
1175
+ const left = ` ${caret} ${truncate(item.title || item.id, room)}`;
1176
+ const gap = Math.max(1, space - visible(left) - visible(right));
1177
+ line = padRight(left + " ".repeat(gap) + right, width - 4);
1155
1178
  } else {
1156
- const price = `${(item.input / 1_000_000).toFixed(2)} / ${(item.output / 1_000_000).toFixed(2)} R$`;
1157
- const label = padRight(` ${caret} ${item.id}`, Math.max(20, width - 28));
1158
- line = padRight(label + price, width - 4);
1179
+ line = padRight(` ${caret} ${item.id} `, width - 4);
1159
1180
  }
1160
1181
  body.push(real === picker.index ? chalk.inverse(line) : line);
1161
1182
  }
@@ -1231,7 +1252,14 @@ function buildFrame(state, cols, rows, commands = {}) {
1231
1252
  const input = inputBlock(state, inner, { hero, tight });
1232
1253
  const status = statusLines(state, inner, { bare });
1233
1254
  const hints = hintsLines(state, inner, { bare });
1234
- const chrome = [...queue, ...menu, ...input.lines, ...status, ...hints];
1255
+ const chrome = [
1256
+ ...queue,
1257
+ ...menu,
1258
+ ...input.lines,
1259
+ ...(input.footer ? [input.footer] : []),
1260
+ ...status,
1261
+ ...hints,
1262
+ ];
1235
1263
 
1236
1264
  const head = hero ? heroLines(state, inner, rows - chrome.length) : [];
1237
1265
 
@@ -1297,15 +1325,45 @@ async function runTUI({
1297
1325
  onCommand,
1298
1326
  interactiveCommands = new Set(),
1299
1327
  autoApprove = false,
1328
+ resume = null,
1300
1329
  } = {}) {
1301
1330
  const stdin = process.stdin;
1302
1331
  const stdout = process.stdout;
1303
1332
  const state = initialState();
1333
+
1334
+ // resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
1335
+ // qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
1336
+ let resumeMissing = null;
1337
+ let resumePick = false;
1338
+ if (resume) {
1339
+ if (resume === "pick") {
1340
+ resumePick = true;
1341
+ } else {
1342
+ const id = resume === "last" || resume === true ? sessions.latest()?.id : String(resume);
1343
+ const saved = sessions.load(id);
1344
+ if (saved) {
1345
+ state.sessionId = saved.id;
1346
+ state.sessionStartedAt = saved.startedAt;
1347
+ state.messages = Array.isArray(saved.messages) ? saved.messages : [];
1348
+ state.context = Array.isArray(saved.context) ? saved.context : [];
1349
+ if (saved.model) state.model = saved.model;
1350
+ if (saved.theme) state.theme = saved.theme;
1351
+ state.tokens = estimateTokens(state);
1352
+ } else {
1353
+ resumeMissing = String(id || resume);
1354
+ }
1355
+ }
1356
+ }
1357
+ if (!state.sessionId) {
1358
+ state.sessionId = sessions.newId();
1359
+ state.sessionStartedAt = new Date().toISOString();
1360
+ }
1304
1361
  // `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) ja comeca sem pedir aprovacao.
1305
1362
  state.autoApprove = Boolean(autoApprove) || process.env.KAFOX_ALLOW_ALL === "1";
1306
1363
  let mcp = new McpHub({ cwd: process.cwd() });
1307
1364
  let spinner = null;
1308
1365
  let escTimer = null;
1366
+ let saveTimer = null;
1309
1367
  let closed = false;
1310
1368
  let previousLines = [];
1311
1369
 
@@ -1365,6 +1423,34 @@ async function runTUI({
1365
1423
  render();
1366
1424
  }
1367
1425
 
1426
+ /** Grava a conversa em ~/.kafox/chats para o --resume achar depois. */
1427
+ function persist() {
1428
+ if (!state.sessionId || closed) return;
1429
+ if (saveTimer) clearTimeout(saveTimer);
1430
+ saveTimer = setTimeout(() => {
1431
+ saveTimer = null;
1432
+ saveNow();
1433
+ }, 400);
1434
+ saveTimer.unref?.();
1435
+ }
1436
+
1437
+ function saveNow() {
1438
+ if (!state.sessionId) return null;
1439
+ try {
1440
+ return sessions.save({
1441
+ id: state.sessionId,
1442
+ cwd: process.cwd(),
1443
+ model: state.model,
1444
+ theme: state.theme,
1445
+ messages: state.messages,
1446
+ context: state.context,
1447
+ startedAt: state.sessionStartedAt,
1448
+ });
1449
+ } catch {
1450
+ return null;
1451
+ }
1452
+ }
1453
+
1368
1454
  function render() {
1369
1455
  if (closed || state.suspended) return;
1370
1456
  const cols = Math.max(30, stdout.columns || 80);
@@ -1406,11 +1492,7 @@ async function runTUI({
1406
1492
  const pricing = await fetchApi("/api/public/pricing")
1407
1493
  .then((res) => res.json())
1408
1494
  .catch(() => null);
1409
- state.models = (pricing?.pricing || []).map((row) => ({
1410
- id: row.model,
1411
- input: row.input_micros_per_1m,
1412
- output: row.output_micros_per_1m,
1413
- }));
1495
+ state.models = (pricing?.pricing || []).map((row) => ({ id: row.model }));
1414
1496
  return state.models;
1415
1497
  }
1416
1498
 
@@ -1449,6 +1531,41 @@ async function runTUI({
1449
1531
  render();
1450
1532
  }
1451
1533
 
1534
+ /** Modal com as conversas salvas (setas/mouse + Enter), igual ao de modelos. */
1535
+ function openChatPicker() {
1536
+ const items = sessions.list();
1537
+ if (!items.length) {
1538
+ state.modal = {
1539
+ title: "Conversas",
1540
+ text: `Nenhuma conversa salva ainda.\nFicam em ${sessions.chatsDir()}.`,
1541
+ };
1542
+ return render();
1543
+ }
1544
+ state.menuDismissed = true;
1545
+ state.picker = { kind: "chat", items, index: 0, loading: false };
1546
+ render();
1547
+ }
1548
+
1549
+ /** Troca a sessao atual pela conversa escolhida no seletor. */
1550
+ function resumeChat(id) {
1551
+ const saved = sessions.load(id);
1552
+ state.picker = null;
1553
+ if (!saved) {
1554
+ state.modal = { title: "Resume", text: "Nao consegui abrir essa conversa." };
1555
+ return render();
1556
+ }
1557
+ state.sessionId = saved.id;
1558
+ state.sessionStartedAt = saved.startedAt;
1559
+ state.messages = Array.isArray(saved.messages) ? saved.messages : [];
1560
+ state.context = Array.isArray(saved.context) ? saved.context : [];
1561
+ if (saved.model) state.model = saved.model;
1562
+ if (saved.theme) state.theme = saved.theme;
1563
+ state.files = [];
1564
+ state.tokens = estimateTokens(state);
1565
+ state.scroll = 0;
1566
+ return render();
1567
+ }
1568
+
1452
1569
  async function openModelPicker() {
1453
1570
  state.menuDismissed = true;
1454
1571
  state.picker = { kind: "model", items: state.models, index: 0, loading: true };
@@ -1525,7 +1642,8 @@ async function runTUI({
1525
1642
  const name = parts[0];
1526
1643
  if (name === "/exit" || name === "/quit") return quit();
1527
1644
 
1528
- if (name === "/model" && !parts[1]) return openModelPicker();
1645
+ // /models e /model sem argumento abrem o mesmo seletor navegavel.
1646
+ if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
1529
1647
  if (name === "/theme") return openThemePicker();
1530
1648
  if (name === "/allow") {
1531
1649
  const argument = (parts[1] || "on").toLowerCase();
@@ -1665,15 +1783,17 @@ async function runTUI({
1665
1783
  state.streaming.reasoning += chunk;
1666
1784
  scheduleRender();
1667
1785
  },
1668
- onToolStart: ({ name, args, label, change, fromMcp }) => {
1786
+ onToolStart: ({ id, name, args, label, change, fromMcp, subagent }) => {
1669
1787
  finishStreaming();
1670
1788
  state.messages.push({
1671
1789
  role: "tool",
1672
1790
  tool: {
1791
+ id,
1673
1792
  name,
1674
1793
  args,
1675
1794
  label,
1676
1795
  change,
1796
+ subagent,
1677
1797
  meta: toolMeta(name, fromMcp ? mcp : null),
1678
1798
  status: "running",
1679
1799
  startedAt: Date.now(),
@@ -1682,10 +1802,34 @@ async function runTUI({
1682
1802
  state.scroll = 0;
1683
1803
  render();
1684
1804
  },
1685
- onToolResult: ({ name, output, change, ok, skipped, summary, ms }) => {
1805
+ onRetry: (info) => {
1806
+ state.retry = { ...info, endsAt: Date.now() + info.delay };
1807
+ scheduleRender();
1808
+ },
1809
+ onSubagentEvent: (event) => {
1810
+ const card = [...state.messages].reverse().find((message) => {
1811
+ if (message.role !== "tool" || !message.tool?.id) return false;
1812
+ return message.tool.id === event.id;
1813
+ });
1814
+ if (!card) return;
1815
+ const prefix = `subagente ${event.index}/${event.total}`;
1816
+ if (event.phase === "start") card.tool.summary = `${prefix}: ${event.objective}`;
1817
+ else if (event.phase === "tool") card.tool.summary = `${prefix}: ${event.tool} · ${event.text || ""}`;
1818
+ else if (event.phase === "tool-done") card.tool.summary = `${prefix}: ${event.tool} ${event.ok ? "ok" : "falhou"}`;
1819
+ else if (event.phase === "done")
1820
+ card.tool.summary = `${prefix} ${event.ok ? "concluído" : "falhou"} em ${event.ms}ms`;
1821
+ state.scroll = 0;
1822
+ scheduleRender();
1823
+ },
1824
+ onToolResult: ({ id, name, output, change, ok, skipped, summary, ms }) => {
1686
1825
  const card = [...state.messages]
1687
1826
  .reverse()
1688
- .find((message) => message.role === "tool" && message.tool?.name === name && message.tool.status === "running");
1827
+ .find(
1828
+ (message) =>
1829
+ message.role === "tool" &&
1830
+ message.tool.status === "running" &&
1831
+ (id ? message.tool.id === id : message.tool.name === name)
1832
+ );
1689
1833
  if (card) {
1690
1834
  card.tool.status = "done";
1691
1835
  card.tool.ok = ok;
@@ -1742,8 +1886,10 @@ async function runTUI({
1742
1886
  error: true,
1743
1887
  });
1744
1888
  }
1889
+ state.retry = null;
1745
1890
  state.tokens = estimateTokens(state);
1746
1891
  state.scroll = 0;
1892
+ persist();
1747
1893
  render();
1748
1894
  }
1749
1895
 
@@ -1873,12 +2019,12 @@ async function runTUI({
1873
2019
  if (!state.picker) return;
1874
2020
  const index = overlay.hit.start + offset;
1875
2021
  if (!state.picker.items[index]) return;
1876
- state.picker.index = index;
1877
-
1878
- if (state.picker.kind === "theme") {
2022
+ state.picker.index = index; if (state.picker.kind === "theme") {
1879
2023
  previewTheme(index);
1880
2024
  return applyTheme();
1881
2025
  }
2026
+ if (state.picker.kind === "chat") return resumeChat(state.picker.items[index]?.id);
2027
+
1882
2028
  return selectModel();
1883
2029
  }
1884
2030
 
@@ -1970,7 +2116,11 @@ async function runTUI({
1970
2116
  if (themePicker) previewTheme(picker.index);
1971
2117
  return render();
1972
2118
  }
1973
- if (key.name === "return") return themePicker ? applyTheme() : selectModel();
2119
+ if (key.name === "return") {
2120
+ if (themePicker) return applyTheme();
2121
+ if (picker.kind === "chat") return resumeChat(picker.items[picker.index]?.id);
2122
+ return selectModel();
2123
+ }
1974
2124
  if (key.name === "escape") {
1975
2125
  if (themePicker) return cancelTheme();
1976
2126
  state.picker = null;
@@ -2077,14 +2227,27 @@ async function runTUI({
2077
2227
  if (spinner) clearInterval(spinner);
2078
2228
  if (renderTimer) clearTimeout(renderTimer);
2079
2229
  if (escTimer) clearTimeout(escTimer);
2230
+ if (saveTimer) clearTimeout(saveTimer);
2080
2231
  stdin.removeListener("data", onData);
2081
2232
  stdout.removeListener("resize", onResize);
2082
2233
  try {
2083
2234
  stdin.setRawMode(false);
2084
2235
  } catch {}
2236
+ // Fechou no meio da conversa: guarda e mostra como voltar para ela.
2237
+ const hasChat = state.messages.some(
2238
+ (message) => message.role === "user" || message.role === "assistant"
2239
+ );
2240
+ if (hasChat) saveNow();
2085
2241
  mcp.close();
2086
2242
  leaveScreen();
2087
2243
  write("\n");
2244
+ if (hasChat) {
2245
+ write(
2246
+ `${chalk.cyan(" Retome esta conversa:")} ${chalk.bold(
2247
+ `kafox --resume ${state.sessionId}`
2248
+ )}\n\n`
2249
+ );
2250
+ }
2088
2251
  process.exit(0);
2089
2252
  }
2090
2253
 
@@ -2103,7 +2266,14 @@ async function runTUI({
2103
2266
  stdout.on("resize", onResize);
2104
2267
 
2105
2268
  await refreshAccount();
2269
+ if (resumeMissing) {
2270
+ state.modal = {
2271
+ title: "Resume",
2272
+ text: `Nao achei a conversa "${resumeMissing}".\nVeja as salvas em ${sessions.chatsDir()}.`,
2273
+ };
2274
+ }
2106
2275
  render();
2276
+ if (resumePick) openChatPicker();
2107
2277
  connectMcp().catch(() => {});
2108
2278
 
2109
2279
  spinner = setInterval(() => {