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.
- package/README.md +52 -3
- package/bin/kafox.js +69 -2
- package/package.json +1 -1
- package/src/agent.js +204 -9
- package/src/auth.js +77 -30
- package/src/cli.js +32 -2
- package/src/gateway.js +103 -9
- package/src/login-screen.js +203 -0
- package/src/models.js +1 -4
- package/src/runner.js +98 -0
- package/src/sessions.js +138 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/tools.js +12 -0
- package/src/tui.js +214 -44
package/src/subagents.js
ADDED
|
@@ -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("
|
|
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
|
-
|
|
979
|
-
|
|
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 = [
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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")
|
|
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(() => {
|