kafox-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,18 @@ 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
+ // Formato do seletor: "titulo do chat - 28/10", com o codigo do
1172
+ // resume discreto na direita para quem quiser copiar.
1173
+ const right = chalk.dim(item.id);
1174
+ const space = width - 6;
1175
+ const room = Math.max(12, space - visible(right) - 12);
1176
+ const when = sessions.dayMonth(item.updatedAt);
1177
+ const left = ` ${caret} ${truncate(item.title || item.id, room)}${when ? ` - ${when}` : ""}`;
1178
+ const gap = Math.max(1, space - visible(left) - visible(right));
1179
+ line = padRight(left + " ".repeat(gap) + right, width - 4);
1155
1180
  } 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);
1181
+ line = padRight(` ${caret} ${item.id} `, width - 4);
1159
1182
  }
1160
1183
  body.push(real === picker.index ? chalk.inverse(line) : line);
1161
1184
  }
@@ -1231,7 +1254,14 @@ function buildFrame(state, cols, rows, commands = {}) {
1231
1254
  const input = inputBlock(state, inner, { hero, tight });
1232
1255
  const status = statusLines(state, inner, { bare });
1233
1256
  const hints = hintsLines(state, inner, { bare });
1234
- const chrome = [...queue, ...menu, ...input.lines, ...status, ...hints];
1257
+ const chrome = [
1258
+ ...queue,
1259
+ ...menu,
1260
+ ...input.lines,
1261
+ ...(input.footer ? [input.footer] : []),
1262
+ ...status,
1263
+ ...hints,
1264
+ ];
1235
1265
 
1236
1266
  const head = hero ? heroLines(state, inner, rows - chrome.length) : [];
1237
1267
 
@@ -1297,15 +1327,45 @@ async function runTUI({
1297
1327
  onCommand,
1298
1328
  interactiveCommands = new Set(),
1299
1329
  autoApprove = false,
1330
+ resume = null,
1300
1331
  } = {}) {
1301
1332
  const stdin = process.stdin;
1302
1333
  const stdout = process.stdout;
1303
1334
  const state = initialState();
1335
+
1336
+ // resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
1337
+ // qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
1338
+ let resumeMissing = null;
1339
+ let resumePick = false;
1340
+ if (resume) {
1341
+ if (resume === "pick") {
1342
+ resumePick = true;
1343
+ } else {
1344
+ const id = resume === "last" || resume === true ? sessions.latest()?.id : String(resume);
1345
+ const saved = resume === "last" || resume === true ? sessions.load(id) : sessions.find(id);
1346
+ if (saved) {
1347
+ state.sessionId = saved.id;
1348
+ state.sessionStartedAt = saved.startedAt;
1349
+ state.messages = Array.isArray(saved.messages) ? saved.messages : [];
1350
+ state.context = Array.isArray(saved.context) ? saved.context : [];
1351
+ if (saved.model) state.model = saved.model;
1352
+ if (saved.theme) state.theme = saved.theme;
1353
+ state.tokens = estimateTokens(state);
1354
+ } else {
1355
+ resumeMissing = String(id || resume);
1356
+ }
1357
+ }
1358
+ }
1359
+ if (!state.sessionId) {
1360
+ state.sessionId = sessions.newId();
1361
+ state.sessionStartedAt = new Date().toISOString();
1362
+ }
1304
1363
  // `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) ja comeca sem pedir aprovacao.
1305
1364
  state.autoApprove = Boolean(autoApprove) || process.env.KAFOX_ALLOW_ALL === "1";
1306
1365
  let mcp = new McpHub({ cwd: process.cwd() });
1307
1366
  let spinner = null;
1308
1367
  let escTimer = null;
1368
+ let saveTimer = null;
1309
1369
  let closed = false;
1310
1370
  let previousLines = [];
1311
1371
 
@@ -1365,6 +1425,34 @@ async function runTUI({
1365
1425
  render();
1366
1426
  }
1367
1427
 
1428
+ /** Grava a conversa em ~/.kafox/chats para o --resume achar depois. */
1429
+ function persist() {
1430
+ if (!state.sessionId || closed) return;
1431
+ if (saveTimer) clearTimeout(saveTimer);
1432
+ saveTimer = setTimeout(() => {
1433
+ saveTimer = null;
1434
+ saveNow();
1435
+ }, 400);
1436
+ saveTimer.unref?.();
1437
+ }
1438
+
1439
+ function saveNow() {
1440
+ if (!state.sessionId) return null;
1441
+ try {
1442
+ return sessions.save({
1443
+ id: state.sessionId,
1444
+ cwd: process.cwd(),
1445
+ model: state.model,
1446
+ theme: state.theme,
1447
+ messages: state.messages,
1448
+ context: state.context,
1449
+ startedAt: state.sessionStartedAt,
1450
+ });
1451
+ } catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+
1368
1456
  function render() {
1369
1457
  if (closed || state.suspended) return;
1370
1458
  const cols = Math.max(30, stdout.columns || 80);
@@ -1406,11 +1494,7 @@ async function runTUI({
1406
1494
  const pricing = await fetchApi("/api/public/pricing")
1407
1495
  .then((res) => res.json())
1408
1496
  .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
- }));
1497
+ state.models = (pricing?.pricing || []).map((row) => ({ id: row.model }));
1414
1498
  return state.models;
1415
1499
  }
1416
1500
 
@@ -1449,6 +1533,41 @@ async function runTUI({
1449
1533
  render();
1450
1534
  }
1451
1535
 
1536
+ /** Modal com as conversas salvas (setas/mouse + Enter), igual ao de modelos. */
1537
+ function openChatPicker() {
1538
+ const items = sessions.list();
1539
+ if (!items.length) {
1540
+ state.modal = {
1541
+ title: "Conversas",
1542
+ text: `Nenhuma conversa salva ainda.\nFicam em ${sessions.chatsDir()}.`,
1543
+ };
1544
+ return render();
1545
+ }
1546
+ state.menuDismissed = true;
1547
+ state.picker = { kind: "chat", items, index: 0, loading: false };
1548
+ render();
1549
+ }
1550
+
1551
+ /** Troca a sessao atual pela conversa escolhida no seletor. */
1552
+ function resumeChat(id) {
1553
+ const saved = sessions.find(id);
1554
+ state.picker = null;
1555
+ if (!saved) {
1556
+ state.modal = { title: "Resume", text: "Nao consegui abrir essa conversa." };
1557
+ return render();
1558
+ }
1559
+ state.sessionId = saved.id;
1560
+ state.sessionStartedAt = saved.startedAt;
1561
+ state.messages = Array.isArray(saved.messages) ? saved.messages : [];
1562
+ state.context = Array.isArray(saved.context) ? saved.context : [];
1563
+ if (saved.model) state.model = saved.model;
1564
+ if (saved.theme) state.theme = saved.theme;
1565
+ state.files = [];
1566
+ state.tokens = estimateTokens(state);
1567
+ state.scroll = 0;
1568
+ return render();
1569
+ }
1570
+
1452
1571
  async function openModelPicker() {
1453
1572
  state.menuDismissed = true;
1454
1573
  state.picker = { kind: "model", items: state.models, index: 0, loading: true };
@@ -1525,7 +1644,8 @@ async function runTUI({
1525
1644
  const name = parts[0];
1526
1645
  if (name === "/exit" || name === "/quit") return quit();
1527
1646
 
1528
- if (name === "/model" && !parts[1]) return openModelPicker();
1647
+ // /models e /model sem argumento abrem o mesmo seletor navegavel.
1648
+ if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
1529
1649
  if (name === "/theme") return openThemePicker();
1530
1650
  if (name === "/allow") {
1531
1651
  const argument = (parts[1] || "on").toLowerCase();
@@ -1665,15 +1785,17 @@ async function runTUI({
1665
1785
  state.streaming.reasoning += chunk;
1666
1786
  scheduleRender();
1667
1787
  },
1668
- onToolStart: ({ name, args, label, change, fromMcp }) => {
1788
+ onToolStart: ({ id, name, args, label, change, fromMcp, subagent }) => {
1669
1789
  finishStreaming();
1670
1790
  state.messages.push({
1671
1791
  role: "tool",
1672
1792
  tool: {
1793
+ id,
1673
1794
  name,
1674
1795
  args,
1675
1796
  label,
1676
1797
  change,
1798
+ subagent,
1677
1799
  meta: toolMeta(name, fromMcp ? mcp : null),
1678
1800
  status: "running",
1679
1801
  startedAt: Date.now(),
@@ -1682,10 +1804,34 @@ async function runTUI({
1682
1804
  state.scroll = 0;
1683
1805
  render();
1684
1806
  },
1685
- onToolResult: ({ name, output, change, ok, skipped, summary, ms }) => {
1807
+ onRetry: (info) => {
1808
+ state.retry = { ...info, endsAt: Date.now() + info.delay };
1809
+ scheduleRender();
1810
+ },
1811
+ onSubagentEvent: (event) => {
1812
+ const card = [...state.messages].reverse().find((message) => {
1813
+ if (message.role !== "tool" || !message.tool?.id) return false;
1814
+ return message.tool.id === event.id;
1815
+ });
1816
+ if (!card) return;
1817
+ const prefix = `subagente ${event.index}/${event.total}`;
1818
+ if (event.phase === "start") card.tool.summary = `${prefix}: ${event.objective}`;
1819
+ else if (event.phase === "tool") card.tool.summary = `${prefix}: ${event.tool} · ${event.text || ""}`;
1820
+ else if (event.phase === "tool-done") card.tool.summary = `${prefix}: ${event.tool} ${event.ok ? "ok" : "falhou"}`;
1821
+ else if (event.phase === "done")
1822
+ card.tool.summary = `${prefix} ${event.ok ? "concluído" : "falhou"} em ${event.ms}ms`;
1823
+ state.scroll = 0;
1824
+ scheduleRender();
1825
+ },
1826
+ onToolResult: ({ id, name, output, change, ok, skipped, summary, ms }) => {
1686
1827
  const card = [...state.messages]
1687
1828
  .reverse()
1688
- .find((message) => message.role === "tool" && message.tool?.name === name && message.tool.status === "running");
1829
+ .find(
1830
+ (message) =>
1831
+ message.role === "tool" &&
1832
+ message.tool.status === "running" &&
1833
+ (id ? message.tool.id === id : message.tool.name === name)
1834
+ );
1689
1835
  if (card) {
1690
1836
  card.tool.status = "done";
1691
1837
  card.tool.ok = ok;
@@ -1742,8 +1888,10 @@ async function runTUI({
1742
1888
  error: true,
1743
1889
  });
1744
1890
  }
1891
+ state.retry = null;
1745
1892
  state.tokens = estimateTokens(state);
1746
1893
  state.scroll = 0;
1894
+ persist();
1747
1895
  render();
1748
1896
  }
1749
1897
 
@@ -1873,12 +2021,12 @@ async function runTUI({
1873
2021
  if (!state.picker) return;
1874
2022
  const index = overlay.hit.start + offset;
1875
2023
  if (!state.picker.items[index]) return;
1876
- state.picker.index = index;
1877
-
1878
- if (state.picker.kind === "theme") {
2024
+ state.picker.index = index; if (state.picker.kind === "theme") {
1879
2025
  previewTheme(index);
1880
2026
  return applyTheme();
1881
2027
  }
2028
+ if (state.picker.kind === "chat") return resumeChat(state.picker.items[index]?.id);
2029
+
1882
2030
  return selectModel();
1883
2031
  }
1884
2032
 
@@ -1970,7 +2118,11 @@ async function runTUI({
1970
2118
  if (themePicker) previewTheme(picker.index);
1971
2119
  return render();
1972
2120
  }
1973
- if (key.name === "return") return themePicker ? applyTheme() : selectModel();
2121
+ if (key.name === "return") {
2122
+ if (themePicker) return applyTheme();
2123
+ if (picker.kind === "chat") return resumeChat(picker.items[picker.index]?.id);
2124
+ return selectModel();
2125
+ }
1974
2126
  if (key.name === "escape") {
1975
2127
  if (themePicker) return cancelTheme();
1976
2128
  state.picker = null;
@@ -2077,14 +2229,27 @@ async function runTUI({
2077
2229
  if (spinner) clearInterval(spinner);
2078
2230
  if (renderTimer) clearTimeout(renderTimer);
2079
2231
  if (escTimer) clearTimeout(escTimer);
2232
+ if (saveTimer) clearTimeout(saveTimer);
2080
2233
  stdin.removeListener("data", onData);
2081
2234
  stdout.removeListener("resize", onResize);
2082
2235
  try {
2083
2236
  stdin.setRawMode(false);
2084
2237
  } catch {}
2238
+ // Fechou no meio da conversa: guarda e mostra como voltar para ela.
2239
+ const hasChat = state.messages.some(
2240
+ (message) => message.role === "user" || message.role === "assistant"
2241
+ );
2242
+ if (hasChat) saveNow();
2085
2243
  mcp.close();
2086
2244
  leaveScreen();
2087
2245
  write("\n");
2246
+ if (hasChat) {
2247
+ write(
2248
+ `${chalk.cyan(" Retome esta conversa:")} ${chalk.bold(
2249
+ `kafox --resume ${state.sessionId}`
2250
+ )}\n\n`
2251
+ );
2252
+ }
2088
2253
  process.exit(0);
2089
2254
  }
2090
2255
 
@@ -2103,7 +2268,14 @@ async function runTUI({
2103
2268
  stdout.on("resize", onResize);
2104
2269
 
2105
2270
  await refreshAccount();
2271
+ if (resumeMissing) {
2272
+ state.modal = {
2273
+ title: "Resume",
2274
+ text: `Nao achei a conversa "${resumeMissing}".\nVeja as salvas em ${sessions.chatsDir()}.`,
2275
+ };
2276
+ }
2106
2277
  render();
2278
+ if (resumePick) openChatPicker();
2107
2279
  connectMcp().catch(() => {});
2108
2280
 
2109
2281
  spinner = setInterval(() => {