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 +2 -8
- package/dist/agent/agent.js +4 -0
- package/dist/agent/loop.js +59 -16
- package/dist/cli/index.js +5 -1
- package/dist/config/providers.js +17 -0
- package/dist/providers/custom.js +1 -0
- package/dist/providers/factory.js +1 -0
- package/dist/providers/openai-compat.js +9 -0
- package/dist/providers/openrouter.js +1 -1
- package/dist/tools/background.js +217 -0
- package/dist/tui/app.js +132 -21
- package/dist/tui/chat.js +219 -35
- package/dist/tui/statusbar.js +35 -15
- package/dist/tui/theme.js +5 -1
- package/dist/tui/themes.js +2 -0
- package/package.json +1 -9
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
|
-
|
|
60
|
-
|
|
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
|
|
package/dist/agent/agent.js
CHANGED
|
@@ -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) {
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/config/providers.js
CHANGED
|
@@ -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
|
package/dist/providers/custom.js
CHANGED
|
@@ -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://
|
|
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
|
-
*
|
|
196
|
-
*
|
|
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
|
|
607
|
-
//
|
|
608
|
-
|
|
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 =
|
|
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' &&
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) +
|
|
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) =
|
|
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 -
|
|
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 =
|
|
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
|
|
547
|
-
const
|
|
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 (
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
828
|
-
for (const
|
|
829
|
-
|
|
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
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
// ones are "visually updated
|
|
872
|
-
//
|
|
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.
|
|
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
|
|
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
|
}
|
package/dist/tui/statusbar.js
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
43
|
-
: `${theme_1.THEME.faint}
|
|
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}
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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}
|
|
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
|
-
|
|
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 {
|
package/dist/tui/themes.js
CHANGED
|
@@ -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.
|
|
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",
|