agen-vektor 0.3.28 → 0.3.30
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 +4 -0
- package/dist/tools/e2b.js +84 -0
- package/dist/tui/app.js +33 -14
- package/dist/tui/chat.js +74 -12
- package/dist/tui/themes.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -471,6 +471,10 @@ secrets stay private):
|
|
|
471
471
|
|
|
472
472
|
## Changelog
|
|
473
473
|
|
|
474
|
+
### 0.3.30
|
|
475
|
+
- **Thinking card matches Freebuff `thinking.tsx` exactly — no mid-run color flip-flop.** The reasoning body renders muted (`#acb3bf`) italic in BOTH streaming and completed states; only the header (dot + bold "Thinking") is foreground-white. Previously the body flipped from white (streaming) to muted (done), which read as the card changing color during a run.
|
|
476
|
+
- **Expanded thinking view is raw muted italic with word wrap.** The in-card markdown re-render was removed — headings/inline code no longer paint their own colors inside the card (the mixed-color expanded view read as noise). Markdown markers in reasoning now show as-is, uniformly styled.
|
|
477
|
+
|
|
474
478
|
### 0.3.15
|
|
475
479
|
- **Thinking cards are segmented per completed agent step.** Reasoning for each step (think → tool → think → tool) now renders as its own `• Thinking` card instead of stacking every step's reasoning into one ever-growing card — stale reasoning no longer appears to "leak" below the live thinking preview during multi-step tasks like web research.
|
|
476
480
|
- Streaming answers keep the guarded `mergeStreamDelta` accumulation across all providers (no more doubled final text on gateways that resend the full text).
|
package/dist/tools/e2b.js
CHANGED
|
@@ -335,6 +335,90 @@ ${lines.join('\n')}`,
|
|
|
335
335
|
};
|
|
336
336
|
},
|
|
337
337
|
},
|
|
338
|
+
{
|
|
339
|
+
definition: {
|
|
340
|
+
name: 'e2b_screenshot',
|
|
341
|
+
description: 'Take a screenshot of a URL with headless Chromium INSIDE a persistent e2b cloud session (from e2b_cloud create) and get it back as a downloadable gateway link (render_ui button opens it). Requires Playwright+Chromium installed in the session (npx playwright install --with-deps chromium). The target URL is fetched FROM INSIDE the sandbox — use http://localhost:<port> for dev servers running in this session. The sandbox needs internet unless the target is localhost.',
|
|
342
|
+
parameters: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
properties: {
|
|
345
|
+
id: { type: 'string', description: 'Session id from e2b_cloud create' },
|
|
346
|
+
url: { type: 'string', description: 'URL to capture, e.g. http://localhost:3000 (default)' },
|
|
347
|
+
wait_ms: { type: 'number', description: 'Milliseconds to wait for page load before capturing (default 1500)' },
|
|
348
|
+
full_page: { type: 'boolean', description: 'Capture full scrollable page (default false)' },
|
|
349
|
+
},
|
|
350
|
+
required: ['id'],
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
async execute(args, ctx) {
|
|
354
|
+
const id = String(args.id || '');
|
|
355
|
+
const target = String(args.url || 'http://localhost:3000').trim();
|
|
356
|
+
if (!/^[A-Za-z0-9-]{8,80}$/.test(id))
|
|
357
|
+
return { output: 'ERROR: invalid session id' };
|
|
358
|
+
if (!/^https?:\/\//.test(target) || target.includes("'"))
|
|
359
|
+
return { output: 'ERROR: url harus http(s) tanpa quote' };
|
|
360
|
+
const waitMs = Math.min(15_000, Math.max(0, Number(args.wait_ms) || 1500));
|
|
361
|
+
const fullPage = args.full_page === true;
|
|
362
|
+
// Dua langkah: (1) exec script Playwright → simpan PNG di /home/user/, (2) beri
|
|
363
|
+
// link download via route /v1/e2b/file (gateway stream PNG → tombol render_ui
|
|
364
|
+
// di TUI membukanya di browser). Script memakai flag jitless WAJIB (gotcha V8
|
|
365
|
+
// OOM di sandbox kecil) dan hanya setelah marker __VECTOR_DONE_ hasil poll.
|
|
366
|
+
// PENTING: script DITULIS ke /home/user (bukan /tmp) — ESM resolve
|
|
367
|
+
// `import 'playwright'` relatif thd lokasi file, dan package terinstall
|
|
368
|
+
// di /home/user/node_modules (npm i tanpa -g).
|
|
369
|
+
const script = [
|
|
370
|
+
'mkdir -p /home/user',
|
|
371
|
+
`cat > /home/user/vector-shot.mjs <<\'EOF\'`,
|
|
372
|
+
`import { chromium } from 'playwright';`,
|
|
373
|
+
`const b = await chromium.launch({ args: ['--no-sandbox','--disable-dev-shm-usage','--disable-gpu','--js-flags=--jitless'] });`,
|
|
374
|
+
`const p = await b.newPage();`,
|
|
375
|
+
`try {`,
|
|
376
|
+
` await p.goto(${JSON.stringify(target)}, { waitUntil: 'load', timeout: 20000 });`,
|
|
377
|
+
` await p.waitForTimeout(${waitMs});`,
|
|
378
|
+
` await p.screenshot({ path: '/home/user/vector-preview.png', fullPage: ${fullPage ? 'true' : 'false'} });`,
|
|
379
|
+
` console.log('SHOT_OK');`,
|
|
380
|
+
`} catch (e) { console.log('SHOT_ERR ' + e.message); await b.close(); process.exit(3); }`,
|
|
381
|
+
`await b.close();`,
|
|
382
|
+
`EOF`,
|
|
383
|
+
`cd /home/user && node /home/user/vector-shot.mjs`,
|
|
384
|
+
].join('\n');
|
|
385
|
+
ctx.onActivity?.('e2b', `screenshot ${target}`);
|
|
386
|
+
// Poll exec manual di sini (bukan e2bExec blocking): kita butuh exit code.
|
|
387
|
+
const started = await fetch(GW_BASE + '/v1/e2b/exec?id=' + encodeURIComponent(id), {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'VectorHead-AI-Agent/0.1' },
|
|
390
|
+
body: JSON.stringify({ command: script }),
|
|
391
|
+
signal: AbortSignal.timeout(30_000),
|
|
392
|
+
});
|
|
393
|
+
const sdata = (await started.json().catch(() => null));
|
|
394
|
+
if (!started.ok || !sdata?.jobId) {
|
|
395
|
+
return { output: `ERROR: e2b exec gagal — ${(0, credentials_1.redact)(sdata?.error || 'HTTP ' + started.status)}`, summary: 'screenshot exec error' };
|
|
396
|
+
}
|
|
397
|
+
const t0 = Date.now();
|
|
398
|
+
while (Date.now() - t0 < 90_000) {
|
|
399
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
400
|
+
const jr = await fetch(GW_BASE + '/v1/e2b/job?id=' + encodeURIComponent(id) + '&job=' + sdata.jobId, {
|
|
401
|
+
signal: AbortSignal.timeout(15_000),
|
|
402
|
+
});
|
|
403
|
+
const j = (await jr.json().catch(() => null));
|
|
404
|
+
if (j?.status === 'done') {
|
|
405
|
+
if (j.exitCode !== 0 || !(j.stdout || '').includes('SHOT_OK')) {
|
|
406
|
+
const why = (j.stderr || j.stdout || '').trim().slice(0, 300);
|
|
407
|
+
return { output: `ERROR: screenshot gagal (exit ${j.exitCode})\n${(0, credentials_1.redact)(why)}` + (why.includes('Cannot find package') ? '\n→ Playwright belum terpasang di sesi ini: e2b_exec {command: "npm i playwright && npx playwright install --with-deps chromium"}' : ''), summary: 'screenshot error' };
|
|
408
|
+
}
|
|
409
|
+
const link = `${GW_BASE}/v1/e2b/file?id=${encodeURIComponent(id)}&path=%2Fhome%2Fuser%2Fvector-preview.png`;
|
|
410
|
+
return {
|
|
411
|
+
output: `screenshot tersimpan: /home/user/vector-preview.png (${target})\nlink: ${link}`,
|
|
412
|
+
summary: `e2b screenshot ${target}`,
|
|
413
|
+
data: { widget: { type: 'button', text: '🖼 Lihat screenshot', link }, screenshot: link },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (j?.status === 'error')
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
return { output: 'ERROR: screenshot timeout (job tidak selesai dalam 90s)', summary: 'screenshot timeout' };
|
|
420
|
+
},
|
|
421
|
+
},
|
|
338
422
|
{
|
|
339
423
|
definition: {
|
|
340
424
|
name: 'e2b_download',
|
package/dist/tui/app.js
CHANGED
|
@@ -1763,7 +1763,13 @@ class App {
|
|
|
1763
1763
|
this.runFollowupMsgs.push(followupMsg);
|
|
1764
1764
|
}
|
|
1765
1765
|
}
|
|
1766
|
-
|
|
1766
|
+
// Tombol UI: tool render_ui — dan kini juga tool e2b (screenshot/
|
|
1767
|
+
// preview) — bisa mengembalikan data.widget {type:'button',text,link}.
|
|
1768
|
+
// Whitelist supaya tool pihak ketiga (MCP) tidak bisa memicu tombol
|
|
1769
|
+
// (surface prompt-injection: deskripsi/hasil tool = untrusted data).
|
|
1770
|
+
if ((tool === 'render_ui' || tool.startsWith('e2b_')) &&
|
|
1771
|
+
data &&
|
|
1772
|
+
data.widget) {
|
|
1767
1773
|
const w = data.widget;
|
|
1768
1774
|
this.activeUiButton = w;
|
|
1769
1775
|
this.messages.push({ kind: 'ui', content: w.text, ui: w, ts: Date.now() });
|
|
@@ -3045,7 +3051,9 @@ class App {
|
|
|
3045
3051
|
continue;
|
|
3046
3052
|
const args = (m.content.split('\n')[0] || '').trim();
|
|
3047
3053
|
const head = `${(0, chat_1.toolIcon)(m.tool)} ${theme_1.THEME.bold}${(0, chat_1.toolLabel)(m.tool)}${theme_1.THEME.reset}`;
|
|
3048
|
-
|
|
3054
|
+
// Freebuff SimpleToolCallItem: description in the FOREGROUND (white),
|
|
3055
|
+
// not faint — the panel is a live indicator, dark gray read as junk.
|
|
3056
|
+
const line = args ? `${head} ${theme_1.THEME.textBright}${args}${theme_1.THEME.reset}` : head;
|
|
3049
3057
|
out.unshift((0, terminal_1.truncate)(line, inner));
|
|
3050
3058
|
}
|
|
3051
3059
|
return out;
|
|
@@ -3162,6 +3170,16 @@ class App {
|
|
|
3162
3170
|
const visible = (0, chat_1.chatWindow)(all, this.scroll, chatH);
|
|
3163
3171
|
const chatTop = 2;
|
|
3164
3172
|
const sepRow = chatTop + chatH;
|
|
3173
|
+
// Responsive content width (Freebuff use-terminal-dimensions.ts
|
|
3174
|
+
// separatorWidth = terminal − 2): EVERY structural element — separator,
|
|
3175
|
+
// tool panel, input box — spans exactly cols − 2 with a 1-col margin on
|
|
3176
|
+
// BOTH sides (symmetric). Dulu separator di-cap 120 kolom sementara input
|
|
3177
|
+
// box full-width → di terminal lebar garis ─ tidak lurus dengan box di
|
|
3178
|
+
// bawahnya, dan box menempel ke tepi sementara teks chat ber-gutter 1
|
|
3179
|
+
// kolom ("sisi sama sisi tidak seimbang"). Chat rows carry the same
|
|
3180
|
+
// 1-col SIDE_GUTTER, so text column == box outer width == frameW.
|
|
3181
|
+
const frameW = Math.max(10, cols - 2);
|
|
3182
|
+
const frameIndent = ' ';
|
|
3165
3183
|
const toolRows = this.toolPanelRows(cols);
|
|
3166
3184
|
const activityH = toolRows.length > 0 ? toolRows.length + 2 : 0;
|
|
3167
3185
|
const suggestH = this.suggestHeight();
|
|
@@ -3171,9 +3189,9 @@ class App {
|
|
|
3171
3189
|
// blank row, input (vertically centered), blank row, bottom border.
|
|
3172
3190
|
// Slash suggestions render between the tool panel and the box.
|
|
3173
3191
|
const boxTop = sepRow + activityH + suggestH + 1;
|
|
3174
|
-
const boxBot = boxTop + this.input.wrappedRows(
|
|
3192
|
+
const boxBot = boxTop + this.input.wrappedRows(frameW) + 3;
|
|
3175
3193
|
const statusRow = boxBot + 1;
|
|
3176
|
-
const rendered = this.input.render(
|
|
3194
|
+
const rendered = this.input.render(frameW, input_1.DEFAULT_PLACEHOLDER);
|
|
3177
3195
|
// Cursor row: prompt line 0 DILUKIS di boxTop + 1 (border ╭ di boxTop−1,
|
|
3178
3196
|
// blank padding di boxTop). Dulu +2 menaruh kursor hardware SATU BARIS di
|
|
3179
3197
|
// bawah teks prompt — baris kosong dasar box, tepat di atas bar "Build •
|
|
@@ -3181,26 +3199,27 @@ class App {
|
|
|
3181
3199
|
// YOLO" (laporan user 2026-09-07). Kursor hardware sendiri kini SELALU
|
|
3182
3200
|
// hidden (lihat doRender di cli/index.ts); posisi tetap diperbaiki agar
|
|
3183
3201
|
// benar untuk audit/bounds.
|
|
3184
|
-
|
|
3202
|
+
// +2: 1-col frame indent + 1 left border column.
|
|
3203
|
+
const cursorCol = rendered.cursorCol + 2;
|
|
3185
3204
|
const cursorRow = boxTop + 1 + rendered.cursorRow;
|
|
3186
3205
|
const frame = new Array(rows).fill('');
|
|
3187
3206
|
frame[0] = (0, statusbar_1.renderHeader)(cols, info);
|
|
3188
3207
|
for (let i = 0; i < chatH; i++) {
|
|
3189
3208
|
frame[chatTop - 1 + i] = visible[i] !== undefined ? visible[i] : '';
|
|
3190
3209
|
}
|
|
3191
|
-
frame[sepRow - 1] = theme_1.THEME.border + terminal_1.BOX.h.repeat(
|
|
3210
|
+
frame[sepRow - 1] = frameIndent + theme_1.THEME.border + terminal_1.BOX.h.repeat(frameW) + theme_1.THEME.reset;
|
|
3192
3211
|
// Boxed tool-call panel: `· Tool args` lines (last TOOL_PANEL_ROWS calls)
|
|
3193
3212
|
// inside a rounded box, directly above the chat prompt — mirrors the
|
|
3194
3213
|
// committed README example instead of a plain unboxed strip.
|
|
3195
3214
|
if (activityH > 0) {
|
|
3196
3215
|
const toolBorder = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
|
|
3197
|
-
frame[sepRow] = `${toolBorder}╭${terminal_1.BOX.h.repeat(Math.max(0,
|
|
3216
|
+
frame[sepRow] = `${frameIndent}${toolBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╮${theme_1.THEME.reset}`;
|
|
3198
3217
|
for (let i = 0; i < toolRows.length; i++) {
|
|
3199
3218
|
const line = toolRows[i];
|
|
3200
|
-
const pad = Math.max(0,
|
|
3201
|
-
frame[sepRow + 1 + i] = `${toolBorder}│${theme_1.THEME.reset}${line}${' '.repeat(pad)}${toolBorder}│${theme_1.THEME.reset}`;
|
|
3219
|
+
const pad = Math.max(0, frameW - 2 - (0, terminal_1.visibleWidth)(line));
|
|
3220
|
+
frame[sepRow + 1 + i] = `${frameIndent}${toolBorder}│${theme_1.THEME.reset}${line}${' '.repeat(pad)}${toolBorder}│${theme_1.THEME.reset}`;
|
|
3202
3221
|
}
|
|
3203
|
-
frame[sepRow + toolRows.length + 1] = `${toolBorder}╰${terminal_1.BOX.h.repeat(Math.max(0,
|
|
3222
|
+
frame[sepRow + toolRows.length + 1] = `${frameIndent}${toolBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╯${theme_1.THEME.reset}`;
|
|
3204
3223
|
}
|
|
3205
3224
|
// Freebuff-style slash command popup (when typing `/…`). The reserved
|
|
3206
3225
|
// height IS the render budget: suggestHeight already fits the whole
|
|
@@ -3214,17 +3233,17 @@ class App {
|
|
|
3214
3233
|
}
|
|
3215
3234
|
// Freebuff renders the input box border in the foreground color (white).
|
|
3216
3235
|
const boxBorder = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
|
|
3217
|
-
frame[boxTop - 1] = `${boxBorder}╭${terminal_1.BOX.h.repeat(Math.max(0,
|
|
3236
|
+
frame[boxTop - 1] = `${frameIndent}${boxBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╮${theme_1.THEME.reset}`;
|
|
3218
3237
|
// Vertical padding (Freebuff chat-input-bar): one blank content row
|
|
3219
3238
|
// above and below the wrapped prompt lines.
|
|
3220
|
-
const blankRow = `${boxBorder}│${theme_1.THEME.reset}${' '.repeat(Math.max(0,
|
|
3239
|
+
const blankRow = `${frameIndent}${boxBorder}│${theme_1.THEME.reset}${' '.repeat(Math.max(0, frameW - 2))}${boxBorder}│${theme_1.THEME.reset}`;
|
|
3221
3240
|
frame[boxTop] = blankRow;
|
|
3222
3241
|
for (let i = 0; i < rendered.lines.length; i++) {
|
|
3223
|
-
frame[boxTop + 1 + i] = `${boxBorder}│${theme_1.THEME.reset}${rendered.lines[i]}${boxBorder}│${theme_1.THEME.reset}`;
|
|
3242
|
+
frame[boxTop + 1 + i] = `${frameIndent}${boxBorder}│${theme_1.THEME.reset}${rendered.lines[i]}${boxBorder}│${theme_1.THEME.reset}`;
|
|
3224
3243
|
}
|
|
3225
3244
|
for (let i = boxTop + 1 + rendered.lines.length; i < boxBot - 1; i++)
|
|
3226
3245
|
frame[i] = blankRow;
|
|
3227
|
-
frame[boxBot - 1] = `${boxBorder}╰${terminal_1.BOX.h.repeat(Math.max(0,
|
|
3246
|
+
frame[boxBot - 1] = `${frameIndent}${boxBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╯${theme_1.THEME.reset}`;
|
|
3228
3247
|
frame[statusRow - 1] = (0, statusbar_1.renderStatusBar)(cols, info);
|
|
3229
3248
|
// OpenCode single-buffer compositing: the modal overlay is composed INTO
|
|
3230
3249
|
// the frame rows so the incremental renderer can row-diff while a dialog
|
package/dist/tui/chat.js
CHANGED
|
@@ -126,6 +126,21 @@ const TOOL_LABELS = {
|
|
|
126
126
|
function toolLabel(tool) {
|
|
127
127
|
return (tool && TOOL_LABELS[tool]) || tool || 'tool';
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Tool-card description color — Freebuff tools/*.tsx parity:
|
|
131
|
+
* SimpleToolCallItem paints the description with theme.foreground (white)
|
|
132
|
+
* unless the tool overrides it: read-url.tsx → theme.muted (#acb3bf),
|
|
133
|
+
* list-directory.tsx → theme.directory (#9CA3AF). The old renderer painted
|
|
134
|
+
* EVERY description with faint #6b7280 (gray-500) — read as a dark smudge
|
|
135
|
+
* next to the white tool name ("Read Url · Glob · List masih gelap").
|
|
136
|
+
*/
|
|
137
|
+
const TOOL_DESC_COLOR = {
|
|
138
|
+
web_fetch: theme_1.THEME.muted,
|
|
139
|
+
list_directory: theme_1.THEME.directory,
|
|
140
|
+
};
|
|
141
|
+
function toolDescColor(tool) {
|
|
142
|
+
return (tool && TOOL_DESC_COLOR[tool]) || theme_1.THEME.textBright;
|
|
143
|
+
}
|
|
129
144
|
/** The suggest_followups batch carried by a 'followup' message, if any. */
|
|
130
145
|
function followupsGroupOf(m) {
|
|
131
146
|
if (m.kind !== 'followup')
|
|
@@ -147,6 +162,30 @@ function formatTimeout(timeoutSeconds) {
|
|
|
147
162
|
return `${r / 60}m timeout`;
|
|
148
163
|
return `${r}s timeout`;
|
|
149
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Strip PAIRED inline markdown markers for plain-text surfaces (thinking
|
|
167
|
+
* preview): `**bold**`/`__bold__` → bold, `*emph*`/`_emph_` → emph,
|
|
168
|
+
* `` `code` `` → code, `~~strike~~` → plain. UNPAIRED leftovers (a lone `
|
|
169
|
+
* backtick or asterisk pair still open mid-stream) are dropped so no
|
|
170
|
+
* "sampah kutipan" sticks to the words. Whitespace collapses back cleanly.
|
|
171
|
+
*/
|
|
172
|
+
function stripPairedMarkdown(text) {
|
|
173
|
+
let out = text;
|
|
174
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
175
|
+
// Pairs first (non-greedy, same line semantics).
|
|
176
|
+
out = out
|
|
177
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
|
|
178
|
+
.replace(/__([^_\n]+)__/g, '$1')
|
|
179
|
+
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2')
|
|
180
|
+
.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1$2')
|
|
181
|
+
.replace(/~~([^~\n]+)~~/g, '$1')
|
|
182
|
+
.replace(/`([^`\n]+)`/g, '$1');
|
|
183
|
+
}
|
|
184
|
+
// Unpaired leftovers — only chars that would EVER render literal junk.
|
|
185
|
+
out = out.replace(/^[*`~]+/, '').replace(/[*`~]+$/, '');
|
|
186
|
+
out = out.replace(/\s*[*`~]\s*/g, ' ').replace(/ {2,}/g, ' ');
|
|
187
|
+
return out.trim();
|
|
188
|
+
}
|
|
150
189
|
/** Center a string (with ANSI) horizontally within `width`. */
|
|
151
190
|
function center(str, width) {
|
|
152
191
|
const pad = Math.max(0, Math.floor((width - (0, terminal_1.visibleWidth)(str)) / 2));
|
|
@@ -959,11 +998,15 @@ function renderMessage(m, width, seenGroups, seenCallIds) {
|
|
|
959
998
|
if (m.tool === 'suggest_followups') {
|
|
960
999
|
return out;
|
|
961
1000
|
}
|
|
962
|
-
// Compact block
|
|
963
|
-
//
|
|
1001
|
+
// Compact block (Freebuff tool-call-item.tsx parity): bullet + BOLD
|
|
1002
|
+
// tool name in the foreground, description in the tool's own color
|
|
1003
|
+
// (white by default — read-url muted, list directory), then the result
|
|
1004
|
+
// preview in muted ITALIC (Freebuff ToolCallItem collapsed preview:
|
|
1005
|
+
// `fg={isStreaming ? foreground : muted}` + ITALIC). The old all-faint
|
|
1006
|
+
// #6b7280 rendering read as dark gray junk under the white label.
|
|
964
1007
|
const icon = toolIcon(m.tool);
|
|
965
1008
|
const parts = body.split('\n');
|
|
966
|
-
const first = `${icon} ${theme_1.THEME.bold}${toolLabel(m.tool)}${theme_1.THEME.reset} ${
|
|
1009
|
+
const first = `${icon} ${theme_1.THEME.bold}${toolLabel(m.tool)}${theme_1.THEME.reset} ${toolDescColor(m.tool)}${(parts[0] || '').trim()}${theme_1.THEME.reset}`;
|
|
967
1010
|
if (first.trim()) {
|
|
968
1011
|
for (const line of (0, terminal_1.wrapText)(first, width))
|
|
969
1012
|
out.push(line);
|
|
@@ -971,7 +1014,7 @@ function renderMessage(m, width, seenGroups, seenCallIds) {
|
|
|
971
1014
|
const rest = parts.slice(1).filter((l) => l.trim());
|
|
972
1015
|
if (rest.length > 0) {
|
|
973
1016
|
for (const line of (0, terminal_1.wrapText)(rest.join('\n'), Math.max(4, width - 2))) {
|
|
974
|
-
out.push(`${theme_1.THEME.
|
|
1017
|
+
out.push(`${theme_1.THEME.muted}${theme_1.THEME.italic}${line}${theme_1.THEME.reset}`);
|
|
975
1018
|
}
|
|
976
1019
|
}
|
|
977
1020
|
return out;
|
|
@@ -1036,14 +1079,19 @@ function renderMessage(m, width, seenGroups, seenCallIds) {
|
|
|
1036
1079
|
if (singleBoldMatch)
|
|
1037
1080
|
return out;
|
|
1038
1081
|
const complete = !m.streaming;
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1082
|
+
// Models (GLM/DeepSeek reasoning) often write MARKDOWN inside their
|
|
1083
|
+
// reasoning — raw `**`, backticks and `#` used to leak onto the card as
|
|
1084
|
+
// literal junk ("banyak kutipan seperti sampah"). Freebuff never shows
|
|
1085
|
+
// markers: its renderer CONSUMES them. Mirror that:
|
|
1086
|
+
// preview → strip PAIRED markers (bold/italic/code), collapse unpaired
|
|
1087
|
+
// leftovers (a lone ` or ** mid-stream must not stick out),
|
|
1088
|
+
// keep words intact;
|
|
1089
|
+
// expanded → render through the SAME markdown engine as the chat
|
|
1090
|
+
// (splitMarkdown consumes **bold**, *emph*, `code`, # heads,
|
|
1091
|
+
// > quotes, lists) so nothing literal survives.
|
|
1044
1092
|
const PREVIEW_LINE_COUNT = 5;
|
|
1045
1093
|
const bodyCols = Math.max(10, width - 2);
|
|
1046
|
-
const normalizedContent = th.text.replace(/\r\n?/g, '\n').replace(/\n+/g, ' ').trim();
|
|
1094
|
+
const normalizedContent = stripPairedMarkdown(th.text.replace(/\r\n?/g, '\n').replace(/\n+/g, ' ').trim());
|
|
1047
1095
|
const effectiveWidth = bodyCols - 3;
|
|
1048
1096
|
const { lines: previewLines, hasMore } = getLastNVisualLines(normalizedContent, effectiveWidth, PREVIEW_LINE_COUNT);
|
|
1049
1097
|
const expandedContent = th.text.replace(/\r\n?/g, '\n').replace(/\n\n+/g, '\n\n').trim();
|
|
@@ -1054,19 +1102,33 @@ function renderMessage(m, width, seenGroups, seenCallIds) {
|
|
|
1054
1102
|
out.push(`${theme_1.THEME.textBright}${toggleIndicator}${theme_1.THEME.reset}${theme_1.THEME.bold}Thinking${theme_1.THEME.reset}`);
|
|
1055
1103
|
if (th.state === 'hidden')
|
|
1056
1104
|
return out;
|
|
1105
|
+
// Body color — Freebuff thinking.tsx EXACT: the preview text element
|
|
1106
|
+
// paints `fg: theme.muted` + TextAttributes.ITALIC in BOTH states
|
|
1107
|
+
// (streaming AND completed); expanded paints the same muted italic
|
|
1108
|
+
// with wrapMode 'word'. There is NO white reasoning state in Freebuff —
|
|
1109
|
+
// the previous "streaming = textBright" flip-flop read as the card
|
|
1110
|
+
// changing color mid-run ("warna thinking masih sama tidak berubah /
|
|
1111
|
+
// makin kacau"). The HEADER (dot + bold label) is the only
|
|
1112
|
+
// foreground-white part of the card.
|
|
1113
|
+
const thinkBody = `${theme_1.THEME.muted}${theme_1.THEME.italic}`;
|
|
1057
1114
|
if (showPreview) {
|
|
1058
1115
|
for (let i = 0; i < previewLines.length; i++) {
|
|
1059
1116
|
// '...' sits at the START of the first visual line (Freebuff:
|
|
1060
1117
|
// '...' + lines.join('\n')).
|
|
1061
1118
|
const body = (i === 0 && hasMore ? '...' : '') + previewLines[i];
|
|
1062
|
-
for (const w of (0, terminal_1.wrapText)(`${
|
|
1119
|
+
for (const w of (0, terminal_1.wrapText)(`${thinkBody}${body}${theme_1.THEME.reset}`, bodyCols)) {
|
|
1063
1120
|
out.push(` ${w}`);
|
|
1064
1121
|
}
|
|
1065
1122
|
}
|
|
1066
1123
|
}
|
|
1067
1124
|
if (showFull) {
|
|
1125
|
+
// Expanded — Freebuff thinking.tsx EXACT: the SAME muted italic body,
|
|
1126
|
+
// raw content with original line breaks (wrapMode 'word'). Plain wrap,
|
|
1127
|
+
// NO markdown re-render: the markdown engine paints headings/inline
|
|
1128
|
+
// code in their own colors inside a card whose body must stay uniformly
|
|
1129
|
+
// muted italic (the mixed-color expanded card read as "kacau").
|
|
1068
1130
|
for (const line of expandedContent.split('\n')) {
|
|
1069
|
-
for (const w of (0, terminal_1.wrapText)(`${
|
|
1131
|
+
for (const w of (0, terminal_1.wrapText)(`${thinkBody}${line}${theme_1.THEME.reset}`, bodyCols)) {
|
|
1070
1132
|
out.push(` ${w}`);
|
|
1071
1133
|
}
|
|
1072
1134
|
}
|
package/dist/tui/themes.js
CHANGED
|
@@ -138,9 +138,31 @@ function resolveValue(spec, value, mode, bg, visited) {
|
|
|
138
138
|
return resolveValue(spec, next, mode, bg, visited);
|
|
139
139
|
return null; // unknown reference → keep the slot default
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* THEME slots a theme may NOT override — the chat TEXT legibility core.
|
|
143
|
+
* Freebuff renders message text with its foreground palette in BOTH its dark
|
|
144
|
+
* and light theme (user-content is never dark-on-dark), so a custom theme
|
|
145
|
+
* with dark/near-background `text`/`textMuted` values must not be able to
|
|
146
|
+
* turn chat prose, reasoning or tool output into an unreadable smudge.
|
|
147
|
+
* Everything else (accent, borders, headings, code chips…) stays themeable.
|
|
148
|
+
*/
|
|
149
|
+
const LOCKED_SLOTS = new Set([
|
|
150
|
+
'text',
|
|
151
|
+
'textBright',
|
|
152
|
+
'muted',
|
|
153
|
+
'faint',
|
|
154
|
+
'quoteText',
|
|
155
|
+
'code',
|
|
156
|
+
]);
|
|
141
157
|
/** Apply a parsed ThemeSpec onto THEME (values that resolve win). */
|
|
142
158
|
function applySpec(spec, mode = 'dark') {
|
|
143
159
|
for (const slot of Object.keys(SLOT_KEYS)) {
|
|
160
|
+
// Chat-body slots keep their Freebuff defaults: custom themes (e.g.
|
|
161
|
+
// solarized/gold dumps) mapped `text` to a dark base color and the chat
|
|
162
|
+
// turned near-invisible. Locked = theme colors are for CHROME, not for
|
|
163
|
+
// the words themselves.
|
|
164
|
+
if (LOCKED_SLOTS.has(slot))
|
|
165
|
+
continue;
|
|
144
166
|
for (const key of SLOT_KEYS[slot]) {
|
|
145
167
|
const raw = spec.theme[key];
|
|
146
168
|
if (raw === undefined)
|
package/package.json
CHANGED