ineedcodes 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/ineedcodes/ineedcodes/main/assets/logo.svg" alt="ineed" width="320">
3
+ <img src="https://raw.githubusercontent.com/salsabila2507/ineedcodes/main/assets/logo.svg" alt="ineed" width="480">
4
4
 
5
5
  **Your terminal, now autonomous.**
6
6
 
@@ -9,7 +9,7 @@ You say what you want. ineed reads the files, writes the code, runs the commands
9
9
  [![npm](https://img.shields.io/npm/v/ineedcodes.svg)](https://www.npmjs.com/package/ineedcodes)
10
10
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
11
  [![node](https://img.shields.io/node/v/ineedcodes.svg)](https://nodejs.org)
12
- [![stars](https://img.shields.io/github/stars/ineedcodes/ineedcodes?style=social)](https://github.com/ineedcodes/ineedcodes)
12
+ [![stars](https://img.shields.io/github/stars/salsabila2507/ineedcodes?style=social)](https://github.com/salsabila2507/ineedcodes)
13
13
 
14
14
  </div>
15
15
 
@@ -113,7 +113,7 @@ This project exists so anyone can start vibecoding: describe what you want, watc
113
113
 
114
114
  ## Contributing
115
115
 
116
- Issues and PRs are welcome at [github.com/ineedcodes/ineedcodes](https://github.com/ineedcodes/ineedcodes).
116
+ Issues and PRs are welcome at [github.com/salsabila2507/ineedcodes](https://github.com/salsabila2507/ineedcodes).
117
117
 
118
118
  ## License
119
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Your terminal, now autonomous. Just say what you want, ineed does the rest.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,10 +26,10 @@
26
26
  "homepage": "https://ineed.codes",
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "git+https://github.com/ineedcodes/ineedcodes.git"
29
+ "url": "git+https://github.com/salsabila2507/ineedcodes.git"
30
30
  },
31
31
  "bugs": {
32
- "url": "https://github.com/ineedcodes/ineedcodes/issues"
32
+ "url": "https://github.com/salsabila2507/ineedcodes/issues"
33
33
  },
34
34
  "keywords": [
35
35
  "ai",
package/src/agent.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { chat } from './provider.js';
4
4
  import { TOOLS, runTool, shellRun, isDestructive } from './tools.js';
5
5
  import { trunc, gray, cyan, dim } from './ui.js';
6
+ import { getMemoryProvider } from './memory.js';
6
7
 
7
8
  export const MAX_STEPS = 30;
8
9
  export const MAX_HISTORY_CHARS = 30_000;
@@ -13,7 +14,9 @@ Rules:
13
14
  - Use the tools to do real work. Never invent output. Every success claim needs evidence from a tool result.
14
15
  - Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
15
16
  - Never push to remotes or delete data without being asked.
16
- - Destructive commands are blocked. Ask the user to run those themselves.
17
+ - Destructive commands are always blocked. Ask the user to run those themselves.
18
+ - 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.
19
+ - 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).
17
20
  - When the objective is done, verify it (run the tests, read the file back, whatever proves it), then reply with the final result in this shape:
18
21
  What changed, what you ran, the evidence you saw.`;
19
22
 
@@ -32,16 +35,29 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
32
35
  hooks.onRunStart?.(ctrl);
33
36
  const plan = cfg.mode === 'plan';
34
37
  const tools = plan ? TOOLS.filter(t => t.allowedInPlan) : TOOLS;
38
+ const canAsk = typeof hooks.onApprove === 'function';
39
+
40
+ // recall durable memory before meaningful work (rule 12/15: MemoryProvider abstraction)
41
+ const memory = getMemoryProvider(cfg);
42
+ let recalled = '';
43
+ if (memory) {
44
+ hooks.onMemoryStart?.();
45
+ try { recalled = await memory.recall(objective); } catch { recalled = ''; }
46
+ hooks.onMemoryEnd?.(recalled);
47
+ }
48
+
35
49
  const messages = [
36
50
  {
37
51
  role: 'system',
38
52
  content: `${SYSTEM}\nWorking directory: ${cwd}\nMode: ${plan ? 'plan (read only, suggest what to change, do not change anything)' : 'build'}`
53
+ + (recalled ? `\nRelevant memory from previous sessions with this user (durable facts, may be stale):\n${recalled}` : '')
39
54
  },
40
55
  ...trimHistory(history),
41
56
  { role: 'user', content: objective }
42
57
  ];
43
58
  const changed = new Set();
44
59
  const ran = [];
60
+ const todos = [];
45
61
  let answer = '';
46
62
  let lastShown = '';
47
63
  try {
@@ -49,8 +65,11 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
49
65
  if (ctrl.signal.aborted) break;
50
66
  let msg;
51
67
  try {
68
+ hooks.onThinkingStart?.();
52
69
  msg = await chat(cfg, messages, tools, ctrl.signal);
70
+ hooks.onThinkingEnd?.();
53
71
  } catch (err) {
72
+ hooks.onThinkingEnd?.();
54
73
  if (ctrl.signal.aborted) break;
55
74
  throw err;
56
75
  }
@@ -62,7 +81,13 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
62
81
  }
63
82
  const calls = msg.tool_calls ?? [];
64
83
  if (calls.length === 0) {
65
- return { answer, changed: [...changed], ran, aborted: false };
84
+ // task finished: store durable knowledge only when something actually changed
85
+ if (memory && answer && (changed.size > 0 || ran.length > 0) && !ctrl.signal.aborted) {
86
+ try {
87
+ await memory.store(`project ${cwd}: ${objective.slice(0, 150)} -> ${answer.slice(0, 300)}`);
88
+ } catch {}
89
+ }
90
+ return { answer, changed: [...changed], ran, todos: [...todos], aborted: false };
66
91
  }
67
92
  for (const call of calls) {
68
93
  let input = {};
@@ -73,17 +98,42 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
73
98
  if (plan) result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
74
99
  else if (isDestructive(String(input.command ?? ''))) {
75
100
  result = { output: 'Refused: that command is destructive. Run it yourself if you are sure.' };
76
- } else {
101
+ } else if (cfg.permShell !== 'allow' && !hooks.approved?.has('shell')) {
102
+ const verdict = canAsk ? await hooks.onApprove('shell', 'shell', input) : true; // cannot ask: CI-style allow
103
+ if (verdict === 'always') hooks.approved?.add('shell');
104
+ if (!verdict) {
105
+ result = { output: 'Denied: the user did not approve this shell command.' };
106
+ }
107
+ }
108
+ if (!result) {
109
+ hooks.onWorkStart?.(`running: ${trunc(String(input.command ?? ''), 60)}`);
77
110
  result = await shellRun(String(input.command ?? ''), cwd, ctrl.signal);
111
+ hooks.onWorkEnd?.();
78
112
  ran.push(String(input.command ?? '').slice(0, 120));
79
113
  }
80
114
  } else {
81
115
  if (plan && !TOOLS.find(t => t.name === call.function?.name)?.allowedInPlan) {
82
116
  result = { output: 'Refused: plan mode is read only. Switch to build mode with /build.' };
117
+ } else if (call.function?.name === 'todo') {
118
+ const list = Array.isArray(input.todos) ? input.todos : [];
119
+ todos.splice(0, todos.length, ...list.slice(0, 50).map(t => ({
120
+ content: String(t.content ?? '').slice(0, 200),
121
+ status: ['pending', 'in_progress', 'completed'].includes(t.status) ? t.status : 'pending'
122
+ })));
123
+ hooks.onTodos?.([...todos]);
124
+ result = { output: `Todo list updated (${todos.filter(t => t.status === 'completed').length}/${todos.length} done).` };
83
125
  } else {
84
- result = runTool(call.function?.name, input, cwd);
85
- if (!plan && ['write_file', 'edit_file', 'delete_file'].includes(call.function?.name)
86
- && !/^(Refused|Error)/.test(String(result.output))) {
126
+ const name = call.function?.name;
127
+ const isEdit = ['write_file', 'edit_file', 'delete_file'].includes(name);
128
+ let allowedNow = true;
129
+ if (isEdit && cfg.permEdit !== 'allow' && !hooks.approved?.has('edit')) {
130
+ const verdict = canAsk ? await hooks.onApprove('edit', name, input) : true; // cannot ask: CI-style allow
131
+ if (verdict === 'always') hooks.approved?.add('edit');
132
+ allowedNow = Boolean(verdict);
133
+ }
134
+ result = allowedNow ? runTool(name, input, cwd) : { output: `Denied: the user did not approve ${name}.` };
135
+ if (allowedNow && !plan && isEdit
136
+ && !/^(Refused|Error|Denied)/.test(String(result.output))) {
87
137
  changed.add(String(input.path ?? ''));
88
138
  }
89
139
  }
@@ -96,7 +146,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}) {
96
146
  hooks.onRunEnd?.();
97
147
  }
98
148
  const stopped = ctrl.signal.aborted;
99
- return { answer, changed: [...changed], ran, aborted: true, stopped };
149
+ return { answer, changed: [...changed], ran, todos: [...todos], aborted: true, stopped };
100
150
  }
101
151
 
102
152
  export function pushTurn(history, objective, result) {
package/src/cli.js CHANGED
@@ -2,7 +2,6 @@
2
2
  // ineed: your terminal, now autonomous.
3
3
  // First open asks for provider setup. After that, just say what you want.
4
4
 
5
- import * as fs from 'node:fs';
6
5
  import { spawn } from 'node:child_process';
7
6
 
8
7
  const [MAJOR] = process.versions.node.split('.').map(Number);
@@ -13,7 +12,7 @@ if (!(MAJOR >= 20)) {
13
12
  }
14
13
 
15
14
  const { loadConfig } = await import('./config.js');
16
- const { VERSION, bold, dim, red, green, yellow } = await import('./ui.js');
15
+ const { VERSION, bold, dim, red, green, yellow, cyan, box } = await import('./ui.js');
17
16
 
18
17
  const args = process.argv.slice(2);
19
18
 
@@ -46,7 +45,12 @@ if (args[0] === '--child') {
46
45
  process.exit(1);
47
46
  }
48
47
  try {
49
- const res = await runObjective(cfg, task, process.cwd(), []);
48
+ const res = await runObjective(cfg, task, process.cwd(), [], {
49
+ onTodos: list => {
50
+ const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
51
+ console.log(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
52
+ }
53
+ });
50
54
  if (res.aborted) {
51
55
  console.log('\n' + yellow('Stopped.') + dim(' Task did not finish (step limit or Ctrl+C). Re-run to continue.'));
52
56
  process.exit(2);
@@ -69,7 +73,6 @@ if (args.length > 0 && args[0] !== '--reset') {
69
73
  const readline = await import('node:readline');
70
74
  const { makeInput } = await import('./ui.js');
71
75
  const { wizard } = await import('./wizard.js');
72
- const { startSession } = await import('./session.js');
73
76
  const { clearConfig } = await import('./config.js');
74
77
 
75
78
  if (args[0] === '--reset') {
@@ -78,6 +81,7 @@ if (args.length > 0 && args[0] !== '--reset') {
78
81
  }
79
82
 
80
83
  let cfg = loadConfig();
84
+ const fresh = !cfg;
81
85
  if (!cfg) {
82
86
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
87
  const ask = makeInput(rl);
@@ -89,5 +93,7 @@ if (args.length > 0 && args[0] !== '--reset') {
89
93
  }
90
94
  rl.close();
91
95
  }
92
- await startSession(cfg);
96
+ const { startSession } = await import('./session.js');
97
+ const wasFresh = fresh || process.env.INEED_FRESH === '1';
98
+ await startSession(cfg, { fresh: wasFresh });
93
99
  }
package/src/config.js CHANGED
@@ -22,7 +22,10 @@ export function normalize(c) {
22
22
  apiKey: String(c.apiKey ?? ''),
23
23
  model: String(c.model),
24
24
  reasoning: c.reasoning === 'high' ? 'high' : 'low',
25
- mode: c.mode === 'plan' ? 'plan' : 'build'
25
+ mode: c.mode === 'plan' ? 'plan' : 'build',
26
+ memory: c.memory !== false,
27
+ permEdit: c.permEdit === 'allow' ? 'allow' : 'ask',
28
+ permShell: c.permShell === 'allow' ? 'allow' : 'ask'
26
29
  };
27
30
  }
28
31
 
package/src/memory.js ADDED
@@ -0,0 +1,61 @@
1
+ // memory.js: MemoryProvider abstraction. Default adapter shells out to the `icm` CLI.
2
+ // The agent never depends on icm internals; if icm is missing or slow, memory is silently empty.
3
+
4
+ import { spawn } from 'node:child_process';
5
+
6
+ function icm(args, timeoutMs = 10_000) {
7
+ return new Promise(resolve => {
8
+ let child;
9
+ try {
10
+ child = spawn('icm', args, { stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
11
+ } catch {
12
+ resolve(null);
13
+ return;
14
+ }
15
+ let out = '';
16
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} resolve(null); }, timeoutMs);
17
+ child.stdout.on('data', c => {
18
+ out += c.toString();
19
+ if (out.length > 20_000) { try { child.kill('SIGKILL'); } catch {} }
20
+ });
21
+ child.stderr.on('data', () => {});
22
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
23
+ child.on('close', code => {
24
+ clearTimeout(timer);
25
+ resolve(code === 0 ? out.slice(0, 8_000) : null);
26
+ });
27
+ });
28
+ }
29
+
30
+ export const ICMAdapter = {
31
+ name: 'icm',
32
+
33
+ // recall relevant durable memory for an objective. Returns '' when nothing/no icm.
34
+ async recall(query) {
35
+ if (!query?.trim()) return '';
36
+ const out = await icm(['recall', query.slice(0, 200), '--limit', '3', '--read-only']);
37
+ if (!out) return '';
38
+ const lines = out.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('memories['));
39
+ const text = lines.join('\n').slice(0, 2_000);
40
+ return /no (memories|results)|\(empty\)/i.test(text) ? '' : text;
41
+ },
42
+
43
+ // store durable knowledge. Fire-and-forget friendly. Never stores secrets (caller filters).
44
+ async store(content) {
45
+ if (!content?.trim()) return false;
46
+ const out = await icm(['remember', content.trim().slice(0, 1_000)]);
47
+ return out !== null;
48
+ },
49
+
50
+ async available() {
51
+ return (await icm(['--help'], 5_000)) !== null;
52
+ }
53
+ };
54
+
55
+ // pick the provider. Only icm exists today; the abstraction keeps that swappable.
56
+ // Disable with config memory:false or env INEED_NO_MEMORY=1 (used by the test suite).
57
+ export function getMemoryProvider(cfg) {
58
+ if (cfg?.memory === false) return null;
59
+ if (process.env.INEED_NO_MEMORY === '1') return null;
60
+ return ICMAdapter;
61
+ }
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,390 @@
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';
9
11
 
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);
12
+ const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
13
+
14
+ function wrapLines(text, width) {
15
+ const out = [];
16
+ for (const raw of String(text).split('\n')) {
17
+ let line = raw;
18
+ if (line === '') { out.push(''); continue; }
19
+ while (plain(line).length > width) {
20
+ let vis = 0, i = 0;
21
+ while (i < line.length && vis < width) {
22
+ if (line[i] === '\x1b') { while (i < line.length && line[i] !== 'm') i++; }
23
+ else vis++;
24
+ i++;
25
+ }
26
+ out.push(line.slice(0, i));
27
+ line = line.slice(i);
28
+ }
29
+ out.push(line);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export async function startSession(cfg, { fresh = false } = {}) {
35
+ const state = normalize(cfg);
15
36
  let history = [];
16
37
  let busy = false;
17
38
  let activeRun = null;
18
39
  let mode = state.mode;
19
- let pendingLines = [];
20
40
  let lastSigint = 0;
21
41
  let closed = false;
42
+ const approved = new Set(); // session-wide "always allow" grants
43
+ const pendingLines = [];
22
44
 
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
- });
45
+ const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
31
46
 
32
- const prompt = () => {
33
- rl.setPrompt(`\n${bold(green('ineed'))}${dim(` [${mode}/${state.reasoning}]`)} ${dim('› ')}`);
34
- rl.prompt();
35
- };
47
+ // ONE readline, ONE line dispatcher for the whole session
48
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
49
+ let handleRef = null;
50
+ const ask = makeInput(rl, l => handleRef?.(l));
36
51
 
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
- };
52
+ const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
41
53
 
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
- };
54
+ function doExit() {
55
+ closed = true;
56
+ if (TUI) { screen.resetRegion(); screen.exit(); }
57
+ console.log(dim('Goodbye.'));
58
+ process.exit(0);
59
+ }
53
60
 
54
- const pickModel = async () => {
55
- console.log(dim(' Fetching models...'));
61
+ async function pickModel() {
56
62
  let models = [];
57
- try { models = await fetchModels(state); } catch (err) { console.log(red(' ' + err.message)); return; }
63
+ try { models = await fetchModels(state); } catch (err) { say(red(' ' + err.message)); return; }
58
64
  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)); }
65
+ say(yellow(' Server sent no list. Change model by editing ~/.ineedcodes/config.json'));
61
66
  return;
62
67
  }
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}`));
68
+ const list = models.slice(0, 5);
69
+ say(dim(` ${models.length} models available, showing ${list.length}. Type a number, or a full model id.`));
70
+ list.forEach((m, i) => say(` ${i + 1}. ${m}`));
67
71
  const pick = await ask(' Model: ');
68
72
  if (!pick) return;
69
73
  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
- };
74
+ if (Number.isInteger(idx) && idx >= 1 && idx <= list.length) Object.assign(state, normalize({ ...state, model: list[idx - 1] }));
75
+ else Object.assign(state, normalize({ ...state, model: pick }));
76
+ say(green(' Model: ' + state.model));
77
+ if (TUI) drawStatus();
78
+ }
74
79
 
75
- const runTask = async input => {
80
+ async function runTask(input) {
76
81
  busy = true;
82
+ let lastStreamed = '';
83
+ if (TUI) tuiUserLine(input);
84
+ let spinner = null;
85
+ const stopSpinner = () => { spinner?.stop(); spinner = null; };
77
86
  try {
78
87
  const res = await runObjective(state, input, process.cwd(), history, {
79
- onTool: (name, input2) => {
80
- console.log(' ' + cyan('· ' + name) + ' ' + gray(trunc(JSON.stringify(input2), 100)));
88
+ onMemoryStart: () => { stopSpinner(); spinner = startSpinner('recalling memory'); },
89
+ onMemoryEnd: () => stopSpinner(),
90
+ onThinkingStart: () => { stopSpinner(); spinner = startSpinner('thinking'); },
91
+ onThinkingEnd: () => stopSpinner(),
92
+ onWorkStart: label => { stopSpinner(); spinner = startSpinner(label || 'working'); },
93
+ onWorkEnd: () => stopSpinner(),
94
+ onTool: (name, input2) => { stopSpinner(); say(cyan(' ● ' + name) + gray(' ' + trunc(JSON.stringify(input2), 90))); },
95
+ onResult: out => { say(gray(' ' + trunc(out, 110))); },
96
+ onText: t => { stopSpinner(); lastStreamed = t; },
97
+ onTodos: list => {
98
+ stopSpinner();
99
+ const mark = s => s === 'completed' ? green('✔') : s === 'in_progress' ? cyan('▸') : dim('○');
100
+ say(box([bold('To-do'), ...list.map(t => ' ' + mark(t.status) + ' ' + t.content)]));
101
+ },
102
+ onApprove: async (cat, name, input2) => {
103
+ stopSpinner();
104
+ say(yellow(' ⚠ approval needed') + ' ' + cyan(name) + gray(' ' + trunc(JSON.stringify(input2), 80)));
105
+ const a = await ask(' [y] once · [a] always for ' + cat + ' · [n] no: ');
106
+ const c = a.trim().toLowerCase();
107
+ if (c === 'a' || c === 'always') { approved.add(cat); say(dim(' always allowed for this session.')); return 'always'; }
108
+ if (c === 'y' || c === 'yes') return true;
109
+ say(dim(' denied.'));
110
+ return false;
81
111
  },
82
- onResult: out => { console.log(' ' + gray(trunc(out, 120))); },
83
- onText: t => { console.log(' ' + dim(trunc(t, 300))); },
112
+ approved,
84
113
  onRunStart: c => { activeRun = c; },
85
- onRunEnd: () => { activeRun = null; }
114
+ onRunEnd: () => { activeRun = null; stopSpinner(); }
86
115
  });
87
116
  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 '));
117
+ stopSpinner();
118
+ if (res.aborted) {
119
+ say(yellow(' ■ Stopped') + dim(' - partly done. Ask me to continue.'));
120
+ } else {
121
+ const rows = [green(bold(' Done'))];
122
+ if (res.changed?.length) rows.push(dim(' files: ') + res.changed.join(', '));
123
+ if (res.answer) String(res.answer).split('\n').slice(0, 14).forEach(l => rows.push(' ' + l));
124
+ else if (!res.changed?.length) rows.push(dim(' (no output)'));
125
+ say(box(rows));
93
126
  }
94
127
  } catch (err) {
95
- console.log('\n' + red('Error: ' + err.message));
96
- console.log(dim(' Check /setup if this keeps happening.'));
128
+ stopSpinner();
129
+ history = pushTurn(history, input, { answer: '(task failed: ' + err.message + ')' });
130
+ say(red(' ✗ ' + err.message) + dim(' context kept.'));
97
131
  } finally {
98
132
  busy = false;
99
133
  activeRun = null;
100
- if (!closed) {
101
- prompt();
102
- drain();
103
- }
134
+ stopSpinner();
135
+ await afterTask();
104
136
  }
105
- };
137
+ }
106
138
 
107
- const drain = () => {
139
+ async function afterTask() {
140
+ if (closed) return;
141
+ if (TUI) { drawStatus(); scrollRegion(); return; }
142
+ plainPrompt();
108
143
  while (!busy && !closed) {
109
144
  const next = pendingLines.shift();
110
145
  if (!next) break;
111
- handle(next);
146
+ await handle(next);
112
147
  }
148
+ }
149
+
150
+ const commands = {
151
+ '/help': () => {
152
+ say(' ' + cyan('/model') + ' pick a model from your provider');
153
+ say(' ' + cyan('/plan') + ' plan mode: read only');
154
+ say(' ' + cyan('/build') + ' build mode: real changes (default)');
155
+ say(' ' + cyan('/reason') + ' toggle reasoning low/high');
156
+ say(' ' + cyan('/perm') + ' permissions: /perm auto | /perm safe | /perm');
157
+ say(' ' + cyan('/config') + ' show provider config (key hidden)');
158
+ say(' ' + cyan('/memory') + ' memory status, /memory on|off to toggle');
159
+ say(' ' + cyan('/clear') + ' forget this conversation');
160
+ say(' ' + cyan('/setup') + ' redo provider setup');
161
+ say(' ' + cyan('/reset') + ' clear saved config');
162
+ say(' ' + cyan('/exit') + ' quit');
163
+ },
164
+ '/config': () => {
165
+ say(box([
166
+ bold('Provider config') + dim(' ~/.ineedcodes/config.json'),
167
+ dim('base URL') + ' ' + state.baseUrl,
168
+ dim('model') + ' ' + state.model,
169
+ dim('reasoning') + ' ' + state.reasoning,
170
+ dim('mode') + ' ' + mode,
171
+ dim('memory') + ' ' + (state.memory === false ? 'off' : 'on (icm)'),
172
+ dim('edit perm') + ' ' + state.permEdit,
173
+ dim('shell perm') + ' ' + state.permShell,
174
+ dim('API key') + ' saved, hidden'
175
+ ]));
176
+ },
177
+ '/clear': () => { history = []; say(dim('Conversation forgotten.')); }
113
178
  };
114
179
 
115
- const handle = input => {
116
- if (!input) { if (!busy && !closed) prompt(); return; }
180
+ async function handle(input) {
181
+ if (!input) return;
117
182
  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
- });
183
+ if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
184
+ if (input === '/help' || input === '?') return commands['/help']();
185
+ if (input === '/config') return commands['/config']();
186
+ if (input === '/clear') return commands['/clear']();
187
+ if (input === '/model') { busy = true; try { await pickModel(); } finally { busy = false; } return afterTask(); }
188
+ if (input === '/plan') { mode = 'plan'; Object.assign(state, normalize({ ...state, mode })); say(yellow('Plan mode: read only.')); if (TUI) drawStatus(); return; }
189
+ if (input === '/build') { mode = 'build'; Object.assign(state, normalize({ ...state, mode })); say(green('Build mode: real changes.')); if (TUI) drawStatus(); return; }
190
+ if (input === '/reason') {
191
+ Object.assign(state, normalize({ ...state, reasoning: state.reasoning === 'high' ? 'low' : 'high' }));
192
+ say(dim('Reasoning effort: ' + state.reasoning));
193
+ if (TUI) drawStatus();
129
194
  return;
130
195
  }
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;
196
+ if (input === '/perm' || input.startsWith('/perm ')) {
197
+ const arg = input.slice(5).trim();
198
+ const apply = (pe, ps, msg) => {
199
+ Object.assign(state, normalize({ ...state, permEdit: pe, permShell: ps }));
200
+ saveConfig(state);
201
+ say(green(msg));
202
+ if (TUI) drawStatus();
203
+ };
204
+ if (arg === 'auto') return apply('allow', 'allow', 'Permissions: edits and shell run without asking.');
205
+ if (arg === 'safe') return apply('ask', 'ask', 'Permissions: edits and shell ask first.');
206
+ const parts = arg.split(/\s+/);
207
+ if (parts[0] === 'edit' && ['allow', 'ask'].includes(parts[1])) {
208
+ return apply(parts[1], state.permShell, `Edit permission: ${parts[1]}.`);
209
+ }
210
+ if (parts[0] === 'shell' && ['allow', 'ask'].includes(parts[1])) {
211
+ return apply(state.permEdit, parts[1], `Shell permission: ${parts[1]}.`);
212
+ }
213
+ say(box([
214
+ bold('Permissions'),
215
+ dim('edit') + ' ' + state.permEdit + dim(' (write_file, edit_file, delete_file)'),
216
+ dim('shell') + ' ' + state.permShell,
217
+ dim('granted this session') + ' ' + ([...approved].join(', ') || 'none'),
218
+ '',
219
+ dim('/perm auto') + ' never ask (saved)',
220
+ dim('/perm safe') + ' ask for edits and shell (saved)',
221
+ dim('/perm edit allow|ask /perm shell allow|ask')
222
+ ]));
223
+ return;
224
+ }
225
+ if (input === '/memory' || input.startsWith('/memory ')) {
226
+ const arg = input.split(/\s+/)[1];
227
+ if (arg === 'on' || arg === 'off') {
228
+ Object.assign(state, normalize({ ...state, memory: arg === 'on' }));
229
+ saveConfig(state);
230
+ say(arg === 'on'
231
+ ? green('Memory: on') + dim(' - durable facts are recalled before tasks and stored after real work.')
232
+ : yellow('Memory: off') + dim(' - nothing is recalled or stored.'));
233
+ if (TUI) drawStatus();
234
+ return;
235
+ }
236
+ busy = true;
237
+ const provider = getMemoryProvider(state);
238
+ if (!provider) { say(yellow('Memory is off.') + dim(' Turn it on with /memory on')); busy = false; return afterTask(); }
239
+ const ok = await ICMAdapter.available();
240
+ say(ok ? green('Memory: on') + dim(` via ${provider.name}.`) : yellow('Memory: provider not installed.') + dim(' Install icm to enable.'));
241
+ busy = false;
242
+ return afterTask();
137
243
  }
138
- if (input === '/clear') { history = []; console.log(dim('Conversation forgotten.')); prompt(); return; }
139
244
  if (input === '/setup' || input === '/reset') {
140
245
  clearConfig();
141
- console.log(dim('Config cleared.'));
142
- wizard(ask, { fromCommand: true }).then(c => {
143
- state = normalize(c);
246
+ say(dim('Config cleared. Running setup...'));
247
+ busy = true;
248
+ try {
249
+ const c = await wizard(ask, { fromCommand: true });
250
+ Object.assign(state, normalize(c));
144
251
  mode = state.mode;
145
- console.log(green('\nReady. ' + state.model));
146
- prompt();
147
- }).catch(() => process.exit(1));
148
- return;
252
+ say(green('Ready. ' + state.model));
253
+ if (TUI) { drawHeader(); drawStatus(); }
254
+ } catch { return doExit(); }
255
+ busy = false;
256
+ return afterTask();
149
257
  }
150
- runTask(input);
258
+ return runTask(input);
259
+ }
260
+
261
+ const plainPrompt = () => {
262
+ rl.setPrompt(`\n[${mode}/${state.reasoning}] ${bold(green('ineed'))} ${green('❯')} `);
263
+ rl.prompt();
151
264
  };
152
265
 
266
+ const printWelcome = () => {
267
+ const lines = fresh
268
+ ? [
269
+ green(bold('Welcome to ineed!')),
270
+ ' You are all set: any OpenAI-compatible provider, any folder.',
271
+ ' Type what you want in normal language, for example:',
272
+ dim(' "buatkan landing page beranimasi di folder ini"'),
273
+ dim(' "fix the failing tests and tell me what was wrong"'),
274
+ dim(' "explain this repository like I am a beginner"'),
275
+ ' ' + dim('Helpers: /help commands · /perm auto or safe · /model · /memory on|off')
276
+ ]
277
+ : [
278
+ dim('Type what you want. /help for commands.')
279
+ + (state.permEdit === 'ask' || state.permShell === 'ask' ? dim(' Edits and shell ask first: /perm auto to relax.') : '')
280
+ ];
281
+ for (const l of lines) say(l);
282
+ };
283
+
284
+ // ── plain REPL mode (pipes, tests, NO_COLOR) ──
285
+ if (!TUI) {
286
+ rl.on('SIGINT', () => {
287
+ if (activeRun) { activeRun.abort(); console.log(dim('\nStopping...')); return; }
288
+ const now = Date.now();
289
+ if (now - lastSigint < 3000) return doExit();
290
+ lastSigint = now;
291
+ console.log(dim('\n(Ctrl+C again to exit)'));
292
+ rl.prompt();
293
+ });
294
+ handleRef = handle;
295
+ console.log(logo());
296
+ console.log(BANNER() + dim(` · ${state.model} · ${process.cwd()}`));
297
+ printWelcome();
298
+ plainPrompt();
299
+ return;
300
+ }
301
+
302
+ // ── full TUI mode ──
303
+ const chatLines = []; // completed chat lines (ANSI strings)
304
+ let chatTop = 0, chatBot = 0; // scroll region rows
305
+ let statusRow = 0;
306
+
307
+ function drawHeader() {
308
+ const rows = 6;
309
+ const lines = logo().split('\n');
310
+ for (let i = 0; i < rows; i++) {
311
+ screen.at(i + 1, 1);
312
+ screen.clearLine();
313
+ process.stdout.write(lines[i] ?? '');
314
+ }
315
+ }
316
+
317
+ function drawStatus() {
318
+ const cols = process.stdout.columns || 80;
319
+ const left = ` ${bold(green('ineed'))} ${dim(`v${VERSION}`)}`;
320
+ const mid = ` ${dim(state.model)}`;
321
+ const right = ` ${mode === 'plan' ? yellow('plan') : green('build')} ${dim('/')} ${dim(state.reasoning)} ${dim('/')} ${state.memory === false ? dim('mem:off') : dim('mem:on')} `;
322
+ screen.at(statusRow, 1);
323
+ screen.clearLine();
324
+ process.stdout.write(dim('─'.repeat(Math.max(0, cols - plain(left).length - plain(mid).length - plain(right).length))) + left + mid + right);
325
+ }
326
+
327
+ function scrollRegion() {
328
+ screen.region(chatTop, chatBot);
329
+ screen.at(chatBot, 1);
330
+ }
331
+
332
+ function redrawChat() {
333
+ for (let i = chatTop; i <= chatBot; i++) {
334
+ screen.at(i, 1);
335
+ screen.clearLine();
336
+ }
337
+ screen.at(chatTop, 1);
338
+ const vis = chatBot - chatTop + 1;
339
+ const show = chatLines.slice(-vis);
340
+ process.stdout.write(show.join('\r\n'));
341
+ scrollRegion();
342
+ }
343
+
344
+ function tuiPrint(text) {
345
+ for (const l of wrapLines(String(text), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
346
+ redrawChat();
347
+ }
348
+
349
+ function tuiUserLine(input) {
350
+ for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
351
+ redrawChat();
352
+ }
353
+
354
+ const layout = () => {
355
+ const rows = process.stdout.rows || 24;
356
+ const headerRows = 6;
357
+ statusRow = rows - 1;
358
+ chatTop = headerRows + 1;
359
+ chatBot = statusRow - 1;
360
+ screen.at(1, 1);
361
+ process.stdout.write('\x1b[2J');
362
+ drawHeader();
363
+ redrawChat();
364
+ drawStatus();
365
+ scrollRegion();
366
+ };
367
+
368
+ rl.on('resize', layout);
369
+
370
+ // keep Ctrl+Z from suspending us in raw mode: swallow the key, tell the user
371
+ const origWrite = rl.write.bind(rl);
372
+ rl.write = (d, key) => {
373
+ if (key && key.ctrl && key.name === 'z') { tuiPrint(dim(' (Ctrl+Z is disabled here. Use /exit, or Ctrl+C twice.)')); return; }
374
+ return origWrite(d, key);
375
+ };
376
+
377
+ rl.on('SIGINT', () => {
378
+ if (activeRun) { activeRun.abort(); return; }
379
+ const now = Date.now();
380
+ if (now - lastSigint < 3000) return doExit();
381
+ lastSigint = now;
382
+ });
383
+
153
384
  handleRef = handle;
154
- header();
155
- prompt();
385
+
386
+ screen.enter();
387
+ layout();
388
+ printWelcome();
389
+ scrollRegion();
156
390
  }
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);
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.0.0';
3
+ export const VERSION = '1.0.2';
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);
@@ -18,17 +18,80 @@ export const trunc = (s, n = 120) => {
18
18
  return o.length > n ? o.slice(0, n - 1) + '...' : o;
19
19
  };
20
20
 
21
- export const LOGO = [
22
- '#### ## ',
23
- ' ## ## ',
24
- ' ## ## ',
25
- ' ## ## ',
26
- ' #### ### '
27
- ].join('\n');
21
+ const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
22
+
23
+ // Rounded box around lines. Width follows the longest line, capped to the terminal.
24
+ export function box(lines, colorFn = t => t) {
25
+ const cols = process.stdout.columns || 80;
26
+ const inner = Math.min(cols - 4, Math.max(10, ...lines.map(l => plain(l).length)) + 2);
27
+ const top = colorFn('╭' + '─'.repeat(inner) + '');
28
+ const bot = colorFn('╰' + '─'.repeat(inner) + '╯');
29
+ const mid = lines.map(l => colorFn('│') + ' ' + l + ' '.repeat(Math.max(0, inner - plain(l).length - 1)) + colorFn('│'));
30
+ return [top, ...mid, bot].join('\n');
31
+ }
32
+
33
+ const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
34
+
35
+ // Spinner for TTYs only. In pipes and tests it becomes a no-op.
36
+ export function startSpinner(text = 'thinking') {
37
+ if (!process.stdout.isTTY || process.env.NO_COLOR) return { stop: () => {} };
38
+ let i = 0;
39
+ let stopped = false;
40
+ const line = () => `\r${cyan(SPIN_FRAMES[i++ % SPIN_FRAMES.length])} ${dim(text + '...')} `;
41
+ process.stdout.write(line());
42
+ const iv = setInterval(() => process.stdout.write(line()), 90);
43
+ return {
44
+ stop() {
45
+ if (stopped) return;
46
+ stopped = true;
47
+ clearInterval(iv);
48
+ process.stdout.write('\r' + ' '.repeat(plain(text).length + 20) + '\r');
49
+ }
50
+ };
51
+ }
52
+
53
+ export const RULE = () => dim('─'.repeat(Math.min(process.stdout.columns || 80, 64)));
54
+
55
+ // user bubble: quoted, colored, compact
56
+ export const userBubble = text => {
57
+ const w = Math.min(process.stdout.columns || 80, 60);
58
+ const wrapped = [];
59
+ for (const raw of String(text).split('\n')) {
60
+ let line = raw;
61
+ while (line.length > w - 4) {
62
+ wrapped.push(' ' + yellow('│ ') + line.slice(0, w - 4));
63
+ line = line.slice(w - 4);
64
+ }
65
+ wrapped.push(' ' + yellow('│ ') + line);
66
+ }
67
+ return wrapped.join('\n') + '\n ' + yellow('╰' + '─'.repeat(w - 4) + '╯');
68
+ };
69
+
70
+ // ── raw screen plumbing for the full-screen chat layout ──
71
+ export const screen = {
72
+ enter() { process.stdout.write('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J'); },
73
+ exit() { process.stdout.write('\x1b[r\x1b[?25h\x1b[?1049l'); },
74
+ region(top, bot) { process.stdout.write(`\x1b[${top};${bot}r`); },
75
+ resetRegion() { process.stdout.write('\x1b[r'); },
76
+ at(row, col = 1) { process.stdout.write(`\x1b[${row};${col}H`); },
77
+ clearLine() { process.stdout.write('\x1b[2K'); }
78
+ };
28
79
 
29
80
  export const BANNER = () =>
30
81
  bold(green('ineed')) + dim(` v${VERSION}`) + dim(' · your terminal, now autonomous');
31
82
 
83
+ // Big startup logo, ANSI-shadow style. Six fixed-width lines.
84
+ const LOGO_LINES = [
85
+ '██╗ ███╗ ██╗ ███████╗ ███████╗ ██████╗ ',
86
+ '██║ ████╗ ██║ ██╔════╝ ██╔════╝ ██╔══██╗',
87
+ '██║ ██╔██╗ ██║ █████╗ █████╗ ██║ ██║',
88
+ '██║ ██║╚██╗██║ ██╔══╝ ██╔══╝ ██║ ██║',
89
+ '██║ ██║ ╚████║ ███████╗ ███████╗ ██████╔╝',
90
+ '╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ '
91
+ ];
92
+ export const LOGO = LOGO_LINES.join('\n');
93
+ export const logo = () => LOGO_LINES.map(l => green(bold(l))).join('\n');
94
+
32
95
  // Ask a question and await one line. `secret` hides typed characters.
33
96
  // onLine: receiver for lines typed when no question is pending (REPL dispatch).
34
97
  export function makeInput(rl, onLine) {
package/src/wizard.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { fetchModels, chat } from './provider.js';
4
4
  import { saveConfig } from './config.js';
5
- import { bold, dim, red, yellow, green, cyan, trunc } from './ui.js';
5
+ import { bold, dim, red, yellow, green, cyan, trunc, logo, BANNER, startSpinner } from './ui.js';
6
6
 
7
7
  export async function testConnection(cfg, signal) {
8
8
  // proves reachability + auth + a working chat in one shot
@@ -15,7 +15,10 @@ export async function wizard(ask, { fromCommand = false } = {}) {
15
15
  const abortIfEnded = () => { if (inputEnded()) throw Object.assign(new Error('Setup aborted: input ended.'), { aborted: true }); };
16
16
 
17
17
  console.log('');
18
- console.log(bold('Welcome to ineed') + dim(' - let\'s connect you to an AI provider. You only do this once.'));
18
+ console.log(logo());
19
+ console.log('');
20
+ console.log(BANNER());
21
+ console.log(bold('Welcome!') + dim(' Let\'s connect you to an AI provider. You only do this once.'));
19
22
  console.log(dim('Any OpenAI-compatible API works: OpenAI, OmniRoute, LM Studio, Ollama, vLLM, and more.'));
20
23
  console.log('');
21
24
 
@@ -39,9 +42,11 @@ export async function wizard(ask, { fromCommand = false } = {}) {
39
42
  const probe = { baseUrl, apiKey, model: 'x' };
40
43
  console.log(dim('\n Checking connection...'));
41
44
  let models = [];
45
+ const connSpin = startSpinner('connecting');
42
46
  try {
43
47
  models = await fetchModels(probe);
44
48
  } catch {}
49
+ connSpin.stop();
45
50
  if (models.length > 0) {
46
51
  console.log(green(` Connected. ${models.length} models available.`));
47
52
  } else {
@@ -65,11 +70,15 @@ export async function wizard(ask, { fromCommand = false } = {}) {
65
70
  console.log(dim(` Testing ${model}...`));
66
71
  let saved = false;
67
72
  while (!saved) {
73
+ let testSpin = null;
68
74
  try {
75
+ testSpin = startSpinner('testing ' + model);
69
76
  const reply = await testConnection({ baseUrl, apiKey, model });
77
+ testSpin.stop();
70
78
  console.log(green(' Works.') + dim(` Replied: ${trunc(reply, 40)}`));
71
79
  saved = true;
72
80
  } catch (err) {
81
+ testSpin?.stop();
73
82
  console.log(red(' Test failed: ' + err.message));
74
83
  const choice = await ask(' [r]etry key, [m]odel, [l]ist models, [b]ase url, or [s]ave anyway? ');
75
84
  if (choice === '') abortIfEnded();