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 +4 -0
- package/dist/agent/agent.js +4 -0
- package/dist/tools/semantic-search.js +276 -0
- package/dist/tui/app.js +26 -13
- 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/agent/agent.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
3192
|
+
const boxBot = boxTop + this.input.wrappedRows(frameW) + 3;
|
|
3181
3193
|
const statusRow = boxBot + 1;
|
|
3182
|
-
const rendered = this.input.render(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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