agen-vektor 0.3.6 → 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 CHANGED
@@ -12,30 +12,25 @@
12
12
  ```
13
13
 
14
14
  ```
15
- ╭──────────────────────────────────────────────────────────────╮
16
- VectorHead ● Ready openrouter · deepseek/deepseek-chat │
17
- │ Project: ~/my-project Mode: ask │
18
- ├──────────────────────────────────────────────────────────────┤
19
- │ │
20
- │ YOU │
21
- │ > Perbaiki error authentication pada project ini │
22
- │ │
23
- VectorHead │
24
- │ ● Inspecting project... │
25
- Searching authentication files
26
- │ │
27
- TOOL │
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
@@ -35,31 +35,41 @@ class Agent {
35
35
  ]);
36
36
  }
37
37
  async run(request, signal) {
38
- return (0, loop_1.runAgentLoop)(request, {
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 task is appended after the history.
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
- // Replay history into the loop by prefixing the task request.
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);
@@ -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
- role: 'user',
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
- if (opts.sessionName) {
172
- // persist
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,11 +272,46 @@ 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
- const out = app.render();
248
- process.stdout.write(terminal_1.ANSI.hideCursor + out);
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(''));
310
+ };
311
+ const onResize = () => {
312
+ app.markDirty();
313
+ doRender();
249
314
  };
250
- const onResize = () => doRender();
251
315
  process.stdout.on('resize', onResize);
252
316
  process.on('SIGINT', () => {
253
317
  // In raw mode Ctrl+C arrives as a key event (0x03), but some terminals
@@ -258,6 +322,12 @@ async function runTui(opts) {
258
322
  else
259
323
  process.exit(0);
260
324
  });
325
+ process.on('SIGTERM', () => {
326
+ if (app)
327
+ app.requestExit();
328
+ else
329
+ process.exit(0);
330
+ });
261
331
  process.on('SIGWINCH', onResize);
262
332
  const keyHandler = (ev) => {
263
333
  void app.handleKey(ev);
@@ -269,11 +339,17 @@ async function runTui(opts) {
269
339
  (0, keyboard_1.keyStream)(keyHandler);
270
340
  app.start();
271
341
  doRender();
272
- // Main tick loop
342
+ app.consumeRender();
343
+ // Main tick loop — repaint only when something changed. Repainting the
344
+ // whole frame every tick caused constant flicker and made keystrokes
345
+ // (Enter, Ctrl+C) feel laggy on slow/mobile connections.
273
346
  while (!exit) {
274
347
  await app.tick();
275
- doRender();
276
- await sleep(50);
348
+ if (app.needsRender()) {
349
+ doRender();
350
+ app.consumeRender();
351
+ }
352
+ await sleep(80);
277
353
  }
278
354
  }
279
355
  finally {
@@ -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
@@ -79,6 +79,21 @@ class App {
79
79
  abortController = null;
80
80
  spinner = new theme_1.Spinner();
81
81
  runStart = null;
82
+ /** set on every mutation so the render loop only repaints when needed */
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;
82
97
  constructor(agent, opts) {
83
98
  this.agent = agent;
84
99
  this.opts = opts;
@@ -88,7 +103,20 @@ class App {
88
103
  }
89
104
  }
90
105
  // ─── Public API ───────────────────────────────────────────────
106
+ /** Mark the screen as needing a repaint. */
107
+ markDirty() {
108
+ this.dirty = true;
109
+ }
110
+ /** True when the screen must be repainted (dirty, or streaming). */
111
+ needsRender() {
112
+ return this.dirty || this.running;
113
+ }
114
+ /** Clear the dirty flag after a repaint. */
115
+ consumeRender() {
116
+ this.dirty = false;
117
+ }
91
118
  start() {
119
+ this.markDirty();
92
120
  this.connected = true;
93
121
  this.status = 'Ready';
94
122
  if (this.opts.continueSession && this.sessionName) {
@@ -106,6 +134,7 @@ class App {
106
134
  }
107
135
  }
108
136
  async handleKey(ev) {
137
+ this.markDirty();
109
138
  if (this.modal.type !== 'none') {
110
139
  await this.handleModalKey(ev);
111
140
  return;
@@ -119,6 +148,10 @@ class App {
119
148
  this.running = false;
120
149
  this.addSystem('⏹ Stopped. Press Ctrl+C again to quit.');
121
150
  }
151
+ else if (ev.name === 'ctrl_d') {
152
+ // Ctrl+D stops and quits in one go (handy on mobile)
153
+ this.requestExit();
154
+ }
122
155
  return;
123
156
  }
124
157
  switch (ev.name) {
@@ -164,10 +197,14 @@ class App {
164
197
  break;
165
198
  case 'pageup':
166
199
  this.scroll = (0, chat_1.clampScroll)(this.scroll - 10, this.messages, this.cols(), this.chatHeight());
200
+ this.followBottom = false;
167
201
  break;
168
- case 'pagedown':
202
+ case 'pagedown': {
169
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());
170
206
  break;
207
+ }
171
208
  case 'ctrl_u':
172
209
  this.input.deleteToStart();
173
210
  break;
@@ -183,6 +220,8 @@ class App {
183
220
  case 'ctrl_l':
184
221
  this.messages = [];
185
222
  this.scroll = 0;
223
+ this.followBottom = true;
224
+ this.activity = null;
186
225
  this.addSystem('Conversation cleared.');
187
226
  break;
188
227
  case 'ctrl_d':
@@ -204,11 +243,13 @@ class App {
204
243
  }
205
244
  async tick() {
206
245
  if (this.pendingPrompt) {
246
+ this.markDirty();
207
247
  const p = this.pendingPrompt;
208
248
  this.pendingPrompt = '';
209
249
  await this.submit(p);
210
250
  }
211
251
  if (this.planToExecute) {
252
+ this.markDirty();
212
253
  const plan = this.planToExecute;
213
254
  this.planToExecute = null;
214
255
  await this.executeWithPlan(plan);
@@ -222,6 +263,7 @@ class App {
222
263
  * Used by Ctrl+C and SIGINT so the TUI never needs to be killed.
223
264
  */
224
265
  requestExit() {
266
+ this.markDirty();
225
267
  if (this.running) {
226
268
  this.abortController?.abort();
227
269
  this.running = false;
@@ -229,13 +271,23 @@ class App {
229
271
  this.exitRequested = true;
230
272
  }
231
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
+ }
232
282
  async submit(text) {
283
+ this.markDirty();
233
284
  if (text.startsWith('/')) {
234
285
  await this.handleCommand(text);
235
286
  return;
236
287
  }
237
288
  this.messages.push({ kind: 'user', content: text });
238
- this.scroll = 0;
289
+ this.activity = null;
290
+ this.scrollToLatest();
239
291
  this.running = true;
240
292
  this.status = 'Thinking';
241
293
  this.statusColor = (0, theme_1.statusColor)('Thinking');
@@ -284,15 +336,19 @@ class App {
284
336
  }) - 1;
285
337
  this.agent.setCallbacks({
286
338
  onDelta: (delta) => {
339
+ this.markDirty();
287
340
  const m = this.messages[msgIndex];
288
341
  if (m)
289
342
  m.content += delta;
343
+ this.scrollToLatest();
290
344
  },
291
345
  onStatus: (status) => {
346
+ this.markDirty();
292
347
  this.status = status;
293
348
  this.statusColor = (0, theme_1.statusColor)(status);
294
349
  },
295
350
  onToolCall: (tool, args) => {
351
+ this.markDirty();
296
352
  let preview = '';
297
353
  try {
298
354
  const parsed = JSON.parse(args);
@@ -303,17 +359,17 @@ class App {
303
359
  catch {
304
360
  preview = args.slice(0, 80);
305
361
  }
306
- this.messages.push({ kind: 'tool', tool, content: `→ ${preview}` });
362
+ // Show tool activity in the compact strip, not the chat transcript.
363
+ this.activity = { tool, detail: preview };
307
364
  },
308
365
  onToolResult: (tool, result) => {
309
- const last = this.messages[this.messages.length - 1];
310
- if (last && last.kind === 'tool' && last.tool === tool) {
311
- last.content = result.split('\n').slice(0, 12).join('\n');
312
- if (result.split('\n').length > 12)
313
- last.content += '\n…';
366
+ this.markDirty();
367
+ if (this.activity && this.activity.tool === tool) {
368
+ this.activity = { ...this.activity, result: result.split('\n').slice(0, 8).join('\n') };
314
369
  }
315
370
  },
316
371
  onError: (err) => {
372
+ this.markDirty();
317
373
  const m = this.messages[msgIndex];
318
374
  if (m) {
319
375
  m.kind = 'error';
@@ -322,18 +378,23 @@ class App {
322
378
  }
323
379
  },
324
380
  onFinal: (result) => {
381
+ this.markDirty();
325
382
  const m = this.messages[msgIndex];
326
383
  if (m) {
327
384
  m.content = result.content || m.content;
328
385
  m.streaming = false;
329
386
  }
387
+ this.scrollToLatest();
330
388
  },
331
389
  });
390
+ let stoppedRun = false;
332
391
  try {
333
- await this.agent.run(task, this.abortController?.signal);
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;
334
394
  }
335
395
  catch (err) {
336
396
  if (err.message === 'aborted') {
397
+ stoppedRun = true;
337
398
  const m = this.messages[msgIndex];
338
399
  if (m) {
339
400
  m.content += '\n\n⏹ (stopped by user)';
@@ -353,8 +414,10 @@ class App {
353
414
  const m = this.messages[msgIndex];
354
415
  if (m) {
355
416
  m.streaming = false;
356
- if (!m.content.trim())
357
- m.content = '(no response)';
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
+ }
358
421
  }
359
422
  this.running = false;
360
423
  this.runStart = null;
@@ -386,6 +449,7 @@ class App {
386
449
  }));
387
450
  }
388
451
  async executeWithPlan(plan) {
452
+ this.markDirty();
389
453
  this.running = true;
390
454
  this.status = 'Executing plan';
391
455
  this.statusColor = terminal_1.ANSI.yellow;
@@ -429,6 +493,8 @@ class App {
429
493
  case '/clear':
430
494
  this.messages = [];
431
495
  this.scroll = 0;
496
+ this.followBottom = true;
497
+ this.activity = null;
432
498
  this.addSystem('Conversation cleared.');
433
499
  break;
434
500
  case '/continue':
@@ -478,6 +544,21 @@ class App {
478
544
  this.addSystem(`Current session: ${this.sessionName || '(none)'}`);
479
545
  }
480
546
  break;
547
+ case '/connect':
548
+ // Wizard for a custom OpenAI-compatible API: base URL + token
549
+ this.agent.config.provider = 'custom';
550
+ this.agent.rebuildProvider();
551
+ (0, config_1.saveConfig)(this.agent.config);
552
+ this.addSystem('Custom provider — masukkan base URL API (mis. https://…/v1), lalu token.');
553
+ this.modal = { type: 'custom-url', value: this.agent.config.apiUrl || '' };
554
+ break;
555
+ case '/agent':
556
+ this.agent.setMode(this.agent.config.permissionMode === 'yolo' ? 'ask' : 'yolo');
557
+ (0, config_1.saveConfig)(this.agent.config);
558
+ this.addSystem(this.agent.config.permissionMode === 'yolo'
559
+ ? 'Agent mode: YOLO — prompt permission untuk aksi ask-level dilewati.'
560
+ : 'Agent mode: ask — prompt permission aktif.');
561
+ break;
481
562
  default:
482
563
  this.addSystem(`Unknown command: ${name} (type /help for commands)`);
483
564
  break;
@@ -512,9 +593,12 @@ class App {
512
593
  }
513
594
  else if (ev.name === 'pageup') {
514
595
  this.scroll = (0, chat_1.clampScroll)(this.scroll - 10, this.messages, this.cols(), this.chatHeight());
596
+ this.followBottom = false;
515
597
  }
516
598
  else if (ev.name === 'pagedown') {
517
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());
518
602
  }
519
603
  break;
520
604
  case 'provider':
@@ -625,14 +709,17 @@ class App {
625
709
  case 'apikey':
626
710
  this.handleTextInput(ev, (value) => {
627
711
  (0, credentials_1.setApiKey)(modal.provider, value);
712
+ this.agent.rebuildProvider();
628
713
  this.addSystem(`API key stored for ${modal.provider} (not printed).`);
629
714
  this.modal = { type: 'none' };
630
715
  });
631
716
  break;
632
717
  case 'custom-url':
633
718
  this.handleTextInput(ev, (value) => {
634
- this.agent.config.apiUrl = value;
719
+ this.agent.config.apiUrl = value.trim();
635
720
  (0, config_1.saveConfig)(this.agent.config);
721
+ // Rebuild so the live provider picks up the new base URL
722
+ this.agent.rebuildProvider();
636
723
  this.addSystem('Custom API URL set.');
637
724
  if (!(0, credentials_1.hasApiKey)('custom')) {
638
725
  this.modal = { type: 'custom-key', value: '' };
@@ -644,7 +731,9 @@ class App {
644
731
  break;
645
732
  case 'custom-key':
646
733
  this.handleTextInput(ev, (value) => {
647
- (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();
648
737
  this.addSystem('Custom API key stored (not printed).');
649
738
  this.modal = { type: 'none' };
650
739
  });
@@ -689,10 +778,13 @@ class App {
689
778
  }
690
779
  // ─── Message helpers ──────────────────────────────────────────
691
780
  addSystem(text) {
781
+ this.markDirty();
692
782
  this.messages.push({ kind: 'system', content: text });
783
+ this.scrollToLatest();
693
784
  }
694
785
  /** Show a permission modal (called by the permission callback). */
695
786
  setPermissionModal(req) {
787
+ this.markDirty();
696
788
  this.modal = {
697
789
  type: 'permission',
698
790
  req,
@@ -706,16 +798,35 @@ class App {
706
798
  this.permissionResolver = fn;
707
799
  }
708
800
  addTool(tool, content) {
801
+ this.markDirty();
709
802
  this.messages.push({ kind: 'tool', tool, content });
803
+ this.scrollToLatest();
710
804
  }
711
805
  // ─── Rendering ────────────────────────────────────────────────
712
806
  cols() {
713
807
  return (0, terminal_1.getTerminalSize)().cols;
714
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
+ }
715
819
  chatHeight() {
716
- return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 6);
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());
717
823
  }
718
- render() {
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() {
719
830
  const { rows, cols } = (0, terminal_1.getTerminalSize)();
720
831
  const info = {
721
832
  status: this.status,
@@ -729,48 +840,77 @@ class App {
729
840
  running: this.running,
730
841
  elapsed: this.running && this.runStart !== null ? Math.floor((Date.now() - this.runStart) / 1000) : undefined,
731
842
  };
732
- const parts = [];
733
- // Row 1-2: header + project line
734
- parts.push((0, terminal_1.cursorTo)(1, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderHeader)(cols, info));
735
- parts.push((0, terminal_1.cursorTo)(2, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderProjectBar)(cols, info));
736
- // Row 3: separator
737
- 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);
738
- // Chat area (rows 4..) — Freebuff-style message blocks (welcome screen when empty)
739
843
  const chatH = this.chatHeight();
740
844
  const all = this.messages.length === 0 ? (0, chat_1.renderWelcome)(cols, chatH, this.agent.cwd) : (0, chat_1.renderMessages)(this.messages, cols);
741
845
  const maxScroll = Math.max(0, all.length - chatH);
742
846
  this.scroll = Math.min(this.scroll, maxScroll);
743
847
  const visible = all.slice(this.scroll, this.scroll + chatH);
744
848
  const chatTop = 4;
745
- for (let i = 0; i < chatH; i++) {
746
- const content = visible[i] !== undefined ? visible[i] : '';
747
- parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + content);
748
- }
749
- // Separator between chat and input
750
849
  const sepRow = chatTop + chatH;
751
- parts.push((0, terminal_1.cursorTo)(sepRow, 1) + terminal_1.ANSI.clearLineEnd + theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset);
752
- // Input row
753
- const inputRow = sepRow + 1;
754
- const rendered = this.input.render(cols);
755
- parts.push((0, terminal_1.cursorTo)(inputRow, 1) + terminal_1.ANSI.clearLineEnd + rendered.line);
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);
756
857
  const cursorCol = rendered.cursorCol + 1;
757
- // Status bar row
758
- const statusRow = inputRow + 1;
759
- parts.push((0, terminal_1.cursorTo)(statusRow, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderStatusBar)(cols, info));
760
- // Modal overlay
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
+ }
761
904
  if (this.modal.type !== 'none') {
762
905
  const overlay = this.renderModal(rows, cols);
763
906
  for (const l of overlay.lines)
764
907
  parts.push(l);
908
+ parts.push(terminal_1.ANSI.hideCursor);
765
909
  }
766
- // Cursor placement
767
- if (this.modal.type === 'none') {
910
+ else {
768
911
  parts.push((0, terminal_1.cursorTo)(inputRow, cursorCol));
769
912
  parts.push(terminal_1.ANSI.showCursor);
770
913
  }
771
- else {
772
- parts.push(terminal_1.ANSI.hideCursor);
773
- }
774
914
  return parts.join('');
775
915
  }
776
916
  renderModal(rows, cols) {
@@ -799,11 +939,12 @@ class App {
799
939
  ' PgUp/Dn scroll chat',
800
940
  ' ? this help',
801
941
  ' Ctrl+C stop agent / quit',
942
+ ' Ctrl+D stop + quit (mobile)',
802
943
  ' Ctrl+L clear conversation',
803
944
  '',
804
- 'Commands: /setup /model /provider /session',
805
- '/settings /status /clear /continue /diff',
806
- '/git /compact /yolo /exit',
945
+ 'Commands: /connect /setup /model /provider',
946
+ '/session /settings /status /clear /continue',
947
+ '/diff /git /compact /agent /exit',
807
948
  ],
808
949
  }, rows, cols);
809
950
  case 'commands':
@@ -811,7 +952,8 @@ class App {
811
952
  title: 'Commands',
812
953
  body: [
813
954
  '/help show help',
814
- '/setup setup provider (base URL + token)',
955
+ '/connect set custom API base URL + token',
956
+ '/setup setup provider',
815
957
  '/model select model',
816
958
  '/provider select provider',
817
959
  '/session manage sessions',
@@ -822,7 +964,7 @@ class App {
822
964
  '/diff show diff',
823
965
  '/git git status',
824
966
  '/compact compact context',
825
- '/yolo toggle permission mode',
967
+ '/agent toggle permission mode (ask/yolo)',
826
968
  '/exit quit',
827
969
  ],
828
970
  }, 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: plain foreground text (Freebuff style no label).
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 cursor, history, and hints.
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
- /** Render the input line, returns the cursor column (0-based within line). */
100
- render(width) {
101
- const prefix = `${theme_1.THEME.accent}❯${terminal_1.ANSI.reset} `;
102
- const prefixWidth = 2; // + space (ANSI codes are invisible)
103
- const available = Math.max(1, width - prefixWidth - 1);
104
- let display = this.text;
105
- let cursorCol = prefixWidth + this.cursor;
106
- if (this.text.length > available) {
107
- // Scroll window around the cursor
108
- let start = this.cursor - Math.floor(available / 2);
109
- start = Math.max(0, Math.min(start, this.text.length - available));
110
- display = this.text.slice(start, start + available);
111
- // Visible column: prefix width (2) + offset inside the window.
112
- // (prefix.length is the raw ANSI byte length — must not be used here.)
113
- cursorCol = prefixWidth + (this.cursor - start);
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
- let suffix = '';
116
- if (!this.text && this.hint) {
117
- suffix = terminal_1.ANSI.dim + this.hint + terminal_1.ANSI.reset;
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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "VectorHead (agen-vektor) — AI Coding Agent CLI/TUI for Linux & Termux. Multi-provider, tool calling, session, permission system.",
5
5
  "type": "commonjs",
6
6
  "bin": {