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 +3 -3
- package/package.json +3 -3
- package/src/agent.js +57 -7
- package/src/cli.js +11 -5
- package/src/config.js +4 -1
- package/src/memory.js +61 -0
- package/src/provider.js +21 -1
- package/src/session.js +333 -99
- package/src/tools.js +20 -1
- package/src/ui.js +71 -8
- package/src/wizard.js +11 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="https://raw.githubusercontent.com/
|
|
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
|
[](https://www.npmjs.com/package/ineedcodes)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://nodejs.org)
|
|
12
|
-
[](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/
|
|
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.
|
|
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/
|
|
29
|
+
"url": "git+https://github.com/salsabila2507/ineedcodes.git"
|
|
30
30
|
},
|
|
31
31
|
"bugs": {
|
|
32
|
-
"url": "https://github.com/
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.log(
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
-
console.log(dim(' Fetching models...'));
|
|
61
|
+
async function pickModel() {
|
|
56
62
|
let models = [];
|
|
57
|
-
try { models = await fetchModels(state); } catch (err) {
|
|
63
|
+
try { models = await fetchModels(state); } catch (err) { say(red(' ' + err.message)); return; }
|
|
58
64
|
if (models.length === 0) {
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
71
|
-
else state
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
drain();
|
|
103
|
-
}
|
|
134
|
+
stopSpinner();
|
|
135
|
+
await afterTask();
|
|
104
136
|
}
|
|
105
|
-
}
|
|
137
|
+
}
|
|
106
138
|
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
if (!input)
|
|
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))
|
|
119
|
-
if (input === '/help' || input === '?')
|
|
120
|
-
if (input === '/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 === '/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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();
|