ineedcodes 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/provider.js CHANGED
@@ -39,7 +39,27 @@ export async function chat(cfg, messages, tools, signal) {
39
39
  }));
40
40
  const headers = { 'content-type': 'application/json' };
41
41
  if (cfg.apiKey) headers.authorization = `Bearer ${cfg.apiKey}`;
42
- const res = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
42
+
43
+ // transient failures (network errors, 429, 5xx) get retries with backoff; other HTTP answers do not
44
+ const ATTEMPTS = 4;
45
+ let res;
46
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
47
+ let threw = null;
48
+ try {
49
+ res = await request(`${cfg.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
50
+ } catch (err) {
51
+ threw = err;
52
+ }
53
+ const retryable = threw || [429, 500, 502, 503, 504].includes(res?.status);
54
+ if (!retryable || attempt === ATTEMPTS || signal?.aborted) {
55
+ if (threw) throw threw;
56
+ break;
57
+ }
58
+ if (threw || res) {
59
+ const delay = Math.min(15_000, 1500 * attempt * attempt);
60
+ await new Promise(r => setTimeout(r, delay));
61
+ }
62
+ }
43
63
  if (!res.ok) {
44
64
  const text = await res.text();
45
65
  // provider does not know reasoning_effort: retry once without it
package/src/session.js CHANGED
@@ -1,156 +1,419 @@
1
- // session.js: interactive REPL. Slash commands are shortcuts, never required.
1
+ // session.js: interactive chat TUI. Fixed logo header, scrolling chat area, fixed status + input footer.
2
+ // Falls back to a plain REPL (same commands) when stdout is not a TTY, so tests and pipes keep working.
2
3
 
3
4
  import * as readline from 'node:readline';
4
- import { loadConfig, clearConfig, normalize } from './config.js';
5
+ import { clearConfig, normalize, saveConfig } from './config.js';
5
6
  import { runObjective, pushTurn } from './agent.js';
6
7
  import { fetchModels } from './provider.js';
7
- import { makeInput, bold, dim, red, green, yellow, cyan, gray, trunc, BANNER } from './ui.js';
8
+ import { makeInput, bold, dim, red, green, yellow, cyan, gray, trunc, BANNER, logo, box, startSpinner, VERSION, RULE, userBubble, screen } from './ui.js';
8
9
  import { wizard } from './wizard.js';
10
+ import { getMemoryProvider, ICMAdapter } from './memory.js';
11
+ import { mcpConfigured } from './mcp.js';
9
12
 
10
- export async function startSession(cfg) {
11
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
12
- let handleRef = null;
13
- const ask = makeInput(rl, l => handleRef?.(l));
14
- let state = normalize(cfg);
13
+ const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
14
+
15
+ function wrapLines(text, width) {
16
+ const out = [];
17
+ for (const raw of String(text).split('\n')) {
18
+ let line = raw;
19
+ if (line === '') { out.push(''); continue; }
20
+ while (plain(line).length > width) {
21
+ let vis = 0, i = 0;
22
+ while (i < line.length && vis < width) {
23
+ if (line[i] === '\x1b') { while (i < line.length && line[i] !== 'm') i++; }
24
+ else vis++;
25
+ i++;
26
+ }
27
+ out.push(line.slice(0, i));
28
+ line = line.slice(i);
29
+ }
30
+ out.push(line);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export async function startSession(cfg, { fresh = false } = {}) {
36
+ const state = normalize(cfg);
15
37
  let history = [];
16
38
  let busy = false;
17
39
  let activeRun = null;
18
40
  let mode = state.mode;
19
- let pendingLines = [];
20
41
  let lastSigint = 0;
21
42
  let closed = false;
43
+ const approved = new Set(); // session-wide "always allow" grants
44
+ const pendingLines = [];
22
45
 
23
- rl.on('SIGINT', () => {
24
- if (activeRun) { activeRun.abort(); console.log(dim('\nStopping current task... press Ctrl+C again to force exit.')); return; }
25
- const now = Date.now();
26
- if (now - lastSigint < 3000) { console.log(dim('\nGoodbye.')); process.exit(0); }
27
- lastSigint = now;
28
- console.log(dim('\n(Ctrl+C again to exit)'));
29
- rl.prompt();
30
- });
46
+ const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
31
47
 
32
- const prompt = () => {
33
- rl.setPrompt(`\n${bold(green('ineed'))}${dim(` [${mode}/${state.reasoning}]`)} ${dim('› ')}`);
34
- rl.prompt();
35
- };
48
+ // ONE readline, ONE line dispatcher for the whole session
49
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
+ let handleRef = null;
51
+ const ask = makeInput(rl, l => handleRef?.(l));
36
52
 
37
- const header = () => {
38
- console.log(BANNER() + dim(` · ${state.model} · ${process.cwd()}`));
39
- console.log(dim('Just say what you want. /help shows shortcuts. Ctrl+C twice exits.'));
40
- };
53
+ const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
41
54
 
42
- const showHelp = () => {
43
- console.log(' ' + cyan('/model') + ' pick a model from your provider');
44
- console.log(' ' + cyan('/plan') + ' plan mode: read only, agent suggests, changes nothing');
45
- console.log(' ' + cyan('/build') + ' build mode: agent makes real changes (default)');
46
- console.log(' ' + cyan('/reason') + ' toggle reasoning effort low/high');
47
- console.log(' ' + cyan('/setup') + ' redo provider setup');
48
- console.log(' ' + cyan('/reset') + ' clear saved config');
49
- console.log(' ' + cyan('/clear') + ' forget this session\'s conversation');
50
- console.log(' ' + cyan('/exit') + ' quit');
51
- console.log(dim(' Everything else you type is a task, in normal language.'));
52
- };
55
+ function doExit() {
56
+ closed = true;
57
+ if (TUI) { screen.resetRegion(); screen.exit(); }
58
+ console.log(dim('Goodbye.'));
59
+ process.exit(0);
60
+ }
53
61
 
54
- const pickModel = async () => {
55
- console.log(dim(' Fetching models...'));
62
+ async function pickModel() {
56
63
  let models = [];
57
- try { models = await fetchModels(state); } catch (err) { console.log(red(' ' + err.message)); return; }
64
+ try { models = await fetchModels(state); } catch (err) { say(red(' ' + err.message)); return; }
58
65
  if (models.length === 0) {
59
- const m = await ask(' Server sent no list. Type the model id: ');
60
- if (m) { state = normalize({ ...state, model: m }); console.log(green(' Model: ' + state.model)); }
66
+ say(yellow(' Server sent no list. Change model by editing ~/.ineedcodes/config.json'));
61
67
  return;
62
68
  }
63
- const max = 5;
64
- const list = models.slice(0, max);
65
- console.log(dim(` ${models.length} models available, showing ${list.length}. Type a number, or a full model id.`));
66
- list.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
69
+ const list = models.slice(0, 5);
70
+ say(dim(` ${models.length} models available, showing ${list.length}. Type a number, or a full model id.`));
71
+ list.forEach((m, i) => say(` ${i + 1}. ${m}`));
67
72
  const pick = await ask(' Model: ');
68
73
  if (!pick) return;
69
74
  const idx = Number(pick);
70
- if (Number.isInteger(idx) && idx >= 1 && idx <= list.length) state = normalize({ ...state, model: list[idx - 1] });
71
- else state = normalize({ ...state, model: pick });
72
- console.log(green(' Model: ' + state.model));
73
- };
75
+ if (Number.isInteger(idx) && idx >= 1 && idx <= list.length) Object.assign(state, normalize({ ...state, model: list[idx - 1] }));
76
+ else Object.assign(state, normalize({ ...state, model: pick }));
77
+ say(green(' Model: ' + state.model));
78
+ if (TUI) drawStatus();
79
+ }
74
80
 
75
- const runTask = async input => {
81
+ async function runTask(input) {
76
82
  busy = true;
83
+ let lastStreamed = '';
84
+ if (TUI) tuiUserLine(input);
85
+ let spinner = null;
86
+ const stopSpinner = () => { spinner?.stop(); spinner = null; };
77
87
  try {
78
88
  const res = await runObjective(state, input, process.cwd(), history, {
79
- onTool: (name, input2) => {
80
- console.log(' ' + cyan('· ' + name) + ' ' + gray(trunc(JSON.stringify(input2), 100)));
89
+ onMemoryStart: () => { stopSpinner(); spinner = startSpinner('recalling memory'); },
90
+ onMemoryEnd: () => stopSpinner(),
91
+ onThinkingStart: () => { stopSpinner(); spinner = startSpinner('thinking'); },
92
+ onThinkingEnd: () => stopSpinner(),
93
+ onWorkStart: label => { stopSpinner(); spinner = startSpinner(label || 'working'); },
94
+ onWorkEnd: () => stopSpinner(),
95
+ onTool: (name, input2) => { stopSpinner(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
96
+ onResult: out => { say(gray(' ' + trunc(out, 110))); },
97
+ onText: t => { stopSpinner(); lastStreamed = t; },
98
+ onTodos: list => {
99
+ stopSpinner();
100
+ const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
101
+ say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
102
+ },
103
+ onAgentStart: (id, input) => { stopSpinner(); say(cyan(' ◆ spawn ' + id) + gray(` role=${input.role ?? '?'} task=${trunc(String(input.objective ?? ''), 70)}`)); },
104
+ onAgentEnd: (id, r) => { stopSpinner(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
105
+ onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
106
+ onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
107
+ onApprove: async (cat, name, input2) => {
108
+ stopSpinner();
109
+ say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
110
+ const a = await ask(' [y] once · [a] always for ' + cat + ' · [n] no: ');
111
+ const c = a.trim().toLowerCase();
112
+ if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
113
+ if (c === 'y' || c === 'yes') return true;
114
+ say(dim(' denied.'));
115
+ return false;
81
116
  },
82
- onResult: out => { console.log(' ' + gray(trunc(out, 120))); },
83
- onText: t => { console.log(' ' + dim(trunc(t, 300))); },
117
+ approved,
84
118
  onRunStart: c => { activeRun = c; },
85
- onRunEnd: () => { activeRun = null; }
119
+ onRunEnd: () => { activeRun = null; stopSpinner(); }
86
120
  });
87
121
  history = pushTurn(history, input, res);
88
- if (res.aborted) console.log('\n' + yellow('Stopped.') + dim(' Partly done, tell me to continue if you want.'));
89
- else {
90
- console.log('\n' + bold(green('Done')));
91
- if (res.changed?.length) console.log(dim(' Changed: ' + res.changed.join(', ')));
92
- if (res.answer) console.log(' ' + res.answer.split('\n').slice(0, 14).join('\n '));
122
+ stopSpinner();
123
+ if (res.aborted) {
124
+ say(yellow(' ■ Stopped') + dim(' - partly done. Ask me to continue.'));
125
+ } else {
126
+ const rows = [green(bold(' Done'))];
127
+ if (res.changed?.length) rows.push(dim(' files: ') + res.changed.join(', '));
128
+ if (res.answer) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
129
+ else if (!res.changed?.length) rows.push(dim(' (no output)'));
130
+ say(box(rows));
93
131
  }
94
132
  } catch (err) {
95
- console.log('\n' + red('Error: ' + err.message));
96
- console.log(dim(' Check /setup if this keeps happening.'));
133
+ stopSpinner();
134
+ history = pushTurn(history, input, { answer: '(task failed: ' + err.message + ')' });
135
+ say(red(' ✗ ' + err.message) + dim(' context kept.'));
97
136
  } finally {
98
137
  busy = false;
99
138
  activeRun = null;
100
- if (!closed) {
101
- prompt();
102
- drain();
103
- }
139
+ stopSpinner();
140
+ await afterTask();
104
141
  }
105
- };
142
+ }
106
143
 
107
- const drain = () => {
144
+ async function afterTask() {
145
+ if (closed) return;
146
+ if (TUI) { drawStatus(); scrollRegion(); return; }
147
+ plainPrompt();
108
148
  while (!busy && !closed) {
109
149
  const next = pendingLines.shift();
110
150
  if (!next) break;
111
- handle(next);
151
+ await handle(next);
112
152
  }
153
+ }
154
+
155
+ const commands = {
156
+ '/help': () => {
157
+ say(' ' + cyan('/model') + ' pick a model from your provider');
158
+ say(' ' + cyan('/plan') + ' plan mode: read only');
159
+ say(' ' + cyan('/build') + ' build mode: real changes (default)');
160
+ say(' ' + cyan('/reason') + ' toggle reasoning low/high');
161
+ say(' ' + cyan('/perm') + ' permissions: /perm auto | /perm safe | /perm');
162
+ say(' ' + cyan('/config') + ' show provider config (key hidden)');
163
+ say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
164
+ say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
165
+ say(' ' + cyan('/clear') + ' forget this conversation');
166
+ say(' ' + cyan('/setup') + ' redo provider setup');
167
+ say(' ' + cyan('/reset') + ' clear saved config');
168
+ say(' ' + cyan('/exit') + ' quit');
169
+ },
170
+ '/config': () => {
171
+ say(box([
172
+ bold('Provider config') + dim(' ~/.ineedcodes/config.json'),
173
+ dim('base URL') + ' ' + state.baseUrl,
174
+ dim('model') + ' ' + state.model,
175
+ dim('reasoning') + ' ' + state.reasoning,
176
+ dim('mode') + ' ' + mode,
177
+ dim('memory') + ' ' + (state.memory === false ? 'off' : 'on (icm)'),
178
+ dim('edit perm') + ' ' + state.permEdit,
179
+ dim('shell perm') + ' ' + state.permShell,
180
+ dim('API key') + ' saved, hidden'
181
+ ]));
182
+ },
183
+ '/clear': () => { history = []; say(dim('Conversation forgotten.')); }
113
184
  };
114
185
 
115
- const handle = input => {
116
- if (!input) { if (!busy && !closed) prompt(); return; }
186
+ async function handle(input) {
187
+ if (!input) return;
117
188
  if (busy) { pendingLines.push(input); return; }
118
- if (['/exit', '/quit', 'exit', 'quit'].includes(input)) { console.log(dim('Goodbye.')); closed = true; rl.close(); process.exit(0); }
119
- if (input === '/help' || input === '?') { showHelp(); prompt(); return; }
120
- if (input === '/model') {
121
- busy = true;
122
- pickModel().then(() => {
123
- busy = false;
124
- if (!closed) {
125
- prompt();
126
- drain();
127
- }
128
- });
189
+ if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
190
+ if (input === '/help' || input === '?') return commands['/help']();
191
+ if (input === '/config') return commands['/config']();
192
+ if (input === '/clear') return commands['/clear']();
193
+ if (input === '/model') { busy = true; try { await pickModel(); } finally { busy = false; } return afterTask(); }
194
+ if (input === '/plan') { mode = 'plan'; Object.assign(state, normalize({ ...state, mode })); say(yellow('Plan mode: read only.')); if (TUI) drawStatus(); return; }
195
+ if (input === '/build') { mode = 'build'; Object.assign(state, normalize({ ...state, mode })); say(green('Build mode: real changes.')); if (TUI) drawStatus(); return; }
196
+ if (input === '/reason') {
197
+ Object.assign(state, normalize({ ...state, reasoning: state.reasoning === 'high' ? 'low' : 'high' }));
198
+ say(dim('Reasoning effort: ' + state.reasoning));
199
+ if (TUI) drawStatus();
129
200
  return;
130
201
  }
131
- if (input === '/plan') { mode = 'plan'; state = normalize({ ...state, mode: 'plan' }); console.log(yellow('Plan mode: read only.')); prompt(); return; }
132
- if (input === '/build') { mode = 'build'; state = normalize({ ...state, mode: 'build' }); console.log(green('Build mode: real changes allowed.')); prompt(); return; }
133
- if (input === '/reason') {
134
- state = normalize({ ...state, reasoning: state.reasoning === 'high' ? 'low' : 'high' });
135
- console.log(dim('Reasoning effort: ' + state.reasoning));
136
- prompt(); return;
202
+ if (input === '/perm' || input.startsWith('/perm ')) {
203
+ const arg = input.slice(5).trim();
204
+ const apply = (pe, ps, msg) => {
205
+ Object.assign(state, normalize({ ...state, permEdit: pe, permShell: ps }));
206
+ saveConfig(state);
207
+ say(green(msg));
208
+ if (TUI) drawStatus();
209
+ };
210
+ if (arg === 'auto') return apply('allow', 'allow', 'Permissions: edits and shell run without asking.');
211
+ if (arg === 'safe') return apply('ask', 'ask', 'Permissions: edits and shell ask first.');
212
+ const parts = arg.split(/\s+/);
213
+ if (parts[0] === 'edit' && ['allow', 'ask'].includes(parts[1])) {
214
+ return apply(parts[1], state.permShell, `Edit permission: ${parts[1]}.`);
215
+ }
216
+ if (parts[0] === 'shell' && ['allow', 'ask'].includes(parts[1])) {
217
+ return apply(state.permEdit, parts[1], `Shell permission: ${parts[1]}.`);
218
+ }
219
+ say(box([
220
+ bold('Permissions'),
221
+ dim('edit') + ' ' + state.permEdit + dim(' (write_file, edit_file, delete_file)'),
222
+ dim('shell') + ' ' + state.permShell,
223
+ dim('granted this session') + ' ' + ([...approved].join(', ') || 'none'),
224
+ '',
225
+ dim('/perm auto') + ' never ask (saved)',
226
+ dim('/perm safe') + ' ask for edits and shell (saved)',
227
+ dim('/perm edit allow|ask /perm shell allow|ask')
228
+ ]));
229
+ return;
230
+ }
231
+ if (input === '/memory' || input.startsWith('/memory ')) {
232
+ const arg = input.split(/\s+/)[1];
233
+ if (arg === 'on' || arg === 'off') {
234
+ Object.assign(state, normalize({ ...state, memory: arg === 'on' }));
235
+ saveConfig(state);
236
+ say(arg === 'on'
237
+ ? green('Memory: on') + dim(' - durable facts are recalled before tasks and stored after real work.')
238
+ : yellow('Memory: off') + dim(' - nothing is recalled or stored.'));
239
+ if (TUI) drawStatus();
240
+ return;
241
+ }
242
+ busy = true;
243
+ const provider = getMemoryProvider(state);
244
+ if (!provider) { say(yellow('Memory is off.') + dim(' Turn it on with /memory on')); busy = false; return afterTask(); }
245
+ const ok = await ICMAdapter.available();
246
+ say(ok ? green('Memory: on') + dim(` via ${provider.name}.`) : yellow('Memory: provider not installed.') + dim(' Install icm to enable.'));
247
+ busy = false;
248
+ return afterTask();
249
+ }
250
+ if (input === '/mcp' || input === '/mcp reload') {
251
+ if (!mcpConfigured()) {
252
+ say(yellow('No MCP servers configured.') + dim(' Add them to ~/.ineedcodes/mcp.json, e.g.: {"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"]}}'));
253
+ return;
254
+ }
255
+ busy = true;
256
+ try {
257
+ const { McpManager } = await import('./mcp.js');
258
+ const mgr = new McpManager();
259
+ const errors = await mgr.loadFromConfig();
260
+ for (const e of errors) say(red(' ✗ ' + e));
261
+ const tools = await mgr.allTools();
262
+ if (tools.length) {
263
+ say(green(` ${mgr.servers.size} MCP server(s), ${tools.length} tool(s):`));
264
+ for (const t of tools) say(' ' + cyan(t.name) + gray(' ' + trunc(t.description, 90)));
265
+ } else if (!errors.length) {
266
+ say(yellow(' Servers connected but exposed no tools.'));
267
+ }
268
+ mgr.killAll();
269
+ } catch (err) { say(red(' ✗ ' + err.message)); }
270
+ busy = false;
271
+ return afterTask();
137
272
  }
138
- if (input === '/clear') { history = []; console.log(dim('Conversation forgotten.')); prompt(); return; }
139
273
  if (input === '/setup' || input === '/reset') {
140
274
  clearConfig();
141
- console.log(dim('Config cleared.'));
142
- wizard(ask, { fromCommand: true }).then(c => {
143
- state = normalize(c);
275
+ say(dim('Config cleared. Running setup...'));
276
+ busy = true;
277
+ try {
278
+ const c = await wizard(ask, { fromCommand: true });
279
+ Object.assign(state, normalize(c));
144
280
  mode = state.mode;
145
- console.log(green('\nReady. ' + state.model));
146
- prompt();
147
- }).catch(() => process.exit(1));
148
- return;
281
+ say(green('Ready. ' + state.model));
282
+ if (TUI) { drawHeader(); drawStatus(); }
283
+ } catch { return doExit(); }
284
+ busy = false;
285
+ return afterTask();
286
+ }
287
+ return runTask(input);
288
+ }
289
+
290
+ const plainPrompt = () => {
291
+ rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))} ${green('❯')} `);
292
+ rl.prompt();
293
+ };
294
+
295
+ const printWelcome = () => {
296
+ const lines = fresh
297
+ ? [
298
+ green(bold('Welcome to ineed!')),
299
+ ' You are all set: any OpenAI-compatible provider, any folder.',
300
+ ' Type what you want in normal language, for example:',
301
+ dim(' "buatkan landing page beranimasi di folder ini"'),
302
+ dim(' "fix the failing tests and tell me what was wrong"'),
303
+ dim(' "explain this repository like I am a beginner"'),
304
+ ' ' + dim('Helpers: /help commands · /perm auto or safe · /model · /memory on|off')
305
+ ]
306
+ : [
307
+ dim('Type what you want. /help for commands.')
308
+ + (state.permEdit === 'ask' || state.permShell === 'ask' ? dim(' Edits and shell ask first: /perm auto to relax.') : '')
309
+ ];
310
+ for (const l of lines) say(l);
311
+ };
312
+
313
+ // ── plain REPL mode (pipes, tests, NO_COLOR) ──
314
+ if (!TUI) {
315
+ rl.on('SIGINT', () => {
316
+ if (activeRun) { activeRun.abort(); console.log(dim('\nStopping...')); return; }
317
+ const now = Date.now();
318
+ if (now - lastSigint < 3000) return doExit();
319
+ lastSigint = now;
320
+ console.log(dim('\n(Ctrl+C again to exit)'));
321
+ rl.prompt();
322
+ });
323
+ handleRef = handle;
324
+ console.log(logo());
325
+ console.log(BANNER() + dim(` · ${state.model} · ${process.cwd()}`));
326
+ printWelcome();
327
+ plainPrompt();
328
+ return;
329
+ }
330
+
331
+ // ── full TUI mode ──
332
+ const chatLines = []; // completed chat lines (ANSI strings)
333
+ let chatTop = 0, chatBot = 0; // scroll region rows
334
+ let statusRow = 0;
335
+
336
+ function drawHeader() {
337
+ const rows = 6;
338
+ const lines = logo().split('\n');
339
+ for (let i = 0; i < rows; i++) {
340
+ screen.at(i + 1, 1);
341
+ screen.clearLine();
342
+ process.stdout.write(lines[i] ?? '');
149
343
  }
150
- runTask(input);
344
+ }
345
+
346
+ function drawStatus() {
347
+ const cols = process.stdout.columns || 80;
348
+ const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}`;
349
+ const mid = ` ${dim(state.model)}`;
350
+ const right = ` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)} ${dim('/')} ${state.memory === false ? dim('mem:off') : dim('mem:on')} `;
351
+ screen.at(statusRow, 1);
352
+ screen.clearLine();
353
+ process.stdout.write(dim('─'.repeat(Math.max(0, cols - plain(left).length - plain(mid).length - plain(right).length))) + left + mid + right);
354
+ }
355
+
356
+ function scrollRegion() {
357
+ screen.region(chatTop, chatBot);
358
+ screen.at(chatBot, 1);
359
+ }
360
+
361
+ function redrawChat() {
362
+ for (let i = chatTop; i <= chatBot; i++) {
363
+ screen.at(i, 1);
364
+ screen.clearLine();
365
+ }
366
+ screen.at(chatTop, 1);
367
+ const vis = chatBot - chatTop + 1;
368
+ const show = chatLines.slice(-vis);
369
+ process.stdout.write(show.join('\r\n'));
370
+ scrollRegion();
371
+ }
372
+
373
+ function tuiPrint(text) {
374
+ for (const l of wrapLines(String(text), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
375
+ redrawChat();
376
+ }
377
+
378
+ function tuiUserLine(input) {
379
+ for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
380
+ redrawChat();
381
+ }
382
+
383
+ const layout = () => {
384
+ const rows = process.stdout.rows || 24;
385
+ const headerRows = 6;
386
+ statusRow = rows - 1;
387
+ chatTop = headerRows + 1;
388
+ chatBot = statusRow - 1;
389
+ screen.at(1, 1);
390
+ process.stdout.write('\x1b[2J');
391
+ drawHeader();
392
+ redrawChat();
393
+ drawStatus();
394
+ scrollRegion();
151
395
  };
152
396
 
397
+ rl.on('resize', layout);
398
+
399
+ // keep Ctrl+Z from suspending us in raw mode: swallow the key, tell the user
400
+ const origWrite = rl.write.bind(rl);
401
+ rl.write = (d, key) => {
402
+ if (key && key.ctrl && key.name === 'z') { tuiPrint(dim(' (Ctrl+Z is disabled here. Use /exit, or Ctrl+C twice.)')); return; }
403
+ return origWrite(d, key);
404
+ };
405
+
406
+ rl.on('SIGINT', () => {
407
+ if (activeRun) { activeRun.abort(); return; }
408
+ const now = Date.now();
409
+ if (now - lastSigint < 3000) return doExit();
410
+ lastSigint = now;
411
+ });
412
+
153
413
  handleRef = handle;
154
- header();
155
- prompt();
414
+
415
+ screen.enter();
416
+ layout();
417
+ printWelcome();
418
+ scrollRegion();
156
419
  }
package/src/tools.js CHANGED
@@ -55,6 +55,25 @@ export const TOOLS = [
55
55
  parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
56
56
  allowedInPlan: false
57
57
  },
58
+ {
59
+ name: 'todo',
60
+ description: 'Maintain a visible task checklist for multi-step objectives. Replace the whole list each time.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ todos: {
65
+ type: 'array',
66
+ items: {
67
+ type: 'object',
68
+ properties: { content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } },
69
+ required: ['content', 'status']
70
+ }
71
+ }
72
+ },
73
+ required: ['todos']
74
+ },
75
+ allowedInPlan: true
76
+ },
58
77
  {
59
78
  name: 'shell',
60
79
  description: 'Run a shell command in the working directory. Returns exit code with stdout and stderr.',
@@ -76,7 +95,7 @@ export function runTool(name, input, cwd) {
76
95
  try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
77
96
  for (const e of entries) {
78
97
  if (skip.has(e.name)) continue;
79
- out.push((e.isDirectory() ? e.name + '/' : e.name) + (e.isDirectory() && depth < 4 ? '/' : ''));
98
+ out.push(e.isDirectory() ? e.name + '/' : e.name);
80
99
  if (e.isDirectory()) walk(path.join(d, e.name), depth + 1);
81
100
  }
82
101
  })(abs, 0);