agen-vektor 0.3.29 → 0.3.31

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
@@ -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).
@@ -5,6 +5,7 @@ const factory_1 = require("../providers/factory");
5
5
  const registry_1 = require("../tools/registry");
6
6
  const filesystem_1 = require("../tools/filesystem");
7
7
  const search_1 = require("../tools/search");
8
+ const semantic_search_1 = require("../tools/semantic-search");
8
9
  const shell_1 = require("../tools/shell");
9
10
  const git_1 = require("../tools/git");
10
11
  const web_1 = require("../tools/web");
@@ -59,6 +60,9 @@ class Agent {
59
60
  this.tools.registerMany([
60
61
  ...(0, filesystem_1.createFilesystemTools)(),
61
62
  ...(0, search_1.createSearchTools)(),
63
+ // Semantic code search (via gateway embeddings relay): semantic_index /
64
+ // semantic_search — cari kode by MEANING, zero-config (key di gateway).
65
+ ...(0, semantic_search_1.createSemanticSearchTools)(),
62
66
  (0, shell_1.createShellTool)(),
63
67
  (0, git_1.createGitTool)(),
64
68
  ...(0, web_1.createWebTools)(),
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createSemanticSearchTools = createSemanticSearchTools;
37
+ /**
38
+ * Semantic code search — "agent yang hafal codebase" (2026-09-14).
39
+ *
40
+ * Alur: chunk file proyek → embedding via GATEWAY relay (/v1/embeddings,
41
+ * zero-config & zero-key — key provider sk- hidup di gateway, pola sama
42
+ * dengan e2b.ts/web.ts) → simpan vektor di file lokal (~/.vector/index.json)
43
+ * → cosine similarity saat query → top-k potongan paling relevan.
44
+ *
45
+ * Zero dependency: embedding = HTTP call, similarity = dot product manual,
46
+ * storage = satu file JSON. Index dibangun manual via tool semantic_index
47
+ * (atau otomatis saat semantic_search pertama tanpa index).
48
+ *
49
+ * Batas wajar (anti-abuse & anti-bloat):
50
+ * - max file 400 (arg hingga 2000), skip >200KB & file binary-ish
51
+ * - max chunk 4000; batch embedding 16 input/request; jeda antar-batch
52
+ * 1.1s (rate limit gateway 60/menit — env VECTOR_EMBED_DELAY_MS utk test)
53
+ * - skip nama file sensitif (credential/secret/.env/key/pem) — index tidak
54
+ * boleh jadi salinan rahasia
55
+ */
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ const free_tier_1 = require("../config/free-tier");
59
+ const paths_1 = require("../utils/paths");
60
+ const GW_BASE = free_tier_1.FREE_GATEWAY_URL.replace(/\/v1$/, '');
61
+ const EMBED_URL = GW_BASE + '/v1/embeddings';
62
+ const DEFAULT_EMBED_MODEL = 'text-embedding-v4';
63
+ const IGNORE = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', '.cache', '.next', 'target', 'coverage', 'sessions', 'logs', 'memory']);
64
+ const TEXT_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.go', '.rs', '.java', '.rb', '.php', '.sh', '.bash', '.yml', '.yaml', '.toml', '.html', '.css', '.scss', '.sql', '.c', '.h', '.cpp', '.hpp', '.cs', '.swift', '.kt', '.txt', '.dockerfile', '.ini', '.cfg']);
65
+ const SENSITIVE = /credential|secret|\.env|\.pem|\.key|id_rsa|password/i;
66
+ const MAX_FILE_BYTES = 200_000;
67
+ const MAX_FILES_DEFAULT = 400;
68
+ const MAX_FILES_HARD = 2000;
69
+ const MAX_CHUNKS = 4000;
70
+ const CHUNK_LINES = 64;
71
+ const BATCH = 16;
72
+ const DELAY_MS = Math.max(0, Number(process.env.VECTOR_EMBED_DELAY_MS ?? 1100));
73
+ function indexPath() {
74
+ return path.join((0, paths_1.getVectorDir)(), 'index.json');
75
+ }
76
+ function loadIndex() {
77
+ try {
78
+ const raw = fs.readFileSync(indexPath(), 'utf8');
79
+ const j = JSON.parse(raw);
80
+ return j && j.version === 1 && Array.isArray(j.chunks) ? j : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function saveIndex(idx) {
87
+ fs.mkdirSync((0, paths_1.getVectorDir)(), { recursive: true });
88
+ fs.writeFileSync(indexPath(), JSON.stringify(idx), { mode: 0o600 });
89
+ }
90
+ function walkFiles(dir, out, max) {
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(dir, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ for (const e of entries) {
99
+ if (out.length >= max)
100
+ return;
101
+ if (IGNORE.has(e.name) || e.name.startsWith('.'))
102
+ continue;
103
+ if (SENSITIVE.test(e.name))
104
+ continue;
105
+ const full = path.join(dir, e.name);
106
+ if (e.isDirectory())
107
+ walkFiles(full, out, max);
108
+ else if (TEXT_EXT.has(path.extname(e.name).toLowerCase()) || e.name === 'Dockerfile')
109
+ out.push(full);
110
+ }
111
+ }
112
+ function chunkText(content) {
113
+ const lines = content.split('\n');
114
+ const chunks = [];
115
+ for (let i = 0; i < lines.length && chunks.length < MAX_CHUNKS; i += CHUNK_LINES) {
116
+ const slice = lines.slice(i, i + CHUNK_LINES);
117
+ const text = slice.join('\n').trim();
118
+ if (text)
119
+ chunks.push({ start: i + 1, end: i + slice.length, text });
120
+ }
121
+ return chunks;
122
+ }
123
+ async function embed(inputs, model) {
124
+ const controller = new AbortController();
125
+ const timer = setTimeout(() => controller.abort(), 30_000);
126
+ try {
127
+ const res = await fetch(EMBED_URL, {
128
+ method: 'POST',
129
+ signal: controller.signal,
130
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'VectorHead-AI-Agent/0.1' },
131
+ body: JSON.stringify({ model, input: inputs }),
132
+ });
133
+ const data = await res.json().catch(() => null);
134
+ if (!res.ok || !data || !Array.isArray(data.data)) {
135
+ const snippet = data && data.error ? JSON.stringify(data.error).slice(0, 200) : `HTTP ${res.status}`;
136
+ throw new Error(`gateway embeddings gagal: ${snippet}`);
137
+ }
138
+ // OpenAI format: data[i].embedding — urut by index utk aman.
139
+ const sorted = [...data.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
140
+ return sorted.map((d) => d.embedding);
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
147
+ function cosine(a, b) {
148
+ let dot = 0, na = 0, nb = 0;
149
+ const n = Math.min(a.length, b.length);
150
+ for (let i = 0; i < n; i++) {
151
+ dot += a[i] * b[i];
152
+ na += a[i] * a[i];
153
+ nb += b[i] * b[i];
154
+ }
155
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
156
+ }
157
+ async function buildIndex(root, maxFiles) {
158
+ const files = [];
159
+ walkFiles(root, files, maxFiles);
160
+ if (!files.length)
161
+ return `ERROR: tidak ada file teks yang bisa di-index di ${root}`;
162
+ const model = DEFAULT_EMBED_MODEL;
163
+ const chunks = [];
164
+ for (const full of files) {
165
+ let content;
166
+ try {
167
+ if (fs.statSync(full).size > MAX_FILE_BYTES)
168
+ continue;
169
+ content = fs.readFileSync(full, 'utf8');
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ if (content.includes('\u0000'))
175
+ continue; // binary-ish
176
+ const rel = path.relative(root, full).split(path.sep).join('/');
177
+ for (const c of chunkText(content))
178
+ chunks.push({ file: rel, ...c, embedding: [] });
179
+ }
180
+ if (!chunks.length)
181
+ return 'ERROR: tidak ada chunk yang bisa di-index';
182
+ for (let i = 0; i < chunks.length; i += BATCH) {
183
+ const vecs = await embed(chunks.slice(i, i + BATCH).map((c) => c.text), model);
184
+ if (vecs.length !== Math.min(BATCH, chunks.length - i)) {
185
+ return `ERROR: jumlah vektor upstream tidak cocok (${vecs.length})`;
186
+ }
187
+ vecs.forEach((v, j) => { chunks[i + j].embedding = v; });
188
+ if (i + BATCH < chunks.length && DELAY_MS)
189
+ await sleep(DELAY_MS);
190
+ }
191
+ saveIndex({ version: 1, model, builtAt: Date.now(), chunks });
192
+ const preview = [...new Set(chunks.map((c) => c.file))].slice(0, 5).join(', ');
193
+ return `OK: index dibangun — ${chunks.length} chunk dari ${files.length} file (model ${model}) → ${indexPath()}${preview ? '\nContoh: ' + preview : ''}`;
194
+ }
195
+ function searchIndex(idx, queryVec, k) {
196
+ const scored = idx.chunks
197
+ .map((c) => ({ c, score: cosine(queryVec, c.embedding) }))
198
+ .sort((a, b) => b.score - a.score)
199
+ .slice(0, Math.max(1, k));
200
+ if (!scored.length || scored[0].score <= 0) {
201
+ return 'Tidak ada hasil relevan (index kosong / skor 0) — coba semantic_index ulang.';
202
+ }
203
+ const lines = scored.map((s, i) => {
204
+ const head = s.c.text.replace(/\s+/g, ' ').slice(0, 180);
205
+ return `${i + 1}. ${s.c.file}:${s.c.start}-${s.c.end} (skor ${s.score.toFixed(3)})\n ${head}`;
206
+ });
207
+ return `Hasil semantic search (top ${scored.length} dari ${idx.chunks.length} chunk):\n${lines.join('\n')}`;
208
+ }
209
+ function createSemanticSearchTools() {
210
+ return [
211
+ {
212
+ definition: {
213
+ name: 'semantic_index',
214
+ description: 'Build/refresh the local semantic index: embed project files (via the VectorHead gateway, no API key needed) and store vectors in ~/.vector/index.json. Run once per project (or after big changes) — then semantic_search can find code by MEANING, not just keywords.',
215
+ parameters: {
216
+ type: 'object',
217
+ properties: {
218
+ path: { type: 'string', description: 'Directory to index, relative to project root (default ".")' },
219
+ max_files: { type: 'number', description: 'Max files to index (default 400, max 2000)' },
220
+ },
221
+ required: [],
222
+ },
223
+ },
224
+ async execute(args, ctx) {
225
+ const base = path.resolve(ctx.cwd, String(args.path || '.'));
226
+ const max = Math.min(MAX_FILES_HARD, Math.max(1, Number(args.max_files) || MAX_FILES_DEFAULT));
227
+ try {
228
+ return { output: await buildIndex(base, max) };
229
+ }
230
+ catch (e) {
231
+ return { output: 'ERROR: ' + String(e.message || e) };
232
+ }
233
+ },
234
+ },
235
+ {
236
+ definition: {
237
+ name: 'semantic_search',
238
+ description: 'Search the codebase by MEANING (semantic similarity over embedded chunks). Automatically builds the index first if none exists. Use for "where is the retry logic?" style questions where grep/keywords fail.',
239
+ parameters: {
240
+ type: 'object',
241
+ properties: {
242
+ query: { type: 'string', description: 'Natural-language query, e.g. "where do we validate the device id"' },
243
+ k: { type: 'number', description: 'Max results (default 5, max 20)' },
244
+ path: { type: 'string', description: 'Directory that was indexed (default ".")' },
245
+ },
246
+ required: ['query'],
247
+ },
248
+ },
249
+ async execute(args, ctx) {
250
+ const query = String(args.query || '').trim();
251
+ if (!query)
252
+ return { output: 'ERROR: query wajib' };
253
+ const k = Math.min(20, Math.max(1, Number(args.k) || 5));
254
+ try {
255
+ let idx = loadIndex();
256
+ if (!idx) {
257
+ const base = path.resolve(ctx.cwd, String(args.path || '.'));
258
+ const built = await buildIndex(base, MAX_FILES_DEFAULT);
259
+ if (built.startsWith('ERROR'))
260
+ return { output: built };
261
+ idx = loadIndex();
262
+ if (!idx)
263
+ return { output: 'ERROR: index gagal dibaca setelah build' };
264
+ }
265
+ const [qv] = await embed([query], idx.model || DEFAULT_EMBED_MODEL);
266
+ if (!qv)
267
+ return { output: 'ERROR: embedding query kosong' };
268
+ return { output: searchIndex(idx, qv, k) };
269
+ }
270
+ catch (e) {
271
+ return { output: 'ERROR: ' + String(e.message || e) };
272
+ }
273
+ },
274
+ },
275
+ ];
276
+ }
package/dist/tui/app.js CHANGED
@@ -3051,7 +3051,9 @@ class App {
3051
3051
  continue;
3052
3052
  const args = (m.content.split('\n')[0] || '').trim();
3053
3053
  const head = `${(0, chat_1.toolIcon)(m.tool)} ${theme_1.THEME.bold}${(0, chat_1.toolLabel)(m.tool)}${theme_1.THEME.reset}`;
3054
- const line = args ? `${head} ${theme_1.THEME.faint}${args}${theme_1.THEME.reset}` : head;
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;
3055
3057
  out.unshift((0, terminal_1.truncate)(line, inner));
3056
3058
  }
3057
3059
  return out;
@@ -3168,6 +3170,16 @@ class App {
3168
3170
  const visible = (0, chat_1.chatWindow)(all, this.scroll, chatH);
3169
3171
  const chatTop = 2;
3170
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 = ' ';
3171
3183
  const toolRows = this.toolPanelRows(cols);
3172
3184
  const activityH = toolRows.length > 0 ? toolRows.length + 2 : 0;
3173
3185
  const suggestH = this.suggestHeight();
@@ -3177,9 +3189,9 @@ class App {
3177
3189
  // blank row, input (vertically centered), blank row, bottom border.
3178
3190
  // Slash suggestions render between the tool panel and the box.
3179
3191
  const boxTop = sepRow + activityH + suggestH + 1;
3180
- const boxBot = boxTop + this.input.wrappedRows(cols) + 3;
3192
+ const boxBot = boxTop + this.input.wrappedRows(frameW) + 3;
3181
3193
  const statusRow = boxBot + 1;
3182
- const rendered = this.input.render(cols, input_1.DEFAULT_PLACEHOLDER);
3194
+ const rendered = this.input.render(frameW, input_1.DEFAULT_PLACEHOLDER);
3183
3195
  // Cursor row: prompt line 0 DILUKIS di boxTop + 1 (border ╭ di boxTop−1,
3184
3196
  // blank padding di boxTop). Dulu +2 menaruh kursor hardware SATU BARIS di
3185
3197
  // bawah teks prompt — baris kosong dasar box, tepat di atas bar "Build •
@@ -3187,26 +3199,27 @@ class App {
3187
3199
  // YOLO" (laporan user 2026-09-07). Kursor hardware sendiri kini SELALU
3188
3200
  // hidden (lihat doRender di cli/index.ts); posisi tetap diperbaiki agar
3189
3201
  // benar untuk audit/bounds.
3190
- const cursorCol = rendered.cursorCol + 1;
3202
+ // +2: 1-col frame indent + 1 left border column.
3203
+ const cursorCol = rendered.cursorCol + 2;
3191
3204
  const cursorRow = boxTop + 1 + rendered.cursorRow;
3192
3205
  const frame = new Array(rows).fill('');
3193
3206
  frame[0] = (0, statusbar_1.renderHeader)(cols, info);
3194
3207
  for (let i = 0; i < chatH; i++) {
3195
3208
  frame[chatTop - 1 + i] = visible[i] !== undefined ? visible[i] : '';
3196
3209
  }
3197
- frame[sepRow - 1] = theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset;
3210
+ frame[sepRow - 1] = frameIndent + theme_1.THEME.border + terminal_1.BOX.h.repeat(frameW) + theme_1.THEME.reset;
3198
3211
  // Boxed tool-call panel: `· Tool args` lines (last TOOL_PANEL_ROWS calls)
3199
3212
  // inside a rounded box, directly above the chat prompt — mirrors the
3200
3213
  // committed README example instead of a plain unboxed strip.
3201
3214
  if (activityH > 0) {
3202
3215
  const toolBorder = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
3203
- frame[sepRow] = `${toolBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╮${theme_1.THEME.reset}`;
3216
+ frame[sepRow] = `${frameIndent}${toolBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╮${theme_1.THEME.reset}`;
3204
3217
  for (let i = 0; i < toolRows.length; i++) {
3205
3218
  const line = toolRows[i];
3206
- const pad = Math.max(0, cols - 2 - (0, terminal_1.visibleWidth)(line));
3207
- 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}`;
3208
3221
  }
3209
- frame[sepRow + toolRows.length + 1] = `${toolBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╯${theme_1.THEME.reset}`;
3222
+ frame[sepRow + toolRows.length + 1] = `${frameIndent}${toolBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╯${theme_1.THEME.reset}`;
3210
3223
  }
3211
3224
  // Freebuff-style slash command popup (when typing `/…`). The reserved
3212
3225
  // height IS the render budget: suggestHeight already fits the whole
@@ -3220,17 +3233,17 @@ class App {
3220
3233
  }
3221
3234
  // Freebuff renders the input box border in the foreground color (white).
3222
3235
  const boxBorder = `${theme_1.THEME.bold}${theme_1.THEME.textBright}`;
3223
- frame[boxTop - 1] = `${boxBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╮${theme_1.THEME.reset}`;
3236
+ frame[boxTop - 1] = `${frameIndent}${boxBorder}╭${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╮${theme_1.THEME.reset}`;
3224
3237
  // Vertical padding (Freebuff chat-input-bar): one blank content row
3225
3238
  // above and below the wrapped prompt lines.
3226
- const blankRow = `${boxBorder}│${theme_1.THEME.reset}${' '.repeat(Math.max(0, cols - 2))}${boxBorder}│${theme_1.THEME.reset}`;
3239
+ const blankRow = `${frameIndent}${boxBorder}│${theme_1.THEME.reset}${' '.repeat(Math.max(0, frameW - 2))}${boxBorder}│${theme_1.THEME.reset}`;
3227
3240
  frame[boxTop] = blankRow;
3228
3241
  for (let i = 0; i < rendered.lines.length; i++) {
3229
- 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}`;
3230
3243
  }
3231
3244
  for (let i = boxTop + 1 + rendered.lines.length; i < boxBot - 1; i++)
3232
3245
  frame[i] = blankRow;
3233
- frame[boxBot - 1] = `${boxBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, cols - 2))}╯${theme_1.THEME.reset}`;
3246
+ frame[boxBot - 1] = `${frameIndent}${boxBorder}╰${terminal_1.BOX.h.repeat(Math.max(0, frameW - 2))}╯${theme_1.THEME.reset}`;
3234
3247
  frame[statusRow - 1] = (0, statusbar_1.renderStatusBar)(cols, info);
3235
3248
  // OpenCode single-buffer compositing: the modal overlay is composed INTO
3236
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: <icon> tool · preview (Freebuff tool-call style:
963
- // bullet + bold tool name + description), then dim result lines.
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} ${theme_1.THEME.faint}${(parts[0] || '').trim()}${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.faint}${line}${theme_1.THEME.reset}`);
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
- // Freebuff EXACT: the preview normalizes the content to ONE line and
1040
- // word-wraps it (getLastNVisualLines) — so the 5-line preview is the
1041
- // last 5 VISUAL lines, with '...' prefixed when earlier lines exist.
1042
- // The wrap width accounts for the '...' prefix (−3) and the indent (−2,
1043
- // Freebuff paddingLeft: 2). Expanded keeps the original line breaks.
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)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${body}${theme_1.THEME.reset}`, bodyCols)) {
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)(`${theme_1.THEME.muted}${theme_1.THEME.italic}${line}${theme_1.THEME.reset}`, bodyCols)) {
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
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": {