agen-vektor 0.3.9 → 0.3.10

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
@@ -13,7 +13,6 @@
13
13
 
14
14
  ```
15
15
  VectorHead ● Ready deepseek-ai/DeepSeek-V4-Flash
16
- ───────────────────────────────────────────────────────────────────────────────
17
16
  [09:41]
18
17
  Perbaiki error authentication pada project ini
19
18
 
@@ -56,13 +55,8 @@ Requirements: **Node.js 20+** and **npm** (Termux: `pkg install nodejs`).
56
55
 
57
56
  ### From source
58
57
 
59
- ```bash
60
- git clone https://github.com/clickmamaheti-prog/vector-agent.git
61
- cd vector-agent
62
- npm install
63
- npm run build
64
- npm link # makes `vector` available on PATH
65
- ```
58
+ Dari source: clone repo ini, lalu `npm install && npm run build && npm link`
59
+ (membuat `vector` tersedia di PATH).
66
60
 
67
61
  Then:
68
62
 
@@ -16,6 +16,7 @@ const loop_1 = require("./loop");
16
16
  const rules_1 = require("./rules");
17
17
  const skills_1 = require("./skills");
18
18
  const skills_tool_1 = require("../tools/skills-tool");
19
+ const background_1 = require("../tools/background");
19
20
  class Agent {
20
21
  config;
21
22
  provider;
@@ -49,6 +50,9 @@ class Agent {
49
50
  (0, apply_patch_1.createApplyPatchTool)(),
50
51
  // Hermes-style progressive-disclosure skills (list_skills / read_skill).
51
52
  ...(0, skills_tool_1.createSkillTools)(),
53
+ // Parallelism: non-blocking background shell jobs (run_in_background /
54
+ // output_from_background / stop_background_job).
55
+ ...(0, background_1.createBackgroundTools)(),
52
56
  ]);
53
57
  }
54
58
  async run(request, signal) {
@@ -190,25 +190,68 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
190
190
  toolCalls: result.toolCalls,
191
191
  });
192
192
  if (hasToolCalls) {
193
+ // Parallel execution for READ-ONLY tools (Freebuff-style: when the
194
+ // model requests several independent reads at once — read_file,
195
+ // search, glob — run them concurrently instead of one-by-one).
196
+ // Anything that mutates, prompts for permission, or starts background
197
+ // work stays sequential so side effects keep their order.
198
+ const PARALLEL_TOOLS = new Set([
199
+ 'read_file',
200
+ 'list_directory',
201
+ 'search_files',
202
+ 'glob',
203
+ 'read_subtree',
204
+ 'list_skills',
205
+ 'output_from_background',
206
+ ]);
207
+ const runOne = async (tc) => {
208
+ callbacks.onToolCall?.(tc.name, tc.arguments, tc.id);
209
+ const toolResult = await tools.execute(tc.name, tc.arguments, {
210
+ cwd,
211
+ permissions,
212
+ onActivity: callbacks.onActivity,
213
+ maxOutput: 30_000,
214
+ });
215
+ return { tc, toolResult };
216
+ };
193
217
  try {
218
+ const batches = [];
219
+ let current = [];
194
220
  for (const tc of result.toolCalls) {
221
+ if (current.length > 0 && !PARALLEL_TOOLS.has(tc.name)) {
222
+ batches.push(current);
223
+ current = [];
224
+ }
225
+ current.push(tc);
226
+ if (!PARALLEL_TOOLS.has(tc.name)) {
227
+ batches.push(current);
228
+ current = [];
229
+ }
230
+ }
231
+ if (current.length > 0)
232
+ batches.push(current);
233
+ for (const batch of batches) {
195
234
  checkAbort();
196
- toolCalls++;
197
- callbacks.onToolCall?.(tc.name, tc.arguments);
198
- callbacks.onStatus?.(`Running ${tc.name}`);
199
- const toolResult = await tools.execute(tc.name, tc.arguments, {
200
- cwd,
201
- permissions,
202
- onActivity: callbacks.onActivity,
203
- maxOutput: 30_000,
204
- });
205
- callbacks.onToolResult?.(tc.name, toolResult.output, toolResult.data);
206
- messages.push({
207
- role: 'tool',
208
- toolCallId: tc.id,
209
- name: tc.name,
210
- content: toolResult.output.slice(0, 40_000),
211
- });
235
+ const parallel = batch.length > 1 && batch.every((tc) => PARALLEL_TOOLS.has(tc.name));
236
+ toolCalls += batch.length;
237
+ if (!parallel) {
238
+ callbacks.onStatus?.(`Running ${batch[0].name}`);
239
+ }
240
+ else {
241
+ callbacks.onStatus?.(`Running ${batch.length}× ${batch[0].name} (parallel)`);
242
+ }
243
+ const results = parallel
244
+ ? await Promise.all(batch.map(runOne))
245
+ : [await runOne(batch[0])];
246
+ for (const { tc, toolResult } of results) {
247
+ callbacks.onToolResult?.(tc.name, toolResult.output, toolResult.data, tc.id);
248
+ messages.push({
249
+ role: 'tool',
250
+ toolCallId: tc.id,
251
+ name: tc.name,
252
+ content: toolResult.output.slice(0, 40_000),
253
+ });
254
+ }
212
255
  }
213
256
  }
214
257
  catch {
package/dist/cli/index.js CHANGED
@@ -185,7 +185,11 @@ async function runNonInteractive(opts) {
185
185
  // answer only arrives in result.content (never via onDelta).
186
186
  const reply = result.content.trim();
187
187
  if (reply) {
188
- process.stdout.write(reply + '\n');
188
+ // Freebuff appendInterruptionNotice: a user-interrupted run keeps its
189
+ // partial reply with the marker appended — non-interactive parity.
190
+ process.stdout.write(result.aborted && !reply.endsWith('[response interrupted]')
191
+ ? `${reply}\n\n[response interrupted]\n`
192
+ : reply + '\n');
189
193
  }
190
194
  else if (result.stopped || result.aborted) {
191
195
  console.log(`${terminal_1.ANSI.yellow}⏹ Stopped — no reply was produced.${terminal_1.ANSI.reset}`);
@@ -10,6 +10,7 @@ exports.customProviderPickerOptions = customProviderPickerOptions;
10
10
  exports.providerApiKey = providerApiKey;
11
11
  exports.isCustomProvider = isCustomProvider;
12
12
  exports.providerHeaders = providerHeaders;
13
+ exports.providerBody = providerBody;
13
14
  exports.providerConfigured = providerConfigured;
14
15
  const credentials_1 = require("./credentials");
15
16
  /** Sentinel provider id meaning "add a brand new custom provider". */
@@ -128,6 +129,22 @@ function providerHeaders(config, id) {
128
129
  }
129
130
  return Object.keys(out).length > 0 ? out : undefined;
130
131
  }
132
+ /**
133
+ * Resolve the extra request-body fields for a provider id (OpenCode-style
134
+ * per-provider options). Returns a shallow copy so callers cannot mutate the
135
+ * config; undefined when the definition carries no body overrides.
136
+ */
137
+ function providerBody(config, id) {
138
+ const raw = customProviderDef(config, id)?.options?.body;
139
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
140
+ return undefined;
141
+ const out = {};
142
+ for (const [k, v] of Object.entries(raw)) {
143
+ if (v !== undefined)
144
+ out[k] = v;
145
+ }
146
+ return Object.keys(out).length > 0 ? out : undefined;
147
+ }
131
148
  /**
132
149
  * Is a provider fully usable right now? Built-ins need a key (env or stored);
133
150
  * a legacy custom needs its apiUrl + key; a named custom needs a base URL in
@@ -19,6 +19,7 @@ class CustomProvider extends openai_compat_1.OpenAICompatProvider {
19
19
  defaultModel: cfg.model || 'default',
20
20
  contextWindow: 128000,
21
21
  headers: cfg.headers,
22
+ body: cfg.body,
22
23
  });
23
24
  this.protocol = cfg.protocol;
24
25
  }
@@ -72,6 +72,7 @@ function createProvider(config) {
72
72
  model: config.model,
73
73
  protocol: config.protocol || 'openai',
74
74
  headers: (0, providers_1.providerHeaders)(config, pid),
75
+ body: (0, providers_1.providerBody)(config, pid),
75
76
  });
76
77
  }
77
78
  }
@@ -83,6 +83,8 @@ class OpenAICompatProvider {
83
83
  defaultModel;
84
84
  contextWindow;
85
85
  extraHeaders;
86
+ /** Provider-level request-body defaults (shallow-copied at construction). */
87
+ extraBody;
86
88
  constructor(opts) {
87
89
  this.id = opts.id;
88
90
  this.name = opts.name;
@@ -91,6 +93,7 @@ class OpenAICompatProvider {
91
93
  this.defaultModel = opts.defaultModel || 'gpt-4o-mini';
92
94
  this.contextWindow = opts.contextWindow || 128000;
93
95
  this.extraHeaders = opts.headers || {};
96
+ this.extraBody = { ...(opts.body || {}) };
94
97
  }
95
98
  capabilities() {
96
99
  return {
@@ -136,6 +139,12 @@ class OpenAICompatProvider {
136
139
  if (params.tools && params.tools.length > 0) {
137
140
  body.tools = toToolDefs(params.tools);
138
141
  }
142
+ // Provider-level body defaults FIRST (options.body — e.g. BitDeer's
143
+ // reasoning_effort), then the call-level fields on top: an explicit
144
+ // per-call temperature/max_tokens overrides the provider default.
145
+ for (const [k, v] of Object.entries(this.extraBody)) {
146
+ body[k] = v;
147
+ }
139
148
  if (params.temperature !== undefined)
140
149
  body.temperature = params.temperature;
141
150
  if (params.maxTokens)
@@ -15,7 +15,7 @@ class OpenRouterProvider extends openai_compat_1.OpenAICompatProvider {
15
15
  }
16
16
  headers() {
17
17
  const h = super.headers();
18
- h['HTTP-Referer'] = 'https://github.com/clickmamaheti-prog/vector-agent';
18
+ h['HTTP-Referer'] = 'https://www.npmjs.com/package/agen-vektor';
19
19
  h['X-Title'] = 'VectorHead AI Coding Agent';
20
20
  return h;
21
21
  }
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resetBackgroundJobs = resetBackgroundJobs;
4
+ exports.createBackgroundTools = createBackgroundTools;
5
+ /**
6
+ * Background jobs — long-running shell commands that do NOT block the agent
7
+ * loop (Freebuff/Codebuff-style parallelism: start a dev server or a long
8
+ * test suite, keep working, poll the output, stop it when done).
9
+ *
10
+ * run_in_background → spawn now, return a job id immediately
11
+ * output_from_background → status + NEW output since the last poll
12
+ * stop_background_job → SIGKILL the job
13
+ *
14
+ * The command goes through the SAME permission policy as the shell tool
15
+ * (classifyCommand → decide). Jobs live for the lifetime of the process;
16
+ * output buffers are capped (tail is kept).
17
+ */
18
+ const node_child_process_1 = require("node:child_process");
19
+ const MAX_LOG = 1_000_000; // keep the TAIL of huge outputs
20
+ const jobs = new Map();
21
+ let nextId = 1;
22
+ /** Test helper: kill everything and reset ids. */
23
+ function resetBackgroundJobs() {
24
+ for (const job of jobs.values()) {
25
+ if (job.timeoutTimer)
26
+ clearTimeout(job.timeoutTimer);
27
+ killTree(job);
28
+ }
29
+ jobs.clear();
30
+ nextId = 1;
31
+ }
32
+ /** Kill the job's whole process tree — the shell AND its grandchildren. */
33
+ function killTree(job) {
34
+ const pid = job.child?.pid;
35
+ if (pid != null) {
36
+ try {
37
+ process.kill(-pid, 'SIGKILL'); // detached spawn ⇒ own process group
38
+ }
39
+ catch {
40
+ /* group already gone */
41
+ }
42
+ }
43
+ try {
44
+ job.child?.kill('SIGKILL');
45
+ }
46
+ catch {
47
+ /* already gone */
48
+ }
49
+ }
50
+ function trim(job) {
51
+ if (job.log.length > MAX_LOG) {
52
+ // keep the tail; shift the cursor so nothing unread is lost
53
+ const cut = job.log.length - MAX_LOG;
54
+ job.log = job.log.slice(cut);
55
+ job.readCursor = Math.max(0, job.readCursor - cut);
56
+ }
57
+ }
58
+ function describe(job) {
59
+ const secs = Math.round(((job.endedAt ?? Date.now()) - job.startedAt) / 1000);
60
+ const status = job.exitCode === null ? `RUNNING (${secs}s)` : job.killed ? `STOPPED after ${secs}s` : `EXITED ${job.exitCode} after ${secs}s`;
61
+ const fresh = job.log.slice(job.readCursor);
62
+ job.readCursor = job.log.length;
63
+ const tail = fresh.length > 20_000 ? `... [${fresh.length - 20_000} chars hidden] ${fresh.slice(-20_000)}` : fresh;
64
+ return `[${job.id}] ${status} — $ ${job.command}\n${tail || '(no output yet)'}`;
65
+ }
66
+ async function startJob(command, cwd, ctx, timeoutMs) {
67
+ const level = ctx.permissions.classifyCommand(command);
68
+ if (level === 'blocked') {
69
+ return {
70
+ output: `BLOCKED: command is not permitted by VectorHead security policy:\n${command}`,
71
+ summary: 'blocked by policy',
72
+ };
73
+ }
74
+ const allowed = await ctx.permissions.decide({ tool: 'shell', summary: `[background] ${command}`, level });
75
+ if (!allowed) {
76
+ return { output: `Permission denied by user:\n${command}`, summary: 'denied by user' };
77
+ }
78
+ const id = `bg${nextId++}`;
79
+ const child = (0, node_child_process_1.spawn)(command, {
80
+ cwd,
81
+ shell: '/bin/sh',
82
+ env: { ...process.env },
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ detached: true, // own process group → killTree can take down grandchildren
85
+ });
86
+ const job = {
87
+ id,
88
+ command,
89
+ child,
90
+ log: '',
91
+ readCursor: 0,
92
+ startedAt: Date.now(),
93
+ endedAt: null,
94
+ exitCode: null,
95
+ killed: false,
96
+ timeoutTimer: null,
97
+ };
98
+ jobs.set(id, job);
99
+ child.stdout?.on('data', (d) => {
100
+ job.log += d.toString();
101
+ trim(job);
102
+ });
103
+ child.stderr?.on('data', (d) => {
104
+ job.log += d.toString();
105
+ trim(job);
106
+ });
107
+ // 'exit' = the shell itself died; 'close' = shell AND stdio pipes closed.
108
+ // A grandchild (e.g. `sleep`) can hold the pipes open long after the shell
109
+ // is SIGKILLed, so the final status is recorded on 'exit' — not 'close'.
110
+ child.on('exit', (code) => {
111
+ if (job.exitCode === null)
112
+ job.exitCode = code ?? (job.killed ? -1 : 0);
113
+ if (job.endedAt === null)
114
+ job.endedAt = Date.now();
115
+ });
116
+ child.on('close', () => {
117
+ if (job.endedAt === null)
118
+ job.endedAt = Date.now();
119
+ job.child = null;
120
+ if (job.timeoutTimer)
121
+ clearTimeout(job.timeoutTimer);
122
+ });
123
+ child.on('error', (e) => {
124
+ job.log += `\n[spawn error] ${e.message}`;
125
+ trim(job);
126
+ job.exitCode = -1;
127
+ job.endedAt = Date.now();
128
+ job.child = null;
129
+ });
130
+ if (timeoutMs > 0) {
131
+ job.timeoutTimer = setTimeout(() => {
132
+ killTree(job);
133
+ }, timeoutMs);
134
+ }
135
+ return {
136
+ output: `Started background job ${id}: $ ${command}\nDo NOT wait for it — keep working. Check progress with output_from_background (id: "${id}", or omit for all jobs); stop it with stop_background_job.`,
137
+ summary: `[bg] started ${id}`,
138
+ data: { jobId: id },
139
+ };
140
+ }
141
+ function createBackgroundTools() {
142
+ return [
143
+ {
144
+ definition: {
145
+ name: 'run_in_background',
146
+ description: 'Start a shell command in the BACKGROUND and return immediately (non-blocking) — use for dev servers, watchers, long test suites, or anything that runs a while. You get a job id (e.g. bg1); keep working and poll progress with output_from_background, stop with stop_background_job. Do NOT use for quick commands — use shell instead.',
147
+ parameters: {
148
+ type: 'object',
149
+ properties: {
150
+ command: { type: 'string', description: 'The shell command to run in the background' },
151
+ timeout_ms: { type: 'number', description: 'Optional kill-after timeout in ms (0 or omitted = run until stopped)' },
152
+ },
153
+ required: ['command'],
154
+ },
155
+ },
156
+ async execute(args, ctx) {
157
+ const command = String(args.command || '').trim();
158
+ if (!command)
159
+ return { output: 'ERROR: empty command' };
160
+ ctx.onActivity?.('run_in_background', `[bg] $ ${command}`);
161
+ return startJob(command, ctx.cwd, ctx, Number(args.timeout_ms) || 0);
162
+ },
163
+ },
164
+ {
165
+ definition: {
166
+ name: 'output_from_background',
167
+ description: 'Read the status and NEW output of a background job started with run_in_background (everything since your last poll). Omit "id" to summarize ALL jobs. A RUNNING job may have partial output; EXITED <code> is finished.',
168
+ parameters: {
169
+ type: 'object',
170
+ properties: {
171
+ id: { type: 'string', description: 'Job id from run_in_background (e.g. "bg1"); omit for all jobs' },
172
+ },
173
+ required: [],
174
+ },
175
+ },
176
+ async execute(args, ctx) {
177
+ ctx.onActivity?.('output_from_background', 'polling background jobs');
178
+ if (jobs.size === 0)
179
+ return { output: 'No background jobs have been started.' };
180
+ const id = String(args.id || '').trim();
181
+ if (id) {
182
+ const job = jobs.get(id);
183
+ if (!job) {
184
+ return { output: `ERROR: unknown background job "${id}". Running/known jobs: ${[...jobs.keys()].join(', ') || '(none)'}` };
185
+ }
186
+ return { output: describe(job), summary: `[bg] poll ${id}`, data: { jobId: id, exitCode: job.exitCode } };
187
+ }
188
+ return { output: [...jobs.values()].map((j) => describe(j)).join('\n\n'), summary: `[bg] poll all (${jobs.size})` };
189
+ },
190
+ },
191
+ {
192
+ definition: {
193
+ name: 'stop_background_job',
194
+ description: 'Stop (SIGKILL) a background job started with run_in_background. Use when the server/test/watch is no longer needed or is stuck.',
195
+ parameters: {
196
+ type: 'object',
197
+ properties: {
198
+ id: { type: 'string', description: 'Job id from run_in_background (e.g. "bg1")' },
199
+ },
200
+ required: ['id'],
201
+ },
202
+ },
203
+ async execute(args, ctx) {
204
+ const id = String(args.id || '').trim();
205
+ const job = jobs.get(id);
206
+ if (!job)
207
+ return { output: `ERROR: unknown background job "${id}"` };
208
+ ctx.onActivity?.('stop_background_job', `stopping ${id}`);
209
+ job.killed = true;
210
+ if (job.timeoutTimer)
211
+ clearTimeout(job.timeoutTimer);
212
+ killTree(job);
213
+ return { output: `Stop signal sent to ${id} ($ ${job.command}). Poll output_from_background for its final output.`, summary: `[bg] stopped ${id}` };
214
+ },
215
+ },
216
+ ];
217
+ }
package/dist/tui/app.js CHANGED
@@ -39,7 +39,6 @@ exports.App = void 0;
39
39
  *
40
40
  * Layout (top to bottom):
41
41
  * header VectorHead ● status model
42
- * ───────────────────────────────────────────────
43
42
  * chat scrollable message area
44
43
  * ───────────────────────────────────────────────
45
44
  * suggestions slash-command popup while typing /… (Freebuff-style)
@@ -157,6 +156,11 @@ class App {
157
156
  workingShinePos = 0;
158
157
  /** True once a terminal reply/error/stopped line was pushed this run. */
159
158
  replyHandled = false;
159
+ /** True once THIS run was aborted by the user (Freebuff wasAbortedByUser,
160
+ * cli/src/hooks/helpers/send-message.ts): the abort handler owns the
161
+ * interrupted UI — the completion path must not finalize a reply again
162
+ * on top of the interruption notice. */
163
+ wasAbortedByUser = false;
160
164
  /**
161
165
  * Header status dot: bright↔dim pulse while idle/ready (blinking green),
162
166
  * solid while working, red when API tokens/quota are exhausted.
@@ -192,8 +196,10 @@ class App {
192
196
  /**
193
197
  * Freebuff-style interactive extras from the run: follow-up prompts the
194
198
  * model suggested (suggest_followups) and the latest render_ui button.
195
- * Cleared at the START of every new run so a fresh task never inherits
196
- * suggestions from an older reply.
199
+ * Freebuff parity: suggestions PERSIST and remain clickable across turns
200
+ * — the batch only stops being "active" when a NEWER suggest_followups
201
+ * arrives (which collapses the old one to "▸ Previously suggested
202
+ * followups").
197
203
  */
198
204
  activeFollowups = [];
199
205
  activeUiButton = null;
@@ -290,10 +296,15 @@ class App {
290
296
  this.ctrlCArmed = false;
291
297
  }, App.CTRL_C_WINDOW_MS);
292
298
  if (this.running) {
299
+ this.wasAbortedByUser = true;
293
300
  this.abortController?.abort();
294
301
  this.status = 'Stopping…';
295
302
  this.statusColor = terminal_1.ANSI.yellow;
296
303
  this.running = false;
304
+ // Freebuff appendInterruptionNotice: the reply gets the
305
+ // "[response interrupted]" marker (appended to the partial reply,
306
+ // or standalone when nothing streamed) instead of a status line.
307
+ this.applyInterruptionNotice();
297
308
  this.addSystem('⏹ Interrupted — press Ctrl+C once more within 2s to exit.');
298
309
  }
299
310
  else {
@@ -312,10 +323,12 @@ class App {
312
323
  // While running only Escape stops the agent (Ctrl+C is intercepted
313
324
  // above for the double-press exit); Ctrl+D stops and quits at once.
314
325
  if (ev.name === 'escape') {
326
+ this.wasAbortedByUser = true;
315
327
  this.abortController?.abort();
316
328
  this.status = 'Stopping…';
317
329
  this.statusColor = terminal_1.ANSI.yellow;
318
330
  this.running = false;
331
+ this.applyInterruptionNotice();
319
332
  this.addSystem('⏹ Stopped.');
320
333
  }
321
334
  else if (ev.name === 'ctrl_d') {
@@ -369,6 +382,22 @@ class App {
369
382
  this.markDirty();
370
383
  }
371
384
  }
385
+ else if (this.input.value.length === 0 && ev.char?.toLowerCase() === 'g') {
386
+ // Freebuff suggest-followups.tsx: past batches render the
387
+ // "▸ Previously suggested followups" toggle (▸/▾). Keyboard: 'g'
388
+ // with an empty input flips every past batch (usually at most
389
+ // one) between collapsed and expanded.
390
+ let toggled = false;
391
+ for (const m of this.messages) {
392
+ const g = (0, chat_1.followupsGroupOf)(m);
393
+ if (g?.past) {
394
+ g.expanded = !g.expanded;
395
+ toggled = true;
396
+ }
397
+ }
398
+ if (toggled)
399
+ this.markDirty();
400
+ }
372
401
  else if (ev.char) {
373
402
  this.input.insert(ev.char);
374
403
  this.resetSuggest();
@@ -487,6 +516,7 @@ class App {
487
516
  this.workingLine = null;
488
517
  this.lastToolMsg = null;
489
518
  this.replyBubble = null;
519
+ this.wasAbortedByUser = false;
490
520
  this.thinkingMsg = null;
491
521
  this.todoMsg = null;
492
522
  this.todoSteps = [];
@@ -583,8 +613,10 @@ class App {
583
613
  requestExit() {
584
614
  this.markDirty();
585
615
  if (this.running) {
616
+ this.wasAbortedByUser = true;
586
617
  this.abortController?.abort();
587
618
  this.running = false;
619
+ this.applyInterruptionNotice();
588
620
  }
589
621
  this.exitRequested = true;
590
622
  }
@@ -603,9 +635,11 @@ class App {
603
635
  await this.handleCommand(text);
604
636
  return;
605
637
  }
606
- // A new task resets the interactive extras (follow-ups / button) from
607
- // the previous run.
608
- this.activeFollowups = [];
638
+ // A new task resets the render_ui button, but NOT the follow-up
639
+ // suggestions: Freebuff keeps them "persist and remain clickable" —
640
+ // the digit keys still send them while the input is empty, and the
641
+ // batch only collapses to "▸ Previously suggested followups" when a
642
+ // NEWER suggest_followups tool call arrives.
609
643
  this.activeUiButton = null;
610
644
  this.messages.push({ kind: 'user', content: text, ts: Date.now() });
611
645
  this.activity = null;
@@ -1064,7 +1098,32 @@ class App {
1064
1098
  this.replyBubble = null;
1065
1099
  }
1066
1100
  }
1101
+ /** Freebuff marker text (message-block-helpers.ts). */
1102
+ static INTERRUPT_MARKER = '[response interrupted]';
1103
+ /** Freebuff interruption flow (cli/src/hooks/helpers/send-message.ts abort
1104
+ * listener + appendInterruptionNotice): freeze the partial reply and
1105
+ * append "\n\n[response interrupted]" to the SAME bubble — or create a
1106
+ * standalone marker bubble when nothing streamed yet. No completion
1107
+ * timestamp (it is not a real reply — no stray [HH:MM] footer). */
1108
+ applyInterruptionNotice() {
1109
+ if (this.replyHandled)
1110
+ return; // idempotent — never stack two markers
1111
+ this.replyHandled = true;
1112
+ const m = this.ensureReplyBubble();
1113
+ if (!m)
1114
+ return;
1115
+ m.streaming = false;
1116
+ m.content = m.content.trim()
1117
+ ? `${m.content}\n\n${App.INTERRUPT_MARKER}`
1118
+ : App.INTERRUPT_MARKER;
1119
+ m.ts = undefined;
1120
+ this.bubbleToEnd(m);
1121
+ this.scrollToLatest();
1122
+ }
1067
1123
  async runTask(text, planSteps) {
1124
+ // Per-run interrupt flag (Freebuff wasAbortedByUser): set by the key
1125
+ // handlers when they abort THIS run, consumed in the finally block.
1126
+ this.wasAbortedByUser = false;
1068
1127
  const task = planSteps
1069
1128
  ? `TASK: ${text}\n\nFollow this plan:\n${planSteps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
1070
1129
  : text;
@@ -1112,6 +1171,11 @@ class App {
1112
1171
  },
1113
1172
  onDelta: (delta) => {
1114
1173
  this.markDirty();
1174
+ // Aborted mid-stream: the interrupt notice froze the partial reply —
1175
+ // late deltas must not resurrect the streaming bubble after the
1176
+ // "[response interrupted]" marker (Freebuff parity).
1177
+ if (this.wasAbortedByUser)
1178
+ return;
1115
1179
  // First visible text: the thinking phase is over — collapse the live
1116
1180
  // thinking block to preview (default) and stop streaming it.
1117
1181
  if (this.thinkingMsg) {
@@ -1139,8 +1203,11 @@ class App {
1139
1203
  // working line never reads "Working — Thinking…".
1140
1204
  this.showWorking('Working');
1141
1205
  },
1142
- onToolCall: (tool, args) => {
1206
+ onToolCall: (tool, args, id) => {
1143
1207
  this.markDirty();
1208
+ // Aborted: no new tool lines may appear under the interrupt marker.
1209
+ if (this.wasAbortedByUser)
1210
+ return;
1144
1211
  // write_todos is rendered as the checklist itself (kind 'todo'), not
1145
1212
  // as a tool line — the model owns it via write_todos, chat never
1146
1213
  // gets one.
@@ -1224,13 +1291,30 @@ class App {
1224
1291
  // Hold the message object (see the edit_file branch above): indexes
1225
1292
  // shift when showWorking() splices the previous working line out.
1226
1293
  const msg = { kind: 'tool', tool, content: preview, ts: Date.now() };
1294
+ // Parallel tool calls (read-only batch): results arrive by
1295
+ // toolCallId, so remember which transcript line owns this call.
1296
+ if (id)
1297
+ msg.toolCallId = id;
1227
1298
  this.messages.push(msg);
1228
1299
  this.lastToolMsg = msg;
1229
1300
  this.showWorking(`Working — ${(0, chat_1.toolIcon)(tool)} ${(0, chat_1.toolLabel)(tool)}`);
1230
1301
  this.scrollToLatest();
1231
1302
  },
1232
- onToolResult: (tool, result, data) => {
1303
+ onToolResult: (tool, result, data, id) => {
1233
1304
  this.markDirty();
1305
+ // Aborted: results of in-flight tools are dropped (Freebuff clears
1306
+ // streaming agents in the abort listener) — the transcript stays as
1307
+ // it was at the moment of the interrupt.
1308
+ if (this.wasAbortedByUser)
1309
+ return;
1310
+ // Resolve the owning tool line: by toolCallId first (parallel calls
1311
+ // interleave), falling back to the last one (sequential unchanged).
1312
+ let owner = this.lastToolMsg;
1313
+ if (id) {
1314
+ const byId = [...this.messages].reverse().find((m) => m.kind === 'tool' && m.toolCallId === id);
1315
+ if (byId)
1316
+ owner = byId;
1317
+ }
1234
1318
  // write_todos: the model's own checklist state is the source of
1235
1319
  // truth — sync ☐/☑ from the returned data, never tick via the
1236
1320
  // fallback per-tool bump (that is for plans executed directly).
@@ -1246,7 +1330,7 @@ class App {
1246
1330
  // Edit cards: attach the unified diff computed from the old/new
1247
1331
  // file snapshots the tools return in `data` (Freebuff extractDiff
1248
1332
  // equivalent). Errors keep the header-only card.
1249
- const editMsg = this.lastToolMsg;
1333
+ const editMsg = owner;
1250
1334
  if (editMsg?.edit && (tool === 'edit_file' || tool === 'write_file')) {
1251
1335
  const d = (data ?? {});
1252
1336
  if (typeof d.oldContent === 'string' && typeof d.newContent === 'string') {
@@ -1262,7 +1346,7 @@ class App {
1262
1346
  // Shell cards: attach the output (last-3-lines tail renders under
1263
1347
  // the `$ command` header — Freebuff terminal-command-display). The
1264
1348
  // LLM-facing result keeps its full `exit N` + output form.
1265
- if (tool === 'shell' && this.lastToolMsg?.kind === 'tool' && this.lastToolMsg.shell) {
1349
+ if (tool === 'shell' && owner?.kind === 'tool' && owner.shell) {
1266
1350
  const d = (data ?? {});
1267
1351
  const parts = [];
1268
1352
  if (typeof d.stdout === 'string' && d.stdout.trim())
@@ -1270,8 +1354,8 @@ class App {
1270
1354
  if (typeof d.stderr === 'string' && d.stderr.trim())
1271
1355
  parts.push(`[stderr]\n${d.stderr.replace(/\r\n?/g, '\n').trimEnd()}`);
1272
1356
  const output = parts.join('\n').slice(0, 4_000);
1273
- this.lastToolMsg.shell.output = output || undefined;
1274
- this.lastToolMsg.shell.noOutput = !output;
1357
+ owner.shell.output = output || undefined;
1358
+ owner.shell.noOutput = !output;
1275
1359
  this.markDirty();
1276
1360
  return;
1277
1361
  }
@@ -1282,8 +1366,8 @@ class App {
1282
1366
  .slice(0, 3)
1283
1367
  .join('\n')
1284
1368
  .slice(0, 300);
1285
- if (lines.trim())
1286
- this.lastToolMsg.content += `\n${lines}`;
1369
+ if (lines.trim() && owner?.kind === 'tool')
1370
+ owner.content += `\n${lines}`;
1287
1371
  }
1288
1372
  // Freebuff-style interactive extras: suggest_followups hands back a
1289
1373
  // structured list of clickable prompts, render_ui a button widget.
@@ -1294,8 +1378,21 @@ class App {
1294
1378
  if (tool === 'suggest_followups' && data && Array.isArray(data.followups)) {
1295
1379
  const list = data.followups.slice(0, 3);
1296
1380
  this.activeFollowups = list;
1381
+ // Freebuff suggest-followups.tsx: only the LATEST batch is active
1382
+ // (isActive = latestFollowupToolCallId === toolCallId) — a newer
1383
+ // batch collapses every older one to the "▸ Previously suggested
1384
+ // followups" toggle. The batch hangs off ONE group object shared
1385
+ // by every row of this tool call.
1386
+ for (const m of this.messages) {
1387
+ const g = (0, chat_1.followupsGroupOf)(m);
1388
+ if (g && !g.past) {
1389
+ g.past = true;
1390
+ g.expanded = false;
1391
+ }
1392
+ }
1393
+ const group = { followups: list };
1297
1394
  for (const f of list) {
1298
- this.messages.push({ kind: 'followup', content: f.prompt, followup: f, ts: Date.now() });
1395
+ this.messages.push({ kind: 'followup', content: f.prompt, followup: f, followupsGroup: group, ts: Date.now() });
1299
1396
  }
1300
1397
  }
1301
1398
  if (tool === 'render_ui' && data && data.widget) {
@@ -1372,7 +1469,13 @@ class App {
1372
1469
  this.replyHandled = true;
1373
1470
  if (err.message === 'aborted') {
1374
1471
  stoppedRun = true;
1375
- this.messages.push({ kind: 'system', content: '⏹ (stopped by user)', ts: Date.now() });
1472
+ // User interrupt: the reply bubble already carries the Freebuff
1473
+ // "[response interrupted]" notice — no extra system line. A false
1474
+ // flag here means the provider threw AbortError on its own
1475
+ // (timeout): keep the legacy notice for that rare path.
1476
+ if (!this.wasAbortedByUser) {
1477
+ this.messages.push({ kind: 'system', content: '⏹ (stopped by user)', ts: Date.now() });
1478
+ }
1376
1479
  }
1377
1480
  else {
1378
1481
  this.messages.push({ kind: 'error', content: `⚠️ ${err.message}`, ts: Date.now() });
@@ -1388,6 +1491,12 @@ class App {
1388
1491
  this.markDirty();
1389
1492
  // A run stopped mid-reasoning: finalize (or drop) the thinking block.
1390
1493
  this.finalizeThinkingBlock();
1494
+ // Freebuff handleRunCompletion: when the USER aborted, the abort
1495
+ // handler already finalized the UI (partial reply + the
1496
+ // "[response interrupted]" notice) — don't finalize again on top.
1497
+ if (this.wasAbortedByUser) {
1498
+ this.replyHandled = true;
1499
+ }
1391
1500
  // Freebuff ordering: the reply bubble lands after all activity lines.
1392
1501
  if (!this.replyHandled) {
1393
1502
  const m = this.ensureReplyBubble();
@@ -1413,6 +1522,7 @@ class App {
1413
1522
  this.lastToolMsg = null;
1414
1523
  this.replyBubble = null;
1415
1524
  this.replyHandled = false;
1525
+ this.wasAbortedByUser = false;
1416
1526
  this.running = false;
1417
1527
  this.runStart = null;
1418
1528
  // Re-snap to the REAL bottom: the transient Working bubble made the
@@ -1612,6 +1722,7 @@ class App {
1612
1722
  this.workingLine = null;
1613
1723
  this.lastToolMsg = null;
1614
1724
  this.replyBubble = null;
1725
+ this.wasAbortedByUser = false;
1615
1726
  this.thinkingMsg = null;
1616
1727
  this.todoMsg = null;
1617
1728
  this.todoSteps = [];
@@ -1637,6 +1748,7 @@ class App {
1637
1748
  this.lastToolMsg = null;
1638
1749
  this.replyBubble = null;
1639
1750
  this.replyHandled = false;
1751
+ this.wasAbortedByUser = false;
1640
1752
  this.todoMsg = null;
1641
1753
  this.todoSteps = [];
1642
1754
  this.todoDone = 0;
@@ -2265,11 +2377,11 @@ class App {
2265
2377
  this.suggestDismissed = false;
2266
2378
  }
2267
2379
  chatHeight() {
2268
- // Fixed rows: header(1) + sep(1) + chatSep(1) + input box(5 — Freebuff
2380
+ // Fixed rows: header(1) + chatSep(1) + input box(5 — Freebuff
2269
2381
  // chat-input-bar: content area minHeight 3 with the input vertically
2270
- // centered) + status(1) = 9, plus the activity strip when working and
2382
+ // centered) + status(1) = 8, plus the activity strip when working and
2271
2383
  // the slash suggestion popup when typing a command.
2272
- return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 9 - this.activityHeight() - this.suggestHeight());
2384
+ return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 8 - this.activityHeight() - this.suggestHeight());
2273
2385
  }
2274
2386
  /**
2275
2387
  * Compute the full frame as plain row strings (index 0 = terminal row 1)
@@ -2321,7 +2433,7 @@ class App {
2321
2433
  // input sits directly under the last content line). Scrolling up switches
2322
2434
  // back to classic top-anchored paging.
2323
2435
  const visible = (0, chat_1.chatWindow)(all, this.scroll, chatH);
2324
- const chatTop = 3;
2436
+ const chatTop = 2;
2325
2437
  const sepRow = chatTop + chatH;
2326
2438
  const toolRows = this.toolPanelRows(cols);
2327
2439
  const activityH = toolRows.length > 0 ? toolRows.length + 2 : 0;
@@ -2341,7 +2453,6 @@ class App {
2341
2453
  const padInner = Math.max(0, cols - 2 - (0, terminal_1.visibleWidth)(rendered.line));
2342
2454
  const frame = new Array(rows).fill('');
2343
2455
  frame[0] = (0, statusbar_1.renderHeader)(cols, info);
2344
- frame[1] = theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset;
2345
2456
  for (let i = 0; i < chatH; i++) {
2346
2457
  frame[chatTop - 1 + i] = visible[i] !== undefined ? visible[i] : '';
2347
2458
  }
package/dist/tui/chat.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LOGO_SHEEN_MAX = exports.LOGO_SHEEN_INTERVAL_MS = exports.LOGO_SHEEN_STEP = exports.LOGO_FULL_MIN_COLS = void 0;
3
+ exports.LOGO_SHEEN_MAX = exports.LOGO_SHEEN_INTERVAL_MS = exports.LOGO_SHEEN_STEP = exports.LOGO_FULL_MIN_COLS = exports.SIDE_GUTTER = void 0;
4
4
  exports.toolIcon = toolIcon;
5
5
  exports.toolLabel = toolLabel;
6
+ exports.followupsGroupOf = followupsGroupOf;
6
7
  exports.mergeStreamDelta = mergeStreamDelta;
7
8
  exports.renderMessages = renderMessages;
8
9
  exports.chatWindow = chatWindow;
@@ -120,6 +121,13 @@ const TOOL_LABELS = {
120
121
  function toolLabel(tool) {
121
122
  return (tool && TOOL_LABELS[tool]) || tool || 'tool';
122
123
  }
124
+ /** The suggest_followups batch carried by a 'followup' message, if any. */
125
+ function followupsGroupOf(m) {
126
+ if (m.kind !== 'followup')
127
+ return undefined;
128
+ const g = m.followupsGroup;
129
+ return g && Array.isArray(g.followups) ? g : undefined;
130
+ }
123
131
  /** Center a string (with ANSI) horizontally within `width`. */
124
132
  function center(str, width) {
125
133
  const pad = Math.max(0, Math.floor((width - (0, terminal_1.visibleWidth)(str)) / 2));
@@ -130,6 +138,64 @@ function fmtClock(ts) {
130
138
  const d = new Date(ts);
131
139
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
132
140
  }
141
+ /**
142
+ * EXACT port of Freebuff `getLastNVisualLines` (cli/src/utils/text-layout.ts):
143
+ * word-wrap `text` to `cols` visible columns and return the LAST n visual
144
+ * lines plus whether earlier lines were cut (→ the '...' prefix). Used by
145
+ * the thinking card so the 5-line preview matches Freebuff byte-for-byte.
146
+ */
147
+ function getLastNVisualLines(text, cols, n) {
148
+ if (n <= 0 || cols <= 0)
149
+ return { lines: [], hasMore: false };
150
+ const lines = [];
151
+ if (!text)
152
+ return { lines, hasMore: false };
153
+ const tokens = text.split(/(\s+)/);
154
+ let current = '';
155
+ let currentWidth = 0;
156
+ const pushLine = () => {
157
+ lines.push(current);
158
+ current = '';
159
+ currentWidth = 0;
160
+ };
161
+ const appendSegment = (segment) => {
162
+ if (!segment)
163
+ return;
164
+ const segWidth = (0, terminal_1.visibleWidth)(segment);
165
+ if (segWidth > cols) {
166
+ for (const ch of Array.from(segment)) {
167
+ const w = (0, terminal_1.visibleWidth)(ch);
168
+ if (currentWidth + w > cols)
169
+ pushLine();
170
+ current += ch;
171
+ currentWidth += w;
172
+ }
173
+ return;
174
+ }
175
+ if (currentWidth + segWidth > cols)
176
+ pushLine();
177
+ current += segment;
178
+ currentWidth += segWidth;
179
+ };
180
+ for (const token of tokens) {
181
+ if (!token)
182
+ continue;
183
+ if (token.includes('\n')) {
184
+ const parts = token.split('\n');
185
+ for (let i = 0; i < parts.length; i++) {
186
+ appendSegment(parts[i]);
187
+ if (i < parts.length - 1)
188
+ pushLine();
189
+ }
190
+ continue;
191
+ }
192
+ appendSegment(token);
193
+ }
194
+ if (current.length > 0 || lines.length === 0)
195
+ pushLine();
196
+ const hasMore = lines.length > n;
197
+ return { lines: lines.slice(-n), hasMore };
198
+ }
133
199
  /**
134
200
  * Wrap a stream of styled chunks to `width`, breaking at spaces (or hard
135
201
  * newlines) while preserving each chunk's style — unlike wrapText on a
@@ -528,38 +594,57 @@ function inlineStyle(seg) {
528
594
  * yellow (Freebuff paints header cells with headingFg[3]), body cells
529
595
  * plain, cell content word-wrapped, columns sized to content and
530
596
  * proportionally shrunk (min 3 cols each) when the table exceeds width.
597
+ *
598
+ * The grid must NEVER exceed `width`: every row costs ΣcolW + 3·numCols + 1
599
+ * visible columns (corner + per column (w+2) fill + one junction each),
600
+ * so column widths are budgeted to ΣcolW ≤ width − 3·numCols − 1. A wider
601
+ * row wraps on the terminal and shoves the whole screen down a line on
602
+ * every repaint (the "layar bergerak liar" / jitter bug).
531
603
  */
532
604
  function renderTableBlock(seg, width) {
533
605
  const rows = [seg.header, ...seg.rows];
534
606
  const numCols = Math.max(...rows.map((r) => r.length), 1);
535
- const separatorWidth = 3; // ' │ '
536
- // Grid rows cost ΣcolW + 3·numCols + 1 visible columns (per-cell padding
537
- // 'x2 plus a │ border per cell AND at both ends), so budget width-4 of
538
- // content to guarantee the grid never exceeds the terminal width.
539
- const availableWidth = Math.max(20, width - 4);
540
607
  const naturalWidths = Array.from({ length: numCols }, () => 3);
541
608
  for (const row of rows) {
542
609
  for (let i = 0; i < numCols; i++) {
543
610
  naturalWidths[i] = Math.max(naturalWidths[i], (0, terminal_1.visibleWidth)(row[i] ?? ''));
544
611
  }
545
612
  }
546
- const numSeparators = numCols - 1;
547
- const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + numSeparators * separatorWidth;
613
+ const budget = width - (3 * numCols + 1); // max ΣcolumnWidths that fits
614
+ const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
548
615
  let columnWidths;
549
- if (totalNaturalWidth <= availableWidth) {
616
+ if (totalNatural <= budget) {
550
617
  columnWidths = naturalWidths.slice();
551
618
  }
619
+ else if (budget < numCols * 3) {
620
+ // Too narrow even for 3-column cells: shrink evenly (min 1).
621
+ const per = Math.max(1, Math.floor(budget / numCols));
622
+ columnWidths = Array.from({ length: numCols }, () => per);
623
+ }
552
624
  else {
553
- const availableForContent = availableWidth - numSeparators * separatorWidth;
554
- const totalNaturalContent = naturalWidths.reduce((a, b) => a + b, 0);
555
- const scale = availableForContent / Math.max(1, totalNaturalContent);
625
+ const scale = budget / Math.max(1, totalNatural);
556
626
  columnWidths = naturalWidths.map((w) => Math.max(3, Math.floor(w * scale)));
557
- let remaining = availableForContent - columnWidths.reduce((a, b) => a + b, 0);
558
- for (let i = 0; i < columnWidths.length && remaining > 0; i++) {
559
- if (columnWidths[i] < naturalWidths[i]) {
560
- const add = Math.min(remaining, naturalWidths[i] - columnWidths[i]);
561
- columnWidths[i] += add;
562
- remaining -= add;
627
+ // Scale floors + the 3-col minimum can land above or below the budget:
628
+ // trim the widest columns (never below 3) or give leftovers back to the
629
+ // columns that were shrunk most, so ΣcolumnWidths fits exactly.
630
+ const order = columnWidths.map((_, i) => i).sort((x, y) => columnWidths[y] - columnWidths[x]);
631
+ let diff = budget - columnWidths.reduce((a, b) => a + b, 0);
632
+ if (diff < 0) {
633
+ for (let k = 0; k < order.length && diff < 0; k++) {
634
+ const i = order[k];
635
+ while (diff < 0 && columnWidths[i] > 3) {
636
+ columnWidths[i]--;
637
+ diff++;
638
+ }
639
+ }
640
+ }
641
+ else if (diff > 0) {
642
+ for (let k = 0; k < order.length && diff > 0; k++) {
643
+ const i = order[k];
644
+ while (diff > 0 && columnWidths[i] < naturalWidths[i]) {
645
+ columnWidths[i]++;
646
+ diff--;
647
+ }
563
648
  }
564
649
  }
565
650
  }
@@ -751,6 +836,15 @@ function renderMessage(m, width) {
751
836
  if (m.shell) {
752
837
  return renderShellCard(m.shell, width);
753
838
  }
839
+ // suggest_followups renders NO generic tool card, EVER (Freebuff
840
+ // suggest-followups.tsx: the tool block shows ONLY the followup
841
+ // group — never an arg preview like "💡 Follow-ups
842
+ // followups=[object Object]", not even transiently while the call
843
+ // is in flight before its result). The group itself renders via the
844
+ // 'followup' messages that share this batch (pushed at result time).
845
+ if (m.tool === 'suggest_followups') {
846
+ return out;
847
+ }
754
848
  // Compact block: <icon> tool · preview (Freebuff tool-call style:
755
849
  // bullet + bold tool name + description), then dim result lines.
756
850
  const icon = toolIcon(m.tool);
@@ -814,19 +908,46 @@ function renderMessage(m, width) {
814
908
  const th = m.thinking;
815
909
  if (!th || !th.text.trim())
816
910
  return out;
911
+ // Freebuff thinking.tsx special case: a single short **bold** string
912
+ // (< 100 chars) renders NOTHING (compact dedup — the bold text already
913
+ // shows in the answer bubble).
914
+ const singleBoldMatch = th.text.length < 100 ? th.text.trim().match(/^\*\*([^*]+)\*\*$/) : null;
915
+ if (singleBoldMatch)
916
+ return out;
817
917
  const complete = !m.streaming;
818
- const indicator = !complete ? '• ' : th.state === 'expanded' ? '▾ ' : '• ';
819
- out.push(`${theme_1.THEME.info}${indicator}${theme_1.THEME.reset}${theme_1.THEME.bold}Thinking${theme_1.THEME.reset}`);
918
+ // Freebuff EXACT: the preview normalizes the content to ONE line and
919
+ // word-wraps it (getLastNVisualLines) — so the 5-line preview is the
920
+ // last 5 VISUAL lines, with '...' prefixed when earlier lines exist.
921
+ // The wrap width accounts for the '...' prefix (−3) and the indent (−2,
922
+ // Freebuff paddingLeft: 2). Expanded keeps the original line breaks.
923
+ const PREVIEW_LINE_COUNT = 5;
924
+ const bodyCols = Math.max(10, width - 2);
925
+ const normalizedContent = th.text.replace(/\r\n?/g, '\n').replace(/\n+/g, ' ').trim();
926
+ const effectiveWidth = bodyCols - 3;
927
+ const { lines: previewLines, hasMore } = getLastNVisualLines(normalizedContent, effectiveWidth, PREVIEW_LINE_COUNT);
928
+ const expandedContent = th.text.replace(/\r\n?/g, '\n').replace(/\n\n+/g, '\n\n').trim();
929
+ const showFull = th.state === 'expanded';
930
+ const showPreview = th.state === 'preview' && previewLines.length > 0;
931
+ const toggleIndicator = !complete ? '• ' : showFull ? '▾ ' : showPreview ? '• ' : '▸ ';
932
+ // Header: theme.foreground (not green) + bold label — Freebuff EXACT.
933
+ out.push(`${theme_1.THEME.textBright}${toggleIndicator}${theme_1.THEME.reset}${theme_1.THEME.bold}Thinking${theme_1.THEME.reset}`);
820
934
  if (th.state === 'hidden')
821
935
  return out;
822
- const lines = th.text.replace(/\r\n?/g, '\n').split('\n');
823
- const bodyLines = th.state === 'expanded' ? lines : lines.slice(-5);
824
- if (th.state === 'preview' && lines.length > 5) {
825
- out.push(` ${theme_1.THEME.muted}${theme_1.THEME.italic}...${theme_1.THEME.reset}`);
936
+ if (showPreview) {
937
+ for (let i = 0; i < previewLines.length; i++) {
938
+ // '...' sits at the START of the first visual line (Freebuff:
939
+ // '...' + lines.join('\n')).
940
+ const body = (i === 0 && hasMore ? '...' : '') + previewLines[i];
941
+ for (const w of (0, terminal_1.wrapText)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${body}${theme_1.THEME.reset}`, bodyCols)) {
942
+ out.push(` ${w}`);
943
+ }
944
+ }
826
945
  }
827
- for (const line of bodyLines) {
828
- for (const w of (0, terminal_1.wrapText)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${line}${theme_1.THEME.reset}`, Math.max(4, width - 2))) {
829
- out.push(` ${w}`);
946
+ if (showFull) {
947
+ for (const line of expandedContent.split('\n')) {
948
+ for (const w of (0, terminal_1.wrapText)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${line}${theme_1.THEME.reset}`, bodyCols)) {
949
+ out.push(` ${w}`);
950
+ }
830
951
  }
831
952
  }
832
953
  return out;
@@ -865,18 +986,61 @@ function renderMessage(m, width) {
865
986
  return out;
866
987
  }
867
988
  case 'followup': {
868
- // Freebuff suggest_followups: a clickable "➤ label" row. Rendered dim
869
- // under the reply; clicking sends the prompt (keyboard: press the
870
- // matching number while the input is empty). Freebuff parity: clicked
871
- // ones are "visually updated to show they were used" — the arrow
872
- // turns into a green ✓ and the text dims.
989
+ // Freebuff suggest-followups.tsx port. The ACTIVE batch (the latest
990
+ // suggest_followups tool call) renders a muted "Suggested followups:"
991
+ // header + one "→ label" row per suggestion (icon muted, label
992
+ // foreground; clicked ones are "visually updated": ✓ in success green
993
+ // + muted text). Batches from EARLIER turns collapse to a
994
+ // "▸ Previously suggested followups" toggle (muted italic, expand
995
+ // with 'g') whose items indent 2 and gain a muted-italic full-prompt
996
+ // sub-line when a label differs from the prompt.
997
+ const grp = followupsGroupOf(m);
998
+ if (grp) {
999
+ if (grp.past) {
1000
+ const ind = grp.expanded ? '▾' : '▸';
1001
+ for (const w of (0, terminal_1.wrapText)(`${theme_1.THEME.muted}${ind}${theme_1.THEME.reset}${theme_1.THEME.muted}${theme_1.THEME.italic} Previously suggested followups${theme_1.THEME.reset}`, width)) {
1002
+ out.push(w);
1003
+ }
1004
+ if (grp.expanded) {
1005
+ for (const f of grp.followups) {
1006
+ if (!f || !f.prompt)
1007
+ continue;
1008
+ const t = f.label || f.prompt;
1009
+ const row = f.used
1010
+ ? `${theme_1.THEME.success}✓${theme_1.THEME.reset}${theme_1.THEME.muted} ${t}${theme_1.THEME.reset}`
1011
+ : `${theme_1.THEME.muted}→${theme_1.THEME.reset}${theme_1.THEME.textBright} ${t}${theme_1.THEME.reset}`;
1012
+ for (const w of (0, terminal_1.wrapText)(row, Math.max(10, width - 2)))
1013
+ out.push(` ${w}`);
1014
+ if (f.label && f.label !== f.prompt) {
1015
+ for (const w of (0, terminal_1.wrapText)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${f.prompt}${theme_1.THEME.reset}`, Math.max(10, width - 4))) {
1016
+ out.push(` ${w}`);
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+ return out;
1022
+ }
1023
+ out.push(`${theme_1.THEME.muted}Suggested followups:${theme_1.THEME.reset}`);
1024
+ for (const f of grp.followups) {
1025
+ if (!f || !f.prompt)
1026
+ continue;
1027
+ const t = f.label || f.prompt;
1028
+ const row = f.used
1029
+ ? `${theme_1.THEME.success}✓${theme_1.THEME.reset}${theme_1.THEME.muted} ${t}${theme_1.THEME.reset}`
1030
+ : `${theme_1.THEME.muted}→${theme_1.THEME.reset}${theme_1.THEME.textBright} ${t}${theme_1.THEME.reset}`;
1031
+ for (const w of (0, terminal_1.wrapText)(row, width))
1032
+ out.push(w);
1033
+ }
1034
+ return out;
1035
+ }
1036
+ // Legacy shape (old saved sessions): one message per suggestion.
873
1037
  const f = m.followup;
874
1038
  if (!f || !f.prompt)
875
1039
  return out;
876
1040
  const t = f.label || f.prompt;
877
1041
  const row = f.used
878
1042
  ? `${theme_1.THEME.success}✓ ${theme_1.THEME.reset}${theme_1.THEME.muted}${t}${theme_1.THEME.reset}`
879
- : `${theme_1.THEME.info}➤ ${theme_1.THEME.reset}${theme_1.THEME.textBright}${t}${theme_1.THEME.reset}`;
1043
+ : `${theme_1.THEME.muted}→ ${theme_1.THEME.reset}${theme_1.THEME.textBright}${t}${theme_1.THEME.reset}`;
880
1044
  for (const w of (0, terminal_1.wrapText)(row, width)) {
881
1045
  out.push(w);
882
1046
  }
@@ -992,15 +1156,35 @@ function renderShellCard(shell, width) {
992
1156
  }
993
1157
  return out;
994
1158
  }
1159
+ /**
1160
+ * Freebuff SIDE_GUTTER (message-with-agents.tsx): every message content box
1161
+ * gets paddingLeft: 1 + paddingRight: 1 — chat text NEVER touches the screen
1162
+ * edges, so the conversation reads as a tidy centered column. The pinned
1163
+ * welcome block is exempt (Freebuff's landing/chat-header centers on the
1164
+ * full terminal width).
1165
+ */
1166
+ exports.SIDE_GUTTER = 1;
995
1167
  function renderMessages(messages, width) {
996
1168
  const out = [];
1169
+ // `width` is the FULL terminal width here (same contract as before) — the
1170
+ // gutter is subtracted once, at this single point, so every caller (frame,
1171
+ // scroll helpers, audits) stays width-safe: content wraps to width − 2 and
1172
+ // the gutters bring each row back to exactly ≤ width. No wrap, no jitter.
1173
+ // On very narrow terminals (< 12 cols) the gutter is dropped: the 10-col
1174
+ // content floors inside renderMessage already fill the screen there.
1175
+ const gutted = width - 2 * exports.SIDE_GUTTER;
1176
+ const inner = Math.max(10, gutted);
1177
+ const useGutter = gutted >= 10;
1178
+ const gutter = (line) => (line.length === 0 ? '' : ` ${line} `);
997
1179
  for (let i = 0; i < messages.length; i++) {
998
- const rendered = renderMessage(messages[i], width);
1180
+ const m = messages[i];
1181
+ const wrap = m.kind === 'welcome' || !useGutter ? (line) => line : gutter;
1182
+ const rendered = renderMessage(m, inner);
999
1183
  if (rendered.length === 0)
1000
1184
  continue;
1001
1185
  if (out.length > 0)
1002
1186
  out.push('');
1003
- out.push(...rendered);
1187
+ out.push(...rendered.map(wrap));
1004
1188
  }
1005
1189
  return out;
1006
1190
  }
@@ -23,27 +23,30 @@ function fitTwo(left, right, inner) {
23
23
  const maxR = Math.max(0, avail - maxL);
24
24
  return { left: (0, terminal_1.truncate)(left, maxL), right: (0, terminal_1.truncate)(right, maxR) };
25
25
  }
26
- /** Render the top header line: VectorHead status ... model ● */
26
+ /** Render the top header line: VectorHead status ... model • */
27
27
  function renderHeader(width, info) {
28
- const brand = `${theme_1.THEME.bold}${theme_1.THEME.accent}VectorHead${theme_1.THEME.reset}`;
28
+ // Brand chip (user request): the green "VectorHead" sits on a GRAY
29
+ // background block — text color stays accent green, only the chip is gray.
30
+ // Padded with a space on each side so the green never touches the gray edge.
31
+ const brand = `${theme_1.THEME.brandBg}${theme_1.THEME.bold}${theme_1.THEME.accent} VectorHead ${theme_1.THEME.reset}`;
29
32
  // Status dot sits at the top-right, right next to the model (Freebuff-style).
30
33
  // Red = API tokens/quota exhausted; blinking green pulse = idle/ready
31
34
  // (agent waiting for input); solid green = working; gray ring = disconnected.
32
35
  let dot;
33
36
  if (info.tokenExhausted) {
34
- dot = `${theme_1.THEME.error}●${theme_1.THEME.reset}`;
37
+ dot = `${theme_1.THEME.error}•${theme_1.THEME.reset}`;
35
38
  }
36
39
  else if (info.needsSetup) {
37
40
  // Provider was removed / never configured — solid red until /connect.
38
- dot = `${theme_1.THEME.error}●${theme_1.THEME.reset}`;
41
+ dot = `${theme_1.THEME.error}•${theme_1.THEME.reset}`;
39
42
  }
40
43
  else if (info.blinking) {
41
44
  dot = info.blinkPhase
42
- ? `${theme_1.THEME.success}●${theme_1.THEME.reset}`
43
- : `${theme_1.THEME.faint}●${theme_1.THEME.reset}`;
45
+ ? `${theme_1.THEME.success}•${theme_1.THEME.reset}`
46
+ : `${theme_1.THEME.faint}•${theme_1.THEME.reset}`;
44
47
  }
45
48
  else {
46
- dot = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
49
+ dot = info.connected ? `${theme_1.THEME.success}•${theme_1.THEME.reset}` : `${theme_1.THEME.error}◦${theme_1.THEME.reset}`;
47
50
  }
48
51
  const status = `${(0, theme_1.statusColor)(info.status)}${info.status}${theme_1.THEME.reset}`;
49
52
  const left = `${brand} ${status}`;
@@ -54,12 +57,19 @@ function renderHeader(width, info) {
54
57
  // Exception: when no provider is configured, keep the red "connect to
55
58
  // provider" hint so the user knows what to do next.
56
59
  const model = `${theme_1.THEME.muted}${info.model}${theme_1.THEME.reset}`;
57
- const right = info.needsSetup
58
- ? `${theme_1.THEME.error}${info.provider}${theme_1.THEME.reset} ${dot}`
59
- : `${model} ${dot}`;
60
- const fit = fitTwo(left, right, width);
61
- const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
62
- return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
60
+ // The truncatable right-side text (model, or the red provider hint). The
61
+ // status dot is NOT part of it: it is appended AFTER width fitting, so
62
+ // on narrow terminals only the model gets "…" and the dot can never be
63
+ // truncated away — a vanishing/repositioning dot reads as the indicator
64
+ // "jumping" between the header (model •) and the status bar (• ask).
65
+ const right = info.needsSetup ? `${theme_1.THEME.error}${info.provider}${theme_1.THEME.reset}` : model;
66
+ // Budget 3 columns for the gap before the right text, the space, and the
67
+ // dot itself, and leave >= 1 gap column (mid) — the composed row is then
68
+ // exactly `width` wide with the dot as its last visible glyph, so it
69
+ // never wraps and never shifts the rows below (jitter bug).
70
+ const fit = fitTwo(left, right, width - 3);
71
+ const mid = Math.max(1, width - 2 - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length);
72
+ return theme_1.THEME.bgBar + `${fit.left}${' '.repeat(mid)}${fit.right} ${dot}` + theme_1.THEME.reset;
63
73
  }
64
74
  /**
65
75
  * Render the dedicated agent-activity strip (Freebuff-style): a compact
@@ -90,6 +100,14 @@ function renderStatusBar(width, info) {
90
100
  if (info.running && info.spinner) {
91
101
  left = `${statusColorCode}${info.spinner}${theme_1.THEME.reset} ${statusColorCode}${info.status}${theme_1.THEME.reset}`;
92
102
  }
103
+ else if (info.status === 'Ready') {
104
+ // User request: the idle bottom-bar label reads "Build" — BOLD dark
105
+ // gray (#374151), replacing the old green "Ready". A leading space
106
+ // insets it one column so the label never sits flush against the left
107
+ // screen edge. Functional statuses (Thinking/Working/Error/…) keep
108
+ // their own words and colors.
109
+ left = ` ${theme_1.THEME.bold}${theme_1.THEME.build}Build${theme_1.THEME.reset}`;
110
+ }
93
111
  else {
94
112
  left = `${statusColorCode}${info.status}${theme_1.THEME.reset}`;
95
113
  }
@@ -97,10 +115,12 @@ function renderStatusBar(width, info) {
97
115
  if (info.running && info.elapsed !== undefined) {
98
116
  right += `${theme_1.THEME.dim}${info.elapsed}s${theme_1.THEME.reset} `;
99
117
  }
100
- const conn = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
118
+ const conn = info.connected ? `${theme_1.THEME.success}•${theme_1.THEME.reset}` : `${theme_1.THEME.error}◦${theme_1.THEME.reset}`;
101
119
  const mode = info.mode === 'yolo' ? `${theme_1.THEME.warn}YOLO${theme_1.THEME.reset}` : `${theme_1.THEME.faint}ask${theme_1.THEME.reset}`;
102
120
  right += `${conn} ${mode}`;
103
- const fit = fitTwo(left, right, width);
121
+ // Same width-1 budget as the header (see renderHeader) — left+right must
122
+ // leave room for the mandatory gap column so the row never exceeds width.
123
+ const fit = fitTwo(left, right, width - 1);
104
124
  const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
105
125
  return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
106
126
  }
package/dist/tui/theme.js CHANGED
@@ -33,6 +33,8 @@ const FB = {
33
33
  codeHeader: '\x1b[38;2;91;100;122m', // #5b647a (markdown codeHeaderFg)
34
34
  surface: '\x1b[48;2;32;35;39m', // #202327
35
35
  surfaceHover: '\x1b[48;2;51;65;85m', // #334155
36
+ brandBg: '\x1b[48;2;55;65;81m', // #374151 (gray chip behind the header brand — Freebuff codeBackground gray)
37
+ buildFg: '\x1b[38;2;55;65;81m', // #374151 fg (dark gray for the bottom-bar Build label)
36
38
  };
37
39
  exports.THEME = {
38
40
  accent: FB.primary, // Freebuff primary green #9EFC62
@@ -68,6 +70,8 @@ exports.THEME = {
68
70
  divider: '\x1b[38;2;40;48;66m', // #283042 (Freebuff markdown.dividerFg)
69
71
  bgBar: FB.surface,
70
72
  bgPanel: FB.surfaceHover,
73
+ brandBg: FB.brandBg, // gray chip behind the header brand (teks tetap hijau)
74
+ build: FB.buildFg, // bottom-bar idle label "Build" — dark gray #374151
71
75
  bold: '\x1b[1m',
72
76
  italic: '\x1b[3m',
73
77
  dim: '\x1b[2m',
@@ -97,7 +101,7 @@ exports.SPINNERS = {
97
101
  dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
98
102
  line: ['─', '╌', '╍', '═', '╍', '╌'],
99
103
  arrows: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
100
- pulse: ['●', '◐', '◑', '○'],
104
+ pulse: ['•', '◐', '◑', '◦'], // round glyphs only — no oval ●/○ anywhere
101
105
  };
102
106
  /** A frame-advancing spinner (call frame() on each render tick). */
103
107
  class Spinner {
@@ -102,6 +102,8 @@ const SLOT_KEYS = {
102
102
  userLineBg: ['userLineBg', 'userLine', 'primary'],
103
103
  bgBar: ['backgroundElement', 'backgroundPanel'],
104
104
  bgPanel: ['backgroundPanel', 'backgroundElement'],
105
+ brandBg: ['brandBg', 'codeBg', 'backgroundPanel'],
106
+ build: ['build', 'brandBg', 'textMuted'],
105
107
  };
106
108
  function ansi(bg, n) {
107
109
  return bg ? `\x1b[48;5;${n}m` : `\x1b[38;5;${n}m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
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": {
@@ -39,14 +39,6 @@
39
39
  "ollama"
40
40
  ],
41
41
  "license": "MIT",
42
- "repository": {
43
- "type": "git",
44
- "url": "https://github.com/clickmamaheti-prog/vector-agent.git"
45
- },
46
- "homepage": "https://github.com/clickmamaheti-prog/vector-agent",
47
- "bugs": {
48
- "url": "https://github.com/clickmamaheti-prog/vector-agent/issues"
49
- },
50
42
  "dependencies": {},
51
43
  "devDependencies": {
52
44
  "@types/node": "^22.10.0",