@bahulam/code 0.1.18 → 0.1.20
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/package.json +1 -1
- package/src/config/model-defaults-default.json +9 -0
- package/src/config/model-defaults.mjs +42 -0
- package/src/config/settings.mjs +3 -2
- package/src/core/lint-resolver.mjs +48 -16
- package/src/core/tasks.mjs +47 -0
- package/src/core/tool-executor.mjs +49 -15
- package/src/terminal/main.mjs +4 -1
- package/src/terminal/repl.mjs +123 -14
- package/src/tools/agent.mjs +2 -1
- package/src/tools/todo-write.mjs +15 -2
- package/src/ui/commands.mjs +8 -5
- package/src/ui/input-dock.mjs +2 -2
- package/src/ui/slash-commands.mjs +5 -1
- package/src/ui/text-layout.mjs +7 -4
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped npm-side model defaults.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth is backend `app/services/model_defaults.py`; release sync
|
|
5
|
+
* writes `model-defaults-default.json` next to the shipped catalog.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
export const FALLBACK_MODEL_DEFAULTS = Object.freeze({
|
|
13
|
+
reasoning: 'deepseek/deepseek-v4-flash',
|
|
14
|
+
fast: 'deepseek/deepseek-v4-flash',
|
|
15
|
+
planning: 'deepseek/deepseek-v4-pro',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const _dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const DEFAULTS_PATH = path.join(_dirname, 'model-defaults-default.json');
|
|
20
|
+
|
|
21
|
+
function clean(value) {
|
|
22
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readShippedModelDefaults() {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf-8'));
|
|
28
|
+
const defaults = data?.defaults && typeof data.defaults === 'object' ? data.defaults : {};
|
|
29
|
+
return {
|
|
30
|
+
reasoning: clean(defaults.reasoning) || FALLBACK_MODEL_DEFAULTS.reasoning,
|
|
31
|
+
fast: clean(defaults.fast) || FALLBACK_MODEL_DEFAULTS.fast,
|
|
32
|
+
planning: clean(defaults.planning) || FALLBACK_MODEL_DEFAULTS.planning,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { ...FALLBACK_MODEL_DEFAULTS };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SHIPPED_MODEL_DEFAULTS = readShippedModelDefaults();
|
|
40
|
+
export const DEFAULT_REASONING_MODEL = SHIPPED_MODEL_DEFAULTS.reasoning;
|
|
41
|
+
export const DEFAULT_FAST_MODEL = SHIPPED_MODEL_DEFAULTS.fast;
|
|
42
|
+
export const DEFAULT_PLANNING_MODEL = SHIPPED_MODEL_DEFAULTS.planning;
|
package/src/config/settings.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
|
+
import { DEFAULT_FAST_MODEL, DEFAULT_REASONING_MODEL } from './model-defaults.mjs';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Full settings schema with defaults.
|
|
@@ -30,9 +31,9 @@ export const SETTINGS_SCHEMA = {
|
|
|
30
31
|
Stop: [],
|
|
31
32
|
SessionStart: [],
|
|
32
33
|
},
|
|
33
|
-
model:
|
|
34
|
+
model: DEFAULT_REASONING_MODEL,
|
|
34
35
|
subagentModel: null,
|
|
35
|
-
fastModel:
|
|
36
|
+
fastModel: DEFAULT_FAST_MODEL,
|
|
36
37
|
fastMode: false,
|
|
37
38
|
alwaysThinkingEnabled: false,
|
|
38
39
|
autoCompactEnabled: true,
|
|
@@ -68,7 +68,12 @@ export function resolveLintCommand(targetPath, {
|
|
|
68
68
|
return resolvePythonLint(target, { stat, root });
|
|
69
69
|
}
|
|
70
70
|
if (language === 'typescript') {
|
|
71
|
-
return resolveJavaScriptLint(target, {
|
|
71
|
+
return resolveJavaScriptLint(target, {
|
|
72
|
+
stat,
|
|
73
|
+
root,
|
|
74
|
+
typescript: true,
|
|
75
|
+
preferTypeCheck: !allowProjectScript,
|
|
76
|
+
});
|
|
72
77
|
}
|
|
73
78
|
if (language === 'javascript') {
|
|
74
79
|
return resolveJavaScriptLint(target, { stat, root, typescript: false });
|
|
@@ -279,7 +284,47 @@ function captured(command) {
|
|
|
279
284
|
return `${command} 2>&1 || true`;
|
|
280
285
|
}
|
|
281
286
|
|
|
282
|
-
function
|
|
287
|
+
function resolveTypeScriptProjectCheck(target, { stat, root }) {
|
|
288
|
+
const baseDir = stat?.isDirectory() ? target : path.dirname(target);
|
|
289
|
+
const tsconfig = findUpFile(baseDir, root, ['tsconfig.json']);
|
|
290
|
+
if (!tsconfig) return null;
|
|
291
|
+
const cwd = path.dirname(tsconfig);
|
|
292
|
+
const hasTypescript = hasNodeTool(baseDir, root, 'tsc', ['typescript']);
|
|
293
|
+
if (!hasTypescript) return null;
|
|
294
|
+
return {
|
|
295
|
+
command: captured(`npx --no-install tsc --noEmit --pretty false -p ${shellPathArg(tsconfig, cwd)}`),
|
|
296
|
+
cwd,
|
|
297
|
+
language: 'typescript',
|
|
298
|
+
target,
|
|
299
|
+
scope: 'project',
|
|
300
|
+
source: 'typescript',
|
|
301
|
+
reason: 'tsconfig project check',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function normalizeLintOutput(lint, { stdout = '', stderr = '', errored = false } = {}) {
|
|
306
|
+
const output = String(errored ? (stderr || stdout || '') : (stdout || stderr || '')).trim();
|
|
307
|
+
if (!output) return null;
|
|
308
|
+
if (lint?.source !== 'eslint' || !/Oops!\s+Something went wrong!/i.test(output)) {
|
|
309
|
+
return output;
|
|
310
|
+
}
|
|
311
|
+
const lines = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
312
|
+
const detail = lines.find(line => (
|
|
313
|
+
!/^Oops!/i.test(line) &&
|
|
314
|
+
!/^ESLint:/i.test(line) &&
|
|
315
|
+
!/^If you still have problems/i.test(line) &&
|
|
316
|
+
!/^Please include/i.test(line)
|
|
317
|
+
));
|
|
318
|
+
const suffix = detail ? ` ${detail}` : ' Check the project ESLint config, parser, and plugins.';
|
|
319
|
+
return `eslint failed:${suffix}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolveJavaScriptLint(target, { stat, root, typescript, preferTypeCheck = false }) {
|
|
323
|
+
if (typescript && preferTypeCheck) {
|
|
324
|
+
const typecheck = resolveTypeScriptProjectCheck(target, { stat, root });
|
|
325
|
+
if (typecheck) return typecheck;
|
|
326
|
+
}
|
|
327
|
+
|
|
283
328
|
const baseDir = stat?.isDirectory() ? target : path.dirname(target);
|
|
284
329
|
const eslintReady = hasConfig(baseDir, root, ESLINT_CONFIGS) ||
|
|
285
330
|
hasNodeTool(baseDir, root, 'eslint', ['eslint']);
|
|
@@ -312,20 +357,7 @@ function resolveJavaScriptLint(target, { stat, root, typescript }) {
|
|
|
312
357
|
}
|
|
313
358
|
|
|
314
359
|
if (typescript) {
|
|
315
|
-
|
|
316
|
-
if (!tsconfig) return null;
|
|
317
|
-
const cwd = path.dirname(tsconfig);
|
|
318
|
-
const hasTypescript = hasNodeTool(baseDir, root, 'tsc', ['typescript']);
|
|
319
|
-
if (!hasTypescript) return null;
|
|
320
|
-
return {
|
|
321
|
-
command: captured(`npx --no-install tsc --noEmit --pretty false -p ${shellPathArg(tsconfig, cwd)}`),
|
|
322
|
-
cwd,
|
|
323
|
-
language: 'typescript',
|
|
324
|
-
target,
|
|
325
|
-
scope: 'project',
|
|
326
|
-
source: 'typescript',
|
|
327
|
-
reason: 'tsconfig project check',
|
|
328
|
-
};
|
|
360
|
+
return resolveTypeScriptProjectCheck(target, { stat, root });
|
|
329
361
|
}
|
|
330
362
|
|
|
331
363
|
const ext = path.extname(target).toLowerCase();
|
package/src/core/tasks.mjs
CHANGED
|
@@ -16,6 +16,9 @@ const DEFAULT_CONTENT = Object.freeze({
|
|
|
16
16
|
'done.md': '# Done\n\n',
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
const MANAGED_START = '<!-- bahulam:todo-write:start -->';
|
|
20
|
+
const MANAGED_END = '<!-- bahulam:todo-write:end -->';
|
|
21
|
+
|
|
19
22
|
export function ensureTaskFiles({ cwd = process.cwd() } = {}) {
|
|
20
23
|
const dir = path.join(cwd, '.bahulam', 'tasks');
|
|
21
24
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -125,6 +128,38 @@ export function taskCounts(board) {
|
|
|
125
128
|
);
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
export function syncTodoWriteToTaskFiles({ cwd = process.cwd(), todos = [] } = {}) {
|
|
132
|
+
ensureTaskFiles({ cwd });
|
|
133
|
+
const grouped = { active: [], backlog: [], blocked: [], done: [] };
|
|
134
|
+
for (const todo of Array.isArray(todos) ? todos : []) {
|
|
135
|
+
const text = String(todo?.content || '').trim();
|
|
136
|
+
if (!text) continue;
|
|
137
|
+
const status = String(todo?.status || 'pending').toLowerCase();
|
|
138
|
+
const list = status === 'completed'
|
|
139
|
+
? 'done'
|
|
140
|
+
: status === 'in_progress'
|
|
141
|
+
? 'active'
|
|
142
|
+
: 'backlog';
|
|
143
|
+
grouped[list].push({ text, checked: list === 'done' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const written = [];
|
|
147
|
+
for (const [list, fileName] of Object.entries(TASK_FILES)) {
|
|
148
|
+
const filePath = path.join(cwd, '.bahulam', 'tasks', fileName);
|
|
149
|
+
const existing = readText(filePath) || DEFAULT_CONTENT[fileName] || `# ${list}\n\n`;
|
|
150
|
+
const managed = grouped[list]
|
|
151
|
+
.map(task => taskLine(task.text, list, task.checked))
|
|
152
|
+
.join('\n');
|
|
153
|
+
const block = `${MANAGED_START}\n${managed}${managed ? '\n' : ''}${MANAGED_END}`;
|
|
154
|
+
const next = replaceManagedBlock(existing, block);
|
|
155
|
+
if (next !== existing) {
|
|
156
|
+
fs.writeFileSync(filePath, next);
|
|
157
|
+
written.push(filePath);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { written, counts: Object.fromEntries(Object.entries(grouped).map(([list, items]) => [list, items.length])) };
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
export function normalizeList(value) {
|
|
129
164
|
const key = String(value || '').toLowerCase();
|
|
130
165
|
if (key === 'todo' || key === 'pending') return 'backlog';
|
|
@@ -149,6 +184,18 @@ function taskLine(text, list, checked = false) {
|
|
|
149
184
|
return `- [${mark}] ${text}`;
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
function replaceManagedBlock(content, block) {
|
|
188
|
+
const value = String(content || '');
|
|
189
|
+
const pattern = new RegExp(`${escapeRegExp(MANAGED_START)}[\\s\\S]*?${escapeRegExp(MANAGED_END)}`);
|
|
190
|
+
if (pattern.test(value)) return value.replace(pattern, block);
|
|
191
|
+
const trimmed = value.replace(/\s*$/, '');
|
|
192
|
+
return `${trimmed}\n\n${block}\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function escapeRegExp(value) {
|
|
196
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
197
|
+
}
|
|
198
|
+
|
|
152
199
|
function readText(filePath) {
|
|
153
200
|
try {
|
|
154
201
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
@@ -28,7 +28,7 @@ import { buildFileDiff } from './file-diff.mjs';
|
|
|
28
28
|
import { buildWorkScope } from './work-scope.mjs';
|
|
29
29
|
import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
|
|
30
30
|
import { backgroundTasks } from './background-tasks.mjs';
|
|
31
|
-
import { resolveLintCommand } from './lint-resolver.mjs';
|
|
31
|
+
import { normalizeLintOutput, resolveLintCommand } from './lint-resolver.mjs';
|
|
32
32
|
import { PluginRegistry } from '../plugins/registry.mjs';
|
|
33
33
|
import { loadPluginTool } from '../plugins/executor.mjs';
|
|
34
34
|
import * as fs from 'node:fs';
|
|
@@ -137,7 +137,8 @@ export function createToolExecutor({
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
|
|
140
|
-
let
|
|
140
|
+
let _structureCacheGeneration = 0;
|
|
141
|
+
let _searchCacheGeneration = 0;
|
|
141
142
|
const readOnlyResultCache = new Map();
|
|
142
143
|
|
|
143
144
|
function resolvePath(p, args = {}, options = {}) {
|
|
@@ -339,11 +340,12 @@ export function createToolExecutor({
|
|
|
339
340
|
);
|
|
340
341
|
}
|
|
341
342
|
|
|
342
|
-
function updateProjectIndex(filePath) {
|
|
343
|
+
function updateProjectIndex(filePath, { contentChanged = true, structureChanged = false } = {}) {
|
|
343
344
|
try {
|
|
344
345
|
projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
|
|
345
346
|
} catch { /* best effort */ }
|
|
346
|
-
|
|
347
|
+
if (contentChanged) _searchCacheGeneration++;
|
|
348
|
+
if (structureChanged) _structureCacheGeneration++;
|
|
347
349
|
}
|
|
348
350
|
|
|
349
351
|
function readTextIfExists(filePath) {
|
|
@@ -390,7 +392,6 @@ export function createToolExecutor({
|
|
|
390
392
|
kind,
|
|
391
393
|
args,
|
|
392
394
|
fingerprint,
|
|
393
|
-
generation: _readOnlyCacheGeneration,
|
|
394
395
|
}));
|
|
395
396
|
}
|
|
396
397
|
|
|
@@ -498,9 +499,11 @@ export function createToolExecutor({
|
|
|
498
499
|
maxBuffer: 1_000_000,
|
|
499
500
|
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
|
|
500
501
|
}, (err, stdout = '', stderr = '') => {
|
|
501
|
-
const output =
|
|
502
|
-
|
|
503
|
-
: stripAnsi(
|
|
502
|
+
const output = normalizeLintOutput(lint, {
|
|
503
|
+
stdout: stripAnsi(stdout),
|
|
504
|
+
stderr: stripAnsi(stderr),
|
|
505
|
+
errored: Boolean(err),
|
|
506
|
+
});
|
|
504
507
|
resolve(output || null);
|
|
505
508
|
});
|
|
506
509
|
});
|
|
@@ -920,6 +923,28 @@ export function createToolExecutor({
|
|
|
920
923
|
};
|
|
921
924
|
},
|
|
922
925
|
|
|
926
|
+
TodoWrite: async (args, options = {}) => {
|
|
927
|
+
throwIfAborted(options.signal);
|
|
928
|
+
const result = await occRegistry.call('TodoWrite', args || {}, {
|
|
929
|
+
...options,
|
|
930
|
+
cwd: process.cwd(),
|
|
931
|
+
onTaskFilesWritten: (files) => {
|
|
932
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
933
|
+
updateProjectIndex(file, { contentChanged: true, structureChanged: true });
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
success: !/^Validation error:/i.test(String(result || '')),
|
|
939
|
+
output: String(result || ''),
|
|
940
|
+
_tool: 'TodoWrite',
|
|
941
|
+
};
|
|
942
|
+
},
|
|
943
|
+
|
|
944
|
+
todo_write: async (args, options = {}) => {
|
|
945
|
+
return toolMap.TodoWrite(args, options);
|
|
946
|
+
},
|
|
947
|
+
|
|
923
948
|
// Reserved meta-tool adapter. Cloud backends may implement Delegate
|
|
924
949
|
// natively; local callbacks use this to route through the exact same
|
|
925
950
|
// registry + dispatch funnel as /run and workflows.
|
|
@@ -1421,6 +1446,7 @@ export function createToolExecutor({
|
|
|
1421
1446
|
return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Register the project, then use an absolute path.`, _tool: 'write_file' };
|
|
1422
1447
|
}
|
|
1423
1448
|
const filePath = await resolvePath(rawPath, args, { allowMissing: true });
|
|
1449
|
+
const existedBefore = fs.existsSync(filePath);
|
|
1424
1450
|
const before = readTextIfExists(filePath);
|
|
1425
1451
|
const writeCheck = validateWrite(filePath, args.content, projectRootFor(filePath));
|
|
1426
1452
|
if (!writeCheck.safe) {
|
|
@@ -1443,7 +1469,10 @@ export function createToolExecutor({
|
|
|
1443
1469
|
const wrapped = wrapResult(result, 'write_file');
|
|
1444
1470
|
const after = readTextIfExists(filePath);
|
|
1445
1471
|
attachFileDiff(wrapped, filePath, before, after);
|
|
1446
|
-
updateProjectIndex(filePath
|
|
1472
|
+
updateProjectIndex(filePath, {
|
|
1473
|
+
contentChanged: before !== after,
|
|
1474
|
+
structureChanged: !existedBefore && fs.existsSync(filePath),
|
|
1475
|
+
});
|
|
1447
1476
|
|
|
1448
1477
|
// Auto-lint the written file
|
|
1449
1478
|
const lintOutput = await autoLint(filePath);
|
|
@@ -1489,6 +1518,7 @@ export function createToolExecutor({
|
|
|
1489
1518
|
// Ensure parent directory exists
|
|
1490
1519
|
const dir = path.dirname(filePath);
|
|
1491
1520
|
fs.mkdirSync(dir, { recursive: true });
|
|
1521
|
+
const existedBefore = fs.existsSync(filePath);
|
|
1492
1522
|
const before = readTextIfExists(filePath);
|
|
1493
1523
|
|
|
1494
1524
|
// Read first if exists (OCC Write requirement)
|
|
@@ -1501,7 +1531,10 @@ export function createToolExecutor({
|
|
|
1501
1531
|
await occRegistry.call('write_file', { file_path: filePath, content });
|
|
1502
1532
|
const after = readTextIfExists(filePath);
|
|
1503
1533
|
diffs.push(buildResultFileDiff(filePath, before, after));
|
|
1504
|
-
updateProjectIndex(filePath
|
|
1534
|
+
updateProjectIndex(filePath, {
|
|
1535
|
+
contentChanged: before !== after,
|
|
1536
|
+
structureChanged: !existedBefore && fs.existsSync(filePath),
|
|
1537
|
+
});
|
|
1505
1538
|
results.push(rawPath);
|
|
1506
1539
|
} catch (err) {
|
|
1507
1540
|
errors.push(`${rawPath}: ${err.message}`);
|
|
@@ -1615,7 +1648,7 @@ export function createToolExecutor({
|
|
|
1615
1648
|
};
|
|
1616
1649
|
}
|
|
1617
1650
|
attachFileDiff(wrapped, filePath, before, after);
|
|
1618
|
-
updateProjectIndex(filePath);
|
|
1651
|
+
updateProjectIndex(filePath, { contentChanged: before !== after, structureChanged: false });
|
|
1619
1652
|
_hasEdited = true;
|
|
1620
1653
|
|
|
1621
1654
|
// Auto-lint the edited file
|
|
@@ -1643,7 +1676,7 @@ export function createToolExecutor({
|
|
|
1643
1676
|
format: args.format || (args.tree === true ? 'tree' : 'glob'),
|
|
1644
1677
|
max_depth: args.max_depth ?? args.maxDepth ?? null,
|
|
1645
1678
|
},
|
|
1646
|
-
{
|
|
1679
|
+
{ structureGeneration: _structureCacheGeneration },
|
|
1647
1680
|
async () => {
|
|
1648
1681
|
if (args.format === 'tree' || args.tree === true) {
|
|
1649
1682
|
const requestedDepth = Number(args.max_depth ?? args.maxDepth ?? 2);
|
|
@@ -1764,7 +1797,7 @@ export function createToolExecutor({
|
|
|
1764
1797
|
return await withReadOnlyCache(
|
|
1765
1798
|
'search_files',
|
|
1766
1799
|
{ query, path: searchPath, mode: 'glob' },
|
|
1767
|
-
{
|
|
1800
|
+
{ structureGeneration: _structureCacheGeneration },
|
|
1768
1801
|
async () => {
|
|
1769
1802
|
const result = await occRegistry.call('list_files', {
|
|
1770
1803
|
pattern: query,
|
|
@@ -1785,7 +1818,7 @@ export function createToolExecutor({
|
|
|
1785
1818
|
return await withReadOnlyCache(
|
|
1786
1819
|
'search_files',
|
|
1787
1820
|
{ query, path: searchPath, mode: 'grep' },
|
|
1788
|
-
{
|
|
1821
|
+
{ searchGeneration: _searchCacheGeneration },
|
|
1789
1822
|
async () => {
|
|
1790
1823
|
const result = await occRegistry.call('search_code', {
|
|
1791
1824
|
pattern: query,
|
|
@@ -1889,8 +1922,9 @@ export function createToolExecutor({
|
|
|
1889
1922
|
if (checkpoints) {
|
|
1890
1923
|
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
1891
1924
|
}
|
|
1925
|
+
const existedBefore = fs.existsSync(filePath);
|
|
1892
1926
|
fs.unlinkSync(filePath);
|
|
1893
|
-
updateProjectIndex(filePath);
|
|
1927
|
+
updateProjectIndex(filePath, { contentChanged: existedBefore, structureChanged: existedBefore });
|
|
1894
1928
|
return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
|
|
1895
1929
|
} catch (err) {
|
|
1896
1930
|
return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
|
package/src/terminal/main.mjs
CHANGED
|
@@ -380,7 +380,10 @@ async function main() {
|
|
|
380
380
|
/help Show available commands
|
|
381
381
|
/stats Session metrics (tokens, cost, tools)
|
|
382
382
|
/cost Detailed cost breakdown by model
|
|
383
|
-
/model
|
|
383
|
+
/model Open interactive model overrides
|
|
384
|
+
/model status Show model overrides and catalog source
|
|
385
|
+
/model refresh Refresh model catalog from backend
|
|
386
|
+
/model list [category] List curated platform models
|
|
384
387
|
/history Conversation history
|
|
385
388
|
/new Start a new session
|
|
386
389
|
/clear Clear conversation history
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -412,8 +412,34 @@ const NAMED_MODEL_MODES = new Set(NAMED_MODEL_MODES_LIST);
|
|
|
412
412
|
let _modelCatalogCache = null;
|
|
413
413
|
let _modelCatalogError = null;
|
|
414
414
|
let _modelCatalogSource = null; // 'snapshot' | 'backend'
|
|
415
|
+
let _modelCatalogFetchedAt = null;
|
|
415
416
|
let _backendRefreshInFlight = null;
|
|
416
417
|
|
|
418
|
+
function modelCatalogSummary(catalog = _modelCatalogCache) {
|
|
419
|
+
const rows = Array.isArray(catalog) ? catalog : [];
|
|
420
|
+
const curated = rows.filter(m => m?.harness_validated).length;
|
|
421
|
+
return { total: rows.length, curated };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function formatCatalogSource(source = _modelCatalogSource) {
|
|
425
|
+
if (source === 'backend') return 'backend';
|
|
426
|
+
if (source === 'snapshot') return 'shipped snapshot';
|
|
427
|
+
return 'unloaded';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function formatCatalogFetchedAt(value = _modelCatalogFetchedAt) {
|
|
431
|
+
if (!value) return 'not refreshed this session';
|
|
432
|
+
try {
|
|
433
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
434
|
+
hour: '2-digit',
|
|
435
|
+
minute: '2-digit',
|
|
436
|
+
second: '2-digit',
|
|
437
|
+
}).format(value);
|
|
438
|
+
} catch {
|
|
439
|
+
return value.toISOString();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
417
443
|
async function fetchModelCatalog(ctx) {
|
|
418
444
|
// Seed from the shipped snapshot so /model list, /model form and the
|
|
419
445
|
// curation warnings work offline. The backend refresh below overlays
|
|
@@ -423,6 +449,7 @@ async function fetchModelCatalog(ctx) {
|
|
|
423
449
|
if (shipped && shipped.length) {
|
|
424
450
|
_modelCatalogCache = shipped;
|
|
425
451
|
_modelCatalogSource = 'snapshot';
|
|
452
|
+
_modelCatalogFetchedAt = new Date();
|
|
426
453
|
_modelCatalogError = null;
|
|
427
454
|
}
|
|
428
455
|
}
|
|
@@ -467,6 +494,7 @@ async function refreshCatalogFromBackend(ctx) {
|
|
|
467
494
|
if (models && models.length) {
|
|
468
495
|
_modelCatalogCache = models;
|
|
469
496
|
_modelCatalogSource = 'backend';
|
|
497
|
+
_modelCatalogFetchedAt = new Date();
|
|
470
498
|
_modelCatalogError = null;
|
|
471
499
|
} else if (!_modelCatalogCache) {
|
|
472
500
|
_modelCatalogError = models ? 'catalog is empty' : 'unexpected response shape';
|
|
@@ -565,6 +593,8 @@ async function printModelCatalog(ctx, filterCategory = null) {
|
|
|
565
593
|
if (rest > 0) {
|
|
566
594
|
process.stderr.write(` ${c.dim(`+${rest} more models available on the BYOK route (--route byok, own API key)`)}\n`);
|
|
567
595
|
}
|
|
596
|
+
const summary = modelCatalogSummary(catalog);
|
|
597
|
+
process.stderr.write(` ${c.dim(`source: ${formatCatalogSource()} · ${summary.total} models · ${summary.curated} curated · refreshed ${formatCatalogFetchedAt()}`)}\n`);
|
|
568
598
|
process.stderr.write('\n');
|
|
569
599
|
}
|
|
570
600
|
|
|
@@ -611,7 +641,7 @@ function applyLaunchModelArgs(cliArgs, ctx) {
|
|
|
611
641
|
return Promise.all(pending);
|
|
612
642
|
}
|
|
613
643
|
|
|
614
|
-
function printModelStatus() {
|
|
644
|
+
async function printModelStatus(ctx = null) {
|
|
615
645
|
process.stderr.write(`\n ${c.bold('Models')}\n`);
|
|
616
646
|
process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
|
|
617
647
|
process.stderr.write(` ${c.gray('Active coding')} ${session.model || 'backend default'}\n`);
|
|
@@ -620,6 +650,17 @@ function printModelStatus() {
|
|
|
620
650
|
process.stderr.write(` ${c.gray('Mode ')} ${session.modelMode}\n`);
|
|
621
651
|
}
|
|
622
652
|
|
|
653
|
+
const catalog = await fetchModelCatalog(ctx);
|
|
654
|
+
const summary = modelCatalogSummary(catalog);
|
|
655
|
+
process.stderr.write(`\n ${c.bold('Catalog')}\n`);
|
|
656
|
+
if (catalog) {
|
|
657
|
+
process.stderr.write(` ${c.gray('Source ')} ${formatCatalogSource()}\n`);
|
|
658
|
+
process.stderr.write(` ${c.gray('Models ')} ${summary.total} total · ${summary.curated} curated\n`);
|
|
659
|
+
process.stderr.write(` ${c.gray('Refreshed ')} ${formatCatalogFetchedAt()}\n`);
|
|
660
|
+
} else {
|
|
661
|
+
process.stderr.write(` ${c.yellow('!')} ${c.dim(_modelCatalogError || 'unavailable')}\n`);
|
|
662
|
+
}
|
|
663
|
+
|
|
623
664
|
const limits = session.modelLimits || {};
|
|
624
665
|
const subAgentModels = session.subAgentModels || {};
|
|
625
666
|
const planningModel = subAgentModels.plan || limits.planning?.model || limits.orchestrator?.model;
|
|
@@ -716,13 +757,13 @@ async function handleModelCommand(rest = '', ctx) {
|
|
|
716
757
|
if (process.stdin.isTTY) {
|
|
717
758
|
await openModelForm(ctx);
|
|
718
759
|
} else {
|
|
719
|
-
printModelStatus();
|
|
760
|
+
await printModelStatus(ctx);
|
|
720
761
|
}
|
|
721
762
|
return;
|
|
722
763
|
}
|
|
723
764
|
|
|
724
765
|
if (parts[0] === 'status') {
|
|
725
|
-
printModelStatus();
|
|
766
|
+
await printModelStatus(ctx);
|
|
726
767
|
return;
|
|
727
768
|
}
|
|
728
769
|
|
|
@@ -751,8 +792,9 @@ async function handleModelCommand(rest = '', ctx) {
|
|
|
751
792
|
_modelCatalogCache = null;
|
|
752
793
|
_modelCatalogError = null;
|
|
753
794
|
_modelCatalogSource = null;
|
|
795
|
+
_modelCatalogFetchedAt = null;
|
|
754
796
|
_backendRefreshInFlight = null;
|
|
755
|
-
process.stderr.write(` ${c.dim('Refreshing model catalog…')}\n`);
|
|
797
|
+
process.stderr.write(` ${c.dim('Refreshing model catalog from backend…')}\n`);
|
|
756
798
|
await refreshCatalogFromBackend(ctx);
|
|
757
799
|
if (_modelCatalogSource !== 'backend') {
|
|
758
800
|
// Backend fetch failed — restore the shipped snapshot so subsequent
|
|
@@ -761,8 +803,12 @@ async function handleModelCommand(rest = '', ctx) {
|
|
|
761
803
|
if (shipped && shipped.length) {
|
|
762
804
|
_modelCatalogCache = shipped;
|
|
763
805
|
_modelCatalogSource = 'snapshot';
|
|
806
|
+
_modelCatalogFetchedAt = new Date();
|
|
764
807
|
}
|
|
765
808
|
process.stderr.write(` ${c.yellow('!')} ${c.dim(`Backend refresh failed — ${_modelCatalogError || 'unknown error'}. Showing shipped snapshot.`)}\n`);
|
|
809
|
+
} else {
|
|
810
|
+
const summary = modelCatalogSummary();
|
|
811
|
+
process.stderr.write(` ${c.green('✓')} ${c.dim(`Refreshed ${summary.total} models from backend · ${summary.curated} curated · ${formatCatalogFetchedAt()}`)}\n`);
|
|
766
812
|
}
|
|
767
813
|
await printModelCatalog(ctx);
|
|
768
814
|
return;
|
|
@@ -1247,9 +1293,32 @@ async function handleSkillsCommand(rest = '', ctx) {
|
|
|
1247
1293
|
printSkillsUsage();
|
|
1248
1294
|
}
|
|
1249
1295
|
|
|
1296
|
+
const MODEL_COMMAND_COMPLETIONS = [
|
|
1297
|
+
{ command: '/model', description: 'Open interactive model overrides' },
|
|
1298
|
+
{ command: '/model status', description: 'Show model overrides and catalog source' },
|
|
1299
|
+
{ command: '/model refresh', description: 'Refresh model catalog from backend' },
|
|
1300
|
+
{ command: '/model list', description: 'List curated platform models' },
|
|
1301
|
+
{ command: '/model list text', description: 'List text models' },
|
|
1302
|
+
{ command: '/model list image', description: 'List image models' },
|
|
1303
|
+
{ command: '/model clear', description: 'Clear model overrides' },
|
|
1304
|
+
];
|
|
1305
|
+
|
|
1306
|
+
function slashCompletionDescription(command) {
|
|
1307
|
+
const modelHint = MODEL_COMMAND_COMPLETIONS.find(item => item.command === command);
|
|
1308
|
+
if (modelHint) return modelHint.description;
|
|
1309
|
+
return COMMANDS[command] || (command === '/quit' ? 'Exit CLI' : '');
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1250
1312
|
function commandCompletions(line) {
|
|
1251
|
-
|
|
1252
|
-
|
|
1313
|
+
const text = String(line || '').trimStart();
|
|
1314
|
+
if (text === '/model' || text.startsWith('/model ')) {
|
|
1315
|
+
const modelCompletions = MODEL_COMMAND_COMPLETIONS
|
|
1316
|
+
.map(item => item.command)
|
|
1317
|
+
.filter(cmd => cmd.startsWith(text));
|
|
1318
|
+
if (modelCompletions.length) return modelCompletions;
|
|
1319
|
+
}
|
|
1320
|
+
if (text.startsWith('/help ')) {
|
|
1321
|
+
const topic = text.slice('/help '.length).toLowerCase();
|
|
1253
1322
|
const categories = ['all', ...HELP_GROUPS.map(g => g.key)];
|
|
1254
1323
|
const hits = categories.map(c => `/help ${c}`).filter(cmd => cmd.startsWith(`/help ${topic}`));
|
|
1255
1324
|
return hits.length ? hits : categories.map(c => `/help ${c}`);
|
|
@@ -1260,19 +1329,19 @@ function commandCompletions(line) {
|
|
|
1260
1329
|
// No fallback-to-all: a non-matching prefix ("/Users/...", a pasted
|
|
1261
1330
|
// path) must yield NOTHING so the hint overlay hides, not the full
|
|
1262
1331
|
// catalog. Bare "/" still matches every command via startsWith.
|
|
1263
|
-
return all.filter(cmd => cmd.startsWith(
|
|
1332
|
+
return all.filter(cmd => cmd.startsWith(text));
|
|
1264
1333
|
}
|
|
1265
1334
|
|
|
1266
1335
|
function slashCommandSuggestions(line, limit = 5) {
|
|
1267
1336
|
const text = String(line || '').trimStart();
|
|
1268
1337
|
if (!text.startsWith('/')) return [];
|
|
1269
|
-
const partial = text.split(/\s+/)[0] || '/';
|
|
1338
|
+
const partial = text.startsWith('/model ') ? text : (text.split(/\s+/)[0] || '/');
|
|
1270
1339
|
return commandCompletions(partial)
|
|
1271
1340
|
.filter(cmd => cmd.startsWith('/'))
|
|
1272
1341
|
.slice(0, limit)
|
|
1273
1342
|
.map(cmd => ({
|
|
1274
1343
|
command: cmd,
|
|
1275
|
-
description:
|
|
1344
|
+
description: slashCompletionDescription(cmd),
|
|
1276
1345
|
}));
|
|
1277
1346
|
}
|
|
1278
1347
|
|
|
@@ -1399,12 +1468,13 @@ function buildContextStrip() {
|
|
|
1399
1468
|
return parts.join(c.dim(' · '));
|
|
1400
1469
|
}
|
|
1401
1470
|
|
|
1402
|
-
// ── Dock meta line (cwd ⎇ branch · turn N)
|
|
1471
|
+
// ── Dock meta line (cwd ⎇ branch · turn N · task …) ────────────────────
|
|
1403
1472
|
//
|
|
1404
|
-
// The dock's meta row shows durable session context. Git branch
|
|
1405
|
-
// so we don't shell
|
|
1473
|
+
// The dock's meta row shows durable session context. Git branch and task
|
|
1474
|
+
// state are cached so we don't hit disk/shell on every keystroke.
|
|
1406
1475
|
|
|
1407
1476
|
const _dockGitCache = { branch: null, at: 0, cwd: null };
|
|
1477
|
+
const _dockTaskCache = { summary: '', at: 0, cwd: null };
|
|
1408
1478
|
|
|
1409
1479
|
function activeDockModel() {
|
|
1410
1480
|
return session.modelOverrides?.reasoning
|
|
@@ -1438,6 +1508,36 @@ function _probeGitBranch(cwd) {
|
|
|
1438
1508
|
return branch;
|
|
1439
1509
|
}
|
|
1440
1510
|
|
|
1511
|
+
function compactTaskText(text, max = 36) {
|
|
1512
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
1513
|
+
if (value.length <= max) return value;
|
|
1514
|
+
return value.slice(0, Math.max(0, max - 1)) + '…';
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function _probeTaskSummary(cwd) {
|
|
1518
|
+
const now = Date.now();
|
|
1519
|
+
if (_dockTaskCache.cwd === cwd && (now - _dockTaskCache.at) < 1500) {
|
|
1520
|
+
return _dockTaskCache.summary;
|
|
1521
|
+
}
|
|
1522
|
+
let summary = '';
|
|
1523
|
+
try {
|
|
1524
|
+
const board = loadTaskBoard({ cwd });
|
|
1525
|
+
const counts = taskCounts(board);
|
|
1526
|
+
const active = board.lists.active?.tasks?.find(task => !task.checked) || board.lists.active?.tasks?.[0];
|
|
1527
|
+
if (active?.text) {
|
|
1528
|
+
summary = `task ${compactTaskText(active.text)}`;
|
|
1529
|
+
} else if (counts.blocked > 0 || counts.backlog > 0) {
|
|
1530
|
+
summary = `tasks ${counts.active} active, ${counts.blocked} blocked, ${counts.backlog} backlog`;
|
|
1531
|
+
}
|
|
1532
|
+
} catch {
|
|
1533
|
+
summary = '';
|
|
1534
|
+
}
|
|
1535
|
+
_dockTaskCache.cwd = cwd;
|
|
1536
|
+
_dockTaskCache.at = now;
|
|
1537
|
+
_dockTaskCache.summary = summary;
|
|
1538
|
+
return summary;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1441
1541
|
function buildDockMeta() {
|
|
1442
1542
|
const parts = [];
|
|
1443
1543
|
|
|
@@ -1446,6 +1546,9 @@ function buildDockMeta() {
|
|
|
1446
1546
|
const branch = _probeGitBranch(cwd);
|
|
1447
1547
|
parts.push(branch ? `${projectName} ⎇ ${branch}` : projectName);
|
|
1448
1548
|
|
|
1549
|
+
const taskSummary = _probeTaskSummary(cwd);
|
|
1550
|
+
if (taskSummary) parts.push(taskSummary);
|
|
1551
|
+
|
|
1449
1552
|
if (session.turns > 0) {
|
|
1450
1553
|
parts.push(`turn ${session.turns}`);
|
|
1451
1554
|
}
|
|
@@ -2236,6 +2339,9 @@ function renderEvent(event) {
|
|
|
2236
2339
|
case 'tool_result':
|
|
2237
2340
|
case 'tool_done': {
|
|
2238
2341
|
const eventData = normalizeSubAgentRunData(data);
|
|
2342
|
+
if (String(eventData?.tool || '').toLowerCase() === 'todowrite' || String(eventData?._tool || '').toLowerCase() === 'todowrite') {
|
|
2343
|
+
_dockTaskCache.at = 0;
|
|
2344
|
+
}
|
|
2239
2345
|
if (watchState.active) {
|
|
2240
2346
|
const success = eventData?.success !== false;
|
|
2241
2347
|
watchState.addEntry('done', { label: eventData?.tool, detail: success ? '✓' : '✗' });
|
|
@@ -3062,6 +3168,7 @@ function refreshTaskContext(ctx) {
|
|
|
3062
3168
|
const previous = ctx.latestProjectContext || null;
|
|
3063
3169
|
ctx.latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous });
|
|
3064
3170
|
ctx.latestEnvelope = null;
|
|
3171
|
+
_dockTaskCache.at = 0;
|
|
3065
3172
|
} catch { /* best effort */ }
|
|
3066
3173
|
}
|
|
3067
3174
|
|
|
@@ -5236,10 +5343,12 @@ export async function startTerminalRepl() {
|
|
|
5236
5343
|
function selectedSlashCommandFor(line) {
|
|
5237
5344
|
const input = String(line || '').trim();
|
|
5238
5345
|
if (!input.startsWith('/')) return null;
|
|
5239
|
-
|
|
5346
|
+
const parts = input.split(/\s+/);
|
|
5347
|
+
const firstToken = parts[0] || '';
|
|
5348
|
+
if (COMMANDS[firstToken] || firstToken === '/help') return input;
|
|
5240
5349
|
const item = slashHintItems[slashHintSelected];
|
|
5241
5350
|
if (!item) return input;
|
|
5242
|
-
return item.command;
|
|
5351
|
+
return parts.length > 1 ? `${item.command} ${parts.slice(1).join(' ')}` : item.command;
|
|
5243
5352
|
}
|
|
5244
5353
|
|
|
5245
5354
|
function reservePromptBottomPadding() {
|
package/src/tools/agent.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { createAgentLoop } from '../core/agent-loop.mjs';
|
|
12
12
|
import { createToolRegistry } from './registry.mjs';
|
|
13
13
|
import { createPermissionChecker } from '../permissions/checker.mjs';
|
|
14
|
+
import { DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
|
|
14
15
|
|
|
15
16
|
export const AgentTool = {
|
|
16
17
|
name: 'Agent',
|
|
@@ -59,7 +60,7 @@ export const AgentTool = {
|
|
|
59
60
|
_nextBgId: 0,
|
|
60
61
|
|
|
61
62
|
async call(input, options = {}) {
|
|
62
|
-
const model = input.model || process.env.SUBAGENT_MODEL ||
|
|
63
|
+
const model = input.model || process.env.SUBAGENT_MODEL || DEFAULT_REASONING_MODEL;
|
|
63
64
|
const tools = createToolRegistry({
|
|
64
65
|
pluginRegistry: options.pluginRegistry || null,
|
|
65
66
|
stateEmit: options.stateEmit || null,
|
package/src/tools/todo-write.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { syncTodoWriteToTaskFiles } from '../core/tasks.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* TodoWrite Tool — in-memory task management.
|
|
3
5
|
*
|
|
@@ -45,7 +47,7 @@ export const TodoWriteTool = {
|
|
|
45
47
|
return [];
|
|
46
48
|
},
|
|
47
49
|
|
|
48
|
-
async call(input) {
|
|
50
|
+
async call(input, options = {}) {
|
|
49
51
|
// Replace entire todo list (matches Claude Code behavior)
|
|
50
52
|
todos.length = 0;
|
|
51
53
|
nextId = 1;
|
|
@@ -63,6 +65,17 @@ export const TodoWriteTool = {
|
|
|
63
65
|
`[${t.status === 'completed' ? 'x' : t.status === 'in_progress' ? '~' : ' '}] ${t.id}. ${t.content} (${t.priority})`
|
|
64
66
|
).join('\n');
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
let syncLine = '';
|
|
69
|
+
try {
|
|
70
|
+
const synced = syncTodoWriteToTaskFiles({ cwd: options.cwd || process.cwd(), todos });
|
|
71
|
+
if (typeof options.onTaskFilesWritten === 'function') {
|
|
72
|
+
options.onTaskFilesWritten(synced.written);
|
|
73
|
+
}
|
|
74
|
+
syncLine = `\nSynced to .bahulam/tasks: ${synced.counts.active} active, ${synced.counts.backlog} backlog, ${synced.counts.done} done`;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
syncLine = `\nTask markdown sync skipped: ${err.message || String(err)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return `Updated ${todos.length} todos:\n${summary}${syncLine}`;
|
|
67
80
|
},
|
|
68
81
|
};
|
package/src/ui/commands.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { SessionManager } from '../core/session.mjs';
|
|
|
9
9
|
import { CheckpointManager } from '../core/checkpoints.mjs';
|
|
10
10
|
import { readEnv, listEnvVars } from '../config/env.mjs';
|
|
11
11
|
import * as telemetry from '../telemetry/index.mjs';
|
|
12
|
+
import { DEFAULT_FAST_MODEL, DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
|
|
12
13
|
|
|
13
14
|
const checkpoints = new CheckpointManager();
|
|
14
15
|
let sessionManager = null;
|
|
@@ -134,12 +135,14 @@ export const COMMANDS = {
|
|
|
134
135
|
'/fast': {
|
|
135
136
|
description: 'Toggle fast mode (uses faster, cheaper model)',
|
|
136
137
|
handler(args, state) {
|
|
137
|
-
if (state.
|
|
138
|
-
state.
|
|
139
|
-
|
|
138
|
+
if (state.fastMode) {
|
|
139
|
+
state.fastMode = false;
|
|
140
|
+
state.model = DEFAULT_REASONING_MODEL;
|
|
141
|
+
return `Fast mode OFF — using ${DEFAULT_REASONING_MODEL}`;
|
|
140
142
|
}
|
|
141
|
-
state.
|
|
142
|
-
|
|
143
|
+
state.fastMode = true;
|
|
144
|
+
state.model = DEFAULT_FAST_MODEL;
|
|
145
|
+
return `Fast mode ON — using ${DEFAULT_FAST_MODEL}`;
|
|
143
146
|
},
|
|
144
147
|
},
|
|
145
148
|
|
package/src/ui/input-dock.mjs
CHANGED
|
@@ -242,7 +242,7 @@ function overlayRowsForWrapped(wrappedLength, requestedMaxRows = DEFAULT_OVERLAY
|
|
|
242
242
|
// takes over inside drawInputLines so at most inputRowsMax rows render.
|
|
243
243
|
function computeInputRowsForBuffer(prefix, value) {
|
|
244
244
|
const budget = inputTextBudget();
|
|
245
|
-
const wrapped = wrapToLines(`${prefix || ''}${value || ''}`, budget);
|
|
245
|
+
const wrapped = wrapToLines(`${prefix || ''}${value || ''}`, budget, { preserveTrailingWhitespace: true });
|
|
246
246
|
const wanted = Math.max(MIN_INPUT_ROWS, wrapped.length);
|
|
247
247
|
return Math.min(inputRowsMax, wanted);
|
|
248
248
|
}
|
|
@@ -485,7 +485,7 @@ export function clearDockArea({ restore = true, geometry = null } = {}) {
|
|
|
485
485
|
function layoutInput(prefix, value) {
|
|
486
486
|
const budget = inputTextBudget();
|
|
487
487
|
const combined = `${prefix || ''}${value || ''}`;
|
|
488
|
-
const wrapped = wrapToLines(combined, budget);
|
|
488
|
+
const wrapped = wrapToLines(combined, budget, { preserveTrailingWhitespace: true });
|
|
489
489
|
const tail = tailWithEllipsis(wrapped, inputRows);
|
|
490
490
|
return {
|
|
491
491
|
lines: tail.visible,
|
|
@@ -88,7 +88,11 @@ export const HELP_GROUPS = [
|
|
|
88
88
|
['/status context', 'Loaded .bahulam context'],
|
|
89
89
|
['/status metrics', 'Progress bars and runtime metrics'],
|
|
90
90
|
['/status cost', 'Credits and message window'],
|
|
91
|
-
['/model
|
|
91
|
+
['/model', 'Open interactive model overrides'],
|
|
92
|
+
['/model status', 'Show model overrides and catalog source'],
|
|
93
|
+
['/model refresh', 'Refresh model catalog from backend'],
|
|
94
|
+
['/model list [category]', 'List curated platform models'],
|
|
95
|
+
['/model [role] [model]', 'Set session model override'],
|
|
92
96
|
['/attach <image>', 'Attach image to the next prompt'],
|
|
93
97
|
['/attach clipboard', 'Attach image currently copied to macOS/Windows clipboard'],
|
|
94
98
|
['/attachments', 'List pending image attachments'],
|
package/src/ui/text-layout.mjs
CHANGED
|
@@ -19,11 +19,14 @@ import { strip as stripAnsi, width as visibleWidth } from './palette.mjs';
|
|
|
19
19
|
*
|
|
20
20
|
* @param {string} text
|
|
21
21
|
* @param {number} maxWidth
|
|
22
|
+
* @param {{ preserveTrailingWhitespace?: boolean }} [options]
|
|
22
23
|
* @returns {string[]}
|
|
23
24
|
*/
|
|
24
|
-
export function wrapToLines(text, maxWidth) {
|
|
25
|
+
export function wrapToLines(text, maxWidth, options = {}) {
|
|
25
26
|
const width = Math.max(1, Math.floor(maxWidth));
|
|
26
27
|
const source = String(text ?? '');
|
|
28
|
+
const preserveTrailingWhitespace = Boolean(options?.preserveTrailingWhitespace);
|
|
29
|
+
const finishLine = (line) => preserveTrailingWhitespace ? line : line.replace(/\s+$/, '');
|
|
27
30
|
if (!source) return [''];
|
|
28
31
|
|
|
29
32
|
const out = [];
|
|
@@ -38,8 +41,8 @@ export function wrapToLines(text, maxWidth) {
|
|
|
38
41
|
const isSpace = /^\s+$/.test(stripAnsi(token));
|
|
39
42
|
const candidate = current + token;
|
|
40
43
|
if (visibleWidth(candidate) <= width) { current = candidate; continue; }
|
|
41
|
-
if (current) { out.push(current
|
|
42
|
-
if (isSpace) continue; // don't start a new line with pure whitespace
|
|
44
|
+
if (current) { out.push(finishLine(current)); current = ''; }
|
|
45
|
+
if (isSpace && !preserveTrailingWhitespace) continue; // don't start a new line with pure whitespace
|
|
43
46
|
// Token alone still too wide — chunk it.
|
|
44
47
|
if (visibleWidth(token) > width) {
|
|
45
48
|
for (const chunk of chunkByVisibleWidth(token, width)) out.push(chunk);
|
|
@@ -47,7 +50,7 @@ export function wrapToLines(text, maxWidth) {
|
|
|
47
50
|
current = token;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
|
-
if (current) out.push(current
|
|
53
|
+
if (current) out.push(finishLine(current));
|
|
51
54
|
}
|
|
52
55
|
return out.length ? out : [''];
|
|
53
56
|
}
|