dsh-ssh-tui 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +27 -6
- package/README.md +16 -8
- package/lib/approval-reviewer.js +56 -15
- package/lib/approval-reviewer.js.map +1 -1
- package/lib/auto-approval.js +251 -35
- package/lib/auto-approval.js.map +1 -1
- package/lib/footer.js +337 -0
- package/lib/footer.js.map +1 -0
- package/lib/i18n/en.js +48 -2
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +48 -2
- package/lib/i18n/zh.js.map +1 -1
- package/lib/json-args.js +30 -0
- package/lib/json-args.js.map +1 -0
- package/lib/paint.js +262 -0
- package/lib/paint.js.map +1 -0
- package/lib/picker.js +407 -56
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +369 -0
- package/lib/plan.js.map +1 -0
- package/lib/quota.js +408 -0
- package/lib/quota.js.map +1 -0
- package/lib/session-list.js +18 -21
- package/lib/session-list.js.map +1 -1
- package/lib/term-text.js +827 -0
- package/lib/term-text.js.map +1 -0
- package/lib/tool-present.js +744 -0
- package/lib/tool-present.js.map +1 -0
- package/lib/transcript-types.js +6 -0
- package/lib/transcript-types.js.map +1 -0
- package/lib/tui.js +419 -3200
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-reviewer.d.ts +8 -2
- package/lib/types/auto-approval.d.ts +28 -0
- package/lib/types/footer.d.ts +155 -0
- package/lib/types/json-args.d.ts +7 -0
- package/lib/types/paint.d.ts +78 -0
- package/lib/types/picker.d.ts +99 -7
- package/lib/types/plan.d.ts +80 -0
- package/lib/types/quota.d.ts +94 -0
- package/lib/types/session-list.d.ts +13 -0
- package/lib/types/term-text.d.ts +130 -0
- package/lib/types/tool-present.d.ts +165 -0
- package/lib/types/transcript-types.d.ts +152 -0
- package/lib/types/tui.d.ts +21 -721
- package/package.json +1 -1
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-card presentation: headers, diffs, compact bursts, JSON bodies.
|
|
3
|
+
*/
|
|
4
|
+
import { t } from './i18n/index.js';
|
|
5
|
+
import { wrap, truncate, sliceCodePoints } from './term-text.js';
|
|
6
|
+
import { firstString, parseJsonArgs, scalarText } from './json-args.js';
|
|
7
|
+
import { parsePlanTodos, planTitleFromMarkdown, todoProgressLabel, todoSummary, askSummary, TODO_STATUS_MARK, todoItemKind, planMarkdownFromArgs, } from './plan.js';
|
|
8
|
+
export const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
9
|
+
export const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
10
|
+
/** Format a model list compactly: show the first few entries and an ellipsis. */
|
|
11
|
+
export function formatModelList(models, max = 5) {
|
|
12
|
+
const shown = models.slice(0, max);
|
|
13
|
+
const text = shown.join(', ');
|
|
14
|
+
return models.length > max ? t('models.ellipsis', { text, count: models.length }) : text;
|
|
15
|
+
}
|
|
16
|
+
/** Prefer the fields a human scans for; fall back to the first scalar pairs. */
|
|
17
|
+
export function friendlyArgsSummary(name, args) {
|
|
18
|
+
const parsed = parseJsonArgs(args);
|
|
19
|
+
if (parsed === null)
|
|
20
|
+
return sliceCodePoints(args, 120);
|
|
21
|
+
const preferred = [
|
|
22
|
+
'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
|
|
23
|
+
'name', 'skill', 'description', 'content', 'file_text', 'old_string', 'new_string',
|
|
24
|
+
'old_str', 'new_str', 'insert_line', 'line', 'offset', 'limit',
|
|
25
|
+
];
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (const key of preferred) {
|
|
28
|
+
const value = parsed[key];
|
|
29
|
+
if (value === undefined || value === null || typeof value === 'object')
|
|
30
|
+
continue;
|
|
31
|
+
parts.push(`${key}: ${String(value)}`);
|
|
32
|
+
if (parts.length >= 3)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
if (parts.length === 0) {
|
|
36
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
37
|
+
const text = scalarText(value);
|
|
38
|
+
if (text !== null) {
|
|
39
|
+
parts.push(`${key}: ${text}`);
|
|
40
|
+
if (parts.length >= 3)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const summary = parts.join(' ');
|
|
46
|
+
return summary === '' ? name : sliceCodePoints(summary, 160);
|
|
47
|
+
}
|
|
48
|
+
export function countDiffLines(hunks) {
|
|
49
|
+
if (hunks === undefined || hunks.length === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
let total = 0;
|
|
52
|
+
for (const hunk of hunks) {
|
|
53
|
+
const added = hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
|
|
54
|
+
if (hunk.oldText === null) {
|
|
55
|
+
total += added;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const removed = hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
|
|
59
|
+
total += added + removed;
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
/** Added / removed line counts for a diff (`oldText: null` means a new file). */
|
|
64
|
+
export function countDiffAddDel(hunks) {
|
|
65
|
+
const stat = { add: 0, del: 0 };
|
|
66
|
+
if (hunks === undefined)
|
|
67
|
+
return stat;
|
|
68
|
+
for (const hunk of hunks) {
|
|
69
|
+
stat.add += hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
|
|
70
|
+
if (hunk.oldText !== null) {
|
|
71
|
+
stat.del += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return stat;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Git diffstat token, deletions first like `-13 +24`. Zero parts drop out
|
|
78
|
+
* (a new file shows only `+24`); empty when the diff has no counted lines.
|
|
79
|
+
*/
|
|
80
|
+
export function diffStatToken(add, del) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (del > 0)
|
|
83
|
+
parts.push(`-${del}`);
|
|
84
|
+
if (add > 0)
|
|
85
|
+
parts.push(`+${add}`);
|
|
86
|
+
return parts.join(' ');
|
|
87
|
+
}
|
|
88
|
+
export const READ_TOOL_NAMES = new Set(['read']);
|
|
89
|
+
export const TOOL_FLIP_MS = 280;
|
|
90
|
+
export function toolTargetPath(name, args, fallback = '') {
|
|
91
|
+
const parsed = parseJsonArgs(args);
|
|
92
|
+
if (READ_TOOL_NAMES.has(name)) {
|
|
93
|
+
if (parsed === null)
|
|
94
|
+
return fallback;
|
|
95
|
+
return firstString(parsed, ['path', 'file_path', 'url']) || fallback;
|
|
96
|
+
}
|
|
97
|
+
if (DIFF_TOOL_NAMES.has(name)) {
|
|
98
|
+
if (parsed === null)
|
|
99
|
+
return fallback;
|
|
100
|
+
return firstString(parsed, ['file_path', 'path']) || fallback;
|
|
101
|
+
}
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
/** Path shown on a compact single-file edit summary. */
|
|
105
|
+
export function compactEditPath(item) {
|
|
106
|
+
const fromArgs = toolTargetPath(item.name, item.args);
|
|
107
|
+
if (fromArgs !== '')
|
|
108
|
+
return fromArgs;
|
|
109
|
+
const fromDiff = item.diff?.map(hunk => hunk.path ?? '').find(path => path !== '');
|
|
110
|
+
if (fromDiff !== undefined && fromDiff !== '')
|
|
111
|
+
return fromDiff;
|
|
112
|
+
const summary = item.summary?.trim() ?? '';
|
|
113
|
+
return summary;
|
|
114
|
+
}
|
|
115
|
+
export function sameToolPath(left, right) {
|
|
116
|
+
if (left === '' || right === '')
|
|
117
|
+
return false;
|
|
118
|
+
const normalize = (value) => value.replaceAll('\\', '/').replace(/\/+$/u, '');
|
|
119
|
+
return normalize(left) === normalize(right);
|
|
120
|
+
}
|
|
121
|
+
export function countOutputLines(text) {
|
|
122
|
+
if (text === '')
|
|
123
|
+
return 0;
|
|
124
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
125
|
+
return body === '' ? 0 : body.split('\n').length;
|
|
126
|
+
}
|
|
127
|
+
export function mergeableToolKind(name) {
|
|
128
|
+
if (READ_TOOL_NAMES.has(name))
|
|
129
|
+
return 'read';
|
|
130
|
+
if (DIFF_TOOL_NAMES.has(name))
|
|
131
|
+
return 'edit';
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Consecutive same-path reads (or edits) collapse onto one card.
|
|
136
|
+
* A → B → C → A becomes four cards; A ×5 stays one card with repeats=5.
|
|
137
|
+
*/
|
|
138
|
+
export function canMergeToolCall(previous, next) {
|
|
139
|
+
if (previous === undefined)
|
|
140
|
+
return false;
|
|
141
|
+
const kind = mergeableToolKind(next.name);
|
|
142
|
+
if (kind === undefined || mergeableToolKind(previous.name) !== kind)
|
|
143
|
+
return false;
|
|
144
|
+
const previousPath = toolTargetPath(previous.name, previous.args, previous.summary);
|
|
145
|
+
const nextPath = toolTargetPath(next.name, next.args, previousPath);
|
|
146
|
+
return sameToolPath(previousPath, nextPath);
|
|
147
|
+
}
|
|
148
|
+
export function compactToolGroups(tools) {
|
|
149
|
+
const edits = [];
|
|
150
|
+
const calls = [];
|
|
151
|
+
for (const tool of tools) {
|
|
152
|
+
if (DIFF_TOOL_NAMES.has(tool.name) || (tool.diff !== undefined && tool.diff.length > 0))
|
|
153
|
+
edits.push(tool);
|
|
154
|
+
else
|
|
155
|
+
calls.push(tool);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
edits,
|
|
159
|
+
calls,
|
|
160
|
+
failedCalls: calls.filter(tool => tool.status === 'error').length,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Split compact-view tools into the bursts that belong with each assistant
|
|
165
|
+
* reply: tools after reply N sit with that reply, until the next reply.
|
|
166
|
+
*/
|
|
167
|
+
export function compactToolBursts(rows) {
|
|
168
|
+
const bursts = [];
|
|
169
|
+
let current = { after: undefined, tools: [] };
|
|
170
|
+
bursts.push(current);
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
if (row.kind === 'assistant') {
|
|
173
|
+
current = { after: row, tools: [] };
|
|
174
|
+
bursts.push(current);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (row.kind === 'tool')
|
|
178
|
+
current.tools.push(row);
|
|
179
|
+
}
|
|
180
|
+
return bursts
|
|
181
|
+
.map(burst => ({ after: burst.after, groups: compactToolGroups(burst.tools) }))
|
|
182
|
+
.filter(burst => burst.groups.calls.length > 0 || burst.groups.edits.length > 0);
|
|
183
|
+
}
|
|
184
|
+
export const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
|
|
185
|
+
/**
|
|
186
|
+
* Tool calls that already have a dedicated transcript card (goal/change,
|
|
187
|
+
* plan dock, question dialog). Showing them again as raw `get_goal` cards
|
|
188
|
+
* just duplicates chrome.
|
|
189
|
+
*/
|
|
190
|
+
export const HIDDEN_TOOL_NAMES = new Set(['get_goal']);
|
|
191
|
+
const TOOL_TITLE_KEYS = [
|
|
192
|
+
'edit', 'write', 'str_replace_editor', 'fetch', 'list_files', 'list', 'ls',
|
|
193
|
+
'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills', 'skill',
|
|
194
|
+
'create_goal', 'update_goal', 'complete_goal', 'clear_goal', 'pause_goal',
|
|
195
|
+
'resume_goal', 'todo_write', 'todo', 'compact', 'glob', 'grep', 'read',
|
|
196
|
+
'web_search', 'web_fetch',
|
|
197
|
+
];
|
|
198
|
+
export function toolTitle(name) {
|
|
199
|
+
if (name === '' || name.startsWith('call-'))
|
|
200
|
+
return t('card.tool');
|
|
201
|
+
return t(`toolTitle.${name}`, undefined, name === 'tool' ? t('card.tool') : name);
|
|
202
|
+
}
|
|
203
|
+
export function planReviewOf(question) {
|
|
204
|
+
return question.intent?.kind === 'plan-review' && question.detail !== undefined && question.detail !== '';
|
|
205
|
+
}
|
|
206
|
+
/** Derive the intended file change from a mutation tool's arguments. */
|
|
207
|
+
export function diffHunksFromArgs(name, argsRaw) {
|
|
208
|
+
const args = parseJsonArgs(argsRaw);
|
|
209
|
+
if (args === null)
|
|
210
|
+
return null;
|
|
211
|
+
if (name === 'edit' || name === 'write') {
|
|
212
|
+
const path = typeof args.file_path === 'string' ? args.file_path : '';
|
|
213
|
+
if (path === '')
|
|
214
|
+
return null;
|
|
215
|
+
if (name === 'edit') {
|
|
216
|
+
return [{
|
|
217
|
+
path,
|
|
218
|
+
oldText: typeof args.old_string === 'string' ? args.old_string : null,
|
|
219
|
+
newText: typeof args.new_string === 'string' ? args.new_string : '',
|
|
220
|
+
}];
|
|
221
|
+
}
|
|
222
|
+
return [{
|
|
223
|
+
path,
|
|
224
|
+
oldText: null,
|
|
225
|
+
newText: typeof args.content === 'string' ? args.content : '',
|
|
226
|
+
}];
|
|
227
|
+
}
|
|
228
|
+
if (name === 'str_replace_editor') {
|
|
229
|
+
const path = typeof args.path === 'string' ? args.path : '';
|
|
230
|
+
const command = typeof args.command === 'string' ? args.command : '';
|
|
231
|
+
if (path === '')
|
|
232
|
+
return null;
|
|
233
|
+
if (command === 'create') {
|
|
234
|
+
return [{ path, oldText: null, newText: typeof args.file_text === 'string' ? args.file_text : '' }];
|
|
235
|
+
}
|
|
236
|
+
if (command === 'str_replace') {
|
|
237
|
+
return [{
|
|
238
|
+
path,
|
|
239
|
+
oldText: typeof args.old_str === 'string' ? args.old_str : null,
|
|
240
|
+
newText: typeof args.new_str === 'string' ? args.new_str : '',
|
|
241
|
+
}];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
/** One-line friendly tool-call presentation (command / path / arg summary). */
|
|
247
|
+
export function presentToolCall(name, args) {
|
|
248
|
+
const parsed = parseJsonArgs(args);
|
|
249
|
+
if (SHELL_TOOL_NAMES.has(name)) {
|
|
250
|
+
const command = typeof parsed?.command === 'string' ? parsed.command : sliceCodePoints(args, 80);
|
|
251
|
+
return {
|
|
252
|
+
title: name,
|
|
253
|
+
summary: `$ ${command}`,
|
|
254
|
+
command,
|
|
255
|
+
cwd: typeof parsed?.workdir === 'string' ? parsed.workdir : undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (DIFF_TOOL_NAMES.has(name)) {
|
|
259
|
+
const diff = diffHunksFromArgs(name, args);
|
|
260
|
+
const path = diff?.[0]?.path;
|
|
261
|
+
return {
|
|
262
|
+
title: toolTitle(name),
|
|
263
|
+
summary: path ?? friendlyArgsSummary(name, args),
|
|
264
|
+
...diff === null || diff === undefined ? {} : { diff },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (SUBAGENT_TOOL_NAMES.has(name)) {
|
|
268
|
+
const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
|
|
269
|
+
return {
|
|
270
|
+
title: toolTitle(name === 'subagent_fork' ? 'subagent_fork' : 'subagent'),
|
|
271
|
+
summary: description === '' ? friendlyArgsSummary(name, args) : description,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (name === 'todo_write' || name === 'todo') {
|
|
275
|
+
return { title: toolTitle('todo_write'), summary: todoSummary(parsed) };
|
|
276
|
+
}
|
|
277
|
+
if (name === 'ask_user_question') {
|
|
278
|
+
return { title: toolTitle('ask_user_question'), summary: askSummary(parsed) };
|
|
279
|
+
}
|
|
280
|
+
if (name === 'exit_plan_mode') {
|
|
281
|
+
const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
|
|
282
|
+
return { title: toolTitle('exit_plan_mode'), summary: planTitleFromMarkdown(plan) ?? t('plan.waitConfirm') };
|
|
283
|
+
}
|
|
284
|
+
if (name === 'update_goal' || name === 'create_goal') {
|
|
285
|
+
const action = typeof parsed?.action === 'string' ? parsed.action.trim() : '';
|
|
286
|
+
const objective = typeof parsed?.objective === 'string' ? parsed.objective.trim() : '';
|
|
287
|
+
const titleKey = name === 'create_goal' || action === 'create' || action === 'set'
|
|
288
|
+
? 'create_goal'
|
|
289
|
+
: action === 'pause' ? 'pause_goal'
|
|
290
|
+
: action === 'resume' ? 'resume_goal'
|
|
291
|
+
: action === 'clear' ? 'clear_goal'
|
|
292
|
+
: action === 'complete' ? 'complete_goal'
|
|
293
|
+
: 'update_goal';
|
|
294
|
+
return { title: toolTitle(titleKey), summary: objective || action || friendlyArgsSummary(name, args) };
|
|
295
|
+
}
|
|
296
|
+
if (name === 'get_goal') {
|
|
297
|
+
return { title: toolTitle('get_goal'), summary: friendlyArgsSummary(name, args) };
|
|
298
|
+
}
|
|
299
|
+
if (name === 'skill' || name === 'skills') {
|
|
300
|
+
const skill = typeof parsed?.name === 'string' ? parsed.name.trim()
|
|
301
|
+
: typeof parsed?.skill === 'string' ? parsed.skill.trim()
|
|
302
|
+
: typeof parsed?.id === 'string' ? parsed.id.trim()
|
|
303
|
+
: '';
|
|
304
|
+
return { title: toolTitle('skill'), summary: skill || friendlyArgsSummary(name, args) };
|
|
305
|
+
}
|
|
306
|
+
if (name === 'read') {
|
|
307
|
+
const path = typeof parsed?.path === 'string' ? parsed.path
|
|
308
|
+
: typeof parsed?.file_path === 'string' ? parsed.file_path
|
|
309
|
+
: typeof parsed?.url === 'string' ? parsed.url
|
|
310
|
+
: '';
|
|
311
|
+
return { title: toolTitle('read'), summary: path || friendlyArgsSummary(name, args) };
|
|
312
|
+
}
|
|
313
|
+
if (name === 'grep') {
|
|
314
|
+
const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
|
|
315
|
+
const path = typeof parsed?.path === 'string' ? parsed.path : '';
|
|
316
|
+
return { title: toolTitle('grep'), summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
|
|
317
|
+
}
|
|
318
|
+
if (name === 'glob') {
|
|
319
|
+
const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
|
|
320
|
+
: typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
|
|
321
|
+
: '';
|
|
322
|
+
return { title: toolTitle('glob'), summary: pattern || friendlyArgsSummary(name, args) };
|
|
323
|
+
}
|
|
324
|
+
if (name === 'web_search') {
|
|
325
|
+
const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
|
|
326
|
+
return { title: toolTitle('web_search'), summary: query || friendlyArgsSummary(name, args) };
|
|
327
|
+
}
|
|
328
|
+
if (name === 'web_fetch') {
|
|
329
|
+
const url = typeof parsed?.url === 'string' ? parsed.url : '';
|
|
330
|
+
return { title: toolTitle('web_fetch'), summary: url || friendlyArgsSummary(name, args) };
|
|
331
|
+
}
|
|
332
|
+
return { title: toolTitle(name), summary: friendlyArgsSummary(name, args) };
|
|
333
|
+
}
|
|
334
|
+
/** Validate a tool/result meta payload's structured diff, mirroring the web card. */
|
|
335
|
+
export function diffMetaDiffs(meta) {
|
|
336
|
+
if (typeof meta !== 'object' || meta === null)
|
|
337
|
+
return null;
|
|
338
|
+
const diffs = meta.diffs;
|
|
339
|
+
if (!Array.isArray(diffs) || diffs.length === 0)
|
|
340
|
+
return null;
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const hunk of diffs) {
|
|
343
|
+
if (typeof hunk !== 'object' || hunk === null)
|
|
344
|
+
return null;
|
|
345
|
+
const { path, oldText, newText } = hunk;
|
|
346
|
+
if (typeof path !== 'string' || typeof newText !== 'string')
|
|
347
|
+
return null;
|
|
348
|
+
if (oldText !== null && typeof oldText !== 'string')
|
|
349
|
+
return null;
|
|
350
|
+
out.push({ path, oldText: oldText, newText });
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
/** Split one diff side into content lines (trailing newline is a terminator). */
|
|
355
|
+
export function diffContentLines(text) {
|
|
356
|
+
if (text === '')
|
|
357
|
+
return [];
|
|
358
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
359
|
+
return body.split('\n');
|
|
360
|
+
}
|
|
361
|
+
/** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
|
|
362
|
+
export function capDisplayLines(lines, maxLines) {
|
|
363
|
+
const budget = Math.max(1, Math.floor(maxLines));
|
|
364
|
+
if (lines.length <= budget)
|
|
365
|
+
return [...lines];
|
|
366
|
+
const omitted = lines.length - budget + 1;
|
|
367
|
+
const marker = { kind: 'tool-result', text: `… ${omitted} more line(s) …` };
|
|
368
|
+
if (budget === 1)
|
|
369
|
+
return [marker];
|
|
370
|
+
return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
|
|
371
|
+
}
|
|
372
|
+
/** Running / ok / error → ANSI color for the status dot and status word only. */
|
|
373
|
+
export function toolStateColor(status) {
|
|
374
|
+
if (status === 'ok')
|
|
375
|
+
return '32';
|
|
376
|
+
if (status === 'error')
|
|
377
|
+
return '31';
|
|
378
|
+
return '33';
|
|
379
|
+
}
|
|
380
|
+
export function toolStateLabel(status) {
|
|
381
|
+
if (status === 'ok')
|
|
382
|
+
return 'ok';
|
|
383
|
+
if (status === 'error')
|
|
384
|
+
return 'error';
|
|
385
|
+
return 'running…';
|
|
386
|
+
}
|
|
387
|
+
/** Header + SGR spans: default title, dim operand, colored ●. `[ok]` is omitted — the green dot is enough. */
|
|
388
|
+
export function buildToolHeader(input) {
|
|
389
|
+
const running = input.status === undefined || input.status === 'running';
|
|
390
|
+
const stateToken = input.status === 'ok' ? '' : `[${toolStateLabel(input.status)}]`;
|
|
391
|
+
const exit = !running && input.command !== undefined
|
|
392
|
+
? input.signal !== undefined
|
|
393
|
+
? t('tool.signal', { signal: input.signal })
|
|
394
|
+
: (input.exitCode ?? 0) !== 0
|
|
395
|
+
? t('tool.exitCode', { code: input.exitCode ?? 0 })
|
|
396
|
+
: ''
|
|
397
|
+
: '';
|
|
398
|
+
const spinner = input.spinner ?? '';
|
|
399
|
+
const prefix = input.focused ? '▶ ' : ' ';
|
|
400
|
+
const flipping = input.flipping === true;
|
|
401
|
+
const marker = flipping ? '◇' : input.expanded ? '▾' : '▸';
|
|
402
|
+
const lead = `${prefix}${marker} ● ${input.title}`;
|
|
403
|
+
const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
|
|
404
|
+
const statToken = input.diffStat === undefined ? '' : diffStatToken(input.diffStat.add, input.diffStat.del);
|
|
405
|
+
const statText = statToken === '' ? '' : ` ${statToken}`;
|
|
406
|
+
const stateGap = stateToken === '' ? '' : ' ';
|
|
407
|
+
const tail = `${stateGap}${stateToken}${exit}${spinner}`;
|
|
408
|
+
const plain = `${lead}${summaryText}${statText}${tail}`;
|
|
409
|
+
const stateCode = toolStateColor(input.status);
|
|
410
|
+
const dotIndex = lead.indexOf('●');
|
|
411
|
+
const stateIndex = stateToken === '' ? -1 : lead.length + summaryText.length + statText.length + stateGap.length;
|
|
412
|
+
const segments = [];
|
|
413
|
+
if (flipping) {
|
|
414
|
+
const markerIndex = prefix.length;
|
|
415
|
+
segments.push({ start: markerIndex, end: markerIndex + marker.length, sgr: '36' });
|
|
416
|
+
}
|
|
417
|
+
if (dotIndex >= 0)
|
|
418
|
+
segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
|
|
419
|
+
if (summaryText.length > 0) {
|
|
420
|
+
segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
|
|
421
|
+
}
|
|
422
|
+
if (statToken !== '') {
|
|
423
|
+
// Git diffstat colors: deletions red, additions green. The token sits
|
|
424
|
+
// two cells after the summary, deletions before the joining space.
|
|
425
|
+
const statStart = lead.length + summaryText.length + 2;
|
|
426
|
+
const delEnd = statToken.indexOf(' +');
|
|
427
|
+
if (statToken.startsWith('-')) {
|
|
428
|
+
segments.push({
|
|
429
|
+
start: statStart,
|
|
430
|
+
end: statStart + (delEnd === -1 ? statToken.length : delEnd),
|
|
431
|
+
sgr: '31',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (delEnd !== -1) {
|
|
435
|
+
const addStart = statStart + delEnd + 1;
|
|
436
|
+
segments.push({ start: addStart, end: statStart + statToken.length, sgr: '32' });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (stateIndex >= 0) {
|
|
440
|
+
segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
|
|
441
|
+
}
|
|
442
|
+
else if (exit !== '') {
|
|
443
|
+
const exitIndex = lead.length + summaryText.length + statText.length;
|
|
444
|
+
segments.push({ start: exitIndex, end: exitIndex + exit.length, sgr: stateCode });
|
|
445
|
+
}
|
|
446
|
+
if (spinner !== '') {
|
|
447
|
+
const spinnerStart = (stateIndex >= 0 ? stateIndex + stateToken.length + exit.length : lead.length + summaryText.length + statText.length + exit.length);
|
|
448
|
+
segments.push({
|
|
449
|
+
start: spinnerStart,
|
|
450
|
+
end: plain.length,
|
|
451
|
+
sgr: '90',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return { plain, segments: segments.filter(segment => segment.end > segment.start) };
|
|
455
|
+
}
|
|
456
|
+
/** How many terminal rows a tool body occupies after wrapping. */
|
|
457
|
+
export function wrappedToolBodyLineCount(lines, width) {
|
|
458
|
+
const inner = Math.max(1, width - 2);
|
|
459
|
+
let count = 0;
|
|
460
|
+
for (const line of lines) {
|
|
461
|
+
count += Math.max(1, wrap(line.text, inner).length);
|
|
462
|
+
}
|
|
463
|
+
return count;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* True when the full tool body plus a one-line header fits in the workspace
|
|
467
|
+
* (the rows between the title bar and the input chrome). Oversized bodies
|
|
468
|
+
* open a dedicated inspect overlay instead of dumping into the transcript.
|
|
469
|
+
*/
|
|
470
|
+
export function toolBodyFitsWorkspace(bodyLines, workspaceRows) {
|
|
471
|
+
return bodyLines + 1 <= Math.max(1, workspaceRows);
|
|
472
|
+
}
|
|
473
|
+
/** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
|
|
474
|
+
export function renderToolDiff(diffs, maxLines) {
|
|
475
|
+
const rows = [];
|
|
476
|
+
const paths = new Set();
|
|
477
|
+
let added = 0;
|
|
478
|
+
let removed = 0;
|
|
479
|
+
let prevPath;
|
|
480
|
+
for (const hunk of diffs) {
|
|
481
|
+
paths.add(hunk.path);
|
|
482
|
+
rows.push(hunk.path === prevPath
|
|
483
|
+
? { kind: 'diff-path', text: '⋯' }
|
|
484
|
+
: { kind: 'diff-path', text: hunk.path });
|
|
485
|
+
prevPath = hunk.path;
|
|
486
|
+
if (hunk.oldText !== null) {
|
|
487
|
+
for (const line of diffContentLines(hunk.oldText)) {
|
|
488
|
+
rows.push({ kind: 'diff-del', text: `- ${line}` });
|
|
489
|
+
removed += 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const line of diffContentLines(hunk.newText)) {
|
|
493
|
+
rows.push({ kind: 'diff-add', text: `+ ${line}` });
|
|
494
|
+
added += 1;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
rows.push({
|
|
498
|
+
kind: 'tool-result',
|
|
499
|
+
text: `└ +${added} -${removed} · ${paths.size} file${paths.size === 1 ? '' : 's'}`,
|
|
500
|
+
});
|
|
501
|
+
return capDisplayLines(rows, maxLines);
|
|
502
|
+
}
|
|
503
|
+
/** Keys whose multiline strings render as indented content blocks. */
|
|
504
|
+
const LONG_TEXT_KEYS = new Set([
|
|
505
|
+
'program', 'content', 'file_text', 'new_string', 'old_string',
|
|
506
|
+
'plan', 'markdown', 'details', 'description', 'text',
|
|
507
|
+
]);
|
|
508
|
+
const JSON_STRING_CAP = 400;
|
|
509
|
+
const JSON_MAX_DEPTH = 16;
|
|
510
|
+
const JSON_MAX_ENTRIES = 60;
|
|
511
|
+
/** Convert any parsed JSON value into readable indented display lines. */
|
|
512
|
+
export function friendlyJsonLines(value, depth = 0) {
|
|
513
|
+
const pad = ' '.repeat(depth);
|
|
514
|
+
if (value === null)
|
|
515
|
+
return [`${pad}null`];
|
|
516
|
+
if (typeof value === 'string') {
|
|
517
|
+
const capped = value.length > JSON_STRING_CAP ? `${value.slice(0, JSON_STRING_CAP)}…` : value;
|
|
518
|
+
return [`${pad}${capped}`];
|
|
519
|
+
}
|
|
520
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
521
|
+
return [`${pad}${String(value)}`];
|
|
522
|
+
}
|
|
523
|
+
if (depth >= JSON_MAX_DEPTH) {
|
|
524
|
+
return [`${pad}…`];
|
|
525
|
+
}
|
|
526
|
+
if (Array.isArray(value)) {
|
|
527
|
+
if (value.length === 0)
|
|
528
|
+
return [`${pad}[]`];
|
|
529
|
+
const shown = value.slice(0, JSON_MAX_ENTRIES);
|
|
530
|
+
const lines = [];
|
|
531
|
+
for (const item of shown) {
|
|
532
|
+
if (item !== null && typeof item === 'object') {
|
|
533
|
+
lines.push(`${pad}-`);
|
|
534
|
+
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
lines.push(`${pad}- ${friendlyJsonLines(item, 0)[0] ?? ''}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (value.length > shown.length)
|
|
541
|
+
lines.push(`${pad}… ${value.length - shown.length} more item(s)`);
|
|
542
|
+
return lines;
|
|
543
|
+
}
|
|
544
|
+
if (typeof value === 'object') {
|
|
545
|
+
const entries = Object.entries(value);
|
|
546
|
+
if (entries.length === 0)
|
|
547
|
+
return [`${pad}{}`];
|
|
548
|
+
const shown = entries.slice(0, JSON_MAX_ENTRIES);
|
|
549
|
+
const lines = [];
|
|
550
|
+
for (const [key, item] of shown) {
|
|
551
|
+
if (typeof item === 'string' && item.includes('\n') && LONG_TEXT_KEYS.has(key)) {
|
|
552
|
+
const contentLines = item.split('\n');
|
|
553
|
+
lines.push(`${pad}${key}:`);
|
|
554
|
+
for (const contentLine of contentLines.slice(0, 80)) {
|
|
555
|
+
lines.push(`${pad} │ ${contentLine}`);
|
|
556
|
+
}
|
|
557
|
+
if (contentLines.length > 80) {
|
|
558
|
+
lines.push(`${pad} … ${contentLines.length - 80} more line(s)`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (item !== null && typeof item === 'object') {
|
|
562
|
+
lines.push(`${pad}${key}:`);
|
|
563
|
+
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const scalar = friendlyJsonLines(item, 0)[0] ?? '';
|
|
567
|
+
lines.push(`${pad}${key}: ${scalar}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (entries.length > shown.length)
|
|
571
|
+
lines.push(`${pad}… ${entries.length - shown.length} more field(s)`);
|
|
572
|
+
return lines;
|
|
573
|
+
}
|
|
574
|
+
return [`${pad}${String(value)}`];
|
|
575
|
+
}
|
|
576
|
+
/** Try to parse a result body as one JSON document, when it looks like one. */
|
|
577
|
+
export function parseJsonBody(text) {
|
|
578
|
+
const trimmed = text.trim();
|
|
579
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
580
|
+
return null;
|
|
581
|
+
try {
|
|
582
|
+
return JSON.parse(trimmed);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The expanded body of one tool card: diffs and shell output keep their
|
|
590
|
+
* dedicated views; every other tool's JSON arguments and JSON result are
|
|
591
|
+
* converted into readable indented content instead of raw JSON text.
|
|
592
|
+
*/
|
|
593
|
+
export function toolBodyLines(row, maxLines) {
|
|
594
|
+
const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
|
|
595
|
+
if (row.diff !== undefined && row.diff.length > 0) {
|
|
596
|
+
// File-edit diffs are never truncated in the card: omitting hunks would
|
|
597
|
+
// hide the exact code change the model applied. `maxLines` only governs
|
|
598
|
+
// shell and generic JSON output bodies (and the inspect overlay).
|
|
599
|
+
return renderToolDiff(row.diff, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
|
|
600
|
+
}
|
|
601
|
+
if (row.command !== undefined) {
|
|
602
|
+
const out = [];
|
|
603
|
+
if (row.output !== '') {
|
|
604
|
+
const text = unlimited ? row.output : truncate(row.output, maxLines);
|
|
605
|
+
for (const line of text.split('\n')) {
|
|
606
|
+
out.push({ kind: 'tool-result', text: line });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
else if (row.status !== 'running' && row.status !== undefined) {
|
|
610
|
+
out.push({ kind: 'tool-result', text: t('tool.noOutput') });
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
const specialized = specializedToolBody(row, unlimited ? Number.MAX_SAFE_INTEGER : maxLines);
|
|
615
|
+
if (specialized !== null) {
|
|
616
|
+
return unlimited ? specialized : capDisplayLines(specialized, maxLines);
|
|
617
|
+
}
|
|
618
|
+
const out = [];
|
|
619
|
+
const args = parseJsonArgs(row.args);
|
|
620
|
+
if (args !== null && Object.keys(args).length > 0) {
|
|
621
|
+
out.push({ kind: 'diff-path', text: t('tool.args') });
|
|
622
|
+
for (const line of friendlyJsonLines(args)) {
|
|
623
|
+
out.push({ kind: 'tool-result', text: line });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (row.output !== '') {
|
|
627
|
+
out.push({ kind: 'diff-path', text: t('tool.result') });
|
|
628
|
+
const parsed = parseJsonBody(row.output);
|
|
629
|
+
if (parsed !== null) {
|
|
630
|
+
for (const line of friendlyJsonLines(parsed)) {
|
|
631
|
+
out.push({ kind: 'tool-result', text: line });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
const text = unlimited ? row.output : truncate(row.output, maxLines);
|
|
636
|
+
for (const line of text.split('\n')) {
|
|
637
|
+
out.push({ kind: 'tool-result', text: line });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return unlimited ? out : capDisplayLines(out, maxLines);
|
|
642
|
+
}
|
|
643
|
+
export function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
|
|
644
|
+
const name = row.name ?? '';
|
|
645
|
+
const args = parseJsonArgs(row.args);
|
|
646
|
+
const unlimited = !Number.isFinite(maxLines) || maxLines >= Number.MAX_SAFE_INTEGER;
|
|
647
|
+
const take = (text, fallback) => unlimited ? text : truncate(text, Math.min(maxLines, fallback));
|
|
648
|
+
if (name === 'todo_write' || name === 'todo') {
|
|
649
|
+
const todos = parsePlanTodos(args ?? row.args);
|
|
650
|
+
const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || t('todo.list') }];
|
|
651
|
+
if (todos.length === 0) {
|
|
652
|
+
out.push({ kind: 'tool-result', text: t('todo.empty') });
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
for (const item of todos) {
|
|
656
|
+
out.push({ kind: todoItemKind(item.status), text: `${TODO_STATUS_MARK[item.status]} ${item.content}` });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
if (name === 'exit_plan_mode') {
|
|
662
|
+
const markdown = planMarkdownFromArgs(args ?? row.args) ?? '';
|
|
663
|
+
const out = [{ kind: 'diff-path', text: planTitleFromMarkdown(markdown) ?? t('plan.reviewing') }];
|
|
664
|
+
if (markdown === '') {
|
|
665
|
+
out.push({ kind: 'tool-result', text: t('plan.emptyBody') });
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
for (const line of markdown.split('\n')) {
|
|
669
|
+
out.push({ kind: 'assistant', text: line });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
if (name === 'read' && args !== null) {
|
|
675
|
+
const path = firstString(args, ['path', 'file_path', 'url']);
|
|
676
|
+
const out = [];
|
|
677
|
+
if (path !== '')
|
|
678
|
+
out.push({ kind: 'diff-path', text: path });
|
|
679
|
+
const offset = typeof args.offset === 'number' ? args.offset : undefined;
|
|
680
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
681
|
+
if (offset !== undefined || limit !== undefined) {
|
|
682
|
+
out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
|
|
683
|
+
}
|
|
684
|
+
if (row.output !== '') {
|
|
685
|
+
for (const line of take(row.output, 40).split('\n')) {
|
|
686
|
+
out.push({ kind: 'tool-result', text: line });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
else if (row.status === 'running') {
|
|
690
|
+
out.push({ kind: 'tool-result', text: t('tool.reading') });
|
|
691
|
+
}
|
|
692
|
+
return out.length > 0 ? out : null;
|
|
693
|
+
}
|
|
694
|
+
if ((name === 'grep' || name === 'glob') && args !== null) {
|
|
695
|
+
const pattern = firstString(args, ['pattern', 'glob_pattern', 'query']);
|
|
696
|
+
const path = firstString(args, ['path', 'glob']);
|
|
697
|
+
const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
|
|
698
|
+
if (row.output !== '') {
|
|
699
|
+
for (const line of take(row.output, 30).split('\n')) {
|
|
700
|
+
out.push({ kind: 'tool-result', text: line });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
if ((name === 'web_search' || name === 'web_fetch') && args !== null) {
|
|
706
|
+
const query = firstString(args, ['query', 'q', 'url']);
|
|
707
|
+
const out = [{ kind: 'diff-path', text: query || name }];
|
|
708
|
+
if (row.output !== '') {
|
|
709
|
+
for (const line of take(row.output, 24).split('\n')) {
|
|
710
|
+
out.push({ kind: 'assistant', text: line });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
if (name === 'update_goal' || name === 'create_goal' || name === 'get_goal') {
|
|
716
|
+
const objective = args === null ? '' : firstString(args, ['objective', 'goal']);
|
|
717
|
+
const action = args === null ? '' : firstString(args, ['action']);
|
|
718
|
+
const out = [];
|
|
719
|
+
if (action !== '')
|
|
720
|
+
out.push({ kind: 'diff-path', text: action });
|
|
721
|
+
if (objective !== '')
|
|
722
|
+
out.push({ kind: 'assistant', text: objective });
|
|
723
|
+
if (row.output !== '') {
|
|
724
|
+
for (const line of take(row.output, 12).split('\n')) {
|
|
725
|
+
out.push({ kind: 'tool-result', text: line });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return out.length > 0 ? out : null;
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
733
|
+
export function parseExitStatus(text) {
|
|
734
|
+
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
|
|
735
|
+
if (signal?.[1] !== undefined) {
|
|
736
|
+
return { body: text.slice(0, signal.index), signal: signal[1] };
|
|
737
|
+
}
|
|
738
|
+
const exit = /\n\[exit code: (\d+)\]$/.exec(text);
|
|
739
|
+
if (exit?.[1] !== undefined) {
|
|
740
|
+
return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) };
|
|
741
|
+
}
|
|
742
|
+
return { body: text, exitCode: 0 };
|
|
743
|
+
}
|
|
744
|
+
//# sourceMappingURL=tool-present.js.map
|