@neetru/cli 2.14.0 → 2.16.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/build.d.ts +14 -0
  3. package/dist/commands/build.js +26 -2
  4. package/dist/commands/build.js.map +1 -1
  5. package/dist/commands/code.d.ts +10 -31
  6. package/dist/commands/code.js +269 -220
  7. package/dist/commands/code.js.map +1 -1
  8. package/dist/commands/servers.js +8 -7
  9. package/dist/commands/servers.js.map +1 -1
  10. package/dist/lib/agent-commands.d.ts +16 -7
  11. package/dist/lib/agent-commands.js +40 -26
  12. package/dist/lib/agent-commands.js.map +1 -1
  13. package/dist/lib/ai/orchestrator.js +1 -42
  14. package/dist/lib/ai/orchestrator.js.map +1 -1
  15. package/dist/lib/code-agent-files.d.ts +48 -0
  16. package/dist/lib/code-agent-files.js +285 -2
  17. package/dist/lib/code-agent-files.js.map +1 -1
  18. package/dist/lib/code-agent-gateway.d.ts +27 -0
  19. package/dist/lib/code-agent-gateway.js +115 -0
  20. package/dist/lib/code-agent-gateway.js.map +1 -0
  21. package/dist/lib/code-agent-protocol.d.ts +8 -0
  22. package/dist/lib/code-agent-protocol.js +22 -0
  23. package/dist/lib/code-agent-protocol.js.map +1 -1
  24. package/dist/lib/code-agent-session.d.ts +78 -0
  25. package/dist/lib/code-agent-session.js +188 -0
  26. package/dist/lib/code-agent-session.js.map +1 -0
  27. package/dist/lib/code-agent-tools.d.ts +17 -0
  28. package/dist/lib/code-agent-tools.js +52 -0
  29. package/dist/lib/code-agent-tools.js.map +1 -0
  30. package/dist/lib/code-agent-types.d.ts +34 -0
  31. package/dist/lib/code-agent-types.js +7 -0
  32. package/dist/lib/code-agent-types.js.map +1 -0
  33. package/dist/lib/code-agent-ui.d.ts +49 -0
  34. package/dist/lib/code-agent-ui.js +313 -0
  35. package/dist/lib/code-agent-ui.js.map +1 -0
  36. package/dist/lib/sse.d.ts +14 -0
  37. package/dist/lib/sse.js +43 -0
  38. package/dist/lib/sse.js.map +1 -0
  39. package/package.json +1 -1
@@ -3,146 +3,18 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { createInterface } from 'node:readline/promises';
6
- import { apiRequest } from '../lib/api-client.js';
7
6
  import { config } from '../lib/config.js';
8
7
  import { getActiveTokenSync } from '../lib/token-resolver.js';
9
8
  import { log } from '../utils/logger.js';
10
9
  import { setSafeExitCode } from '../utils/safe-exit.js';
11
10
  import { parseToolRequest, extractPatch } from '../lib/code-agent-protocol.js';
12
- import { applyUnifiedPatch, listFiles, readFileContext, renderColoredDiff, runCodeTool, } from '../lib/code-agent-files.js';
11
+ import { applyUnifiedPatch, getGitDiff, getProjectOverview, listFiles, readFileContext, renderColoredDiff, runCodeTool, truncateForGateway, } from '../lib/code-agent-files.js';
12
+ import { compactCodeHistory, compactHistoryToFit, MAX_HISTORY_TOTAL_CHARS, saveCodeSession, listCodeSessions, loadCodeSession, logAppliedPatch, CodeAgentStateMachine, } from '../lib/code-agent-session.js';
13
13
  import { DEFAULT_CODE_MAX_TOKENS, DEFAULT_CODE_MAX_TURNS, DEFAULT_CODE_TEMPERATURE, parseBoundedNumber, resolveCodeModel, } from '../lib/code-agent-settings.js';
14
- const AI_GATEWAY_CHAT_PATH = '/api/cli/v1/ai/chat';
15
- const UI_MIN_WIDTH = 72;
16
- const UI_MAX_WIDTH = 110;
17
- const SYSTEM_PROMPT = `
18
- Voce e o Neetru Code, um assistente local de codificacao da Neetru.
19
- Voce conversa com modelos do AI Gateway do Neetru Core, normalmente Groq/NVIDIA.
20
- Nao use Anthropic/Claude como caminho assumido.
21
-
22
- Voce nao tem tool calling nativo. Use exatamente estes protocolos textuais:
23
-
24
- NEETRU_TOOL {"tool":"read_file","path":"caminho/relativo.ext"}
25
- NEETRU_TOOL {"tool":"list_files","pattern":"texto opcional"}
26
- NEETRU_TOOL {"tool":"search","query":"texto literal"}
27
-
28
- Regras:
29
- - Peca arquivos antes de alterar codigo quando faltar contexto.
30
- - Nunca invente conteudo de arquivo.
31
- - Peca uma tool por resposta, no maximo.
32
- - Quando estiver pronto para alterar, responda somente com um diff aplicavel por git:
33
-
34
- NEETRU_PATCH
35
- diff --git a/path b/path
36
- ...
37
- NEETRU_END
38
-
39
- - Nao coloque explicacao dentro do bloco NEETRU_PATCH.
40
- - Se a tarefa nao exigir alteracao, responda em texto curto.
41
- `.trim();
42
- function stripAnsi(text) {
43
- return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
44
- }
45
- function visibleLength(text) {
46
- return Array.from(stripAnsi(text)).length;
47
- }
48
- function padVisible(text, width) {
49
- return `${text}${' '.repeat(Math.max(0, width - visibleLength(text)))}`;
50
- }
51
- function terminalWidth() {
52
- const raw = process.stdout.columns ?? 88;
53
- return Math.max(UI_MIN_WIDTH, Math.min(UI_MAX_WIDTH, raw));
54
- }
55
- function truncateMiddle(text, max) {
56
- if (visibleLength(text) <= max)
57
- return text;
58
- if (max <= 5)
59
- return text.slice(0, max);
60
- const clean = stripAnsi(text);
61
- const left = Math.ceil((max - 3) / 2);
62
- const right = Math.floor((max - 3) / 2);
63
- return `${clean.slice(0, left)}...${clean.slice(clean.length - right)}`;
64
- }
65
- function wrapPlain(text, width) {
66
- const clean = stripAnsi(text);
67
- if (clean.length <= width)
68
- return [clean];
69
- const out = [];
70
- let rest = clean;
71
- while (rest.length > width) {
72
- const slice = rest.slice(0, width + 1);
73
- const breakAt = Math.max(slice.lastIndexOf(' '), slice.lastIndexOf('/'), slice.lastIndexOf('\\'));
74
- const idx = breakAt > Math.floor(width * 0.45) ? breakAt : width;
75
- out.push(rest.slice(0, idx).trimEnd());
76
- rest = rest.slice(idx).trimStart();
77
- }
78
- if (rest)
79
- out.push(rest);
80
- return out;
81
- }
82
- function printBox(title, rows = []) {
83
- const width = terminalWidth();
84
- const inner = width - 2;
85
- const label = title ? ` ${title} ` : '';
86
- const top = `+${label}${'-'.repeat(Math.max(0, inner - visibleLength(label)))}+`;
87
- console.log(chalk.dim(top));
88
- for (const row of rows) {
89
- const wrapped = wrapPlain(row, inner - 2);
90
- for (const line of wrapped) {
91
- console.log(chalk.dim('| ') + padVisible(truncateMiddle(line, inner - 2), inner - 2) + chalk.dim(' |'));
92
- }
93
- }
94
- console.log(chalk.dim(`+${'-'.repeat(inner)}+`));
95
- }
96
- function printDivider(label) {
97
- const width = terminalWidth();
98
- if (!label) {
99
- console.log(chalk.dim('-'.repeat(width)));
100
- return;
101
- }
102
- const title = ` ${label} `;
103
- console.log(chalk.dim(`${title}${'-'.repeat(Math.max(0, width - visibleLength(title)))}`));
104
- }
105
- function printEvent(kind, title, detail) {
106
- const colors = {
107
- wait: chalk.cyan,
108
- tool: chalk.blue,
109
- ok: chalk.green,
110
- warn: chalk.yellow,
111
- error: chalk.red,
112
- };
113
- const tag = colors[kind](kind.toUpperCase().padEnd(5));
114
- const suffix = detail ? chalk.dim(` ${detail}`) : '';
115
- console.log(`${tag} ${chalk.bold(title)}${suffix}`);
116
- }
117
- function toolDetail(tool) {
118
- if (!tool)
119
- return '';
120
- if (tool.tool === 'read_file' && typeof tool.path === 'string')
121
- return tool.path;
122
- if (tool.tool === 'list_files' && typeof tool.pattern === 'string' && tool.pattern)
123
- return tool.pattern;
124
- if (tool.tool === 'search' && typeof tool.query === 'string')
125
- return tool.query;
126
- return '';
127
- }
128
- function summarizeToolResult(result) {
129
- const lines = result.split(/\r?\n/).filter(Boolean).length;
130
- const chars = result.length;
131
- return `${lines} linha(s), ${chars} char(s)`;
132
- }
133
- function printAssistantText(content) {
134
- printDivider('assistant');
135
- process.stdout.write(`${content.trim()}\n`);
136
- printDivider();
137
- }
138
- function printPatchPreview(patch) {
139
- const files = Array.from(patch.matchAll(/^diff --git a\/(.+?) b\//gm)).map((m) => m[1]);
140
- printBox('patch proposto', [
141
- files.length ? `arquivos: ${files.join(', ')}` : 'arquivos: nao detectado',
142
- 'aplicacao: git apply depois de confirmacao',
143
- ]);
144
- process.stdout.write(renderColoredDiff(patch));
145
- }
14
+ import { callGateway, streamGatewayChat, AI_GATEWAY_CHAT_PATH } from '../lib/code-agent-gateway.js';
15
+ import { buildSystemPrompt } from '../lib/code-agent-tools.js';
16
+ import { printBox, printEvent, printAssistantText, printPatchPreview, patchSummaryRows, renderHeader, renderInteractiveHelp, renderSessionStatus, renderPrompt, printFileSample, printFilePicker, printSessionList, startTurnSpinner, summarizeToolResult, toolDetail, } from '../lib/code-agent-ui.js';
17
+ const SYSTEM_PROMPT = buildSystemPrompt();
146
18
  function asOptionalString(value) {
147
19
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
148
20
  }
@@ -189,59 +61,35 @@ export function resolveSettings(opts) {
189
61
  timeoutMs: 120_000,
190
62
  };
191
63
  }
192
- function renderHeader(settings, mode = 'oneshot') {
193
- console.log();
194
- printBox('Neetru Code', [
195
- `AI Gateway ${AI_GATEWAY_CHAT_PATH}`,
196
- `Model ${settings.model}`,
197
- `Workspace ${truncateMiddle(settings.cwd, terminalWidth() - 18)}`,
198
- `Mode ${mode} | patch ${settings.apply ? 'confirm' : 'plan-only'} | turns ${settings.maxTurns} | tokens ${settings.maxTokens}`,
199
- ]);
200
- if (settings.modelWarning)
201
- log.warn(settings.modelWarning);
202
- console.log();
203
- }
204
- function renderInteractiveHelp() {
205
- printBox('controles', [
206
- '/files listar arquivos do workspace',
207
- '/model mostrar modelo atual',
208
- '/clear limpar tela e esquecer o historico da conversa',
209
- '/help mostrar estes controles',
210
- '/exit sair',
211
- ]);
212
- console.log(chalk.dim('Digite uma tarefa em linguagem natural para a IA inspecionar e propor patch.'));
213
- console.log();
64
+ async function runReviewCommand(settings, confirm, history) {
65
+ const previousApply = settings.apply;
66
+ const previousYes = settings.yes;
67
+ settings.apply = false;
68
+ settings.yes = false;
69
+ try {
70
+ await runAgentTask([
71
+ 'Revise as mudancas atuais do projeto.',
72
+ 'Use NEETRU_TOOL {"tool":"git_status"} e NEETRU_TOOL {"tool":"git_diff"} se precisar de contexto.',
73
+ 'Nao proponha patch automaticamente. Liste achados por severidade, com arquivo/linha quando possivel.',
74
+ ].join('\n'), settings, confirm, history);
75
+ }
76
+ finally {
77
+ settings.apply = previousApply;
78
+ settings.yes = previousYes;
79
+ }
214
80
  }
215
81
  function buildInitialPrompt(task, settings) {
216
82
  const fileBlocks = [];
217
83
  for (const file of settings.files) {
218
84
  fileBlocks.push(readFileContext(settings.cwd, file));
219
85
  }
220
- return [
86
+ return truncateForGateway([
221
87
  `Tarefa do usuario:\n${task}`,
222
88
  `Cwd: ${settings.cwd}`,
223
89
  fileBlocks.length
224
90
  ? `Contexto inicial de arquivos:\n\n${fileBlocks.join('\n\n')}`
225
91
  : 'Nenhum arquivo foi anexado inicialmente. Use NEETRU_TOOL para pedir contexto local.',
226
- ].join('\n\n');
227
- }
228
- async function callGateway(settings, messages) {
229
- const response = await apiRequest(AI_GATEWAY_CHAT_PATH, {
230
- method: 'POST',
231
- body: {
232
- model: settings.model,
233
- messages,
234
- stream: false,
235
- maxTokens: settings.maxTokens,
236
- temperature: settings.temperature,
237
- },
238
- timeoutMs: settings.timeoutMs,
239
- });
240
- const content = response.choices?.[0]?.message?.content;
241
- if (typeof content !== 'string') {
242
- throw new Error('AI Gateway retornou resposta sem choices[0].message.content.');
243
- }
244
- return content;
92
+ ].join('\n\n'));
245
93
  }
246
94
  async function defaultConfirm(question) {
247
95
  if (!process.stdin.isTTY)
@@ -255,17 +103,64 @@ async function defaultConfirm(question) {
255
103
  rl.close();
256
104
  }
257
105
  }
106
+ /**
107
+ * Chama o AI Gateway mostrando progresso ao vivo — spinner animado + cor
108
+ * rotativa + tempo decorrido (`startTurnSpinner`, code-agent-ui.ts) em vez de
109
+ * ficar estatico durante respostas longas. Cobre TANTO o streaming quanto o
110
+ * fallback sem stream (antes o fallback ficava mudo do aviso ate a resposta
111
+ * chegar ou o timeout estourar). So anima em TTY real — em saida
112
+ * nao-interativa (pipe/CI/teste) isso so poluiria o log.
113
+ *
114
+ * Fallback: qualquer falha no streaming (rede, formato inesperado) refaz a
115
+ * chamada sem streaming — a corretude do turno nunca depende do streaming
116
+ * funcionar, so a experiencia visual.
117
+ */
118
+ async function callGatewayWithProgress(settings, messages) {
119
+ const spinner = startTurnSpinner('AI Gateway');
120
+ try {
121
+ const content = await streamGatewayChat(settings, messages, (_delta, total) => {
122
+ spinner.update(`${total} char(s) recebido(s)`);
123
+ });
124
+ spinner.stop();
125
+ return content;
126
+ }
127
+ catch (error) {
128
+ spinner.stop();
129
+ printEvent('warn', 'Streaming falhou, tentando sem streaming', error instanceof Error ? error.message : String(error));
130
+ const fallbackSpinner = startTurnSpinner('AI Gateway (sem stream)');
131
+ try {
132
+ const content = await callGateway(settings, messages);
133
+ fallbackSpinner.stop();
134
+ return content;
135
+ }
136
+ catch (fallbackError) {
137
+ fallbackSpinner.stop();
138
+ throw fallbackError;
139
+ }
140
+ }
141
+ }
258
142
  /**
259
143
  * `history` e mutado e reaproveitado entre chamadas: e o que da ao modo
260
144
  * interativo memoria de conversa entre turnos do REPL (como Claude Code/Codex),
261
145
  * em vez de cada linha digitada virar uma requisicao amnesica. O one-shot
262
146
  * (`neetru code "tarefa"`) chama sem `history` — cada `runAgentTask` comeca do
263
147
  * zero, igual sempre foi.
148
+ *
149
+ * `onState` (opcional): observa a maquina de estados da sessao (Fase 2,
150
+ * NEETRU_CODE_ARCHITECTURE.md) — usado por testes pra verificar a sequencia
151
+ * idle -> thinking -> tool_requested -> tool_running -> ... sem mockar o
152
+ * AI Gateway inteiro. Cada chamada de runAgentTask e um ciclo completo
153
+ * (comeca e termina em idle/applied/failed/aborted).
264
154
  */
265
- export async function runAgentTask(task, settings, confirm, history = []) {
155
+ export async function runAgentTask(task, settings, confirm, history = [], onState) {
266
156
  if (!getActiveTokenSync()) {
267
157
  throw new Error('Sessao Neetru ausente ou expirada. Rode `neetru login`.');
268
158
  }
159
+ const machine = new CodeAgentStateMachine();
160
+ const goto = (next) => {
161
+ machine.transition(next);
162
+ onState?.(next);
163
+ };
269
164
  const messages = history;
270
165
  const isFirstTurn = messages.length === 0;
271
166
  if (isFirstTurn)
@@ -274,54 +169,78 @@ export async function runAgentTask(task, settings, confirm, history = []) {
274
169
  role: 'user',
275
170
  content: isFirstTurn ? buildInitialPrompt(task, settings) : task,
276
171
  });
277
- for (let turn = 1; turn <= settings.maxTurns; turn++) {
278
- printEvent('wait', 'AI Gateway', `turno ${turn}/${settings.maxTurns}`);
279
- const content = await callGateway(settings, messages);
280
- messages.push({ role: 'assistant', content });
281
- const tool = parseToolRequest(content);
282
- if (tool) {
283
- printEvent('tool', tool.tool, toolDetail(tool));
284
- const result = runCodeTool(settings.cwd, tool);
285
- printEvent('ok', 'contexto local', summarizeToolResult(result));
286
- messages.push({
287
- role: 'user',
288
- content: `Resultado da tool ${tool.tool}:\n\n${result}`,
289
- });
290
- continue;
291
- }
292
- const patch = extractPatch(content);
293
- if (patch) {
294
- console.log();
295
- printPatchPreview(patch);
296
- if (!settings.apply) {
297
- printEvent('warn', 'Patch nao aplicado', '--plan/--no-apply');
298
- return;
172
+ try {
173
+ for (let turn = 1; turn <= settings.maxTurns; turn++) {
174
+ goto('thinking');
175
+ // GAP-M1-15: auto-compact por TAMANHO antes de cada chamada — sem isso
176
+ // uma sessao com varias tool results grandes (cada uma ate
177
+ // MAX_GATEWAY_SAFE_CHARS) estoura o limite de payload TOTAL do
178
+ // provider (HTTP 413) mesmo com cada mensagem individual dentro do
179
+ // limite. `/compact` manual continua existindo pro usuario forcar
180
+ // antes disso se quiser.
181
+ const autoCompacted = compactHistoryToFit(messages, MAX_HISTORY_TOTAL_CHARS);
182
+ if (autoCompacted > 0) {
183
+ printEvent('warn', 'Historico compactado automaticamente', `${autoCompacted} mensagem(ns) antiga(s) removida(s)`);
299
184
  }
300
- const approved = settings.yes || (await confirm('\nAplicar este patch? [y/N] '));
301
- if (!approved) {
302
- printEvent('warn', 'Patch nao aplicado');
185
+ printEvent('wait', 'AI Gateway', `turno ${turn}/${settings.maxTurns}`);
186
+ const content = await callGatewayWithProgress(settings, messages);
187
+ messages.push({ role: 'assistant', content });
188
+ const tool = parseToolRequest(content);
189
+ if (tool) {
190
+ goto('tool_requested');
191
+ printEvent('tool', tool.tool, toolDetail(tool));
192
+ goto('tool_running');
193
+ const result = runCodeTool(settings.cwd, tool);
194
+ printEvent('ok', 'contexto local', summarizeToolResult(result));
195
+ messages.push({
196
+ role: 'user',
197
+ content: truncateForGateway(`Resultado da tool ${tool.tool}:\n\n${result}`),
198
+ });
199
+ continue;
200
+ }
201
+ const patch = extractPatch(content);
202
+ if (patch) {
203
+ goto('patch_ready');
204
+ console.log();
205
+ printPatchPreview(patch);
206
+ if (!settings.apply) {
207
+ printEvent('warn', 'Patch nao aplicado', '--plan/--no-apply');
208
+ goto('aborted');
209
+ return;
210
+ }
211
+ const approved = settings.yes || (await (async () => {
212
+ goto('awaiting_confirm');
213
+ return confirm('\nAplicar este patch? [y/N] ');
214
+ })());
215
+ if (!approved) {
216
+ printEvent('warn', 'Patch nao aplicado');
217
+ goto('aborted');
218
+ return;
219
+ }
220
+ applyUnifiedPatch(settings.cwd, patch);
221
+ logAppliedPatch(settings.cwd, patchSummaryRows(patch)[0] ?? 'arquivos: nao detectado');
222
+ printEvent('ok', 'Patch aplicado', 'git apply');
223
+ goto('applied');
303
224
  return;
304
225
  }
305
- applyUnifiedPatch(settings.cwd, patch);
306
- printEvent('ok', 'Patch aplicado', 'git apply');
226
+ console.log();
227
+ printAssistantText(content);
228
+ goto('idle');
307
229
  return;
308
230
  }
309
- console.log();
310
- printAssistantText(content);
311
- return;
231
+ goto('failed');
232
+ throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
312
233
  }
313
- throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
314
- }
315
- function printFileSample(settings) {
316
- const files = listFiles(settings.cwd).slice(0, 60);
317
- if (files.length === 0) {
318
- printEvent('warn', 'Nenhum arquivo encontrado');
319
- return;
234
+ catch (error) {
235
+ if (machine.state !== 'failed' && machine.state !== 'aborted' && machine.state !== 'applied') {
236
+ goto('failed');
237
+ }
238
+ throw error;
320
239
  }
321
- printBox('arquivos', [
322
- ...files,
323
- ...(files.length === 60 ? ['... lista truncada em 60 arquivos.'] : []),
324
- ]);
240
+ }
241
+ function printFileSampleForSettings(settings) {
242
+ const all = listFiles(settings.cwd);
243
+ printFileSample(all.slice(0, 60), all.length);
325
244
  }
326
245
  async function runInteractive(settings) {
327
246
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -329,11 +248,13 @@ async function runInteractive(settings) {
329
248
  }
330
249
  renderHeader(settings, 'interactive');
331
250
  renderInteractiveHelp();
251
+ if (settings.modelWarning)
252
+ log.warn(settings.modelWarning);
332
253
  let history = [];
333
254
  const rl = createInterface({ input: process.stdin, output: process.stdout });
334
255
  try {
335
256
  for (;;) {
336
- const line = (await rl.question(chalk.cyan('> '))).trim();
257
+ const line = (await rl.question(renderPrompt(settings))).trim();
337
258
  if (!line)
338
259
  continue;
339
260
  const lower = line.toLowerCase();
@@ -344,16 +265,139 @@ async function runInteractive(settings) {
344
265
  process.stdout.write('\x1B[2J\x1B[H');
345
266
  renderHeader(settings, 'interactive');
346
267
  renderInteractiveHelp();
268
+ printEvent('ok', 'Conversa limpa');
347
269
  continue;
348
270
  }
349
271
  if (lower === '/files') {
350
- printFileSample(settings);
272
+ printFileSampleForSettings(settings);
273
+ continue;
274
+ }
275
+ if (lower === '/pick') {
276
+ const all = listFiles(settings.cwd);
277
+ const shown = all.slice(0, 60);
278
+ printFilePicker(shown, all.length);
279
+ if (shown.length === 0)
280
+ continue;
281
+ const answer = await rl.question('Numero(s) separado(s) por virgula (Enter cancela): ');
282
+ const raw = answer.trim();
283
+ if (!raw) {
284
+ printEvent('warn', 'Cancelado');
285
+ continue;
286
+ }
287
+ const picked = [];
288
+ for (const part of raw.split(',')) {
289
+ const idx = Number.parseInt(part.trim(), 10);
290
+ if (Number.isInteger(idx) && idx >= 1 && idx <= shown.length) {
291
+ picked.push(shown[idx - 1]);
292
+ }
293
+ }
294
+ if (picked.length === 0) {
295
+ printEvent('warn', 'Nenhum arquivo valido selecionado');
296
+ continue;
297
+ }
298
+ const blocks = picked.map((file) => readFileContext(settings.cwd, file));
299
+ history.push({
300
+ role: 'user',
301
+ content: truncateForGateway(`Arquivos anexados manualmente via /pick:\n\n${blocks.join('\n\n')}`),
302
+ });
303
+ printEvent('ok', 'Arquivo(s) anexado(s) ao contexto', picked.join(', '));
304
+ continue;
305
+ }
306
+ if (lower === '/overview' || lower === '/visao') {
307
+ printBox('visao do projeto', getProjectOverview(settings.cwd).split(/\r?\n/));
308
+ continue;
309
+ }
310
+ if (lower === '/diff') {
311
+ const diff = getGitDiff(settings.cwd);
312
+ if (diff === '(sem diff)') {
313
+ printEvent('ok', 'Sem diff local');
314
+ }
315
+ else if (diff.startsWith('ERRO:')) {
316
+ printEvent('error', 'git diff', diff);
317
+ }
318
+ else {
319
+ printBox('diff atual', ['git diff -- .']);
320
+ process.stdout.write(renderColoredDiff(diff));
321
+ if (!diff.endsWith('\n'))
322
+ process.stdout.write('\n');
323
+ }
324
+ continue;
325
+ }
326
+ if (lower === '/review') {
327
+ await runReviewCommand(settings, async (question) => {
328
+ const answer = await rl.question(question);
329
+ return /^(y|yes|s|sim)$/i.test(answer.trim());
330
+ }, history);
331
+ continue;
332
+ }
333
+ if (lower === '/save') {
334
+ const saved = saveCodeSession(settings, history);
335
+ printEvent('ok', 'Sessao salva', saved);
336
+ continue;
337
+ }
338
+ if (lower === '/resume') {
339
+ const sessions = listCodeSessions(settings.cwd);
340
+ printSessionList(sessions);
341
+ if (sessions.length === 0)
342
+ continue;
343
+ if (history.length > 0) {
344
+ const overwrite = await rl.question('\nConversa atual sera substituida. Continuar? [y/N] ');
345
+ if (!/^(y|yes|s|sim)$/i.test(overwrite.trim())) {
346
+ printEvent('warn', 'Cancelado');
347
+ continue;
348
+ }
349
+ }
350
+ const answer = await rl.question('Numero da sessao (Enter cancela): ');
351
+ const idx = Number.parseInt(answer.trim(), 10);
352
+ if (!Number.isInteger(idx) || idx < 1 || idx > sessions.length) {
353
+ printEvent('warn', 'Cancelado');
354
+ continue;
355
+ }
356
+ const picked = sessions[idx - 1];
357
+ try {
358
+ history = loadCodeSession(settings.cwd, picked.file);
359
+ printEvent('ok', 'Sessao retomada', `${picked.file} (${history.length} mensagem(ns))`);
360
+ }
361
+ catch (error) {
362
+ printEvent('error', 'Falha ao retomar sessao', error instanceof Error ? error.message : String(error));
363
+ }
364
+ continue;
365
+ }
366
+ if (lower === '/compact') {
367
+ const removed = compactCodeHistory(history);
368
+ printEvent('ok', 'Historico compactado', `${removed} mensagem(ns) removida(s)`);
369
+ continue;
370
+ }
371
+ if (lower === '/status' || lower === '/context') {
372
+ renderSessionStatus(settings, history);
351
373
  continue;
352
374
  }
353
375
  if (lower === '/model') {
354
376
  printBox('modelo', [settings.model]);
355
377
  continue;
356
378
  }
379
+ if (lower === '/plan') {
380
+ settings.apply = false;
381
+ printEvent('ok', 'Modo plano', 'patches nao serao aplicados');
382
+ continue;
383
+ }
384
+ if (lower === '/apply') {
385
+ settings.apply = true;
386
+ settings.yes = false;
387
+ printEvent('ok', 'Modo aplicar', 'pedira confirmacao antes do git apply');
388
+ continue;
389
+ }
390
+ if (lower === '/yes') {
391
+ settings.apply = true;
392
+ settings.yes = true;
393
+ printEvent('warn', 'Auto-aplicar ativo', 'patches validos serao aplicados sem pergunta');
394
+ continue;
395
+ }
396
+ if (lower === '/no') {
397
+ settings.yes = false;
398
+ printEvent('ok', 'Confirmacao manual ativa');
399
+ continue;
400
+ }
357
401
  if (lower === '/help' || lower === '/ajuda') {
358
402
  renderInteractiveHelp();
359
403
  continue;
@@ -378,9 +422,12 @@ function runDoctor(settings) {
378
422
  const apiUrl = config.get('neetruApiUrl') ?? 'https://api.neetru.com';
379
423
  const git = spawnSync('git', ['--version'], { cwd: settings.cwd, encoding: 'utf8' });
380
424
  renderHeader(settings, 'doctor');
425
+ if (settings.modelWarning)
426
+ log.warn(settings.modelWarning);
381
427
  console.log(chalk.bold('Doctor'));
382
428
  console.log(` token Neetru: ${token ? chalk.green('ok') : chalk.red('ausente')}`);
383
429
  console.log(` Core API: ${chalk.dim(apiUrl)}`);
430
+ console.log(` AI Gateway: ${chalk.dim(AI_GATEWAY_CHAT_PATH)}`);
384
431
  console.log(` git: ${git.status === 0 ? chalk.green('ok') : chalk.red('falhou')}`);
385
432
  console.log(` modo patch: ${settings.apply ? 'aplica com confirmacao' : 'somente plano'}`);
386
433
  console.log();
@@ -423,6 +470,8 @@ export async function runCodeCommand(promptParts, opts = {}) {
423
470
  return;
424
471
  }
425
472
  renderHeader(settings, 'oneshot');
473
+ if (settings.modelWarning)
474
+ log.warn(settings.modelWarning);
426
475
  await runAgentTask(task, settings, defaultConfirm);
427
476
  }
428
477
  catch (error) {