codemini-cli 0.2.6 → 0.2.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemini-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Coding CLI optimized for small-model workflows and Windows PowerShell",
5
5
  "keywords": [
6
6
  "cli",
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import { handleConfig } from './commands/config.js';
4
4
  import { handleDoctor } from './commands/doctor.js';
5
5
  import { handleSkill } from './commands/skill.js';
6
6
 
7
- const VERSION = '0.2.6';
7
+ const VERSION = '0.2.8';
8
8
 
9
9
  function printHelp() {
10
10
  console.log(`codemini ${VERSION}
@@ -7,10 +7,108 @@ function safeJsonParse(raw) {
7
7
  try {
8
8
  return JSON.parse(raw);
9
9
  } catch {
10
- return {};
10
+ return {
11
+ _raw: String(raw),
12
+ _invalid_json: true
13
+ };
11
14
  }
12
15
  }
13
16
 
17
+ function parseInlineRangePath(value) {
18
+ const text = String(value || '').trim();
19
+ if (!text) return null;
20
+ const match = text.match(/^(.*?):(\d+)(?:-(\d+))?$/);
21
+ if (!match) return null;
22
+ const [, maybePath, startRaw, endRaw] = match;
23
+ if (!maybePath || /^(?:[A-Za-z])$/.test(maybePath)) return null;
24
+ const start = Number(startRaw);
25
+ const end = Number(endRaw || startRaw);
26
+ if (!Number.isFinite(start) || start <= 0) return null;
27
+ if (!Number.isFinite(end) || end < start) return null;
28
+ return { path: maybePath, start_line: start, end_line: end };
29
+ }
30
+
31
+ function normalizeToolArguments(toolName, args, rawArguments) {
32
+ const rawText = typeof rawArguments === 'string' ? rawArguments.trim() : '';
33
+ const primitive =
34
+ args == null || Array.isArray(args) || typeof args !== 'object'
35
+ ? args
36
+ : null;
37
+ const source =
38
+ args && typeof args === 'object' && !Array.isArray(args)
39
+ ? { ...args }
40
+ : {};
41
+
42
+ if (primitive != null && typeof primitive !== 'object') {
43
+ source._raw = rawText || String(primitive);
44
+ } else if (!source._raw && rawText && source._invalid_json) {
45
+ source._raw = rawText;
46
+ }
47
+
48
+ const stringValue =
49
+ typeof primitive === 'string'
50
+ ? primitive.trim()
51
+ : String(source._raw || '').trim();
52
+
53
+ if (toolName === 'read') {
54
+ const value = String(source.path || source.file_path || source.file || stringValue || '').trim();
55
+ if (value) source.path = value;
56
+ if (source.offset != null && source.start_line == null) source.start_line = source.offset;
57
+ if (source.limit != null && source.end_line == null && Number(source.start_line) > 0) {
58
+ source.end_line = Number(source.start_line) + Number(source.limit) - 1;
59
+ }
60
+ const range = parseInlineRangePath(source.path);
61
+ if (range) {
62
+ source.path = range.path;
63
+ if (source.start_line == null) source.start_line = range.start_line;
64
+ if (source.end_line == null) source.end_line = range.end_line;
65
+ }
66
+ return source;
67
+ }
68
+
69
+ if (toolName === 'list') {
70
+ const value = String(source.path || source.dir || source.directory || stringValue || '.').trim();
71
+ return { ...source, path: value || '.' };
72
+ }
73
+
74
+ if (toolName === 'glob') {
75
+ const pattern = String(source.pattern || source.glob || source.query || stringValue || '').trim();
76
+ if (pattern) source.pattern = pattern;
77
+ if (!source.path && source.directory) source.path = source.directory;
78
+ return source;
79
+ }
80
+
81
+ if (toolName === 'grep') {
82
+ const pattern = String(source.pattern || source.query || source.symbol || source.q || stringValue || '').trim();
83
+ if (pattern) source.pattern = pattern;
84
+ if (!source.path && (source.directory || source.dir || source.cwd)) {
85
+ source.path = source.directory || source.dir || source.cwd;
86
+ }
87
+ return source;
88
+ }
89
+
90
+ if (toolName === 'write') {
91
+ const value = String(source.path || source.file_path || source.file || stringValue || '').trim();
92
+ if (value) source.path = value;
93
+ if (source.content == null && source.text != null) source.content = source.text;
94
+ if (source.content == null && source.new_content != null) source.content = source.new_content;
95
+ return source;
96
+ }
97
+
98
+ if (toolName === 'edit') {
99
+ const value = String(source.path || source.file || source.file_path || '').trim();
100
+ if (value && !source.path) source.path = value;
101
+ return source;
102
+ }
103
+
104
+ return source;
105
+ }
106
+
107
+ function emptyToolResultMarker(toolName) {
108
+ const name = String(toolName || 'tool').trim() || 'tool';
109
+ return `(${name} completed with no output)`;
110
+ }
111
+
14
112
  function clipToolResult(result, maxChars = 12000) {
15
113
  const raw = typeof result === 'string' ? result : JSON.stringify(result);
16
114
  if (!maxChars || raw.length <= maxChars) return raw;
@@ -371,9 +469,12 @@ function formatToolDisplayName(name, args) {
371
469
  function formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars) {
372
470
  if (toolFormatters && typeof toolFormatters[toolName] === 'function') {
373
471
  const formatted = toolFormatters[toolName](toolResult, args);
374
- if (typeof formatted === 'string') return formatted;
472
+ if (typeof formatted === 'string') {
473
+ return formatted.trim() ? formatted : emptyToolResultMarker(toolName);
474
+ }
375
475
  }
376
- return compactToolResult(toolResult, toolName, args, toolResultMaxChars);
476
+ const fallback = compactToolResult(toolResult, toolName, args, toolResultMaxChars);
477
+ return String(fallback || '').trim() ? fallback : emptyToolResultMarker(toolName);
377
478
  }
378
479
 
379
480
  // ─── Main agent loop ────────────────────────────────────────────────
@@ -466,8 +567,8 @@ export async function runAgentLoop({
466
567
  // ─── P1a: Partition into read-only (parallel) and write (serial) ──
467
568
 
468
569
  const callsWithMeta = toolCalls.map((call) => {
469
- const args = safeJsonParse(call.arguments);
470
570
  const toolName = normalizeToolCallName(call.name);
571
+ const args = normalizeToolArguments(toolName, safeJsonParse(call.arguments), call.arguments);
471
572
  const displayName = formatToolDisplayName(toolName, args);
472
573
  const isReadOnly = READ_ONLY_TOOLS.has(toolName);
473
574
  return { call, args, toolName, displayName, isReadOnly };
@@ -2,6 +2,42 @@ import os from 'node:os';
2
2
  import fs from 'node:fs';
3
3
  import { getShellSystemPrompt } from './shell-profile.js';
4
4
 
5
+ function getToolFewShotBlock() {
6
+ const cwd = process.cwd();
7
+ return `# Tool Examples
8
+
9
+ Use these as style examples for tool calls:
10
+
11
+ Current working directory: ${cwd}
12
+ When a tool takes file_path, build it from the current working directory and prefer absolute paths.
13
+ If the user mentions a project-relative path like src/app.ts, resolve it from ${cwd} instead of guessing parent directories.
14
+
15
+ 1. File discovery then read
16
+ User: compare the auth flow
17
+ Assistant: first locate the relevant files
18
+ Tool: glob({"pattern":"src/**/*auth*.ts"})
19
+ Tool: read({"file_path":"${cwd}/src/auth/service.ts"})
20
+
21
+ 2. Targeted search then exact text edit
22
+ User: rename loginUser to signInUser
23
+ Assistant: first find the exact occurrences
24
+ Tool: grep({"pattern":"loginUser","path":"src"})
25
+ Tool: edit({"file_path":"${cwd}/src/auth/service.ts","old_string":"loginUser","new_string":"signInUser"})
26
+
27
+ 3. Read a specific range
28
+ User: inspect the reducer around line 120
29
+ Assistant: read only the needed range
30
+ Tool: read({"path":"${cwd}/src/store/reducer.ts:110-150"})
31
+
32
+ 4. Create a new file
33
+ User: add a notes file
34
+ Assistant: create the file directly
35
+ Tool: write({"file":"${cwd}/notes.txt","text":"todo\\n"})
36
+
37
+ Prefer these direct tool shapes over multi-step metadata reads or shell fallbacks.
38
+ Prefer explicit absolute file_path values when the current working directory is known.`;
39
+ }
40
+
5
41
  function getEnvBlock() {
6
42
  const cwd = process.cwd();
7
43
  let isGitRepo = false;
@@ -22,5 +58,7 @@ OS Version: ${os.version || os.release()}
22
58
  export function buildDefaultSystemPrompt(config = {}) {
23
59
  return `${getShellSystemPrompt(config?.shell?.default)}
24
60
 
61
+ ${getToolFewShotBlock()}
62
+
25
63
  ${getEnvBlock()}`;
26
64
  }
@@ -47,6 +47,16 @@ function normalizeToolCallArguments(argumentsText) {
47
47
  return '{}';
48
48
  }
49
49
 
50
+ function normalizeIncomingToolCallArguments(argumentsValue) {
51
+ if (typeof argumentsValue === 'string') return argumentsValue;
52
+ if (argumentsValue == null) return '{}';
53
+ try {
54
+ return JSON.stringify(argumentsValue);
55
+ } catch {
56
+ return '{}';
57
+ }
58
+ }
59
+
50
60
  function sanitizeGatewayMessages(messages) {
51
61
  const source = Array.isArray(messages) ? messages : [];
52
62
  return source
@@ -115,7 +125,18 @@ function buildPayload({ model, temperature, messages, tools, stream = false }) {
115
125
  return payload;
116
126
  }
117
127
 
118
- function buildFinalStreamResult(text, toolCallsByIndex, usage) {
128
+ function hasTrailingToolContext(messages) {
129
+ const source = Array.isArray(messages) ? messages : [];
130
+ for (let index = source.length - 1; index >= 0; index -= 1) {
131
+ const message = source[index];
132
+ if (!message || typeof message !== 'object') continue;
133
+ if (message.role === 'tool') return true;
134
+ if (message.role === 'assistant' || message.role === 'user') return false;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ function buildFinalStreamResult(text, toolCallsByIndex, usage, messages) {
119
140
  const toolCalls = Array.from(toolCallsByIndex.entries())
120
141
  .sort((a, b) => a[0] - b[0])
121
142
  .map(([, tc], i) => ({
@@ -126,6 +147,13 @@ function buildFinalStreamResult(text, toolCallsByIndex, usage) {
126
147
  .filter((tc) => tc.name);
127
148
 
128
149
  if (!text && toolCalls.length === 0) {
150
+ if (hasTrailingToolContext(messages)) {
151
+ return {
152
+ text: '',
153
+ toolCalls: [],
154
+ usage
155
+ };
156
+ }
129
157
  throw new Error('Gateway stream returned empty assistant response');
130
158
  }
131
159
 
@@ -216,7 +244,7 @@ export async function createChatCompletion({
216
244
  const toolCalls = (message.tool_calls || []).map((tc) => ({
217
245
  id: tc.id,
218
246
  name: tc.function?.name,
219
- arguments: tc.function?.arguments || '{}'
247
+ arguments: normalizeIncomingToolCallArguments(tc.function?.arguments)
220
248
  }));
221
249
 
222
250
  if (!text && toolCalls.length === 0) {
@@ -280,7 +308,9 @@ export async function createChatCompletionStream({
280
308
  const current = toolCallsByIndex.get(idx) || emptyToolCall(idx);
281
309
  if (td.id) current.id = td.id;
282
310
  if (td.function?.name) current.name = `${current.name}${td.function.name}`;
283
- if (td.function?.arguments) current.arguments = `${current.arguments}${td.function.arguments}`;
311
+ if (td.function?.arguments !== undefined) {
312
+ current.arguments = `${current.arguments}${normalizeIncomingToolCallArguments(td.function.arguments)}`;
313
+ }
284
314
  toolCallsByIndex.set(idx, current);
285
315
  if (onToolCallDelta) {
286
316
  onToolCallDelta({
@@ -293,5 +323,5 @@ export async function createChatCompletionStream({
293
323
  }
294
324
  }
295
325
 
296
- return buildFinalStreamResult(text, toolCallsByIndex, usage);
326
+ return buildFinalStreamResult(text, toolCallsByIndex, usage, messages);
297
327
  }
@@ -123,11 +123,11 @@ export function getShellSystemPrompt(value) {
123
123
  # Using your tools
124
124
 
125
125
  ALWAYS prefer dedicated tools over raw shell commands:
126
- - Use read to inspect files — NEVER use cat, head, or tail via run
126
+ - Use read to inspect files — NEVER use cat, head, or tail via run. read returns content directly by default; demo-style shapes like {file_path:"src/app.ts"}, {path:"src/app.ts:10-40"}, or {file_path:"src/app.ts", offset:10, limit:30} are accepted
127
127
  - Use grep to search file contents — NEVER use grep or rg via run
128
128
  - Use glob to find files by pattern — NEVER use find via run
129
- - Use edit to modify existing files — this is the DEFAULT path for code changes
130
- - Use write only for creating new files or complete rewrites (set full_file_rewrite=true for existing code files)
129
+ - Use edit to modify existing files — this is the DEFAULT path for code changes. Demo-style aliases like {file_path:"src/app.ts", old_string:"foo", new_string:"bar"} are accepted
130
+ - Use write only for creating new files or complete rewrites (set full_file_rewrite=true for existing code files). Aliases like {file:"notes.txt", text:"..."} are accepted
131
131
  - Use patch to apply unified diffs
132
132
  - Use run for one-shot shell commands: install, build, test, or other finite tasks
133
133
  - For long-running processes (dev servers, watchers), use start_service instead of run
@@ -140,6 +140,17 @@ For services: use start_service to launch, list_services/get_service_status/get_
140
140
 
141
141
  Some tools are loaded on demand. If a needed tool is not listed, call tool_search first to load it.
142
142
 
143
+ Common tool call patterns:
144
+ - Read a file: {path:"src/app.ts"} or {file_path:"src/app.ts", offset:20, limit:40}
145
+ - Read a specific range inline: {path:"src/app.ts:20-60"}
146
+ - Search text: {pattern:"loginUser", path:"src"} or {query:"loginUser", directory:"src"}
147
+ - Find files: {pattern:"src/**/*.ts"} or {query:"src/**/*.ts"}
148
+ - Edit exact text: {file_path:"src/app.ts", old_string:"foo", new_string:"bar"}
149
+ - Edit with shorthand: {path:"src/app.ts", old_text:"foo", content:"bar"}
150
+ - Write a new file: {file:"notes.txt", text:"..."} or {path:"src/page.tsx", content:"..."}
151
+ - When the environment provides a Working directory, prefer absolute file_path values rooted there instead of guessing prefixes
152
+ - If the user gives a relative path like src/app.ts, resolve it from the current Working directory rather than inventing ../ or sibling folders
153
+
143
154
  # Doing tasks
144
155
 
145
156
  - You are a terminal-first CLI coding agent, not a generic chat assistant
package/src/core/tools.js CHANGED
@@ -119,6 +119,111 @@ function splitLines(text) {
119
119
  return String(text || '').split('\n');
120
120
  }
121
121
 
122
+ function parseInlineReadRange(value) {
123
+ const text = String(value || '').trim();
124
+ if (!text) return null;
125
+ const match = text.match(/^(.*?):(\d+)(?:-(\d+))?$/);
126
+ if (!match) return null;
127
+ const [, maybePath, startRaw, endRaw] = match;
128
+ if (!maybePath || /^(?:[A-Za-z])$/.test(maybePath)) return null;
129
+ const startLine = Number(startRaw);
130
+ const endLine = Number(endRaw || startRaw);
131
+ if (!Number.isFinite(startLine) || startLine <= 0) return null;
132
+ if (!Number.isFinite(endLine) || endLine < startLine) return null;
133
+ return {
134
+ path: maybePath,
135
+ start_line: startLine,
136
+ end_line: endLine
137
+ };
138
+ }
139
+
140
+ function normalizeReadArgs(rawArgs) {
141
+ const source =
142
+ rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
143
+ ? { ...rawArgs }
144
+ : { path: typeof rawArgs === 'string' ? rawArgs : '' };
145
+
146
+ const normalized = { ...source };
147
+ const aliasPath = String(source.path || source.file_path || source.file || source.target || '').trim();
148
+ if (aliasPath) normalized.path = aliasPath;
149
+
150
+ if (!Number.isFinite(Number(normalized.start_line)) && Number.isFinite(Number(source.offset))) {
151
+ normalized.start_line = Number(source.offset);
152
+ }
153
+
154
+ if (!Number.isFinite(Number(normalized.end_line)) && Number.isFinite(Number(source.limit))) {
155
+ const startLine = Number(normalized.start_line);
156
+ const limit = Number(source.limit);
157
+ if (startLine > 0 && limit > 0) {
158
+ normalized.end_line = startLine + limit - 1;
159
+ }
160
+ }
161
+
162
+ const inlineRange = parseInlineReadRange(normalized.path);
163
+ if (inlineRange) {
164
+ normalized.path = inlineRange.path;
165
+ if (!Number.isFinite(Number(normalized.start_line))) normalized.start_line = inlineRange.start_line;
166
+ if (!Number.isFinite(Number(normalized.end_line))) normalized.end_line = inlineRange.end_line;
167
+ }
168
+
169
+ return normalized;
170
+ }
171
+
172
+ function normalizePathArgs(rawArgs, aliases = []) {
173
+ const source =
174
+ rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
175
+ ? { ...rawArgs }
176
+ : { path: typeof rawArgs === 'string' ? rawArgs : '' };
177
+ const normalized = { ...source };
178
+ const keys = ['path', ...aliases];
179
+ for (const key of keys) {
180
+ const value = String(source?.[key] || '').trim();
181
+ if (value) {
182
+ normalized.path = value;
183
+ break;
184
+ }
185
+ }
186
+ return normalized;
187
+ }
188
+
189
+ function normalizePatternArgs(rawArgs, aliases = [], defaultPathAliases = []) {
190
+ const source =
191
+ rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
192
+ ? { ...rawArgs }
193
+ : { pattern: typeof rawArgs === 'string' ? rawArgs : '' };
194
+ const normalized = { ...source };
195
+ for (const key of ['pattern', ...aliases]) {
196
+ const value = String(source?.[key] || '').trim();
197
+ if (value) {
198
+ normalized.pattern = value;
199
+ break;
200
+ }
201
+ }
202
+ for (const key of ['path', ...defaultPathAliases]) {
203
+ const value = String(source?.[key] || '').trim();
204
+ if (value) {
205
+ normalized.path = value;
206
+ break;
207
+ }
208
+ }
209
+ return normalized;
210
+ }
211
+
212
+ function normalizeWriteArgs(rawArgs) {
213
+ const source =
214
+ rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
215
+ ? { ...rawArgs }
216
+ : { path: typeof rawArgs === 'string' ? rawArgs : '' };
217
+ const normalized = { ...source };
218
+ const filePath = String(source.path || source.file_path || source.file || '').trim();
219
+ if (filePath) normalized.path = filePath;
220
+ if (normalized.content == null) {
221
+ if (source.text != null) normalized.content = source.text;
222
+ if (source.new_content != null) normalized.content = source.new_content;
223
+ }
224
+ return normalized;
225
+ }
226
+
122
227
  function findUniqueLineBlock(lines, blockContent) {
123
228
  const probeLines = splitLines(blockContent);
124
229
  if (probeLines.length === 0 || (probeLines.length === 1 && probeLines[0] === '')) return null;
@@ -665,16 +770,17 @@ async function getFileState(root, relativePath) {
665
770
  }
666
771
 
667
772
  async function readFile(root, args) {
668
- const target = resolveInWorkspace(root, args?.path);
773
+ const normalizedArgs = normalizeReadArgs(args);
774
+ const target = resolveInWorkspace(root, normalizedArgs?.path);
669
775
  const stat = await fs.stat(target);
670
776
  const text = await fs.readFile(target, 'utf8');
671
777
  const lines = splitLines(text);
672
778
  const totalLines = lines.length;
673
- const startLineRaw = Number(args?.start_line);
674
- const endLineRaw = Number(args?.end_line);
675
- const defaultLines = Number(args?.default_lines || 220);
676
- const maxChars = Number(args?.max_chars || 24000);
677
- const includeContent = Boolean(args?.include_content);
779
+ const startLineRaw = Number(normalizedArgs?.start_line);
780
+ const endLineRaw = Number(normalizedArgs?.end_line);
781
+ const defaultLines = Number(normalizedArgs?.default_lines || 220);
782
+ const maxChars = Number(normalizedArgs?.max_chars || 24000);
783
+ const wantsMetadataOnly = normalizedArgs?.metadata_only === true || normalizedArgs?.include_content === false;
678
784
 
679
785
  let startLine = Number.isFinite(startLineRaw) && startLineRaw > 0 ? startLineRaw : 1;
680
786
  let endLine =
@@ -684,12 +790,12 @@ async function readFile(root, args) {
684
790
  startLine = Math.max(1, Math.min(startLine, totalLines));
685
791
  endLine = Math.max(startLine, Math.min(endLine, totalLines));
686
792
 
687
- const tokenSeed = `${args?.path}|${stat.size}|${stat.mtimeMs}|${startLine}|${endLine}`;
793
+ const tokenSeed = `${normalizedArgs?.path}|${stat.size}|${stat.mtimeMs}|${startLine}|${endLine}`;
688
794
  const readToken = sha1(tokenSeed).slice(0, 16);
689
795
 
690
- if (!includeContent) {
796
+ if (wantsMetadataOnly) {
691
797
  return {
692
- path: args?.path,
798
+ path: normalizedArgs?.path,
693
799
  phase: 'metadata',
694
800
  size_bytes: stat.size,
695
801
  modified_at: new Date(stat.mtimeMs).toISOString(),
@@ -701,21 +807,6 @@ async function readFile(root, args) {
701
807
  };
702
808
  }
703
809
 
704
- if (String(args?.read_token || '') !== readToken) {
705
- return {
706
- path: args?.path,
707
- phase: 'metadata',
708
- error: 'read_token mismatch or missing',
709
- size_bytes: stat.size,
710
- modified_at: new Date(stat.mtimeMs).toISOString(),
711
- total_lines: totalLines,
712
- suggested_start_line: startLine,
713
- suggested_end_line: endLine,
714
- read_token: readToken,
715
- next: 'Retry with include_content=true and read_token from latest metadata'
716
- };
717
- }
718
-
719
810
  let content = lines.slice(startLine - 1, endLine).join('\n');
720
811
  let truncated = false;
721
812
  if (maxChars > 0 && content.length > maxChars) {
@@ -725,14 +816,14 @@ async function readFile(root, args) {
725
816
 
726
817
  // Read deduplication: if same path+range+mtime was read before, return a short stub
727
818
  const isDuplicate = checkReadDedup(
728
- args?.path,
819
+ normalizedArgs?.path,
729
820
  startLine,
730
821
  endLine,
731
822
  stat.mtimeMs
732
823
  );
733
824
  if (isDuplicate) {
734
825
  return {
735
- path: args?.path,
826
+ path: normalizedArgs?.path,
736
827
  phase: 'content',
737
828
  start_line: startLine,
738
829
  end_line: endLine,
@@ -744,7 +835,7 @@ async function readFile(root, args) {
744
835
  }
745
836
 
746
837
  return {
747
- path: args?.path,
838
+ path: normalizedArgs?.path,
748
839
  phase: 'content',
749
840
  start_line: startLine,
750
841
  end_line: endLine,
@@ -755,7 +846,8 @@ async function readFile(root, args) {
755
846
  }
756
847
 
757
848
  async function writeFile(root, args) {
758
- const rawPath = String(args?.path || '').trim();
849
+ const normalizedArgs = normalizeWriteArgs(args);
850
+ const rawPath = String(normalizedArgs?.path || '').trim();
759
851
  if (!rawPath) {
760
852
  throw new Error('write requires a file path like weather/WeatherForecast.js');
761
853
  }
@@ -778,18 +870,18 @@ async function writeFile(root, args) {
778
870
  } catch {
779
871
  existed = false;
780
872
  }
781
- if (existed && !args?.append && !args?.full_file_rewrite && isCodeLikePath(rawPath)) {
873
+ if (existed && !normalizedArgs?.append && !normalizedArgs?.full_file_rewrite && isCodeLikePath(rawPath)) {
782
874
  throw new Error(
783
875
  'write blocks full overwrite for existing code files by default. Use grep/read -> edit for minimal edits, or pass full_file_rewrite=true when a whole-file rewrite is truly intended.'
784
876
  );
785
877
  }
786
878
  await fs.mkdir(path.dirname(target), { recursive: true });
787
- if (args?.append) {
788
- await fs.appendFile(target, args?.content || '', 'utf8');
879
+ if (normalizedArgs?.append) {
880
+ await fs.appendFile(target, normalizedArgs?.content || '', 'utf8');
789
881
  } else {
790
- await fs.writeFile(target, args?.content || '', 'utf8');
882
+ await fs.writeFile(target, normalizedArgs?.content || '', 'utf8');
791
883
  }
792
- const after = args?.append ? `${before}${args?.content || ''}` : args?.content || '';
884
+ const after = normalizedArgs?.append ? `${before}${normalizedArgs?.content || ''}` : normalizedArgs?.content || '';
793
885
  const beforeLines = splitLines(before);
794
886
  const afterLines = splitLines(after);
795
887
  let changeLine = 0;
@@ -805,7 +897,7 @@ async function writeFile(root, args) {
805
897
  return {
806
898
  ok: true,
807
899
  path: rawPath,
808
- action: args?.append ? 'append' : existed ? 'overwrite' : 'create',
900
+ action: normalizedArgs?.append ? 'append' : existed ? 'overwrite' : 'create',
809
901
  changed_line: changeLine || Math.max(1, afterLines.length),
810
902
  diff_preview: previewLines.map((line, idx) => `${previewStart + idx + 1}| ${line}`).join('\n')
811
903
  };
@@ -1208,12 +1300,13 @@ async function searchCode(root, args) {
1208
1300
  }
1209
1301
 
1210
1302
  async function grep(root, args) {
1211
- const pattern = String(args?.pattern || args?.query || '').trim();
1303
+ const normalizedArgs = normalizePatternArgs(args, ['query', 'symbol', 'q'], ['directory', 'dir', 'cwd']);
1304
+ const pattern = String(normalizedArgs?.pattern || '').trim();
1212
1305
  if (!pattern) throw new Error('grep requires pattern');
1213
- const maxResults = Math.max(1, Math.min(200, Number(args?.max_results || 50)));
1214
- const caseSensitive = Boolean(args?.case_sensitive);
1215
- const files = await walkTextFiles(root, args?.path || '.', normalizeFileTypes(args));
1216
- const regex = args?.regex
1306
+ const maxResults = Math.max(1, Math.min(200, Number(normalizedArgs?.max_results || 50)));
1307
+ const caseSensitive = Boolean(normalizedArgs?.case_sensitive);
1308
+ const files = await walkTextFiles(root, normalizedArgs?.path || '.', normalizeFileTypes(normalizedArgs));
1309
+ const regex = normalizedArgs?.regex
1217
1310
  ? new RegExp(pattern, caseSensitive ? 'g' : 'gi')
1218
1311
  : new RegExp(escapeRegex(pattern), caseSensitive ? 'g' : 'gi');
1219
1312
  const matches = [];
@@ -1242,12 +1335,13 @@ async function grep(root, args) {
1242
1335
  }
1243
1336
 
1244
1337
  async function glob(root, args) {
1245
- const pattern = String(args?.pattern || '').trim();
1338
+ const normalizedArgs = normalizePatternArgs(args, ['glob', 'query'], ['directory', 'dir', 'cwd']);
1339
+ const pattern = String(normalizedArgs?.pattern || '').trim();
1246
1340
  if (!pattern) throw new Error('glob requires pattern');
1247
- const maxResults = Math.max(1, Math.min(500, Number(args?.max_results || 200)));
1341
+ const maxResults = Math.max(1, Math.min(500, Number(normalizedArgs?.max_results || 200)));
1248
1342
  const regex = globToRegex(pattern);
1249
- const entries = await walkWorkspaceEntries(root, args?.path || '.', {
1250
- includeHidden: Boolean(args?.include_hidden)
1343
+ const entries = await walkWorkspaceEntries(root, normalizedArgs?.path || '.', {
1344
+ includeHidden: Boolean(normalizedArgs?.include_hidden)
1251
1345
  });
1252
1346
  const matches = entries
1253
1347
  .filter((entry) => entry.type === 'file' && regex.test(entry.path))
@@ -1261,10 +1355,11 @@ async function glob(root, args) {
1261
1355
  }
1262
1356
 
1263
1357
  async function list(root, args) {
1264
- const relativePath = String(args?.path || '.').trim() || '.';
1358
+ const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'target']);
1359
+ const relativePath = String(normalizedArgs?.path || '.').trim() || '.';
1265
1360
  const target = resolveInWorkspace(root, relativePath);
1266
1361
  const entries = await fs.readdir(target, { withFileTypes: true });
1267
- const includeHidden = Boolean(args?.include_hidden);
1362
+ const includeHidden = Boolean(normalizedArgs?.include_hidden);
1268
1363
  const items = entries
1269
1364
  .filter((entry) => includeHidden || !entry.name.startsWith('.'))
1270
1365
  .map((entry) => ({
@@ -1536,7 +1631,7 @@ async function openTarget(root, args) {
1536
1631
  }
1537
1632
 
1538
1633
  function normalizeEditTargetArgs(args = {}) {
1539
- const file = String(args?.file || args?.path || '').trim();
1634
+ const file = String(args?.file || args?.path || args?.file_path || '').trim();
1540
1635
  const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1541
1636
  if (nestedEdit) {
1542
1637
  const normalizedEdit = { ...nestedEdit };
@@ -1552,6 +1647,8 @@ function normalizeEditTargetArgs(args = {}) {
1552
1647
  edit: normalizedEdit
1553
1648
  };
1554
1649
  }
1650
+ const topLevelOldText = args?.old_text ?? args?.old_string;
1651
+ const topLevelContent = args?.content;
1555
1652
  return {
1556
1653
  file,
1557
1654
  ast_target: args?.ast_target,
@@ -1560,7 +1657,9 @@ function normalizeEditTargetArgs(args = {}) {
1560
1657
  target: args?.target,
1561
1658
  new_content: args?.new_content ?? args?.content,
1562
1659
  old_text: args?.old_text,
1563
- new_text: args?.new_text,
1660
+ new_text: args?.new_text ?? (topLevelOldText != null && topLevelContent != null ? topLevelContent : undefined),
1661
+ old_string: args?.old_string,
1662
+ new_string: args?.new_string,
1564
1663
  anchor_text: args?.anchor_text,
1565
1664
  content: args?.content
1566
1665
  }
@@ -1573,6 +1672,12 @@ async function editTarget(root, args) {
1573
1672
  const astTarget = normalized.ast_target;
1574
1673
  const edit = normalized.edit || {};
1575
1674
  let kind = String(edit.kind || '').trim();
1675
+ if (edit.old_text == null && edit.old_string != null) {
1676
+ edit.old_text = edit.old_string;
1677
+ }
1678
+ if (edit.new_text == null && edit.new_string != null) {
1679
+ edit.new_text = edit.new_string;
1680
+ }
1576
1681
  const hasContent = edit.new_content != null || edit.content != null;
1577
1682
  const hasTargetHint = Boolean(edit.symbol || args?.symbol || edit.line || args?.line || edit.target);
1578
1683
  if (!kind) {
@@ -1586,7 +1691,14 @@ async function editTarget(root, args) {
1586
1691
  kind = 'rewrite_file';
1587
1692
  }
1588
1693
  }
1589
- if (!file || !kind) throw new Error('edit requires file and edit.kind');
1694
+ if (!file || !kind) {
1695
+ const recentFile = String(args?.recent_file || '').trim();
1696
+ const rawArgs = typeof args?._raw === 'string' && args._raw.trim() ? ` Raw tool arguments: ${args._raw.trim()}.` : '';
1697
+ const hint = recentFile
1698
+ ? ` If you meant the recently read file ${recentFile}, use edit with {file:"${recentFile}", old_text:"...", new_text:"..."} for a text replacement, or {file:"${recentFile}", edit:{kind:"rewrite_file", new_content:"..."}} for a full rewrite.`
1699
+ : ' Use edit with {file:"path", old_text:"...", new_text:"..."} for a text replacement, or {file:"path", edit:{kind:"rewrite_file", new_content:"..."}} for a full rewrite.';
1700
+ throw new Error(`edit requires file and edit.kind.${rawArgs}${hint}`);
1701
+ }
1590
1702
  if (astTarget) {
1591
1703
  if (kind !== 'replace_block') {
1592
1704
  throw new Error('AST-scoped edit only supports replace_block');
@@ -1659,6 +1771,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1659
1771
  };
1660
1772
  const astSelectionCache = new Map();
1661
1773
  let lastAstTarget = null;
1774
+ let lastReadPath = '';
1662
1775
  const rememberAstSelection = (filePath, astTarget) => {
1663
1776
  const key = String(filePath || '').trim();
1664
1777
  if (!key || !astTarget) return;
@@ -1738,18 +1851,22 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1738
1851
  function: {
1739
1852
  name: 'read',
1740
1853
  description:
1741
- 'Inspect a file. Call once for metadata and a read_token, then again with include_content=true and the same token to get content. Use this before editing. Do not use run with cat, head, or tail for file reads.',
1854
+ 'Inspect a file and return content directly by default. Demo-style aliases like file_path, offset, and limit are accepted. Use metadata_only=true only when you want file metadata without content. Do not use run with cat, head, or tail for file reads.',
1742
1855
  parameters: {
1743
1856
  type: 'object',
1744
1857
  properties: {
1745
- path: { type: 'string', description: 'File path to read' },
1858
+ path: { type: 'string', description: 'File path to read. You can also include an inline range like src/app.ts:10-40.' },
1859
+ file_path: { type: 'string', description: 'Alias for path' },
1746
1860
  start_line: { type: 'number', description: '1-based start line' },
1747
1861
  end_line: { type: 'number', description: 'Inclusive end line' },
1862
+ offset: { type: 'number', description: 'Alias for start_line' },
1863
+ limit: { type: 'number', description: 'Number of lines to read starting from offset/start_line' },
1748
1864
  max_chars: { type: 'number', description: 'Max chars to return' },
1749
- include_content: { type: 'boolean', description: 'Set true on the second call' },
1750
- read_token: { type: 'string', description: 'Token from the first call' }
1865
+ include_content: { type: 'boolean', description: 'Legacy compatibility flag. Content is returned by default.' },
1866
+ read_token: { type: 'string', description: 'Legacy compatibility token. No longer required for content reads.' },
1867
+ metadata_only: { type: 'boolean', description: 'Set true to return metadata without content.' }
1751
1868
  },
1752
- required: ['path']
1869
+ required: []
1753
1870
  }
1754
1871
  }
1755
1872
  },
@@ -1758,13 +1875,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1758
1875
  function: {
1759
1876
  name: 'grep',
1760
1877
  description:
1761
- 'Search file contents. Use this for code search before read or edit. Do not use run with grep or rg for normal code search.',
1878
+ 'Search file contents. Use this for code search before read or edit. Aliases like query and directory are accepted. Do not use run with grep or rg for normal code search.',
1762
1879
  parameters: {
1763
1880
  type: 'object',
1764
1881
  properties: {
1765
1882
  pattern: { type: 'string', description: 'Search pattern' },
1766
1883
  query: { type: 'string', description: 'Alias for pattern' },
1767
1884
  path: { type: 'string', description: 'Directory or file to search' },
1885
+ directory: { type: 'string', description: 'Alias for path' },
1768
1886
  regex: { type: 'boolean', description: 'Treat pattern as regex' },
1769
1887
  case_sensitive: { type: 'boolean', description: 'Case-sensitive matching' },
1770
1888
  max_results: { type: 'number', description: 'Max matches to return' },
@@ -1780,12 +1898,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1780
1898
  function: {
1781
1899
  name: 'glob',
1782
1900
  description:
1783
- 'Find files by glob pattern. Use this for file discovery before read. Do not use run with find for normal file lookup.',
1901
+ 'Find files by glob pattern. Use this for file discovery before read. Aliases like query and directory are accepted. Do not use run with find for normal file lookup.',
1784
1902
  parameters: {
1785
1903
  type: 'object',
1786
1904
  properties: {
1787
1905
  pattern: { type: 'string', description: 'Glob pattern' },
1788
1906
  path: { type: 'string', description: 'Directory to search' },
1907
+ query: { type: 'string', description: 'Alias for pattern' },
1908
+ directory: { type: 'string', description: 'Alias for path' },
1789
1909
  include_hidden: { type: 'boolean', description: 'Include dotfiles' },
1790
1910
  max_results: { type: 'number', description: 'Max results' }
1791
1911
  },
@@ -1797,11 +1917,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1797
1917
  type: 'function',
1798
1918
  function: {
1799
1919
  name: 'list',
1800
- description: 'List files and directories in a workspace path. Use this for quick directory discovery before deeper reads.',
1920
+ description: 'List files and directories in a workspace path. Use this for quick directory discovery before deeper reads. Aliases like directory are accepted, and plain string paths are tolerated by the runtime.',
1801
1921
  parameters: {
1802
1922
  type: 'object',
1803
1923
  properties: {
1804
1924
  path: { type: 'string', description: 'Directory path to list' },
1925
+ directory: { type: 'string', description: 'Alias for path' },
1805
1926
  include_hidden: { type: 'boolean', description: 'Include dotfiles' }
1806
1927
  }
1807
1928
  }
@@ -1812,15 +1933,18 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1812
1933
  function: {
1813
1934
  name: 'edit',
1814
1935
  description:
1815
- 'Edit existing files. Use block edits, exact replacements, or anchored inserts. When ast_target is provided, keep the edit constrained to that node. Read first unless the exact target is already known. Prefer this over write for code changes.',
1936
+ 'Edit existing files. Prefer one of these shapes: 1) {file, old_text, new_text} for exact text replacement, 2) {file, symbol, edit:{kind:"replace_block", new_content:"..."}} for block replacement, 3) {file, anchor_text, position:"before"|"after", content:"..."} for inserts. Demo-style aliases {file_path, old_string, new_string} are also accepted. Read first unless the exact target is already known. Prefer this over write for existing code changes.',
1816
1937
  parameters: {
1817
1938
  type: 'object',
1818
1939
  properties: {
1819
1940
  file: { type: 'string', description: 'File path to edit' },
1820
1941
  path: { type: 'string', description: 'Alias for file' },
1942
+ file_path: { type: 'string', description: 'Alias for file, compatible with simpler demo-style tool calls' },
1821
1943
  new_content: { type: 'string', description: 'Replacement content' },
1822
1944
  old_text: { type: 'string', description: 'Exact text to replace' },
1823
1945
  new_text: { type: 'string', description: 'Replacement text' },
1946
+ old_string: { type: 'string', description: 'Alias for old_text' },
1947
+ new_string: { type: 'string', description: 'Alias for new_text' },
1824
1948
  anchor_text: { type: 'string', description: 'Anchor text for inserts' },
1825
1949
  content: { type: 'string', description: 'Content to insert or append' },
1826
1950
  position: { type: 'string', description: 'before or after' },
@@ -1840,12 +1964,16 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1840
1964
  function: {
1841
1965
  name: 'write',
1842
1966
  description:
1843
- 'Create a new file or overwrite a file. Always include path and content. Use this for new files or explicit full rewrites only. If the file path is not decided yet, do not call write yet. Prefer edit for existing code changes.',
1967
+ 'Create a new file or overwrite a file. Always include path and content. Aliases like file, file_path, text, and new_content are accepted. Use this for new files or explicit full rewrites only. Example: {path:"src/page.html", content:"..."} . If the file path is not decided yet, do not call write yet. Prefer edit for existing code changes.',
1844
1968
  parameters: {
1845
1969
  type: 'object',
1846
1970
  properties: {
1847
1971
  path: { type: 'string', description: 'Required file path like src/app.js or pages/index.html. Never omit this.' },
1972
+ file_path: { type: 'string', description: 'Alias for path, compatible with simpler demo-style tool calls' },
1973
+ file: { type: 'string', description: 'Alias for path' },
1848
1974
  content: { type: 'string', description: 'Content to write' },
1975
+ text: { type: 'string', description: 'Alias for content' },
1976
+ new_content: { type: 'string', description: 'Alias for content' },
1849
1977
  append: { type: 'boolean', description: 'Append instead of overwrite' },
1850
1978
  full_file_rewrite: { type: 'boolean', description: 'Set true for whole-file rewrites' }
1851
1979
  },
@@ -2049,6 +2177,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2049
2177
  typeof args?.max_chars === 'number'
2050
2178
  ? args.max_chars
2051
2179
  : config.context?.read_file_max_chars ?? 24000
2180
+ }).then((result) => {
2181
+ const readPath = String(result?.path || args?.path || '').trim();
2182
+ if (readPath) lastReadPath = readPath;
2183
+ return result;
2052
2184
  }),
2053
2185
  grep: (args) => grep(workspaceRoot, args),
2054
2186
  glob: (args) => glob(workspaceRoot, args),
@@ -2069,7 +2201,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2069
2201
  await ensureProjectIndex();
2070
2202
  const normalizedKind = String(args?.edit?.kind || args?.kind || '').trim();
2071
2203
  const astTarget = resolveCachedAstTarget(args, { requireAstScope: normalizedKind === 'replace_block' });
2072
- const result = await editTarget(workspaceRoot, astTarget ? { ...args, ast_target: astTarget } : args);
2204
+ const result = await editTarget(
2205
+ workspaceRoot,
2206
+ astTarget ? { ...args, ast_target: astTarget, recent_file: lastReadPath } : { ...args, recent_file: lastReadPath }
2207
+ );
2073
2208
  if (result?.path) await refreshProjectFile(result.path);
2074
2209
  return result;
2075
2210
  },
@@ -88,6 +88,7 @@ const TUI_COPY = {
88
88
  startupHint: '使用 /help、/commands、/compact、/exit、!<shell>。Tab 可自动补全 slash 命令。',
89
89
  toolSummaryExpanded: '工具摘要:已展开',
90
90
  toolSummaryCollapsed: '工具摘要:已收起',
91
+ toolChainCollapsed: (count) => `已折叠更早的 ${count} 个工具调用,按 Ctrl+T 展开全部`,
91
92
  toggleToolSummary: 'Ctrl+T 切换',
92
93
  scrollHint: '使用终端自己的滚动条或 scrollback',
93
94
  keyboardDebugEnabled: '键盘调试已开启',
@@ -219,6 +220,7 @@ const TUI_COPY = {
219
220
  startupHint: 'Use /help, /commands, /compact, /exit, !<shell>. Tab for slash autocomplete.',
220
221
  toolSummaryExpanded: 'Tool summary: expanded',
221
222
  toolSummaryCollapsed: 'Tool summary: collapsed',
223
+ toolChainCollapsed: (count) => `${count} earlier tool calls hidden, press Ctrl+T to expand`,
222
224
  toggleToolSummary: 'Ctrl+T to toggle',
223
225
  scrollHint: 'Scroll with your terminal scrollbar or scrollback',
224
226
  keyboardDebugEnabled: 'Keyboard debug enabled',
@@ -353,6 +355,308 @@ function trimText(value, maxLen = 88) {
353
355
  return `${text.slice(0, maxLen - 3)}...`;
354
356
  }
355
357
 
358
+ export function splitMarkdownTableCells(line) {
359
+ const text = String(line || '').trim();
360
+ if (!text.includes('|')) return [];
361
+ return text
362
+ .replace(/^\|/, '')
363
+ .replace(/\|$/, '')
364
+ .split('|')
365
+ .map((cell) => String(cell || '').trim());
366
+ }
367
+
368
+ export function isMarkdownTableSeparator(line) {
369
+ const cells = splitMarkdownTableCells(line);
370
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
371
+ }
372
+
373
+ export function isMarkdownTableHeader(line, nextLine) {
374
+ const cells = splitMarkdownTableCells(line);
375
+ return cells.length > 1 && isMarkdownTableSeparator(nextLine);
376
+ }
377
+
378
+ function getMarkdownTableAlignments(separatorLine, columnCount) {
379
+ const cells = splitMarkdownTableCells(separatorLine);
380
+ return Array.from({ length: columnCount }, (_, index) => {
381
+ const cell = String(cells[index] || '').trim();
382
+ if (/^:-{3,}:$/.test(cell)) return 'center';
383
+ if (/^-{3,}:$/.test(cell)) return 'right';
384
+ return 'left';
385
+ });
386
+ }
387
+
388
+ function stringWidthLite(value) {
389
+ return Array.from(String(value || '')).reduce((sum, ch) => sum + charDisplayWidth(ch), 0);
390
+ }
391
+
392
+ function splitTableWrapUnits(text) {
393
+ return String(text || '')
394
+ .split(/([\s,.;:!?/\\|()[\]{}<>,。;:!?、()【】《》]+)/)
395
+ .filter(Boolean);
396
+ }
397
+
398
+ function wrapPlainText(text, width, hard = false) {
399
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim();
400
+ if (!normalized) return [''];
401
+ if (width <= 1) return [normalized];
402
+
403
+ const words = splitTableWrapUnits(normalized);
404
+ const lines = [];
405
+ let current = '';
406
+
407
+ const pushWord = (word) => {
408
+ if (stringWidthLite(word) <= width) {
409
+ if (!current) {
410
+ current = word;
411
+ return;
412
+ }
413
+ const needsSpacer =
414
+ !/\s$/.test(current) &&
415
+ !/^\s/.test(word) &&
416
+ !/^[,.;:!?/\\|)\]},。;:!?、】【》]/.test(word);
417
+ const next = needsSpacer ? `${current} ${word}` : `${current}${word}`;
418
+ if (stringWidthLite(next) <= width) {
419
+ current = next;
420
+ } else {
421
+ lines.push(current);
422
+ current = word;
423
+ }
424
+ return;
425
+ }
426
+
427
+ if (!hard) {
428
+ if (current) {
429
+ lines.push(current);
430
+ current = '';
431
+ }
432
+ lines.push(word);
433
+ return;
434
+ }
435
+
436
+ if (current) {
437
+ lines.push(current);
438
+ current = '';
439
+ }
440
+ let rest = word;
441
+ while (stringWidthLite(rest) > width) {
442
+ lines.push(Array.from(rest).slice(0, width).join(''));
443
+ rest = Array.from(rest).slice(width).join('');
444
+ }
445
+ current = rest;
446
+ };
447
+
448
+ for (const word of words) pushWord(word);
449
+ if (current) lines.push(current);
450
+ return lines.length > 0 ? lines : [''];
451
+ }
452
+
453
+ function padAlignedText(text, width, align = 'left') {
454
+ const value = String(text || '');
455
+ const visible = stringWidthLite(value);
456
+ if (visible >= width) return value;
457
+ const gap = width - visible;
458
+ if (align === 'right') return `${' '.repeat(gap)}${value}`;
459
+ if (align === 'center') {
460
+ const left = Math.floor(gap / 2);
461
+ const right = gap - left;
462
+ return `${' '.repeat(left)}${value}${' '.repeat(right)}`;
463
+ }
464
+ return `${value}${' '.repeat(gap)}`;
465
+ }
466
+
467
+ function normalizeTableCellText(value) {
468
+ return String(value || '')
469
+ .replace(/`([^`]+)`/g, '$1')
470
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
471
+ .replace(/\*([^*]+)\*/g, '$1')
472
+ .trim();
473
+ }
474
+
475
+ export function formatMarkdownTableBlock(lines, contentWidth = 72) {
476
+ const sourceLines = Array.isArray(lines) ? lines : [];
477
+ if (sourceLines.length < 2) return [];
478
+
479
+ const headerCells = splitMarkdownTableCells(sourceLines[0]);
480
+ const separatorLine = sourceLines[1];
481
+ const bodyRows = sourceLines.slice(2).map(splitMarkdownTableCells).filter((cells) => cells.length > 0);
482
+ if (headerCells.length === 0) return [];
483
+
484
+ const columnCount = Math.max(headerCells.length, ...bodyRows.map((cells) => cells.length));
485
+ const headers = Array.from({ length: columnCount }, (_, index) => normalizeTableCellText(headerCells[index] || ''));
486
+ const rows = bodyRows.map((cells) =>
487
+ Array.from({ length: columnCount }, (_, index) => normalizeTableCellText(cells[index] || ''))
488
+ );
489
+ const alignments = getMarkdownTableAlignments(separatorLine, columnCount);
490
+
491
+ const minColumnWidth = 3;
492
+ const maxRowLines = 6;
493
+ const safetyMargin = 4;
494
+ const borderOverhead = 1 + columnCount * 3;
495
+ const availableWidth = Math.max(contentWidth - borderOverhead - safetyMargin, columnCount * minColumnWidth);
496
+
497
+ const getMinWidth = (text) => {
498
+ const words = splitTableWrapUnits(String(text || '')).filter((word) => !/^\s+$/.test(word));
499
+ if (words.length === 0) return minColumnWidth;
500
+ return Math.max(...words.map((word) => stringWidthLite(word)), minColumnWidth);
501
+ };
502
+
503
+ const getIdealWidth = (text) => Math.max(stringWidthLite(String(text || '').trim()), minColumnWidth);
504
+
505
+ const minWidths = headers.map((header, index) =>
506
+ Math.max(getMinWidth(header), ...rows.map((row) => getMinWidth(row[index])))
507
+ );
508
+ const idealWidths = headers.map((header, index) =>
509
+ Math.max(getIdealWidth(header), ...rows.map((row) => getIdealWidth(row[index])))
510
+ );
511
+
512
+ const totalMin = minWidths.reduce((sum, width) => sum + width, 0);
513
+ const totalIdeal = idealWidths.reduce((sum, width) => sum + width, 0);
514
+ let needsHardWrap = false;
515
+ let columnWidths;
516
+
517
+ if (totalIdeal <= availableWidth) {
518
+ columnWidths = idealWidths.slice();
519
+ } else if (totalMin <= availableWidth) {
520
+ const extraSpace = availableWidth - totalMin;
521
+ const overflows = idealWidths.map((ideal, index) => ideal - minWidths[index]);
522
+ const totalOverflow = overflows.reduce((sum, width) => sum + width, 0);
523
+ columnWidths = minWidths.map((min, index) => {
524
+ if (totalOverflow === 0) return min;
525
+ return min + Math.floor((overflows[index] / totalOverflow) * extraSpace);
526
+ });
527
+ } else {
528
+ needsHardWrap = true;
529
+ const scale = availableWidth / Math.max(totalMin, 1);
530
+ columnWidths = minWidths.map((width) => Math.max(Math.floor(width * scale), minColumnWidth));
531
+ }
532
+
533
+ const wrapCell = (text, width) => wrapPlainText(text, width, needsHardWrap);
534
+
535
+ const computeMaxWrappedLines = () => {
536
+ let maxLines = 1;
537
+ for (let index = 0; index < headers.length; index += 1) {
538
+ maxLines = Math.max(maxLines, wrapCell(headers[index], columnWidths[index]).length);
539
+ }
540
+ for (const row of rows) {
541
+ for (let index = 0; index < columnCount; index += 1) {
542
+ maxLines = Math.max(maxLines, wrapCell(row[index], columnWidths[index]).length);
543
+ }
544
+ }
545
+ return maxLines;
546
+ };
547
+
548
+ const renderVerticalRows = () => {
549
+ const rendered = [];
550
+ const separatorWidth = Math.min(Math.max(contentWidth - 2, 12), 40);
551
+ const separator = '─'.repeat(separatorWidth);
552
+ rows.forEach((row, rowIndex) => {
553
+ if (rowIndex > 0) rendered.push({ kind: 'table-vertical-separator', text: separator });
554
+ row.forEach((cell, cellIndex) => {
555
+ const label = headers[cellIndex] || `Column ${cellIndex + 1}`;
556
+ const firstWidth = Math.max(contentWidth - stringWidthLite(label) - 3, 10);
557
+ const nextWidth = Math.max(contentWidth - 3, 10);
558
+ const firstPass = wrapPlainText(cell, firstWidth, true);
559
+ const firstLine = firstPass[0] || '';
560
+ const remaining = firstPass.slice(1).join(' ');
561
+ const rest = remaining ? wrapPlainText(remaining, nextWidth, true) : [];
562
+ const wrapped = [firstLine, ...rest].filter((line, idx) => idx === 0 || line.trim());
563
+ rendered.push({
564
+ kind: 'table-vertical',
565
+ label,
566
+ text: wrapped[0] || ''
567
+ });
568
+ for (const line of wrapped.slice(1)) {
569
+ rendered.push({
570
+ kind: 'table-vertical-continuation',
571
+ text: line
572
+ });
573
+ }
574
+ });
575
+ });
576
+ return rendered;
577
+ };
578
+
579
+ if (computeMaxWrappedLines() > maxRowLines && contentWidth < 80) {
580
+ return renderVerticalRows();
581
+ }
582
+
583
+ const renderBorder = (type) => {
584
+ const chars = {
585
+ top: ['┌', '─', '┬', '┐'],
586
+ middle: ['├', '─', '┼', '┤'],
587
+ bottom: ['└', '─', '┴', '┘']
588
+ }[type];
589
+ let line = chars[0];
590
+ columnWidths.forEach((width, index) => {
591
+ line += chars[1].repeat(width + 2);
592
+ line += index < columnWidths.length - 1 ? chars[2] : chars[3];
593
+ });
594
+ return line;
595
+ };
596
+
597
+ const renderRowLines = (cells, isHeader = false) => {
598
+ const wrappedColumns = cells.map((cell, index) => wrapCell(cell, columnWidths[index]));
599
+ const maxLines = Math.max(...wrappedColumns.map((entry) => entry.length), 1);
600
+ const verticalOffsets = wrappedColumns.map((entry) => Math.floor((maxLines - entry.length) / 2));
601
+ const rendered = [];
602
+ for (let lineIndex = 0; lineIndex < maxLines; lineIndex += 1) {
603
+ let line = '│';
604
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
605
+ const wrapped = wrappedColumns[columnIndex];
606
+ const offset = verticalOffsets[columnIndex];
607
+ const contentIndex = lineIndex - offset;
608
+ const text = contentIndex >= 0 && contentIndex < wrapped.length ? wrapped[contentIndex] : '';
609
+ const align = isHeader ? 'center' : alignments[columnIndex];
610
+ line += ` ${padAlignedText(text, columnWidths[columnIndex], align)} │`;
611
+ }
612
+ rendered.push({
613
+ kind: 'table',
614
+ text: line,
615
+ isHeader
616
+ });
617
+ }
618
+ return rendered;
619
+ };
620
+
621
+ const tableLines = [
622
+ { kind: 'table-separator', text: renderBorder('top') },
623
+ ...renderRowLines(headers, true),
624
+ { kind: 'table-separator', text: renderBorder('middle') }
625
+ ];
626
+
627
+ rows.forEach((row, index) => {
628
+ tableLines.push(...renderRowLines(row, false));
629
+ if (index < rows.length - 1) {
630
+ tableLines.push({ kind: 'table-separator', text: renderBorder('middle') });
631
+ }
632
+ });
633
+ tableLines.push({ kind: 'table-separator', text: renderBorder('bottom') });
634
+
635
+ const maxLineWidth = Math.max(...tableLines.map((entry) => stringWidthLite(entry.text)));
636
+ if (maxLineWidth > contentWidth - safetyMargin) {
637
+ return renderVerticalRows();
638
+ }
639
+
640
+ return tableLines;
641
+ }
642
+
643
+ function parseRichTextSegments(line, baseColor) {
644
+ const parts = String(line || '').split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
645
+ return parts.map((part, idx) => {
646
+ if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) {
647
+ return h(
648
+ Text,
649
+ { key: `ic-${idx}`, color: 'black', backgroundColor: 'yellow' },
650
+ part.slice(1, -1)
651
+ );
652
+ }
653
+ if (part.startsWith('**') && part.endsWith('**') && part.length >= 4) {
654
+ return h(Text, { key: `bd-${idx}`, color: 'cyanBright', bold: true }, part.slice(2, -2));
655
+ }
656
+ return h(Text, { key: `tx-${idx}`, color: baseColor }, part);
657
+ });
658
+ }
659
+
356
660
  function safeJsonParse(raw) {
357
661
  try {
358
662
  return JSON.parse(String(raw || '{}'));
@@ -880,20 +1184,31 @@ function Header({ sessionId, model, shellName, safeMode = true }) {
880
1184
  }
881
1185
 
882
1186
  function renderInlineCode(line, baseColor) {
883
- const parts = line.split(/(`[^`]+`)/g);
884
- return parts.map((part, idx) => {
885
- if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) {
886
- return h(
887
- Text,
888
- { key: `ic-${idx}`, color: 'black', backgroundColor: 'yellow' },
889
- part.slice(1, -1)
890
- );
891
- }
892
- return h(Text, { key: `tx-${idx}`, color: baseColor }, part);
893
- });
1187
+ return parseRichTextSegments(line, baseColor);
894
1188
  }
895
1189
 
896
1190
  function renderTextLine(msg, line, idx, color) {
1191
+ const headingMatch = String(line || '').match(/^\s{0,3}(#{1,3})\s+(.*)$/);
1192
+ if (headingMatch) {
1193
+ const level = headingMatch[1].length;
1194
+ const title = headingMatch[2].trim();
1195
+ const accent = level === 1 ? 'cyanBright' : level === 2 ? 'greenBright' : 'yellowBright';
1196
+ return h(
1197
+ Box,
1198
+ { key: `ln-wrap-${msg.id}-${idx}` },
1199
+ h(Text, { color: accent, bold: true }, title)
1200
+ );
1201
+ }
1202
+
1203
+ const boldTitleMatch = String(line || '').match(/^\s*\*\*(.+)\*\*\s*$/);
1204
+ if (boldTitleMatch) {
1205
+ return h(
1206
+ Box,
1207
+ { key: `ln-wrap-${msg.id}-${idx}` },
1208
+ h(Text, { key: `ln-${msg.id}-${idx}`, color: 'cyanBright', bold: true }, boldTitleMatch[1].trim())
1209
+ );
1210
+ }
1211
+
897
1212
  return h(
898
1213
  Box,
899
1214
  { key: `ln-wrap-${msg.id}-${idx}` },
@@ -1544,12 +1859,70 @@ export function mergeActivitySummary(previousSummary, nextSummary, activityName)
1544
1859
  return lines.join('\n');
1545
1860
  }
1546
1861
 
1862
+ export function collapseActivityChainRows(inputRows, showToolDetails, copy, maxVisibleActivities = 3) {
1863
+ const rows = Array.isArray(inputRows) ? inputRows : [];
1864
+ if (showToolDetails) return rows.slice();
1865
+ const maxVisible = Math.max(1, Number(maxVisibleActivities) || 3);
1866
+ const collapsed = [];
1867
+
1868
+ const isCollapsibleActivity = (row) =>
1869
+ row?.kind === 'activity' &&
1870
+ ['tool', 'skill', 'system_tool'].includes(String(row?.activityType || 'tool'));
1871
+
1872
+ let index = 0;
1873
+ while (index < rows.length) {
1874
+ const row = rows[index];
1875
+ if (!isCollapsibleActivity(row)) {
1876
+ collapsed.push(row);
1877
+ index += 1;
1878
+ continue;
1879
+ }
1880
+
1881
+ const group = [];
1882
+ while (index < rows.length) {
1883
+ const next = rows[index];
1884
+ if (isCollapsibleActivity(next)) {
1885
+ group.push([next]);
1886
+ index += 1;
1887
+ continue;
1888
+ }
1889
+ if (next?.kind === 'activity-summary' && group.length > 0) {
1890
+ group[group.length - 1].push(next);
1891
+ index += 1;
1892
+ continue;
1893
+ }
1894
+ break;
1895
+ }
1896
+
1897
+ if (group.length <= maxVisible) {
1898
+ for (const item of group) collapsed.push(...item);
1899
+ continue;
1900
+ }
1901
+
1902
+ const hiddenCount = group.length - maxVisible;
1903
+ collapsed.push({
1904
+ kind: 'activity-collapsed',
1905
+ hiddenCount,
1906
+ text:
1907
+ copy?.generic?.toolChainCollapsed != null
1908
+ ? copy.generic.toolChainCollapsed(hiddenCount)
1909
+ : `${hiddenCount} earlier tool calls hidden`
1910
+ });
1911
+ for (const item of group.slice(-maxVisible)) {
1912
+ collapsed.push(...item);
1913
+ }
1914
+ }
1915
+
1916
+ return collapsed;
1917
+ }
1918
+
1547
1919
  function buildMessageRows(msg, showToolDetails, contentWidth = 72, copy) {
1548
1920
  const rows = [];
1549
1921
  const pushTextRows = (text) => {
1550
1922
  const lines = String(text || '').split('\n');
1551
1923
  let codeFence = false;
1552
- for (const line of lines) {
1924
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
1925
+ const line = lines[lineIndex];
1553
1926
  const trimmed = line.trim();
1554
1927
  const planProgress = parsePlanProgressLine(trimmed);
1555
1928
  if (planProgress) {
@@ -1570,6 +1943,16 @@ function buildMessageRows(msg, showToolDetails, contentWidth = 72, copy) {
1570
1943
  pushWrappedRow(rows, { kind: 'code', text: line || ' ', color: 'gray' }, contentWidth);
1571
1944
  continue;
1572
1945
  }
1946
+ if (isMarkdownTableHeader(line, lines[lineIndex + 1])) {
1947
+ const tableLines = [line];
1948
+ lineIndex += 1; // skip separator
1949
+ while (lineIndex + 1 < lines.length && splitMarkdownTableCells(lines[lineIndex + 1]).length > 1) {
1950
+ tableLines.push(lines[lineIndex + 1]);
1951
+ lineIndex += 1;
1952
+ }
1953
+ rows.push(...formatMarkdownTableBlock(tableLines, contentWidth));
1954
+ continue;
1955
+ }
1573
1956
  let color = msg.color || roleStyle(msg.label).text || 'white';
1574
1957
  if (line.startsWith('#')) color = 'cyanBright';
1575
1958
  else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) color = 'magentaBright';
@@ -1651,7 +2034,9 @@ function buildMessageRows(msg, showToolDetails, contentWidth = 72, copy) {
1651
2034
  syntheticRows.push(...statusRows);
1652
2035
  }
1653
2036
 
1654
- return normalizeActivitySpacingRows(insertRowsAfterLastCodeRow(rows, syntheticRows));
2037
+ return normalizeActivitySpacingRows(
2038
+ insertRowsAfterLastCodeRow(collapseActivityChainRows(rows, showToolDetails, copy), syntheticRows)
2039
+ );
1655
2040
  }
1656
2041
 
1657
2042
  function renderMessageRow(msg, row, idx, loaderTick) {
@@ -1695,6 +2080,49 @@ function renderMessageRow(msg, row, idx, loaderTick) {
1695
2080
  h(Text, { color: 'gray' }, `└ ${row.text}`)
1696
2081
  );
1697
2082
  }
2083
+ if (row.kind === 'table') {
2084
+ return h(
2085
+ Box,
2086
+ { key: `row-table-${msg.id}-${idx}`, marginLeft: 1 },
2087
+ h(Text, { color: row.isHeader ? 'cyanBright' : 'gray', bold: Boolean(row.isHeader) }, row.text)
2088
+ );
2089
+ }
2090
+ if (row.kind === 'table-separator') {
2091
+ return h(
2092
+ Box,
2093
+ { key: `row-table-sep-${msg.id}-${idx}`, marginLeft: 1 },
2094
+ h(Text, { color: 'gray' }, row.text)
2095
+ );
2096
+ }
2097
+ if (row.kind === 'table-vertical') {
2098
+ return h(
2099
+ Box,
2100
+ { key: `row-table-v-${msg.id}-${idx}`, marginLeft: 1 },
2101
+ h(Text, { color: 'cyanBright', bold: true }, `${row.label}:`),
2102
+ h(Text, { color: 'gray' }, row.text ? ` ${row.text}` : '')
2103
+ );
2104
+ }
2105
+ if (row.kind === 'table-vertical-continuation') {
2106
+ return h(
2107
+ Box,
2108
+ { key: `row-table-vc-${msg.id}-${idx}`, marginLeft: 3 },
2109
+ h(Text, { color: 'gray' }, row.text)
2110
+ );
2111
+ }
2112
+ if (row.kind === 'table-vertical-separator') {
2113
+ return h(
2114
+ Box,
2115
+ { key: `row-table-vs-${msg.id}-${idx}`, marginLeft: 1 },
2116
+ h(Text, { color: 'gray' }, row.text)
2117
+ );
2118
+ }
2119
+ if (row.kind === 'activity-collapsed') {
2120
+ return h(
2121
+ Box,
2122
+ { key: `row-tool-collapsed-${msg.id}-${idx}`, marginLeft: 1 },
2123
+ h(Text, { color: 'gray' }, `└ ${row.text}`)
2124
+ );
2125
+ }
1698
2126
  if (row.kind === 'plan-progress') {
1699
2127
  return h(
1700
2128
  Box,