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 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,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
- 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(''));
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.scroll = 0;
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
- 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 };
334
364
  },
335
365
  onToolResult: (tool, result) => {
336
366
  this.markDirty();
337
- const last = this.messages[this.messages.length - 1];
338
- if (last && last.kind === 'tool' && last.tool === tool) {
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.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;
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
- if (!m.content.trim())
387
- 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
+ }
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
- 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());
766
823
  }
767
- 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() {
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
- 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);
801
- // Input row
802
- const inputRow = sepRow + 1;
803
- const rendered = this.input.render(cols);
804
- 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);
805
857
  const cursorCol = rendered.cursorCol + 1;
806
- // Status bar row
807
- const statusRow = inputRow + 1;
808
- parts.push((0, terminal_1.cursorTo)(statusRow, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderStatusBar)(cols, info));
809
- // 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
+ }
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
- // Cursor placement
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: 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.7",
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": {