ineedcodes 1.4.0 → 1.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Your terminal, now autonomous. Just say what you want, ineed does the rest.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { chat } from './provider.js';
4
4
  import { TOOLS, runTool, shellRun, isDestructive, GIT_TOOL_DEFS, runGitTool } from './tools.js';
5
5
  import { fetchUrl, webSearch } from './web.js';
6
+ import { PROC_TOOL_DEFS, runProcTool } from './processes.js';
6
7
  import { trunc, gray, cyan, dim } from './ui.js';
7
8
  import { getMemoryProvider } from './memory.js';
8
9
  import * as path from 'node:path';
@@ -30,6 +31,8 @@ Rules:
30
31
  - Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
31
32
  - Never push to remotes or delete data without being asked.
32
33
  - Destructive commands are always blocked. Ask the user to run those themselves.
34
+ - Explain to match the user's depth preference (short: results only; normal: what changed and why; deep: also the reasoning and trade-offs).
35
+ - Suggest "boost" (isolated git-worktree run) when a task involves major refactoring, repeated failed fixes, or architecture changes, by telling the user to run /boost. Do not start it yourself.
33
36
  - Some actions need user approval. A tool result starting with "Denied" means the user said no: do not retry the same call, explain what you wanted instead.
34
37
  - For objectives with 3 or more steps, keep a checklist with the todo tool and update statuses as you go (in_progress for what you are doing now).
35
38
  - A "[steer from the user, newer than the objective]" message is a live steer: it is newer than the original objective. Adapt to it immediately; if it changes direction, change course without redoing finished work.
@@ -93,7 +96,8 @@ async function runWorker(cfg, spec, cwd, depth, hooks) {
93
96
  const res = await runObjective(cfg, objective, cwd, [], {}, {
94
97
  depth: depth + 1,
95
98
  toolFilter: role.tools,
96
- worker: { id: spec.id, role: roleName, prompt: role.prompt }
99
+ worker: { id: spec.id, role: roleName, prompt: role.prompt },
100
+ modelOverride: cfg.models?.[roleName] ?? cfg.models?.worker ?? null
97
101
  });
98
102
  return { id: spec.id, role: roleName, status: res.aborted ? 'incomplete' : 'completed', summary: res.answer || '(no output)', files: res.changed, commands: res.ran };
99
103
  } catch (err) {
@@ -124,8 +128,11 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
124
128
  let tools = plan ? TOOLS.filter(t => t.allowedInPlan) : [...TOOLS, SPAWN_TOOL];
125
129
  // first-class git wrappers (read ones always, mutating ones gated by permEdit)
126
130
  tools.push(...GIT_TOOL_DEFS.filter(t => plan ? !t.mutating : true));
131
+ // background process tools (build mode only)
132
+ if (!plan) tools.push(...PROC_TOOL_DEFS);
127
133
  if (extra.toolFilter) tools = tools.filter(t => (extra.toolFilter).includes(t.name));
128
134
  const canAsk = typeof hooks.onApprove === 'function';
135
+ const roleCfg = extra.modelOverride ? { ...cfg, model: extra.modelOverride } : cfg;
129
136
 
130
137
  // MCP: load configured servers once per top-level objective, expose their tools
131
138
  let mcpManager = null;
@@ -158,6 +165,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
158
165
 
159
166
  const workerPrefix = extra.worker ? `You are ${extra.worker.id} (${extra.worker.role} worker) spawned by the lead agent. ${extra.worker.prompt}\n` : '';
160
167
  const projectInstructions = extra.worker ? '' : loadProjectInstructions(cwd);
168
+ const depthNote = extra.worker ? '' : (cfg.explain === 'short' ? '\nAnswer style: short. Give results, skip explanations unless asked.' : cfg.explain === 'deep' ? '\nAnswer style: deep. Include reasoning, trade-offs, and what you ruled out.' : '');
161
169
  const skills = extra.worker ? [] : listSkills(cwd);
162
170
  const skillsBlock = skills.length ? `\nInstalled skills (follow a skill's instructions when the user invokes it by name or clearly asks for what it does):\n${skills.map(s => `- ${s.name} (${s.scope}): ${s.description}`).join('\n')}` : '';
163
171
  const invokedSkill = !extra.worker
@@ -166,7 +174,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
166
174
  const messages = [
167
175
  {
168
176
  role: 'system',
169
- content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
177
+ content: `${workerPrefix ? workerPrefix + '\n' : ''}${SYSTEM}${depthNote}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
170
178
  + (recalled ? `\nRelevant memory from previous sessions with this user (durable facts, may be stale):\n${recalled}` : '')
171
179
  + (projectInstructions ? `\nProject instructions for this repository (follow them):\n${projectInstructions}` : '')
172
180
  + skillsBlock
@@ -177,6 +185,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
177
185
  const changed = new Set();
178
186
  const ran = [];
179
187
  const todos = [];
188
+ const usage = { input: 0, output: 0 };
180
189
  let answer = '';
181
190
  let lastShown = '';
182
191
  try {
@@ -195,7 +204,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
195
204
  let msg;
196
205
  try {
197
206
  hooks.onThinkingStart?.();
198
- msg = await chat(cfg, messages, tools, ctrl.signal);
207
+ msg = await chat(roleCfg, messages, tools, ctrl.signal, hooks.onDelta);
199
208
  hooks.onThinkingEnd?.();
200
209
  } catch (err) {
201
210
  hooks.onThinkingEnd?.();
@@ -203,6 +212,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
203
212
  throw err;
204
213
  }
205
214
  messages.push(msg);
215
+ if (msg._usage) { usage.input += msg._usage.input; usage.output += msg._usage.output; hooks.onUsage?.({ ...usage }); }
206
216
  if (msg.content && msg.content !== lastShown) {
207
217
  hooks.onText?.(msg.content);
208
218
  lastShown = msg.content;
@@ -216,7 +226,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
216
226
  await memory.store(`project ${cwd}: ${objective.slice(0, 150)} -> ${answer.slice(0, 300)}`);
217
227
  } catch {}
218
228
  }
219
- return { answer, changed: [...changed], ran, todos: [...todos], aborted: false };
229
+ return { answer, changed: [...changed], ran, todos: [...todos], usage: { ...usage }, aborted: false };
220
230
  }
221
231
  // spawn_agent pre-pass: read-only workers run in parallel (max 4), writers sequentially
222
232
  const spawnResults = new Map();
@@ -269,9 +279,31 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
269
279
  result = allowedNow ? runGitTool(call.function?.name, input, cwd) : { output: `Denied: the user did not approve ${call.function?.name}.` };
270
280
  }
271
281
  } else if (call.function?.name === 'fetch_url') {
272
- result = await fetchUrl(input.url);
282
+ if (cfg.permNet === 'ask' && !hooks.approved?.has('net') && canAsk) {
283
+ const verdict = await hooks.onApprove('net', 'fetch_url', input);
284
+ if (verdict === 'always') hooks.approved?.add('net');
285
+ if (!verdict) result = { output: 'Denied: the user did not approve network access.' };
286
+ }
287
+ if (!result) result = await fetchUrl(input.url);
273
288
  } else if (call.function?.name === 'web_search') {
274
- result = await webSearch(cfg, input.query);
289
+ if (cfg.permNet === 'ask' && !hooks.approved?.has('net') && canAsk) {
290
+ const verdict = await hooks.onApprove('net', 'web_search', input);
291
+ if (verdict === 'always') hooks.approved?.add('net');
292
+ if (!verdict) result = { output: 'Denied: the user did not approve network access.' };
293
+ }
294
+ if (!result) result = await webSearch(cfg, input.query);
295
+ } else if (call.function?.name?.startsWith('process_')) {
296
+ if (plan) result = { output: 'Refused: plan mode is read only.' };
297
+ else {
298
+ const def = PROC_TOOL_DEFS.find(t => t.name === call.function?.name);
299
+ let allowedNow = !def.mutating || cfg.permShell === 'allow' || hooks.approved?.has('shell');
300
+ if (!allowedNow && canAsk) {
301
+ const verdict = await hooks.onApprove('shell', call.function?.name, input);
302
+ if (verdict === 'always') hooks.approved?.add('shell');
303
+ allowedNow = Boolean(verdict);
304
+ }
305
+ result = allowedNow ? runProcTool(call.function?.name, input) : { output: `Denied: the user did not approve ${call.function?.name}.` };
306
+ }
275
307
  } else if (call.function?.name === 'shell') {
276
308
  if (plan) result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
277
309
  else if (isDestructive(String(input.command ?? ''))) {
@@ -333,7 +365,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
333
365
  hooks.onRunEnd?.();
334
366
  }
335
367
  const stopped = ctrl.signal.aborted;
336
- return { answer, changed: [...changed], ran, todos: [...todos], aborted: true, stopped };
368
+ return { answer, changed: [...changed], ran, todos: [...todos], usage: { ...usage }, aborted: true, stopped };
337
369
  }
338
370
 
339
371
  export function pushTurn(history, objective, result) {
package/src/config.js CHANGED
@@ -26,9 +26,17 @@ export function normalize(c) {
26
26
  mode: c.mode === 'plan' ? 'plan' : 'build',
27
27
  memory: c.memory !== false,
28
28
  mcp: c.mcp !== false,
29
+ tui: c.tui === true ? true : c.tui === false ? false : null,
29
30
  humanize: c.humanize !== false,
31
+ stream: c.stream === true,
32
+ searchUrl: c.searchUrl ? String(c.searchUrl) : '',
33
+ explain: ['short', 'deep'].includes(c.explain) ? c.explain : 'normal',
30
34
  permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
31
- permShell: c.permShell === 'allow' ? 'allow' : 'ask'
35
+ permShell: c.permShell === 'allow' ? 'allow' : 'ask',
36
+ permNet: c.permNet === 'ask' ? 'ask' : 'allow',
37
+ models: (c.models && typeof c.models === 'object' && !Array.isArray(c.models))
38
+ ? Object.fromEntries(Object.entries(c.models).map(([k, v]) => [k, String(v)]))
39
+ : {}
32
40
  };
33
41
  }
34
42
 
package/src/humanize.js CHANGED
@@ -63,7 +63,7 @@ function humanizeHtmlTextNodes(html, fn) {
63
63
 
64
64
  async function humanizeWithModel(cfg, text, kind, signal) {
65
65
  const prompt = (kind === 'html'
66
- ? `Below is the text content of an HTML page. Rewrite ONLY the marketing copy so it reads like a human wrote it: drop AI cliches ("game-changer", "cutting-edge", "unlock", "seamless"), filler openers, and excessive enthusiasm. Never use em dashes. Keep the message, facts, product names, numbers, and language (id vs en) exactly. Reply with ONLY the rewritten text, same line structure. If nothing needs changing, reply with the text unchanged.`
66
+ ? `Below is the text content of an HTML page. Rewrite ONLY the marketing copy so it reads like a human wrote it: drop AI cliches ("game-changer", "cutting-edge", "unlock", "seamless"), filler openers, and excessive enthusiasm. Never use em dashes. Keep the message, facts, product names, numbers, and the original language exactly. Reply with ONLY the rewritten text, same line structure. If nothing needs changing, reply with the text unchanged.`
67
67
  : `Rewrite the text below so it reads like a human wrote it: drop AI cliches and filler, keep it natural and direct. Never use em dashes. Keep the message, facts, names, numbers, and language exactly. Keep the same line structure. Reply with ONLY the rewritten text. If nothing needs changing, reply with it unchanged.`)
68
68
  + `\n---\n${text.slice(0, 8000)}`;
69
69
  const msg = await chat({ ...cfg, reasoning: 'low' }, [{ role: 'user', content: prompt }], undefined, signal);
@@ -0,0 +1,104 @@
1
+ // processes.js: background process tools (master prompt #25). Start dev servers or
2
+ // long commands, list them, tail their logs, stop them. Zero dependencies.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as os from 'node:os';
8
+
9
+ const registry = new Map(); // name -> { child, logPath, command, started, exited }
10
+ const LOG_DIR = path.join(os.tmpdir(), 'ineed-procs');
11
+
12
+ function cleanExit() {
13
+ for (const [, e] of registry) { try { if (!e.exited) e.child.kill('SIGKILL'); } catch {} }
14
+ }
15
+ process.on('exit', cleanExit);
16
+
17
+ const okName = n => /^[a-zA-Z0-9_-]{1,40}$/.test(String(n));
18
+
19
+ export const PROC_TOOL_DEFS = [
20
+ {
21
+ name: 'process_start',
22
+ description: 'Start a long-running command in the background (dev server, watcher). Output goes to a log file.',
23
+ parameters: { type: 'object', properties: { name: { type: 'string' }, command: { type: 'string' } }, required: ['name', 'command'] },
24
+ proc: true, mutating: true
25
+ },
26
+ {
27
+ name: 'process_status',
28
+ description: 'List background processes with pid, command, and log path.',
29
+ parameters: { type: 'object', properties: {} },
30
+ proc: true
31
+ },
32
+ {
33
+ name: 'process_output',
34
+ description: 'Show the last lines of a background process log.',
35
+ parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
36
+ proc: true
37
+ },
38
+ {
39
+ name: 'process_stop',
40
+ description: 'Stop one background process by name.',
41
+ parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
42
+ proc: true, mutating: true
43
+ }
44
+ ];
45
+
46
+ export function runProcTool(name, input) {
47
+ const n = String(input.name ?? '');
48
+ if (name !== 'process_status' && !okName(n)) return { output: 'Error: name must be letters, digits, _ or - (max 40).' };
49
+
50
+ if (name === 'process_start') {
51
+ const command = String(input.command ?? '').trim();
52
+ if (!command) return { output: 'Error: empty command.' };
53
+ if (registry.has(n) && !registry.get(n).exited) {
54
+ return { output: `Error: "${n}" is already running (pid ${registry.get(n).child.pid}). Stop it first.` };
55
+ }
56
+ fs.mkdirSync(LOG_DIR, { recursive: true });
57
+ const logPath = path.join(LOG_DIR, n + '.log');
58
+ const out = fs.openSync(logPath, 'a');
59
+ let child;
60
+ try {
61
+ child = spawn(command, { cwd: os.homedir(), shell: true, stdio: ['ignore', out, out], env: { ...process.env, NO_COLOR: '1' } });
62
+ } catch (err) {
63
+ fs.closeSync(out);
64
+ return { output: `Error: ${err.message}` };
65
+ }
66
+ fs.closeSync(out);
67
+ const entry = { child, logPath, command: command.slice(0, 120), started: Date.now(), exited: false };
68
+ registry.set(n, entry);
69
+ child.on('exit', () => { entry.exited = true; });
70
+ return { output: `Started "${n}" (pid ${child.pid}). log: ${logPath}` };
71
+ }
72
+
73
+ if (name === 'process_status') {
74
+ if (!registry.size) return { output: 'No background processes.' };
75
+ return {
76
+ output: [...registry.entries()].map(([name, e]) =>
77
+ `${name}: pid ${e.child.pid}${e.exited ? ' (exited)' : ' (running)'} cmd: ${e.command} log: ${e.logPath}`
78
+ ).join('\n')
79
+ };
80
+ }
81
+
82
+ if (name === 'process_output') {
83
+ const e = registry.get(n);
84
+ if (!e) return { output: `Error: no process named "${n}".` };
85
+ try {
86
+ const log = fs.readFileSync(e.logPath, 'utf8');
87
+ return { output: log.split('\n').slice(-40).join('\n') || '(log empty)' };
88
+ } catch { return { output: '(log empty)' }; }
89
+ }
90
+
91
+ if (name === 'process_stop') {
92
+ const e = registry.get(n);
93
+ if (!e) return { output: `Error: no process named "${n}".` };
94
+ try { e.child.kill('SIGKILL'); } catch {}
95
+ registry.delete(n);
96
+ return { output: `Stopped "${n}".` };
97
+ }
98
+
99
+ return { output: `Unknown process tool: ${name}` };
100
+ }
101
+
102
+ export function stopAllProcs() {
103
+ for (const [, e] of registry) { try { if (!e.exited) e.child.kill('SIGKILL'); } catch {} }
104
+ }
package/src/provider.js CHANGED
@@ -30,9 +30,10 @@ export async function fetchModels(cfg, signal) {
30
30
  return [...new Set(ids)];
31
31
  }
32
32
 
33
- export async function chat(cfg, messages, tools, signal) {
33
+ export async function chat(cfg, messages, tools, signal, onDelta) {
34
34
  const body = { model: cfg.model, messages, temperature: 0.2 };
35
35
  if (cfg.reasoning === 'high') body.reasoning_effort = 'high';
36
+ if (cfg.stream === true) body.stream = true;
36
37
  if (tools?.length) body.tools = tools.map(t => ({
37
38
  type: 'function',
38
39
  function: { name: t.name, description: t.description, parameters: t.parameters }
@@ -65,15 +66,74 @@ export async function chat(cfg, messages, tools, signal) {
65
66
  // provider does not know reasoning_effort: retry once without it
66
67
  if (body.reasoning_effort && (res.status === 400 || res.status === 422)) {
67
68
  delete body.reasoning_effort;
69
+ delete body.stream;
68
70
  const retry = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
69
71
  if (!retry.ok) throw new Error(`HTTP ${retry.status}: ${(await retry.text()).slice(0, 200)}`);
70
72
  return parseMessage(await retry.json());
71
73
  }
74
+ // provider does not stream: fall back to a plain call instead of failing
75
+ if (body.stream && (res.status === 400 || res.status === 404 || res.status === 422)) {
76
+ delete body.stream;
77
+ res = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
78
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
79
+ return parseMessage(await res.json());
80
+ }
72
81
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
73
82
  }
83
+ if (body.stream) return readStream(res, onDelta);
74
84
  return parseMessage(await res.json());
75
85
  }
76
86
 
87
+ // SSE stream: accumulate content and tool_calls, emit text deltas as they arrive.
88
+ async function readStream(res, onDelta) {
89
+ const reader = res.body.getReader();
90
+ const decoder = new TextDecoder();
91
+ let buffer = '';
92
+ let content = '';
93
+ const toolCalls = [];
94
+ let usage = null;
95
+ let role = 'assistant';
96
+ for (;;) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+ buffer += decoder.decode(value, { stream: true });
100
+ let idx;
101
+ while ((idx = buffer.indexOf('\n')) >= 0) {
102
+ const line = buffer.slice(0, idx).trim();
103
+ buffer = buffer.slice(idx + 1);
104
+ if (!line.startsWith('data:')) continue;
105
+ const payload = line.slice(5).trim();
106
+ if (payload === '[DONE]') continue;
107
+ let ev;
108
+ try { ev = JSON.parse(payload); } catch { continue; }
109
+ if (ev.usage) usage = ev.usage;
110
+ const d = ev.choices?.[0]?.delta;
111
+ if (!d) continue;
112
+ if (d.role) role = d.role;
113
+ if (d.content) {
114
+ content += d.content;
115
+ onDelta?.(d.content);
116
+ }
117
+ for (const tc of d.tool_calls ?? []) {
118
+ const i = tc.index ?? 0;
119
+ toolCalls[i] ??= { id: tc.id ?? ('call_' + i), type: 'function', function: { name: '', arguments: '' } };
120
+ if (tc.id) toolCalls[i].id = tc.id;
121
+ if (tc.function?.name) toolCalls[i].function.name += tc.function.name;
122
+ if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments;
123
+ }
124
+ }
125
+ }
126
+ const msg = { role, content, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) };
127
+ if (usage) msg._usage = { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0 };
128
+ return msg;
129
+ }
130
+
77
131
  function parseMessage(json) {
78
- return json.choices?.[0]?.message ?? { role: 'assistant', content: '', tool_calls: [] };
132
+ const msg = json.choices?.[0]?.message ?? { role: 'assistant', content: '', tool_calls: [] };
133
+ // surface token usage when the provider returns it (OpenAI-style usage block)
134
+ if (json.usage) msg._usage = {
135
+ input: json.usage.prompt_tokens ?? 0,
136
+ output: json.usage.completion_tokens ?? 0
137
+ };
138
+ return msg;
79
139
  }
package/src/session.js CHANGED
@@ -10,9 +10,15 @@ import { wizard } from './wizard.js';
10
10
  import { getMemoryProvider, ICMAdapter } from './memory.js';
11
11
  import { saveSession, listSessions, loadSession } from './sessions.js';
12
12
  import * as boost from './boost.js';
13
+ import { spawnSync } from 'node:child_process';
13
14
  import { mcpConfigured } from './mcp.js';
14
15
  import { listSkills, findSkill } from './skills.js';
15
16
 
17
+ function currentBranch(cwd) {
18
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8' });
19
+ return r.status === 0 ? r.stdout.trim() : null;
20
+ }
21
+
16
22
  const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
17
23
 
18
24
  // Context compaction (master prompt #32): when the saved conversation grows past the
@@ -20,7 +26,7 @@ const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
20
26
  const COMPACT_CHARS = 24_000;
21
27
  async function compactHistory(cfg, history, hooks = {}) {
22
28
  const size = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
23
- if (size < COMPACT_CHARS || history.length < 6) return history;
29
+ if ((!hooks.force && size < COMPACT_CHARS) || history.length < 6) return history;
24
30
  const cut = Math.floor(history.length / 2);
25
31
  const old = history.slice(0, cut);
26
32
  const rest = history.slice(cut);
@@ -71,14 +77,27 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
71
77
  const pendingLines = [];
72
78
  const steerQueue = []; // notes typed while a task runs, injected mid-task
73
79
 
74
- const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
80
+ // Full-screen TUI: great in ANSI terminals (Linux/macOS/Windows Terminal), but
81
+ // legacy Windows consoles garble alt-screen sequences. Auto: on everywhere except
82
+ // win32; force with config "tui": true, disable with "tui": false.
83
+ const TUI = process.stdout.isTTY && !process.env.NO_COLOR
84
+ && (state.tui === true || (state.tui === null && process.platform !== 'win32'));
75
85
  let sessionId = null;
76
86
  let lastBoost = null;
87
+ let usage = { input: 0, output: 0 };
77
88
 
78
89
  // ONE readline, ONE line dispatcher for the whole session
79
90
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
80
91
  let handleRef = null;
81
92
  const ask = makeInput(rl, l => handleRef?.(l));
93
+ // EOF (Ctrl+D or closed pipe): exit cleanly, unless a task is still running
94
+ rl.on('close', () => {
95
+ if (!busy) doExit();
96
+ else {
97
+ const wait = setInterval(() => { if (!busy) { clearInterval(wait); doExit(); } }, 200);
98
+ setTimeout(() => { clearInterval(wait); doExit(); }, 30_000);
99
+ }
100
+ });
82
101
 
83
102
  const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
84
103
 
@@ -108,6 +127,19 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
108
127
  if (TUI) drawStatus();
109
128
  }
110
129
 
130
+ let lastStreamedForHooks = '';
131
+ let streamFlushTimer = null;
132
+ let streamFlushedCount = 0;
133
+ let streamBaseLines = null;
134
+
135
+ function flushStreamed() {
136
+ if (!TUI || !lastStreamedForHooks) return;
137
+ if (streamBaseLines === null) streamBaseLines = chatLines.length;
138
+ chatLines.length = streamBaseLines; // re-render the growing answer in place
139
+ for (const l of wrapLines(lastStreamedForHooks, Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
140
+ redrawChat();
141
+ }
142
+
111
143
  function hooksForRun(stopSpinner) {
112
144
  let spinner = null;
113
145
  const stop = () => { spinner?.stop(); spinner = null; };
@@ -122,6 +154,13 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
122
154
  onTool: (name, input2) => { stop(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
123
155
  onResult: out => { say(gray(' ' + trunc(out, 110))); },
124
156
  onText: t => { stop(); },
157
+ onDelta: chunk => {
158
+ // stream into the chat buffer; the full text lands on flushStreamed()
159
+ lastStreamedForHooks += chunk;
160
+ if (TUI && !streamFlushTimer) {
161
+ streamFlushTimer = setTimeout(() => { streamFlushTimer = null; flushStreamed(); }, 120);
162
+ }
163
+ },
125
164
  onTodos: list => {
126
165
  stop();
127
166
  const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
@@ -131,8 +170,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
131
170
  onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
132
171
  onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
133
172
  onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
134
- onNote: note => { stop(); say(dim(' ◇ ' + note)); },
135
- drainSteer: () => steerQueue.splice(0),
173
+ onNote: note => { stop(); say(dim(' ◇ ' + note)); },
174
+ onUsage: u => { usage = u; if (TUI) drawStatus(); },
175
+ drainSteer: () => steerQueue.splice(0),
136
176
  onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
137
177
  onApprove: async (cat, name, input2) => {
138
178
  stop();
@@ -160,11 +200,14 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
160
200
 
161
201
  async function runTask(input) {
162
202
  busy = true;
203
+ lastStreamedForHooks = '';
204
+ streamFlushedCount = 0;
163
205
  if (TUI) tuiUserLine(input);
164
206
  const hooks = hooksForRun();
165
207
  const stopSpinner = hooks.spinnerStop;
166
208
  try {
167
209
  const res = await runObjective(state, input, process.cwd(), history, hooks);
210
+ if (lastStreamedForHooks && TUI) process.stdout.write('\n');
168
211
  history = pushTurn(history, input, res);
169
212
  stopSpinner();
170
213
  try { history = await compactHistory(state, history, { onNote: n => say(dim(' ◇ ' + n)) }); } catch {}
@@ -174,7 +217,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
174
217
  } else {
175
218
  const rows = [green(bold('■ Done'))];
176
219
  if (res.changed?.length) rows.push(dim(' files: ') + res.changed.join(', '));
177
- if (res.answer) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
220
+ const answerIsStreamed = lastStreamedForHooks && res.answer === lastStreamedForHooks;
221
+ if (res.answer && !answerIsStreamed) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
222
+ else if (answerIsStreamed) rows.push(dim(' (streamed above)'));
178
223
  else if (!res.changed?.length) rows.push(dim(' (no output)'));
179
224
  say(box(rows));
180
225
  }
@@ -214,6 +259,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
214
259
  say(' ' + cyan('/boost') + ' isolated git-worktree run: /boost <objective>');
215
260
  say(' ' + cyan('/config') + ' show provider config (key hidden)');
216
261
  say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
262
+ say(' ' + cyan('/status') + ' everything about this session at a glance');
263
+ say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
264
+ say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
217
265
  say(' ' + cyan('/resume') + ' bring back a saved conversation');
218
266
  say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
219
267
  say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
@@ -310,6 +358,47 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
310
358
  busy = false;
311
359
  return afterTask();
312
360
  }
361
+ if (input === '/status') {
362
+ say(box([
363
+ bold('Status'),
364
+ dim('model') + ' ' + state.model,
365
+ dim('mode') + ' ' + mode + dim(' / reasoning ' + state.reasoning + ' / depth ' + state.explain),
366
+ dim('perms') + ' edit:' + state.permEdit + ' shell:' + state.permShell + ' net:' + state.permNet,
367
+ dim('memory') + ' ' + (state.memory === false ? 'off' : 'on (icm)'),
368
+ dim('humanizer') + ' ' + (state.humanize === false ? 'off' : 'on'),
369
+ dim('mcp') + ' ' + (mcpConfigured() ? 'configured' : 'not configured'),
370
+ dim('tokens') + ' ' + (usage.input || usage.output ? usage.input + ' in / ' + usage.output + ' out' : '-'),
371
+ dim('session') + ' ' + (sessionId ?? 'not saved yet'),
372
+ dim('cwd') + ' ' + process.cwd()
373
+ ]));
374
+ return;
375
+ }
376
+ if (input === '/compact') {
377
+ busy = true;
378
+ say(dim(' Compacting conversation...'));
379
+ const before = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
380
+ history = await compactHistory(state, history, { force: true, onNote: n => say(dim(' ◇ ' + n)) });
381
+ const after = history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0);
382
+ say(after < before ? green(` Compact: ${(before / 1024).toFixed(1)}k -> ${(after / 1024).toFixed(1)}k chars.`) : dim(' Conversation too small to compact further.'));
383
+ busy = false;
384
+ return afterTask();
385
+ }
386
+ if (input === '/depth' || input.startsWith('/depth ')) {
387
+ const arg = input.split(/\s+/)[1];
388
+ if (['short', 'normal', 'deep'].includes(arg)) {
389
+ Object.assign(state, normalize({ ...state, explain: arg }));
390
+ saveConfig(state);
391
+ say(green('Explanation depth: ' + arg));
392
+ } else {
393
+ say(box([
394
+ bold('Explanation depth'),
395
+ dim('/depth short') + ' results only',
396
+ dim('/depth normal') + ' what changed and why (default)',
397
+ dim('/depth deep') + ' reasoning, trade-offs, ruled-out paths'
398
+ ]));
399
+ }
400
+ return;
401
+ }
313
402
  if (input === '/humanizer' || input.startsWith('/humanizer ')) {
314
403
  const arg = input.split(/\s+/)[1];
315
404
  if (arg === 'on' || arg === 'off') {
@@ -452,7 +541,9 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
452
541
  }
453
542
 
454
543
  const plainPrompt = () => {
455
- rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))} ${green('❯')} `);
544
+ const branch = currentBranch(process.cwd());
545
+ const tok = usage.input || usage.output ? ` ${usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output} tok` : '';
546
+ rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))}${branch ? ' ' + gray('(' + branch + ')') : ''}${tok} ${green('❯')} `);
456
547
  rl.prompt();
457
548
  };
458
549
 
@@ -462,7 +553,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
462
553
  green(bold('Welcome to ineed!')),
463
554
  ' You are all set: any OpenAI-compatible provider, any folder.',
464
555
  ' Type what you want in normal language, for example:',
465
- dim(' "buatkan landing page beranimasi di folder ini"'),
556
+ dim(' "create an animated landing page in this folder"'),
466
557
  dim(' "fix the failing tests and tell me what was wrong"'),
467
558
  dim(' "explain this repository like I am a beginner"'),
468
559
  ' ' + dim('Helpers: /help commands · /perm auto or safe · /model · /memory on|off')
@@ -509,9 +600,16 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
509
600
 
510
601
  function drawStatus() {
511
602
  const cols = process.stdout.columns || 80;
512
- const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}`;
603
+ const branch = currentBranch(process.cwd());
604
+ const ctxK = (history.reduce((n, m) => n + (m.content?.length ?? 0) + 24, 0) / 1024).toFixed(1) + 'k';
605
+ const tok = usage.input || usage.output ? ` ${dim(usage.output >= 1000 ? (usage.output / 1000).toFixed(1) + 'k' : usage.output + '')} tok` : '';
606
+ const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}${branch ? ' ' + cyan('(' + branch + ')') : ''}`;
513
607
  const mid = ` ${dim(state.model)}`;
514
- const right = ` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)} ${dim('/')} ${state.memory === false ? dim('mem:off') : dim('mem:on')} `;
608
+ const right =
609
+ ` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)}` +
610
+ ` ${dim('/')} ${dim(ctxK + ' ctx')}${tok}` +
611
+ ` ${dim('/')} ${dim('mem:' + (state.memory === false ? 'off' : 'on'))}` +
612
+ ` ${dim('/')} ${mode === 'plan' ? dim('perm') : state.permEdit === 'ask' ? green('perm:ask') : dim('perm:auto')} `;
515
613
  screen.at(statusRow, 1);
516
614
  screen.clearLine();
517
615
  process.stdout.write(dim('─'.repeat(Math.max(0, cols - plain(left).length - plain(mid).length - plain(right).length))) + left + mid + right);
@@ -539,10 +637,10 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
539
637
  redrawChat();
540
638
  }
541
639
 
542
- function tuiUserLine(input) {
640
+ const tuiUserLine = input => {
543
641
  for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
544
642
  redrawChat();
545
- }
643
+ };
546
644
 
547
645
  const layout = () => {
548
646
  const rows = process.stdout.rows || 24;
package/src/ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // ui.js: terminal helpers. No dependencies, respects NO_COLOR and non-TTY.
2
2
 
3
- export const VERSION = '1.4.0';
3
+ export const VERSION = '1.5.1';
4
4
 
5
5
  const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
6
6
  const wrap = (code, t) => USE_COLOR ? `\x1b[${code}m${t}\x1b[0m` : String(t);