agen-vektor 0.3.7 → 0.3.8
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 +17 -23
- package/dist/agent/agent.js +19 -9
- package/dist/agent/loop.js +11 -5
- package/dist/cli/index.js +68 -7
- package/dist/providers/openai-compat.js +11 -0
- package/dist/tui/app.js +132 -41
- package/dist/tui/chat.js +3 -2
- package/dist/tui/input.js +31 -29
- package/dist/tui/statusbar.js +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,30 +12,25 @@
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
```
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
│
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
│ $ npm test │
|
|
29
|
-
│ │
|
|
30
|
-
│ ✓ Tests passed │
|
|
31
|
-
│ │
|
|
32
|
-
├──────────────────────────────────────────────────────────────┤
|
|
33
|
-
│ > _ │
|
|
34
|
-
├──────────────────────────────────────────────────────────────┤
|
|
35
|
-
│ ENTER Send │ TAB Commands │ CTRL+C Stop │ ? Help │
|
|
36
|
-
╰──────────────────────────────────────────────────────────────╯
|
|
15
|
+
VectorHead ● Ready custom · deepseek-ai/DeepSeek-V4-Flash
|
|
16
|
+
Project: ~/my-project Mode: ask
|
|
17
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
│ Perbaiki error authentication pada project ini
|
|
19
|
+
VectorHead akan memeriksa file auth, memperbaiki, lalu menjalankan test.
|
|
20
|
+
|
|
21
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
⚙ shell · $ npm test
|
|
23
|
+
✓ Tests passed
|
|
24
|
+
╭─────────────────────────────────────────────────────────────────────────────╮
|
|
25
|
+
│ ▍ Enter a coding task or / for commands │
|
|
26
|
+
╰─────────────────────────────────────────────────────────────────────────────╯
|
|
27
|
+
● Ready ● ask
|
|
37
28
|
```
|
|
38
29
|
|
|
30
|
+
A Freebuff-style terminal UI: chat history with a bold-white assistant line,
|
|
31
|
+
compact agent-activity strip (⚙ tool calls), and a rounded input box with a
|
|
32
|
+
block cursor and placeholder — all rendered incrementally with no flicker.
|
|
33
|
+
|
|
39
34
|
## Features
|
|
40
35
|
|
|
41
36
|
- **Interactive TUI** — chat, agent status, tool execution, diff viewer, input. Keyboard driven, mouse-aware, works on 80×24 terminals and survives resize.
|
|
@@ -213,7 +208,6 @@ npm run build
|
|
|
213
208
|
- Web search tool (provider-backed) behind the `web` abstraction
|
|
214
209
|
- Interactive diff approval before edits in safe mode
|
|
215
210
|
- Persistent permission rules per project
|
|
216
|
-
- Publish under a dedicated npm package name for the VectorHead brand
|
|
217
211
|
- Windows support (WSL-focused)
|
|
218
212
|
|
|
219
213
|
## License
|
package/dist/agent/agent.js
CHANGED
|
@@ -35,31 +35,41 @@ class Agent {
|
|
|
35
35
|
]);
|
|
36
36
|
}
|
|
37
37
|
async run(request, signal) {
|
|
38
|
-
return
|
|
38
|
+
return this.runLoop(request, [], signal);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run continuing from a prior conversation (multi-turn continuity).
|
|
42
|
+
* The previous turn's messages are passed as history so the model
|
|
43
|
+
* remembers earlier exchanges instead of starting fresh every turn.
|
|
44
|
+
*/
|
|
45
|
+
async runWithHistory(request, history, signal) {
|
|
46
|
+
return this.runLoop(request, history, signal);
|
|
47
|
+
}
|
|
48
|
+
async runLoop(request, history, signal) {
|
|
49
|
+
const result = await (0, loop_1.runAgentLoop)(request, {
|
|
39
50
|
provider: this.provider,
|
|
40
51
|
tools: this.tools,
|
|
41
52
|
permissions: this.permissions,
|
|
42
53
|
config: this.config,
|
|
43
54
|
cwd: this.cwd,
|
|
44
55
|
signal,
|
|
56
|
+
history,
|
|
45
57
|
}, this.callbacks);
|
|
58
|
+
this.lastMessages = result.messages;
|
|
59
|
+
return result;
|
|
46
60
|
}
|
|
47
61
|
/**
|
|
48
62
|
* Run with optional prior session messages (non-interactive continue).
|
|
49
|
-
* If sessionMessages is provided, the
|
|
63
|
+
* If sessionMessages is provided, the loop continues from that history.
|
|
50
64
|
*/
|
|
51
65
|
async runWithContext(request, session, cb = {}) {
|
|
52
66
|
this.setCallbacks(cb);
|
|
53
67
|
if (!session)
|
|
54
68
|
return this.run(request);
|
|
55
|
-
|
|
56
|
-
const context = session.messages
|
|
57
|
-
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
58
|
-
.slice(-30)
|
|
59
|
-
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 2000)}`)
|
|
60
|
-
.join('\n\n');
|
|
61
|
-
return this.run(`[Previous conversation summary]\n${context}\n\n---\n\nTASK:\n${request}`);
|
|
69
|
+
return this.runWithHistory(request, session.messages.filter((m) => m.role !== 'system').slice(-40));
|
|
62
70
|
}
|
|
71
|
+
/** Raw messages of the most recent run (for cross-turn continuity). */
|
|
72
|
+
lastMessages = [];
|
|
63
73
|
/** Rebuild the provider (e.g. after changing provider id). */
|
|
64
74
|
rebuildProvider() {
|
|
65
75
|
this.provider = (0, factory_1.createProvider)(this.config);
|
package/dist/agent/loop.js
CHANGED
|
@@ -18,13 +18,19 @@ const prompts_1 = require("./prompts");
|
|
|
18
18
|
async function runAgentLoop(userRequest, opts, callbacks = {}) {
|
|
19
19
|
const { provider, tools, permissions, config, cwd, signal } = opts;
|
|
20
20
|
const maxIterations = config.maxIterations;
|
|
21
|
-
// Seed conversation with system + project context + task
|
|
21
|
+
// Seed conversation with system + project context + task.
|
|
22
|
+
// On a continuation (history present) the project context is not
|
|
23
|
+
// re-injected: the model already saw it in the first turn, and repeating
|
|
24
|
+
// the folder listing on every turn makes the agent re-explain/echo it.
|
|
25
|
+
const history = (opts.history ?? []).filter((m) => m.role !== 'system');
|
|
26
|
+
const isContinuation = history.length > 0;
|
|
27
|
+
const context = isContinuation
|
|
28
|
+
? ''
|
|
29
|
+
: `${(0, context_1.buildProjectContext)(cwd)}\n\n${(0, context_1.listRootEntries)(cwd)}\n\n---\n\n`;
|
|
22
30
|
const messages = [
|
|
23
31
|
{ role: 'system', content: prompts_1.SYSTEM_PROMPT },
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
content: `${(0, context_1.buildProjectContext)(cwd)}\n\n${(0, context_1.listRootEntries)(cwd)}\n\n---\n\nTASK:\n${userRequest}`,
|
|
27
|
-
},
|
|
32
|
+
...history,
|
|
33
|
+
{ role: 'user', content: `${context}TASK:\n${userRequest}` },
|
|
28
34
|
];
|
|
29
35
|
let iterations = 0;
|
|
30
36
|
let toolCalls = 0;
|
package/dist/cli/index.js
CHANGED
|
@@ -23,6 +23,7 @@ const logger_1 = require("../utils/logger");
|
|
|
23
23
|
const keyboard_1 = require("./keyboard");
|
|
24
24
|
const terminal_1 = require("../utils/terminal");
|
|
25
25
|
const session_1 = require("../agent/session");
|
|
26
|
+
const paths_2 = require("../utils/paths");
|
|
26
27
|
// Read version from package.json so --version always matches the published release.
|
|
27
28
|
function getVersion() {
|
|
28
29
|
try {
|
|
@@ -164,12 +165,29 @@ async function runNonInteractive(opts) {
|
|
|
164
165
|
}
|
|
165
166
|
console.log(`${terminal_1.ANSI.brightYellow}⚙ ${t}${terminal_1.ANSI.reset} ${terminal_1.ANSI.dim}${preview}${terminal_1.ANSI.reset}`);
|
|
166
167
|
},
|
|
167
|
-
onDelta: (d) => process.stdout.write(d),
|
|
168
|
-
onFinal: () => process.stdout.write('\n'),
|
|
169
168
|
});
|
|
169
|
+
// Print the final reply. The loop uses the non-streaming path, so the
|
|
170
|
+
// answer only arrives in result.content (never via onDelta).
|
|
171
|
+
const reply = result.content.trim();
|
|
172
|
+
if (reply) {
|
|
173
|
+
process.stdout.write(reply + '\n');
|
|
174
|
+
}
|
|
175
|
+
else if (result.stopped || result.aborted) {
|
|
176
|
+
console.log(`${terminal_1.ANSI.yellow}⏹ Stopped — no reply was produced.${terminal_1.ANSI.reset}`);
|
|
177
|
+
}
|
|
170
178
|
console.log(`\n${terminal_1.ANSI.dim}— ${result.iterations} iterations, ${result.toolCalls} tool calls${terminal_1.ANSI.reset}`);
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
// Persist the conversation (per-project session by default) so the
|
|
180
|
+
// session is not lost after the run finishes.
|
|
181
|
+
try {
|
|
182
|
+
const name = opts.sessionName || defaultSessionName(agent.cwd);
|
|
183
|
+
(0, session_1.saveSession)(name, result.messages, {
|
|
184
|
+
cwd: agent.cwd,
|
|
185
|
+
model: agent.config.model,
|
|
186
|
+
provider: agent.config.provider,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* ignore */
|
|
173
191
|
}
|
|
174
192
|
process.exit(0);
|
|
175
193
|
}
|
|
@@ -193,6 +211,13 @@ function latestSessionName() {
|
|
|
193
211
|
const sessions = (0, session_1.listSessions)();
|
|
194
212
|
return sessions.length > 0 ? sessions[0].name : undefined;
|
|
195
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Default session name: one persistent session per project directory,
|
|
216
|
+
* so conversations are never silently lost when no --session is given.
|
|
217
|
+
*/
|
|
218
|
+
function defaultSessionName(cwd) {
|
|
219
|
+
return (0, paths_2.sanitizeName)(cwd) || 'default';
|
|
220
|
+
}
|
|
196
221
|
async function runTui(opts) {
|
|
197
222
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
198
223
|
await runNonInteractive(opts);
|
|
@@ -207,11 +232,15 @@ async function runTui(opts) {
|
|
|
207
232
|
console.log(`${terminal_1.ANSI.yellow}⚠${terminal_1.ANSI.reset} No API key configured for provider "${config.provider}".\n` +
|
|
208
233
|
`Set ${providerEnv(config.provider)} in your environment, or run /provider inside VectorHead to add one.\n`);
|
|
209
234
|
}
|
|
210
|
-
// Resolve session name
|
|
235
|
+
// Resolve session name — default to a persistent per-project session so
|
|
236
|
+
// conversations are saved even when no --session flag is given.
|
|
211
237
|
let sessionName = opts.sessionName;
|
|
212
238
|
if (opts.continueSession && !sessionName) {
|
|
213
239
|
sessionName = latestSessionName();
|
|
214
240
|
}
|
|
241
|
+
if (!sessionName) {
|
|
242
|
+
sessionName = defaultSessionName(process.cwd());
|
|
243
|
+
}
|
|
215
244
|
let exit = false;
|
|
216
245
|
// Permission callback: drive a modal through the app
|
|
217
246
|
let app = null;
|
|
@@ -243,9 +272,41 @@ async function runTui(opts) {
|
|
|
243
272
|
});
|
|
244
273
|
// Terminal setup
|
|
245
274
|
const cleanup = (0, keyboard_1.enableRawMode)();
|
|
275
|
+
// Incremental renderer: keep the previous frame and only rewrite the rows
|
|
276
|
+
// that actually changed. Full-screen redraws every tick caused constant
|
|
277
|
+
// flicker, especially while the agent is running (spinner + timer animate
|
|
278
|
+
// every frame). Modal overlays are absolutely positioned, so they force a
|
|
279
|
+
// full repaint.
|
|
280
|
+
let lastFrameRows = [];
|
|
281
|
+
let lastCursorPos = '';
|
|
246
282
|
const doRender = () => {
|
|
247
|
-
|
|
248
|
-
|
|
283
|
+
if (app.modalActive()) {
|
|
284
|
+
// A modal (e.g. a permission prompt) is static while open, so only
|
|
285
|
+
// repaint when something actually changed (open/close/keystroke).
|
|
286
|
+
// Full-screen redraws every tick while waiting for permission caused
|
|
287
|
+
// the chat to flicker during multi-tool turns.
|
|
288
|
+
if (app.isDirty()) {
|
|
289
|
+
lastFrameRows = [];
|
|
290
|
+
process.stdout.write(terminal_1.ANSI.hideCursor + app.render());
|
|
291
|
+
app.consumeRender();
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const snap = app.frameSnapshot();
|
|
296
|
+
const rows = snap.rows;
|
|
297
|
+
const out = [];
|
|
298
|
+
for (let i = 0; i < rows.length; i++) {
|
|
299
|
+
if (lastFrameRows[i] !== rows[i]) {
|
|
300
|
+
out.push((0, terminal_1.cursorTo)(i + 1, 1) + terminal_1.ANSI.clearLineEnd + rows[i]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
lastFrameRows = rows;
|
|
304
|
+
const cursor = (0, terminal_1.cursorTo)(snap.inputRow, snap.cursorCol);
|
|
305
|
+
if (cursor !== lastCursorPos)
|
|
306
|
+
out.push(cursor);
|
|
307
|
+
lastCursorPos = cursor;
|
|
308
|
+
out.push(terminal_1.ANSI.showCursor);
|
|
309
|
+
process.stdout.write(terminal_1.ANSI.hideCursor + out.join(''));
|
|
249
310
|
};
|
|
250
311
|
const onResize = () => {
|
|
251
312
|
app.markDirty();
|
|
@@ -82,6 +82,17 @@ class OpenAICompatProvider {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
endpoint(path) {
|
|
85
|
+
if (!this.baseUrl) {
|
|
86
|
+
throw new provider_1.ProviderError('Custom API base URL is not set. Run /connect (or /setup) to enter it.', 0, false);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const u = new URL(this.baseUrl);
|
|
90
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
91
|
+
throw new Error('not http');
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new provider_1.ProviderError(`Invalid API base URL "${this.baseUrl}". Run /connect to fix it (e.g. https://host/v1).`, 0, false);
|
|
95
|
+
}
|
|
85
96
|
return `${this.baseUrl}${path}`;
|
|
86
97
|
}
|
|
87
98
|
headers() {
|
package/dist/tui/app.js
CHANGED
|
@@ -81,6 +81,19 @@ class App {
|
|
|
81
81
|
runStart = null;
|
|
82
82
|
/** set on every mutation so the render loop only repaints when needed */
|
|
83
83
|
dirty = true;
|
|
84
|
+
/**
|
|
85
|
+
* When true the chat view follows the newest messages. Disabled while the
|
|
86
|
+
* user scrolls up (PgUp); re-enabled when they scroll back to the bottom.
|
|
87
|
+
* Without this, new turns render below the visible area and replies to
|
|
88
|
+
* turns 2+ appear to "not respond".
|
|
89
|
+
*/
|
|
90
|
+
followBottom = true;
|
|
91
|
+
/**
|
|
92
|
+
* Compact Freebuff-style activity strip: the current/last tool action
|
|
93
|
+
* (e.g. searching folders, running a command) shown above the input.
|
|
94
|
+
* Kept out of the main chat transcript so the conversation stays clean.
|
|
95
|
+
*/
|
|
96
|
+
activity = null;
|
|
84
97
|
constructor(agent, opts) {
|
|
85
98
|
this.agent = agent;
|
|
86
99
|
this.opts = opts;
|
|
@@ -184,10 +197,14 @@ class App {
|
|
|
184
197
|
break;
|
|
185
198
|
case 'pageup':
|
|
186
199
|
this.scroll = (0, chat_1.clampScroll)(this.scroll - 10, this.messages, this.cols(), this.chatHeight());
|
|
200
|
+
this.followBottom = false;
|
|
187
201
|
break;
|
|
188
|
-
case 'pagedown':
|
|
202
|
+
case 'pagedown': {
|
|
189
203
|
this.scroll = (0, chat_1.clampScroll)(this.scroll + 10, this.messages, this.cols(), this.chatHeight());
|
|
204
|
+
const all = this.messages.length === 0 ? [] : (0, chat_1.renderMessages)(this.messages, this.cols());
|
|
205
|
+
this.followBottom = this.scroll >= Math.max(0, all.length - this.chatHeight());
|
|
190
206
|
break;
|
|
207
|
+
}
|
|
191
208
|
case 'ctrl_u':
|
|
192
209
|
this.input.deleteToStart();
|
|
193
210
|
break;
|
|
@@ -203,6 +220,8 @@ class App {
|
|
|
203
220
|
case 'ctrl_l':
|
|
204
221
|
this.messages = [];
|
|
205
222
|
this.scroll = 0;
|
|
223
|
+
this.followBottom = true;
|
|
224
|
+
this.activity = null;
|
|
206
225
|
this.addSystem('Conversation cleared.');
|
|
207
226
|
break;
|
|
208
227
|
case 'ctrl_d':
|
|
@@ -252,6 +271,14 @@ class App {
|
|
|
252
271
|
this.exitRequested = true;
|
|
253
272
|
}
|
|
254
273
|
// ─── Submission ───────────────────────────────────────────────
|
|
274
|
+
/** Snap the chat view to the newest messages (unless the user scrolled up). */
|
|
275
|
+
scrollToLatest() {
|
|
276
|
+
if (!this.followBottom)
|
|
277
|
+
return;
|
|
278
|
+
const { cols } = (0, terminal_1.getTerminalSize)();
|
|
279
|
+
const all = this.messages.length === 0 ? [] : (0, chat_1.renderMessages)(this.messages, cols);
|
|
280
|
+
this.scroll = Math.max(0, all.length - this.chatHeight());
|
|
281
|
+
}
|
|
255
282
|
async submit(text) {
|
|
256
283
|
this.markDirty();
|
|
257
284
|
if (text.startsWith('/')) {
|
|
@@ -259,7 +286,8 @@ class App {
|
|
|
259
286
|
return;
|
|
260
287
|
}
|
|
261
288
|
this.messages.push({ kind: 'user', content: text });
|
|
262
|
-
this.
|
|
289
|
+
this.activity = null;
|
|
290
|
+
this.scrollToLatest();
|
|
263
291
|
this.running = true;
|
|
264
292
|
this.status = 'Thinking';
|
|
265
293
|
this.statusColor = (0, theme_1.statusColor)('Thinking');
|
|
@@ -312,6 +340,7 @@ class App {
|
|
|
312
340
|
const m = this.messages[msgIndex];
|
|
313
341
|
if (m)
|
|
314
342
|
m.content += delta;
|
|
343
|
+
this.scrollToLatest();
|
|
315
344
|
},
|
|
316
345
|
onStatus: (status) => {
|
|
317
346
|
this.markDirty();
|
|
@@ -330,15 +359,13 @@ class App {
|
|
|
330
359
|
catch {
|
|
331
360
|
preview = args.slice(0, 80);
|
|
332
361
|
}
|
|
333
|
-
|
|
362
|
+
// Show tool activity in the compact strip, not the chat transcript.
|
|
363
|
+
this.activity = { tool, detail: preview };
|
|
334
364
|
},
|
|
335
365
|
onToolResult: (tool, result) => {
|
|
336
366
|
this.markDirty();
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
last.content = result.split('\n').slice(0, 12).join('\n');
|
|
340
|
-
if (result.split('\n').length > 12)
|
|
341
|
-
last.content += '\n…';
|
|
367
|
+
if (this.activity && this.activity.tool === tool) {
|
|
368
|
+
this.activity = { ...this.activity, result: result.split('\n').slice(0, 8).join('\n') };
|
|
342
369
|
}
|
|
343
370
|
},
|
|
344
371
|
onError: (err) => {
|
|
@@ -357,13 +384,17 @@ class App {
|
|
|
357
384
|
m.content = result.content || m.content;
|
|
358
385
|
m.streaming = false;
|
|
359
386
|
}
|
|
387
|
+
this.scrollToLatest();
|
|
360
388
|
},
|
|
361
389
|
});
|
|
390
|
+
let stoppedRun = false;
|
|
362
391
|
try {
|
|
363
|
-
await this.agent.
|
|
392
|
+
const res = await this.agent.runWithHistory(task, this.agent.lastMessages.filter((m) => m.role !== 'system').slice(-40), this.abortController?.signal);
|
|
393
|
+
stoppedRun = res.stopped || res.aborted;
|
|
364
394
|
}
|
|
365
395
|
catch (err) {
|
|
366
396
|
if (err.message === 'aborted') {
|
|
397
|
+
stoppedRun = true;
|
|
367
398
|
const m = this.messages[msgIndex];
|
|
368
399
|
if (m) {
|
|
369
400
|
m.content += '\n\n⏹ (stopped by user)';
|
|
@@ -383,8 +414,10 @@ class App {
|
|
|
383
414
|
const m = this.messages[msgIndex];
|
|
384
415
|
if (m) {
|
|
385
416
|
m.streaming = false;
|
|
386
|
-
|
|
387
|
-
|
|
417
|
+
// Keep any partial reply; only fall back when there is nothing.
|
|
418
|
+
if (!m.content.trim()) {
|
|
419
|
+
m.content = stoppedRun ? '⏹ (stopped — no reply)' : '(no response)';
|
|
420
|
+
}
|
|
388
421
|
}
|
|
389
422
|
this.running = false;
|
|
390
423
|
this.runStart = null;
|
|
@@ -460,6 +493,8 @@ class App {
|
|
|
460
493
|
case '/clear':
|
|
461
494
|
this.messages = [];
|
|
462
495
|
this.scroll = 0;
|
|
496
|
+
this.followBottom = true;
|
|
497
|
+
this.activity = null;
|
|
463
498
|
this.addSystem('Conversation cleared.');
|
|
464
499
|
break;
|
|
465
500
|
case '/continue':
|
|
@@ -558,9 +593,12 @@ class App {
|
|
|
558
593
|
}
|
|
559
594
|
else if (ev.name === 'pageup') {
|
|
560
595
|
this.scroll = (0, chat_1.clampScroll)(this.scroll - 10, this.messages, this.cols(), this.chatHeight());
|
|
596
|
+
this.followBottom = false;
|
|
561
597
|
}
|
|
562
598
|
else if (ev.name === 'pagedown') {
|
|
563
599
|
this.scroll = (0, chat_1.clampScroll)(this.scroll + 10, this.messages, this.cols(), this.chatHeight());
|
|
600
|
+
const all = this.messages.length === 0 ? [] : (0, chat_1.renderMessages)(this.messages, this.cols());
|
|
601
|
+
this.followBottom = this.scroll >= Math.max(0, all.length - this.chatHeight());
|
|
564
602
|
}
|
|
565
603
|
break;
|
|
566
604
|
case 'provider':
|
|
@@ -671,14 +709,17 @@ class App {
|
|
|
671
709
|
case 'apikey':
|
|
672
710
|
this.handleTextInput(ev, (value) => {
|
|
673
711
|
(0, credentials_1.setApiKey)(modal.provider, value);
|
|
712
|
+
this.agent.rebuildProvider();
|
|
674
713
|
this.addSystem(`API key stored for ${modal.provider} (not printed).`);
|
|
675
714
|
this.modal = { type: 'none' };
|
|
676
715
|
});
|
|
677
716
|
break;
|
|
678
717
|
case 'custom-url':
|
|
679
718
|
this.handleTextInput(ev, (value) => {
|
|
680
|
-
this.agent.config.apiUrl = value;
|
|
719
|
+
this.agent.config.apiUrl = value.trim();
|
|
681
720
|
(0, config_1.saveConfig)(this.agent.config);
|
|
721
|
+
// Rebuild so the live provider picks up the new base URL
|
|
722
|
+
this.agent.rebuildProvider();
|
|
682
723
|
this.addSystem('Custom API URL set.');
|
|
683
724
|
if (!(0, credentials_1.hasApiKey)('custom')) {
|
|
684
725
|
this.modal = { type: 'custom-key', value: '' };
|
|
@@ -690,7 +731,9 @@ class App {
|
|
|
690
731
|
break;
|
|
691
732
|
case 'custom-key':
|
|
692
733
|
this.handleTextInput(ev, (value) => {
|
|
693
|
-
(0, credentials_1.setApiKey)('custom', value);
|
|
734
|
+
(0, credentials_1.setApiKey)('custom', value.trim());
|
|
735
|
+
// Rebuild so the live provider picks up the new API key
|
|
736
|
+
this.agent.rebuildProvider();
|
|
694
737
|
this.addSystem('Custom API key stored (not printed).');
|
|
695
738
|
this.modal = { type: 'none' };
|
|
696
739
|
});
|
|
@@ -737,6 +780,7 @@ class App {
|
|
|
737
780
|
addSystem(text) {
|
|
738
781
|
this.markDirty();
|
|
739
782
|
this.messages.push({ kind: 'system', content: text });
|
|
783
|
+
this.scrollToLatest();
|
|
740
784
|
}
|
|
741
785
|
/** Show a permission modal (called by the permission callback). */
|
|
742
786
|
setPermissionModal(req) {
|
|
@@ -756,15 +800,33 @@ class App {
|
|
|
756
800
|
addTool(tool, content) {
|
|
757
801
|
this.markDirty();
|
|
758
802
|
this.messages.push({ kind: 'tool', tool, content });
|
|
803
|
+
this.scrollToLatest();
|
|
759
804
|
}
|
|
760
805
|
// ─── Rendering ────────────────────────────────────────────────
|
|
761
806
|
cols() {
|
|
762
807
|
return (0, terminal_1.getTerminalSize)().cols;
|
|
763
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* Rows reserved for the activity strip above the input.
|
|
811
|
+
* FIXED at 3 while working (or while the last tool action is shown) so the
|
|
812
|
+
* chat area never resizes between tool calls — a dynamic height made the
|
|
813
|
+
* separator/chat reflow on every tool result, which looked like the screen
|
|
814
|
+
* blinking during multi-tool turns (e.g. "apakah vectorhead sehat").
|
|
815
|
+
*/
|
|
816
|
+
activityHeight() {
|
|
817
|
+
return this.running || this.activity ? 3 : 0;
|
|
818
|
+
}
|
|
764
819
|
chatHeight() {
|
|
765
|
-
|
|
820
|
+
// Fixed rows: header(1) + project(1) + sep(1) + chatSep(1) + input box(3)
|
|
821
|
+
// + status(1) = 8, plus the activity strip when working.
|
|
822
|
+
return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 8 - this.activityHeight());
|
|
766
823
|
}
|
|
767
|
-
|
|
824
|
+
/**
|
|
825
|
+
* Compute the full frame as plain row strings (index 0 = terminal row 1)
|
|
826
|
+
* plus the input cursor position. Modal overlays are excluded (they are
|
|
827
|
+
* absolutely positioned and handled separately by render()).
|
|
828
|
+
*/
|
|
829
|
+
computeFrame() {
|
|
768
830
|
const { rows, cols } = (0, terminal_1.getTerminalSize)();
|
|
769
831
|
const info = {
|
|
770
832
|
status: this.status,
|
|
@@ -778,48 +840,77 @@ class App {
|
|
|
778
840
|
running: this.running,
|
|
779
841
|
elapsed: this.running && this.runStart !== null ? Math.floor((Date.now() - this.runStart) / 1000) : undefined,
|
|
780
842
|
};
|
|
781
|
-
const parts = [];
|
|
782
|
-
// Row 1-2: header + project line
|
|
783
|
-
parts.push((0, terminal_1.cursorTo)(1, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderHeader)(cols, info));
|
|
784
|
-
parts.push((0, terminal_1.cursorTo)(2, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderProjectBar)(cols, info));
|
|
785
|
-
// Row 3: separator
|
|
786
|
-
parts.push((0, terminal_1.cursorTo)(3, 1) + terminal_1.ANSI.clearLineEnd + theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset);
|
|
787
|
-
// Chat area (rows 4..) — Freebuff-style message blocks (welcome screen when empty)
|
|
788
843
|
const chatH = this.chatHeight();
|
|
789
844
|
const all = this.messages.length === 0 ? (0, chat_1.renderWelcome)(cols, chatH, this.agent.cwd) : (0, chat_1.renderMessages)(this.messages, cols);
|
|
790
845
|
const maxScroll = Math.max(0, all.length - chatH);
|
|
791
846
|
this.scroll = Math.min(this.scroll, maxScroll);
|
|
792
847
|
const visible = all.slice(this.scroll, this.scroll + chatH);
|
|
793
848
|
const chatTop = 4;
|
|
794
|
-
for (let i = 0; i < chatH; i++) {
|
|
795
|
-
const content = visible[i] !== undefined ? visible[i] : '';
|
|
796
|
-
parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + content);
|
|
797
|
-
}
|
|
798
|
-
// Separator between chat and input
|
|
799
849
|
const sepRow = chatTop + chatH;
|
|
800
|
-
|
|
801
|
-
//
|
|
802
|
-
const
|
|
803
|
-
const
|
|
804
|
-
|
|
850
|
+
const activityH = this.activityHeight();
|
|
851
|
+
// Freebuff-style rounded input box (3 rows: top border, content, bottom).
|
|
852
|
+
const boxTop = sepRow + activityH + 1;
|
|
853
|
+
const boxMid = boxTop + 1;
|
|
854
|
+
const boxBot = boxTop + 2;
|
|
855
|
+
const statusRow = boxBot + 1;
|
|
856
|
+
const rendered = this.input.render(cols, input_1.DEFAULT_PLACEHOLDER);
|
|
805
857
|
const cursorCol = rendered.cursorCol + 1;
|
|
806
|
-
//
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
858
|
+
// Inner width is cols - 2 (between the borders); pad to fill it exactly.
|
|
859
|
+
const padInner = Math.max(0, cols - 2 - (0, terminal_1.visibleWidth)(rendered.line));
|
|
860
|
+
const frame = new Array(rows).fill('');
|
|
861
|
+
frame[0] = (0, statusbar_1.renderHeader)(cols, info);
|
|
862
|
+
frame[1] = (0, statusbar_1.renderProjectBar)(cols, info);
|
|
863
|
+
frame[2] = theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset;
|
|
864
|
+
for (let i = 0; i < chatH; i++) {
|
|
865
|
+
frame[chatTop - 1 + i] = visible[i] !== undefined ? visible[i] : '';
|
|
866
|
+
}
|
|
867
|
+
frame[sepRow - 1] = theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset;
|
|
868
|
+
if (activityH > 0 && this.activity) {
|
|
869
|
+
const actRows = (0, statusbar_1.renderActivity)(this.activity, cols, activityH);
|
|
870
|
+
for (let i = 0; i < actRows.length; i++)
|
|
871
|
+
frame[sepRow + i] = actRows[i];
|
|
872
|
+
}
|
|
873
|
+
// Freebuff renders the input box border in the foreground color (white).
|
|
874
|
+
const boxBorder = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
|
|
875
|
+
frame[boxTop - 1] = `${boxBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╮${theme_1.THEME.reset}`;
|
|
876
|
+
frame[boxMid - 1] = `${boxBorder}│${theme_1.THEME.reset}${rendered.line}${' '.repeat(padInner)}${boxBorder}│${theme_1.THEME.reset}`;
|
|
877
|
+
frame[boxBot - 1] = `${boxBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╯${theme_1.THEME.reset}`;
|
|
878
|
+
frame[statusRow - 1] = (0, statusbar_1.renderStatusBar)(cols, info);
|
|
879
|
+
return { rows: frame, inputRow: boxMid, cursorCol };
|
|
880
|
+
}
|
|
881
|
+
/** True while a modal overlay is shown (its rows are absolutely positioned). */
|
|
882
|
+
modalActive() {
|
|
883
|
+
return this.modal.type !== 'none';
|
|
884
|
+
}
|
|
885
|
+
/** True when a repaint is needed because something actually changed. */
|
|
886
|
+
isDirty() {
|
|
887
|
+
return this.dirty;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Frame snapshot for the CLI's incremental renderer: plain rows + cursor.
|
|
891
|
+
* The caller compares rows with the previous frame and only rewrites the
|
|
892
|
+
* changed lines — this is what eliminates the full-screen flicker.
|
|
893
|
+
*/
|
|
894
|
+
frameSnapshot() {
|
|
895
|
+
return this.computeFrame();
|
|
896
|
+
}
|
|
897
|
+
render() {
|
|
898
|
+
const { rows, cols } = (0, terminal_1.getTerminalSize)();
|
|
899
|
+
const { rows: frame, inputRow, cursorCol } = this.computeFrame();
|
|
900
|
+
const parts = [];
|
|
901
|
+
for (let r = 0; r < rows; r++) {
|
|
902
|
+
parts.push((0, terminal_1.cursorTo)(r + 1, 1) + terminal_1.ANSI.clearLineEnd + frame[r]);
|
|
903
|
+
}
|
|
810
904
|
if (this.modal.type !== 'none') {
|
|
811
905
|
const overlay = this.renderModal(rows, cols);
|
|
812
906
|
for (const l of overlay.lines)
|
|
813
907
|
parts.push(l);
|
|
908
|
+
parts.push(terminal_1.ANSI.hideCursor);
|
|
814
909
|
}
|
|
815
|
-
|
|
816
|
-
if (this.modal.type === 'none') {
|
|
910
|
+
else {
|
|
817
911
|
parts.push((0, terminal_1.cursorTo)(inputRow, cursorCol));
|
|
818
912
|
parts.push(terminal_1.ANSI.showCursor);
|
|
819
913
|
}
|
|
820
|
-
else {
|
|
821
|
-
parts.push(terminal_1.ANSI.hideCursor);
|
|
822
|
-
}
|
|
823
914
|
return parts.join('');
|
|
824
915
|
}
|
|
825
916
|
renderModal(rows, cols) {
|
package/dist/tui/chat.js
CHANGED
|
@@ -83,13 +83,14 @@ function renderMessage(m, width) {
|
|
|
83
83
|
out.push('');
|
|
84
84
|
return out;
|
|
85
85
|
default: {
|
|
86
|
-
// Assistant:
|
|
86
|
+
// Assistant: bold white foreground so the conversation is clearly visible.
|
|
87
87
|
const marker = m.streaming ? `${theme_1.THEME.info}◐ ${theme_1.THEME.reset}` : '';
|
|
88
88
|
const lines = (0, terminal_1.wrapText)(body, width);
|
|
89
89
|
if (lines.length === 0)
|
|
90
90
|
lines.push('(no content)');
|
|
91
|
+
const style = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
|
|
91
92
|
for (let i = 0; i < lines.length; i++) {
|
|
92
|
-
out.push(i === 0 ? `${marker}${lines[i]}` : lines[i]);
|
|
93
|
+
out.push(i === 0 ? `${marker}${style}${lines[i]}${theme_1.THEME.reset}` : `${style}${lines[i]}${theme_1.THEME.reset}`);
|
|
93
94
|
}
|
|
94
95
|
out.push('');
|
|
95
96
|
return out;
|
package/dist/tui/input.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.InputBox = void 0;
|
|
3
|
+
exports.InputBox = exports.DEFAULT_PLACEHOLDER = void 0;
|
|
4
4
|
/**
|
|
5
|
-
* Input box — single-line text input with
|
|
5
|
+
* Input box — single-line text input with a Freebuff-style block cursor (▍)
|
|
6
|
+
* and placeholder, rendered inside a rounded border box.
|
|
6
7
|
*/
|
|
7
|
-
const terminal_1 = require("../utils/terminal");
|
|
8
8
|
const theme_1 = require("./theme");
|
|
9
|
+
/** Freebuff-style placeholder shown while the input is empty. */
|
|
10
|
+
exports.DEFAULT_PLACEHOLDER = 'Enter a coding task or / for commands';
|
|
9
11
|
class InputBox {
|
|
10
12
|
text = '';
|
|
11
13
|
cursor = 0;
|
|
12
14
|
history = [];
|
|
13
15
|
historyIndex = -1;
|
|
14
|
-
hint = '';
|
|
15
|
-
setHint(hint) {
|
|
16
|
-
this.hint = hint;
|
|
17
|
-
}
|
|
18
16
|
get value() {
|
|
19
17
|
return this.text;
|
|
20
18
|
}
|
|
@@ -96,30 +94,34 @@ class InputBox {
|
|
|
96
94
|
}
|
|
97
95
|
this.cursor = this.text.length;
|
|
98
96
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
cursorCol =
|
|
97
|
+
/**
|
|
98
|
+
* Render the input content line (between the box borders) with the ▍ block
|
|
99
|
+
* cursor. `width` is the full terminal row width; the returned `line` starts
|
|
100
|
+
* with 2 leading spaces (Freebuff style) and `cursorCol` is the 0-based
|
|
101
|
+
* column where the block cursor sits.
|
|
102
|
+
*/
|
|
103
|
+
render(width, placeholder = exports.DEFAULT_PLACEHOLDER) {
|
|
104
|
+
const inner = Math.max(4, width - 2); // between the │ borders
|
|
105
|
+
const lead = 2; // spaces after the left border
|
|
106
|
+
const textW = Math.max(1, inner - lead - 1); // room for the text + right padding
|
|
107
|
+
let display;
|
|
108
|
+
let cursorCol;
|
|
109
|
+
if (!this.text) {
|
|
110
|
+
display = `${theme_1.THEME.accent}▍${theme_1.THEME.reset} ${theme_1.THEME.faint}${placeholder}${theme_1.THEME.reset}`;
|
|
111
|
+
cursorCol = lead; // the ▍ sits at column `lead`
|
|
114
112
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
else {
|
|
114
|
+
// Window the text around the cursor when it overflows the box.
|
|
115
|
+
let start = 0;
|
|
116
|
+
if (this.text.length > textW) {
|
|
117
|
+
start = Math.max(0, Math.min(this.cursor - Math.floor(textW / 2), this.text.length - textW));
|
|
118
|
+
}
|
|
119
|
+
const visible = this.text.slice(start, start + textW);
|
|
120
|
+
const cur = Math.min(Math.max(0, this.cursor - start), visible.length);
|
|
121
|
+
display = `${visible.slice(0, cur)}${theme_1.THEME.accent}▍${theme_1.THEME.reset}${visible.slice(cur)}`;
|
|
122
|
+
cursorCol = lead + cur;
|
|
118
123
|
}
|
|
119
|
-
return {
|
|
120
|
-
line: `${prefix}${display}${suffix}`,
|
|
121
|
-
cursorCol,
|
|
122
|
-
};
|
|
124
|
+
return { line: `${' '.repeat(lead)}${display}`, cursorCol };
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
exports.InputBox = InputBox;
|
package/dist/tui/statusbar.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderHeader = renderHeader;
|
|
4
4
|
exports.renderProjectBar = renderProjectBar;
|
|
5
|
+
exports.renderActivity = renderActivity;
|
|
5
6
|
exports.renderStatusBar = renderStatusBar;
|
|
6
7
|
/**
|
|
7
8
|
* Status bar — top header + project line + bottom status bar.
|
|
@@ -44,6 +45,28 @@ function renderProjectBar(width, info) {
|
|
|
44
45
|
const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
|
|
45
46
|
return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Render the dedicated agent-activity strip (Freebuff-style): a compact
|
|
50
|
+
* block above the input showing the current/last tool action instead of
|
|
51
|
+
* flooding the main chat transcript with every tool call.
|
|
52
|
+
*/
|
|
53
|
+
function renderActivity(act, width, maxRows) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const head = `${theme_1.THEME.warn}⚙${theme_1.THEME.reset} ${theme_1.THEME.bold}${act.tool}${theme_1.THEME.reset} ${theme_1.THEME.faint}${act.detail}${theme_1.THEME.reset}`;
|
|
56
|
+
for (const line of (0, terminal_1.wrapText)(head, Math.max(10, width - 2))) {
|
|
57
|
+
out.push(line);
|
|
58
|
+
if (out.length >= maxRows)
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
if (act.result) {
|
|
62
|
+
const lines = act.result.split('\n').filter((l) => l.trim());
|
|
63
|
+
const budget = Math.max(0, maxRows - out.length);
|
|
64
|
+
for (const rl of lines.slice(0, budget)) {
|
|
65
|
+
out.push(`${theme_1.THEME.faint}${rl.slice(0, Math.max(20, width - 4))}${theme_1.THEME.reset}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
47
70
|
/** Render the bottom status bar: live status + spinner + elapsed timer. */
|
|
48
71
|
function renderStatusBar(width, info) {
|
|
49
72
|
const statusColorCode = (0, theme_1.statusColor)(info.status);
|
package/package.json
CHANGED