@neetru/cli 2.14.0 → 2.15.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Convenção: [Keep a Changelog](https://keepachangelog.com/) + [SemVer](https://semver.org/).
4
4
 
5
+ ## [2.15.0] — 2026-07-10 (`neetru code` — Fases 2 a 5: núcleo separado, ferramentas, memória, streaming)
6
+
7
+ > Continuação direta do 2.14.0 (mesmo subcomando, mesma decisão B0). Sem breaking change nos flags/comandos existentes — tudo aditivo. Detalhe completo em `NEETRU_CODE_ARCHITECTURE.md`.
8
+
9
+ ### Adicionado
10
+
11
+ - **Núcleo separado da interface** (Fase 2) — `code-agent-session.ts` ganhou `CodeAgentStateMachine` (idle→thinking→tool_requested→tool_running→patch_ready→awaiting_confirm→applied/failed/aborted, com validação de transição); renderização extraída pra `code-agent-ui.ts`; chamada ao AI Gateway isolada em `code-agent-gateway.ts`; registro de ferramentas centralizado em `code-agent-tools.ts` (`buildSystemPrompt()` monta o prompt a partir do registro — não precisa mais editar prompt à mão a cada ferramenta nova).
12
+ - **4 ferramentas produtivas novas** (Fase 3): `run_tests` (roda só o script `test` já configurado — allowlist fechada, não aceita comando livre do modelo), `read_many_files` (até 20 arquivos por chamada), `search_regex` (busca linha-a-linha com limites anti-ReDoS), `project_symbols` (outline leve de `export function/class/const/interface/type/enum`).
13
+ - **Memória e retomada** (Fase 4): `/save` grava a sessão em `.neetru/code/sessions`; `/resume` lista sessões salvas numeradas (mais recente primeiro, com preview da 1ª mensagem) e recarrega a conversa escolhida; log local de patch aplicado em `.neetru/code/patches.log`.
14
+ - **Streaming de resposta** (Fase 5): `streamGatewayChat` consome SSE do AI Gateway (mesmo endpoint/formato que `neetru ai --debug:model gateway` já usa) — mostra contador de chars recebidos ao vivo no terminal em vez de ficar parado durante respostas longas; cai automaticamente pra chamada sem streaming em qualquer falha (a corretude do turno nunca depende do streaming funcionar). Parser SSE (`readSseLines`) extraído pra `src/lib/sse.ts` — compartilhado com `src/lib/ai/orchestrator.ts`, sem duplicar a lógica de buffer/decode entre os dois subcomandos.
15
+ - **`/pick`** (Fase 5): lista arquivos do projeto numerados e anexa a escolha do usuário ao contexto da conversa.
16
+
17
+ ### Notas
18
+
19
+ - Painel TUI completo (viewport fixo pra conversa/status/diff) fica de fora deste release — exige trocar o modelo de entrada (`readline`) por controle de terminal em modo raw, escopo estrutural próprio.
20
+
5
21
  ## [2.14.0] — 2026-07-09 (`neetru code` — assistente local via AI Gateway)
6
22
 
7
23
  > Novo subcomando, decisão B0 (ver `PLANO_NEETRU_AI_CODE_2026-07-02.md`): assistente de código local roteado pelo Neetru AI Gateway (Groq/NVIDIA — `llama-3.3-70b-versatile` default), não pelo Anthropic/OpenAI SDK direto. Fecha 3 HIGH + 1 MEDIUM de um review de segurança antes de mergear.
@@ -1,32 +1,6 @@
1
- export type CodeRole = 'system' | 'user' | 'assistant';
2
- export interface CodeMessage {
3
- role: CodeRole;
4
- content: string;
5
- }
6
- export interface CodeCommandOptions {
7
- cwd?: string;
8
- file?: string[];
9
- model?: string;
10
- maxTurns?: string | number;
11
- maxTokens?: string | number;
12
- temperature?: string | number;
13
- plan?: boolean;
14
- apply?: boolean;
15
- yes?: boolean;
16
- }
17
- export interface CodeSettings {
18
- cwd: string;
19
- files: string[];
20
- model: string;
21
- modelWarning?: string;
22
- maxTurns: number;
23
- maxTokens: number;
24
- temperature: number;
25
- apply: boolean;
26
- yes: boolean;
27
- timeoutMs: number;
28
- }
29
- type ConfirmFn = (question: string) => Promise<boolean>;
1
+ import { type CodeAgentState } from '../lib/code-agent-session.js';
2
+ import type { CodeMessage, CodeCommandOptions, CodeSettings, ConfirmFn } from '../lib/code-agent-types.js';
3
+ export type { CodeMessage, CodeCommandOptions, CodeSettings } from '../lib/code-agent-types.js';
30
4
  export declare function resolveSettings(opts: CodeCommandOptions): CodeSettings;
31
5
  /**
32
6
  * `history` e mutado e reaproveitado entre chamadas: e o que da ao modo
@@ -34,8 +8,14 @@ export declare function resolveSettings(opts: CodeCommandOptions): CodeSettings;
34
8
  * em vez de cada linha digitada virar uma requisicao amnesica. O one-shot
35
9
  * (`neetru code "tarefa"`) chama sem `history` — cada `runAgentTask` comeca do
36
10
  * zero, igual sempre foi.
11
+ *
12
+ * `onState` (opcional): observa a maquina de estados da sessao (Fase 2,
13
+ * NEETRU_CODE_ARCHITECTURE.md) — usado por testes pra verificar a sequencia
14
+ * idle -> thinking -> tool_requested -> tool_running -> ... sem mockar o
15
+ * AI Gateway inteiro. Cada chamada de runAgentTask e um ciclo completo
16
+ * (comeca e termina em idle/applied/failed/aborted).
37
17
  */
38
- export declare function runAgentTask(task: string, settings: CodeSettings, confirm: ConfirmFn, history?: CodeMessage[]): Promise<void>;
18
+ export declare function runAgentTask(task: string, settings: CodeSettings, confirm: ConfirmFn, history?: CodeMessage[], onState?: (state: CodeAgentState) => void): Promise<void>;
39
19
  /**
40
20
  * Entrada a partir do menu interativo do CLI (`neetru` sem args / `neetru ui`).
41
21
  * Usa settings default (cwd do processo) e gerencia o raw-mode do stdin como
@@ -45,4 +25,3 @@ export declare function runAgentTask(task: string, settings: CodeSettings, confi
45
25
  */
46
26
  export declare function runCodeMenu(): Promise<void>;
47
27
  export declare function runCodeCommand(promptParts: string[] | undefined, opts?: CodeCommandOptions): Promise<void>;
48
- export {};
@@ -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, } from '../lib/code-agent-files.js';
12
+ import { compactCodeHistory, 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, 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,28 +61,22 @@ 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 = [];
@@ -225,24 +91,6 @@ function buildInitialPrompt(task, settings) {
225
91
  : 'Nenhum arquivo foi anexado inicialmente. Use NEETRU_TOOL para pedir contexto local.',
226
92
  ].join('\n\n');
227
93
  }
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;
245
- }
246
94
  async function defaultConfirm(question) {
247
95
  if (!process.stdin.isTTY)
248
96
  return false;
@@ -255,17 +103,61 @@ async function defaultConfirm(question) {
255
103
  rl.close();
256
104
  }
257
105
  }
106
+ /**
107
+ * Chama o AI Gateway com streaming (Fase 5) mostrando progresso ao vivo
108
+ * (contador de chars recebidos na mesma linha, via `\r`) em vez de ficar
109
+ * estatico durante respostas longas. So atualiza a linha em TTY real — em
110
+ * saida nao-interativa (pipe/CI/teste) isso so poluiria o log.
111
+ *
112
+ * Fallback: qualquer falha no streaming (rede, formato inesperado) refaz a
113
+ * chamada sem streaming — a corretude do turno nunca depende do streaming
114
+ * funcionar, so a experiencia visual.
115
+ */
116
+ async function callGatewayWithProgress(settings, messages) {
117
+ const live = Boolean(process.stdout.isTTY);
118
+ let received = 0;
119
+ try {
120
+ const content = await streamGatewayChat(settings, messages, (_delta, total) => {
121
+ received = total;
122
+ if (live) {
123
+ process.stdout.write(`\r${chalk.dim(` ... recebendo resposta (${received} char(s))`)}`);
124
+ }
125
+ });
126
+ if (live && received > 0) {
127
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
128
+ }
129
+ return content;
130
+ }
131
+ catch (error) {
132
+ if (live && received > 0) {
133
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
134
+ }
135
+ printEvent('warn', 'Streaming falhou, tentando sem streaming', error instanceof Error ? error.message : String(error));
136
+ return callGateway(settings, messages);
137
+ }
138
+ }
258
139
  /**
259
140
  * `history` e mutado e reaproveitado entre chamadas: e o que da ao modo
260
141
  * interativo memoria de conversa entre turnos do REPL (como Claude Code/Codex),
261
142
  * em vez de cada linha digitada virar uma requisicao amnesica. O one-shot
262
143
  * (`neetru code "tarefa"`) chama sem `history` — cada `runAgentTask` comeca do
263
144
  * zero, igual sempre foi.
145
+ *
146
+ * `onState` (opcional): observa a maquina de estados da sessao (Fase 2,
147
+ * NEETRU_CODE_ARCHITECTURE.md) — usado por testes pra verificar a sequencia
148
+ * idle -> thinking -> tool_requested -> tool_running -> ... sem mockar o
149
+ * AI Gateway inteiro. Cada chamada de runAgentTask e um ciclo completo
150
+ * (comeca e termina em idle/applied/failed/aborted).
264
151
  */
265
- export async function runAgentTask(task, settings, confirm, history = []) {
152
+ export async function runAgentTask(task, settings, confirm, history = [], onState) {
266
153
  if (!getActiveTokenSync()) {
267
154
  throw new Error('Sessao Neetru ausente ou expirada. Rode `neetru login`.');
268
155
  }
156
+ const machine = new CodeAgentStateMachine();
157
+ const goto = (next) => {
158
+ machine.transition(next);
159
+ onState?.(next);
160
+ };
269
161
  const messages = history;
270
162
  const isFirstTurn = messages.length === 0;
271
163
  if (isFirstTurn)
@@ -274,54 +166,68 @@ export async function runAgentTask(task, settings, confirm, history = []) {
274
166
  role: 'user',
275
167
  content: isFirstTurn ? buildInitialPrompt(task, settings) : task,
276
168
  });
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;
169
+ try {
170
+ for (let turn = 1; turn <= settings.maxTurns; turn++) {
171
+ goto('thinking');
172
+ printEvent('wait', 'AI Gateway', `turno ${turn}/${settings.maxTurns}`);
173
+ const content = await callGatewayWithProgress(settings, messages);
174
+ messages.push({ role: 'assistant', content });
175
+ const tool = parseToolRequest(content);
176
+ if (tool) {
177
+ goto('tool_requested');
178
+ printEvent('tool', tool.tool, toolDetail(tool));
179
+ goto('tool_running');
180
+ const result = runCodeTool(settings.cwd, tool);
181
+ printEvent('ok', 'contexto local', summarizeToolResult(result));
182
+ messages.push({
183
+ role: 'user',
184
+ content: `Resultado da tool ${tool.tool}:\n\n${result}`,
185
+ });
186
+ continue;
299
187
  }
300
- const approved = settings.yes || (await confirm('\nAplicar este patch? [y/N] '));
301
- if (!approved) {
302
- printEvent('warn', 'Patch nao aplicado');
188
+ const patch = extractPatch(content);
189
+ if (patch) {
190
+ goto('patch_ready');
191
+ console.log();
192
+ printPatchPreview(patch);
193
+ if (!settings.apply) {
194
+ printEvent('warn', 'Patch nao aplicado', '--plan/--no-apply');
195
+ goto('aborted');
196
+ return;
197
+ }
198
+ const approved = settings.yes || (await (async () => {
199
+ goto('awaiting_confirm');
200
+ return confirm('\nAplicar este patch? [y/N] ');
201
+ })());
202
+ if (!approved) {
203
+ printEvent('warn', 'Patch nao aplicado');
204
+ goto('aborted');
205
+ return;
206
+ }
207
+ applyUnifiedPatch(settings.cwd, patch);
208
+ logAppliedPatch(settings.cwd, patchSummaryRows(patch)[0] ?? 'arquivos: nao detectado');
209
+ printEvent('ok', 'Patch aplicado', 'git apply');
210
+ goto('applied');
303
211
  return;
304
212
  }
305
- applyUnifiedPatch(settings.cwd, patch);
306
- printEvent('ok', 'Patch aplicado', 'git apply');
213
+ console.log();
214
+ printAssistantText(content);
215
+ goto('idle');
307
216
  return;
308
217
  }
309
- console.log();
310
- printAssistantText(content);
311
- return;
218
+ goto('failed');
219
+ throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
312
220
  }
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;
221
+ catch (error) {
222
+ if (machine.state !== 'failed' && machine.state !== 'aborted' && machine.state !== 'applied') {
223
+ goto('failed');
224
+ }
225
+ throw error;
320
226
  }
321
- printBox('arquivos', [
322
- ...files,
323
- ...(files.length === 60 ? ['... lista truncada em 60 arquivos.'] : []),
324
- ]);
227
+ }
228
+ function printFileSampleForSettings(settings) {
229
+ const all = listFiles(settings.cwd);
230
+ printFileSample(all.slice(0, 60), all.length);
325
231
  }
326
232
  async function runInteractive(settings) {
327
233
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -329,11 +235,13 @@ async function runInteractive(settings) {
329
235
  }
330
236
  renderHeader(settings, 'interactive');
331
237
  renderInteractiveHelp();
238
+ if (settings.modelWarning)
239
+ log.warn(settings.modelWarning);
332
240
  let history = [];
333
241
  const rl = createInterface({ input: process.stdin, output: process.stdout });
334
242
  try {
335
243
  for (;;) {
336
- const line = (await rl.question(chalk.cyan('> '))).trim();
244
+ const line = (await rl.question(renderPrompt(settings))).trim();
337
245
  if (!line)
338
246
  continue;
339
247
  const lower = line.toLowerCase();
@@ -344,16 +252,139 @@ async function runInteractive(settings) {
344
252
  process.stdout.write('\x1B[2J\x1B[H');
345
253
  renderHeader(settings, 'interactive');
346
254
  renderInteractiveHelp();
255
+ printEvent('ok', 'Conversa limpa');
347
256
  continue;
348
257
  }
349
258
  if (lower === '/files') {
350
- printFileSample(settings);
259
+ printFileSampleForSettings(settings);
260
+ continue;
261
+ }
262
+ if (lower === '/pick') {
263
+ const all = listFiles(settings.cwd);
264
+ const shown = all.slice(0, 60);
265
+ printFilePicker(shown, all.length);
266
+ if (shown.length === 0)
267
+ continue;
268
+ const answer = await rl.question('Numero(s) separado(s) por virgula (Enter cancela): ');
269
+ const raw = answer.trim();
270
+ if (!raw) {
271
+ printEvent('warn', 'Cancelado');
272
+ continue;
273
+ }
274
+ const picked = [];
275
+ for (const part of raw.split(',')) {
276
+ const idx = Number.parseInt(part.trim(), 10);
277
+ if (Number.isInteger(idx) && idx >= 1 && idx <= shown.length) {
278
+ picked.push(shown[idx - 1]);
279
+ }
280
+ }
281
+ if (picked.length === 0) {
282
+ printEvent('warn', 'Nenhum arquivo valido selecionado');
283
+ continue;
284
+ }
285
+ const blocks = picked.map((file) => readFileContext(settings.cwd, file));
286
+ history.push({
287
+ role: 'user',
288
+ content: `Arquivos anexados manualmente via /pick:\n\n${blocks.join('\n\n')}`,
289
+ });
290
+ printEvent('ok', 'Arquivo(s) anexado(s) ao contexto', picked.join(', '));
291
+ continue;
292
+ }
293
+ if (lower === '/overview' || lower === '/visao') {
294
+ printBox('visao do projeto', getProjectOverview(settings.cwd).split(/\r?\n/));
295
+ continue;
296
+ }
297
+ if (lower === '/diff') {
298
+ const diff = getGitDiff(settings.cwd);
299
+ if (diff === '(sem diff)') {
300
+ printEvent('ok', 'Sem diff local');
301
+ }
302
+ else if (diff.startsWith('ERRO:')) {
303
+ printEvent('error', 'git diff', diff);
304
+ }
305
+ else {
306
+ printBox('diff atual', ['git diff -- .']);
307
+ process.stdout.write(renderColoredDiff(diff));
308
+ if (!diff.endsWith('\n'))
309
+ process.stdout.write('\n');
310
+ }
311
+ continue;
312
+ }
313
+ if (lower === '/review') {
314
+ await runReviewCommand(settings, async (question) => {
315
+ const answer = await rl.question(question);
316
+ return /^(y|yes|s|sim)$/i.test(answer.trim());
317
+ }, history);
318
+ continue;
319
+ }
320
+ if (lower === '/save') {
321
+ const saved = saveCodeSession(settings, history);
322
+ printEvent('ok', 'Sessao salva', saved);
323
+ continue;
324
+ }
325
+ if (lower === '/resume') {
326
+ const sessions = listCodeSessions(settings.cwd);
327
+ printSessionList(sessions);
328
+ if (sessions.length === 0)
329
+ continue;
330
+ if (history.length > 0) {
331
+ const overwrite = await rl.question('\nConversa atual sera substituida. Continuar? [y/N] ');
332
+ if (!/^(y|yes|s|sim)$/i.test(overwrite.trim())) {
333
+ printEvent('warn', 'Cancelado');
334
+ continue;
335
+ }
336
+ }
337
+ const answer = await rl.question('Numero da sessao (Enter cancela): ');
338
+ const idx = Number.parseInt(answer.trim(), 10);
339
+ if (!Number.isInteger(idx) || idx < 1 || idx > sessions.length) {
340
+ printEvent('warn', 'Cancelado');
341
+ continue;
342
+ }
343
+ const picked = sessions[idx - 1];
344
+ try {
345
+ history = loadCodeSession(settings.cwd, picked.file);
346
+ printEvent('ok', 'Sessao retomada', `${picked.file} (${history.length} mensagem(ns))`);
347
+ }
348
+ catch (error) {
349
+ printEvent('error', 'Falha ao retomar sessao', error instanceof Error ? error.message : String(error));
350
+ }
351
+ continue;
352
+ }
353
+ if (lower === '/compact') {
354
+ const removed = compactCodeHistory(history);
355
+ printEvent('ok', 'Historico compactado', `${removed} mensagem(ns) removida(s)`);
356
+ continue;
357
+ }
358
+ if (lower === '/status' || lower === '/context') {
359
+ renderSessionStatus(settings, history);
351
360
  continue;
352
361
  }
353
362
  if (lower === '/model') {
354
363
  printBox('modelo', [settings.model]);
355
364
  continue;
356
365
  }
366
+ if (lower === '/plan') {
367
+ settings.apply = false;
368
+ printEvent('ok', 'Modo plano', 'patches nao serao aplicados');
369
+ continue;
370
+ }
371
+ if (lower === '/apply') {
372
+ settings.apply = true;
373
+ settings.yes = false;
374
+ printEvent('ok', 'Modo aplicar', 'pedira confirmacao antes do git apply');
375
+ continue;
376
+ }
377
+ if (lower === '/yes') {
378
+ settings.apply = true;
379
+ settings.yes = true;
380
+ printEvent('warn', 'Auto-aplicar ativo', 'patches validos serao aplicados sem pergunta');
381
+ continue;
382
+ }
383
+ if (lower === '/no') {
384
+ settings.yes = false;
385
+ printEvent('ok', 'Confirmacao manual ativa');
386
+ continue;
387
+ }
357
388
  if (lower === '/help' || lower === '/ajuda') {
358
389
  renderInteractiveHelp();
359
390
  continue;
@@ -378,9 +409,12 @@ function runDoctor(settings) {
378
409
  const apiUrl = config.get('neetruApiUrl') ?? 'https://api.neetru.com';
379
410
  const git = spawnSync('git', ['--version'], { cwd: settings.cwd, encoding: 'utf8' });
380
411
  renderHeader(settings, 'doctor');
412
+ if (settings.modelWarning)
413
+ log.warn(settings.modelWarning);
381
414
  console.log(chalk.bold('Doctor'));
382
415
  console.log(` token Neetru: ${token ? chalk.green('ok') : chalk.red('ausente')}`);
383
416
  console.log(` Core API: ${chalk.dim(apiUrl)}`);
417
+ console.log(` AI Gateway: ${chalk.dim(AI_GATEWAY_CHAT_PATH)}`);
384
418
  console.log(` git: ${git.status === 0 ? chalk.green('ok') : chalk.red('falhou')}`);
385
419
  console.log(` modo patch: ${settings.apply ? 'aplica com confirmacao' : 'somente plano'}`);
386
420
  console.log();
@@ -423,6 +457,8 @@ export async function runCodeCommand(promptParts, opts = {}) {
423
457
  return;
424
458
  }
425
459
  renderHeader(settings, 'oneshot');
460
+ if (settings.modelWarning)
461
+ log.warn(settings.modelWarning);
426
462
  await runAgentTask(task, settings, defaultConfirm);
427
463
  }
428
464
  catch (error) {