@deepseek-harness-tui/dsh-tui 0.7.3 → 0.8.0

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.
Files changed (69) hide show
  1. package/README.md +2 -2
  2. package/bin/dsh-tui.js +19 -3
  3. package/cordis.patch.yml +24 -11
  4. package/cordis.yml +9 -1
  5. package/lib/types/commands.d.ts.map +1 -1
  6. package/lib/types/commands.js +2 -1
  7. package/lib/types/components/MessageList.d.ts +3 -1
  8. package/lib/types/components/MessageList.d.ts.map +1 -1
  9. package/lib/types/components/MessageList.js +4 -4
  10. package/lib/types/components/PluginSceneBoundary.d.ts +31 -0
  11. package/lib/types/components/PluginSceneBoundary.d.ts.map +1 -0
  12. package/lib/types/components/PluginSceneBoundary.js +29 -0
  13. package/lib/types/components/SkillsPicker.d.ts +18 -0
  14. package/lib/types/components/SkillsPicker.d.ts.map +1 -0
  15. package/lib/types/components/SkillsPicker.js +50 -0
  16. package/lib/types/components/SplitDiffView.d.ts +28 -0
  17. package/lib/types/components/SplitDiffView.d.ts.map +1 -0
  18. package/lib/types/components/SplitDiffView.js +417 -0
  19. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +3 -1
  20. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  21. package/lib/types/components/messages/AssistantToolUseMessage.js +17 -7
  22. package/lib/types/dsh-adapter/channel.d.ts +68 -0
  23. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  24. package/lib/types/dsh-adapter/channel.js +120 -5
  25. package/lib/types/dsh-adapter/contract.d.ts +1 -1
  26. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  27. package/lib/types/dsh-adapter/contract.js +1 -0
  28. package/lib/types/dsh-adapter/index.d.ts +4 -0
  29. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  30. package/lib/types/dsh-adapter/index.js +1 -0
  31. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  32. package/lib/types/dsh-adapter/plugin.js +59 -0
  33. package/lib/types/dsh-adapter/promptDebug.d.ts +10 -0
  34. package/lib/types/dsh-adapter/promptDebug.d.ts.map +1 -0
  35. package/lib/types/dsh-adapter/promptDebug.js +148 -0
  36. package/lib/types/dsh-adapter/scenes.d.ts +75 -0
  37. package/lib/types/dsh-adapter/scenes.d.ts.map +1 -0
  38. package/lib/types/dsh-adapter/scenes.js +78 -0
  39. package/lib/types/dsh-adapter/settings-sections.d.ts +116 -0
  40. package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -0
  41. package/lib/types/dsh-adapter/settings-sections.js +63 -0
  42. package/lib/types/dsh-adapter/settingsEditor.d.ts +128 -0
  43. package/lib/types/dsh-adapter/settingsEditor.d.ts.map +1 -0
  44. package/lib/types/dsh-adapter/settingsEditor.js +244 -0
  45. package/lib/types/i18n.d.ts +137 -10
  46. package/lib/types/i18n.d.ts.map +1 -1
  47. package/lib/types/i18n.js +36 -3
  48. package/lib/types/jsx-runtime.d.ts +2 -0
  49. package/lib/types/jsx-runtime.d.ts.map +1 -0
  50. package/lib/types/jsx-runtime.js +13 -0
  51. package/lib/types/modelRoute.d.ts +10 -2
  52. package/lib/types/modelRoute.d.ts.map +1 -1
  53. package/lib/types/modelRoute.js +33 -4
  54. package/lib/types/scenes.d.ts +3 -0
  55. package/lib/types/scenes.d.ts.map +1 -0
  56. package/lib/types/scenes.js +4 -0
  57. package/lib/types/screens/Chat.d.ts.map +1 -1
  58. package/lib/types/screens/Chat.js +107 -11
  59. package/lib/types/screens/Settings.d.ts +20 -0
  60. package/lib/types/screens/Settings.d.ts.map +1 -0
  61. package/lib/types/screens/Settings.js +333 -0
  62. package/lib/types/screens/StatusLine.js +1 -1
  63. package/lib/types/settings-sections.d.ts +3 -0
  64. package/lib/types/settings-sections.d.ts.map +1 -0
  65. package/lib/types/settings-sections.js +4 -0
  66. package/lib/types/theme.d.ts +12 -0
  67. package/lib/types/theme.d.ts.map +1 -1
  68. package/lib/types/theme.js +36 -0
  69. package/package.json +17 -1
@@ -0,0 +1,417 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from '../ui.js';
4
+ import * as JsDiff from 'diff';
5
+ import chalk from 'chalk';
6
+ import { extname } from 'node:path';
7
+ import { getCliHighlightPromise } from '../cc/cliHighlight.js';
8
+ import { getTheme } from '../theme.js';
9
+ import { useTheme } from './design-system/ThemeProvider.js';
10
+ /** Replace tabs so width math and alignment hold (pi convention). */
11
+ const expandTabs = (text) => text.replaceAll('\t', ' ');
12
+ // --- syntax highlighting ----------------------------------------------------
13
+ /** highlight.js token classes → theme syntax tokens. `default` catches
14
+ * every unmapped class so cli-highlight's own yellow never leaks through
15
+ * (issue #250, P2-6). */
16
+ const SYNTAX_CLASS_TO_TOKEN = {
17
+ keyword: 'syntaxKeyword',
18
+ built_in: 'syntaxKeyword',
19
+ literal: 'syntaxKeyword',
20
+ string: 'syntaxString',
21
+ subst: 'syntaxString',
22
+ quote: 'syntaxString',
23
+ comment: 'syntaxComment',
24
+ number: 'syntaxNumber',
25
+ title: 'syntaxFunction',
26
+ 'title.function_': 'syntaxFunction',
27
+ function: 'syntaxFunction',
28
+ 'title.class_': 'syntaxType',
29
+ type: 'syntaxType',
30
+ class: 'syntaxType',
31
+ tag: 'syntaxType',
32
+ name: 'syntaxType',
33
+ attr: 'syntaxVariable',
34
+ attribute: 'syntaxVariable',
35
+ variable: 'syntaxVariable',
36
+ 'template-variable': 'syntaxVariable',
37
+ params: 'syntaxVariable',
38
+ operator: 'syntaxOperator',
39
+ punctuation: 'syntaxPunctuation',
40
+ meta: 'syntaxPunctuation',
41
+ symbol: 'syntaxConstant',
42
+ regexp: 'syntaxConstant',
43
+ default: 'syntaxVariable',
44
+ };
45
+ /** chalk style for one raw theme value. Accepts every form the theme
46
+ * loader documents: #rgb, #rrggbb, #rrggbbaa (alpha stripped), rgb() with
47
+ * or without spaces, ansi256(n), ansi:name (issue #250, P2-5). */
48
+ export function chalkFromToken(token) {
49
+ let match = /^#([0-9a-fA-F]{3})$/.exec(token);
50
+ if (match !== null) {
51
+ const [r, g, b] = match[1].split('').map(c => parseInt(c + c, 16));
52
+ return chalk.rgb(r, g, b);
53
+ }
54
+ match = /^#([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?$/.exec(token);
55
+ if (match !== null)
56
+ return chalk.hex(`#${match[1]}`);
57
+ match = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(token);
58
+ if (match !== null)
59
+ return chalk.rgb(Number(match[1]), Number(match[2]), Number(match[3]));
60
+ match = /^ansi256\((\d+)\)$/.exec(token);
61
+ if (match !== null)
62
+ return chalk.ansi256(Number(match[1]));
63
+ match = /^ansi:(\w+)$/.exec(token);
64
+ if (match !== null) {
65
+ const name = match[1] === 'blackBright' ? 'gray' : match[1];
66
+ const style = chalk[name];
67
+ if (style !== undefined)
68
+ return style;
69
+ }
70
+ return (text) => text;
71
+ }
72
+ /** ANSI 16-color SGR codes → rgb() strings (the dark-ansi palette path). */
73
+ const ANSI16 = {
74
+ 30: 'rgb(0,0,0)', 31: 'rgb(128,0,0)', 32: 'rgb(0,128,0)', 33: 'rgb(128,128,0)',
75
+ 34: 'rgb(0,0,128)', 35: 'rgb(128,0,128)', 36: 'rgb(0,128,128)', 37: 'rgb(192,192,192)',
76
+ 90: 'rgb(128,128,128)', 91: 'rgb(255,0,0)', 92: 'rgb(0,255,0)', 93: 'rgb(255,255,0)',
77
+ 94: 'rgb(0,0,255)', 95: 'rgb(255,0,255)', 96: 'rgb(0,255,255)', 97: 'rgb(255,255,255)',
78
+ };
79
+ /** Parse a cli-highlight ANSI string into colored runs, preserving open
80
+ * spans across embedded newlines (multi-line strings/comments). Handles
81
+ * truecolor 38;2, 256-color 38;5 (tmux / FORCE_COLOR=2, issue #250 P1-1),
82
+ * and single-code 16-color SGR. */
83
+ export function parseAnsiRuns(text) {
84
+ const runs = [];
85
+ let color;
86
+ let rest = text;
87
+ // eslint-disable-next-line no-control-regex -- parsing SGR is the point
88
+ const sgr = /\x1b\[([0-9;]+)m/;
89
+ while (rest.length > 0) {
90
+ const match = sgr.exec(rest);
91
+ const chunk = match === null ? rest : rest.slice(0, match.index);
92
+ if (chunk !== '')
93
+ runs.push(color === undefined ? { text: chunk } : { text: chunk, color });
94
+ if (match === null)
95
+ break;
96
+ const codes = match[1].split(';').map(Number);
97
+ if (codes.includes(0) || codes.includes(39))
98
+ color = undefined;
99
+ else if (codes[0] === 38 && codes[1] === 2 && codes.length >= 5) {
100
+ color = `rgb(${codes[2]},${codes[3]},${codes[4]})`;
101
+ }
102
+ else if (codes[0] === 38 && codes[1] === 5 && codes.length >= 3) {
103
+ color = `ansi256(${codes[2]})`;
104
+ }
105
+ else if (codes.length === 1 && ANSI16[codes[0]] !== undefined) {
106
+ color = ANSI16[codes[0]];
107
+ }
108
+ rest = rest.slice(match.index + match[0].length);
109
+ }
110
+ return runs;
111
+ }
112
+ /** Split flat runs into per-line run arrays at embedded newlines. */
113
+ function splitRunsToLines(runs) {
114
+ const lines = [[]];
115
+ for (const run of runs) {
116
+ const parts = run.text.split('\n');
117
+ for (let i = 0; i < parts.length; i++) {
118
+ if (i > 0)
119
+ lines.push([]);
120
+ if (parts[i] !== '') {
121
+ lines[lines.length - 1].push(run.color === undefined ? { text: parts[i] } : { text: parts[i], color: run.color });
122
+ }
123
+ }
124
+ }
125
+ return lines;
126
+ }
127
+ /** Whole-hunk syntax runs per line, cached per (theme signature + language
128
+ * + text): one entry survives any amount of card re-rendering, and the
129
+ * palette change invalidates it (issue #250, P1-2 + P2-8). */
130
+ const syntaxCache = new Map();
131
+ const SYNTAX_CACHE_MAX = 40;
132
+ export function highlightLines(text, language, hl, chTheme, themeSig) {
133
+ if (hl === null || chTheme === undefined || language === undefined)
134
+ return undefined;
135
+ if (text === '' || !hl.supportsLanguage(language))
136
+ return undefined;
137
+ const key = `${themeSig}${language}${text}`;
138
+ const cached = syntaxCache.get(key);
139
+ if (cached !== undefined)
140
+ return cached;
141
+ let lines;
142
+ try {
143
+ lines = splitRunsToLines(parseAnsiRuns(hl.highlight(text, { language, theme: chTheme })));
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ if (syntaxCache.size >= SYNTAX_CACHE_MAX)
149
+ syntaxCache.clear();
150
+ syntaxCache.set(key, lines);
151
+ return lines;
152
+ }
153
+ /**
154
+ * Merge syntax runs with word-diff change flags over the same string: both
155
+ * partition it, so an offset sweep yields runs carrying a syntax color AND
156
+ * a changed flag. The render layer lets `changed` override the color.
157
+ */
158
+ function mergeRuns(syntax, words) {
159
+ const out = [];
160
+ let si = 0;
161
+ let wi = 0;
162
+ let sOff = 0;
163
+ let wOff = 0;
164
+ while (si < syntax.length && wi < words.length) {
165
+ const s = syntax[si];
166
+ const w = words[wi];
167
+ const sLen = s.text.length - sOff;
168
+ const wLen = w.text.length - wOff;
169
+ const take = Math.min(sLen, wLen);
170
+ if (take > 0) {
171
+ out.push({
172
+ text: w.text.slice(wOff, wOff + take),
173
+ changed: w.changed,
174
+ color: w.changed ? undefined : s.color,
175
+ });
176
+ }
177
+ sOff += take;
178
+ wOff += take;
179
+ if (sOff >= s.text.length) {
180
+ si++;
181
+ sOff = 0;
182
+ }
183
+ if (wOff >= w.text.length) {
184
+ wi++;
185
+ wOff = 0;
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ // --- word-level diff --------------------------------------------------------
191
+ /** Strip the shared leading whitespace before word-diffing: otherwise the
192
+ * whole indent reads as one changed blob (pi's renderIntraLineDiff trick). */
193
+ function wordSegments(oldLine, newLine) {
194
+ const oldIndent = /^\s*/.exec(oldLine)?.[0] ?? '';
195
+ const newIndent = /^\s*/.exec(newLine)?.[0] ?? '';
196
+ const sharedIndent = oldIndent === newIndent ? oldIndent : '';
197
+ const parts = JsDiff.diffWords(oldLine.slice(sharedIndent.length), newLine.slice(sharedIndent.length));
198
+ const oldSegments = sharedIndent === '' ? [] : [{ text: sharedIndent, changed: false }];
199
+ const newSegments = sharedIndent === '' ? [] : [{ text: sharedIndent, changed: false }];
200
+ for (const part of parts) {
201
+ if (part.added)
202
+ newSegments.push({ text: part.value, changed: true });
203
+ else if (part.removed)
204
+ oldSegments.push({ text: part.value, changed: true });
205
+ else {
206
+ oldSegments.push({ text: part.value, changed: false });
207
+ newSegments.push({ text: part.value, changed: false });
208
+ }
209
+ }
210
+ return { old: oldSegments, new: newSegments };
211
+ }
212
+ const plainSegments = (line) => [{ text: line, changed: false }];
213
+ // --- hunk alignment ---------------------------------------------------------
214
+ /**
215
+ * Pair the lines of an unequal removed+added block via a case-insensitive
216
+ * LCS: lines equal modulo case align as change pairs, everything before the
217
+ * first match stays block order. An inserted line above an edited one
218
+ * (`foo,bar` → `insert,FOO,bar`) therefore yields `insert` add-only, then
219
+ * `foo ↔ FOO`, instead of the index-zip misalignment (issue #250, P2-4).
220
+ */
221
+ function lcsPairs(oldLines, newLines) {
222
+ const m = oldLines.length;
223
+ const n = newLines.length;
224
+ // dp[i][j] = LCS length of oldLines[i:] and newLines[j:].
225
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
226
+ const eq = (a, b) => a.toLowerCase() === b.toLowerCase();
227
+ for (let i = m - 1; i >= 0; i--) {
228
+ for (let j = n - 1; j >= 0; j--) {
229
+ dp[i][j] = eq(oldLines[i], newLines[j])
230
+ ? dp[i + 1][j + 1] + 1
231
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
232
+ }
233
+ }
234
+ const pairs = [];
235
+ let i = 0;
236
+ let j = 0;
237
+ while (i < m && j < n) {
238
+ if (eq(oldLines[i], newLines[j])) {
239
+ pairs.push([i, j]);
240
+ i++;
241
+ j++;
242
+ }
243
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
244
+ i++;
245
+ }
246
+ else {
247
+ j++;
248
+ }
249
+ }
250
+ return pairs;
251
+ }
252
+ /** Align one file's hunks into rows (plain text + word flags; styling is a
253
+ * render-time concern over the visible slice). */
254
+ function alignFileDiff(fileIndex, oldText, newText) {
255
+ const rows = [];
256
+ const parts = JsDiff.diffLines(expandTabs(oldText ?? ''), expandTabs(newText));
257
+ let oldIndex = 0;
258
+ let newIndex = 0;
259
+ for (let i = 0; i < parts.length; i++) {
260
+ const part = parts[i];
261
+ const lines = part.value.replace(/\n$/, '').split('\n');
262
+ if (!part.added && !part.removed) {
263
+ for (const line of lines) {
264
+ rows.push({
265
+ kind: 'context',
266
+ fileIndex,
267
+ oldIndex: oldIndex++,
268
+ newIndex: newIndex++,
269
+ oldWords: plainSegments(line),
270
+ newWords: plainSegments(line),
271
+ });
272
+ }
273
+ continue;
274
+ }
275
+ if (part.removed) {
276
+ const addedPart = parts[i + 1]?.added === true ? parts[i + 1] : undefined;
277
+ const addedLines = addedPart === undefined ? [] : addedPart.value.replace(/\n$/, '').split('\n');
278
+ // Same-length replacement blocks zip index-for-index (the common
279
+ // edit case); unequal blocks pair through the ci-LCS so an inserted
280
+ // line cannot shift the alignment (issue #250, P2-4).
281
+ const pairs = lines.length === addedLines.length
282
+ ? lines.map((_, index) => [index, index])
283
+ : lcsPairs(lines, addedLines);
284
+ // Block order: walk both lists; unpaired entries emit in place, pairs
285
+ // emit at the later of the two positions.
286
+ const events = [];
287
+ let oi = 0;
288
+ let ai = 0;
289
+ for (const [o, a] of pairs) {
290
+ while (oi < o)
291
+ events.push({ kind: 'del', o: oi++ });
292
+ while (ai < a)
293
+ events.push({ kind: 'add', a: ai++ });
294
+ events.push({ kind: 'change', o: oi++, a: ai++ });
295
+ }
296
+ while (oi < lines.length)
297
+ events.push({ kind: 'del', o: oi++ });
298
+ while (ai < addedLines.length)
299
+ events.push({ kind: 'add', a: ai++ });
300
+ for (const event of events) {
301
+ if (event.kind === 'change') {
302
+ const segments = wordSegments(lines[event.o], addedLines[event.a]);
303
+ rows.push({
304
+ kind: 'change',
305
+ fileIndex,
306
+ oldIndex: oldIndex++,
307
+ newIndex: newIndex++,
308
+ oldWords: segments.old,
309
+ newWords: segments.new,
310
+ });
311
+ }
312
+ else if (event.kind === 'del') {
313
+ rows.push({ kind: 'del', fileIndex, oldIndex: oldIndex++, oldWords: plainSegments(lines[event.o]) });
314
+ }
315
+ else {
316
+ rows.push({ kind: 'add', fileIndex, newIndex: newIndex++, newWords: plainSegments(addedLines[event.a]) });
317
+ }
318
+ }
319
+ if (addedPart !== undefined)
320
+ i++;
321
+ continue;
322
+ }
323
+ for (const line of lines) {
324
+ rows.push({ kind: 'add', fileIndex, newIndex: newIndex++, newWords: plainSegments(line) });
325
+ }
326
+ }
327
+ return rows;
328
+ }
329
+ // --- rendering --------------------------------------------------------------
330
+ function PaneLine({ side, kind, width, tone, padLeft = false, }) {
331
+ const backgroundColor = kind === 'context'
332
+ ? 'toolCardBackground'
333
+ : tone === 'old'
334
+ ? 'diffRemovedDimmed'
335
+ : 'diffAddedDimmed';
336
+ const wordColor = tone === 'old' ? 'diffRemovedWord' : 'diffAddedWord';
337
+ // Status gutter instead of line numbers: ToolFileDiff has no file
338
+ // offsets, and an invented number misleads (issue #250, P2-3).
339
+ const marker = kind === 'context' ? ' ' : tone === 'old' ? '−' : '+';
340
+ const prefix = padLeft ? ` ${marker}` : marker;
341
+ return (_jsxs(Box, { width: width, flexShrink: 0, backgroundColor: backgroundColor, children: [_jsx(Text, { dimColor: true, backgroundColor: backgroundColor, children: `${prefix} ` }), side === undefined ? (_jsx(Text, { backgroundColor: backgroundColor, children: " " })) : (_jsx(Text, { wrap: "truncate", backgroundColor: backgroundColor, children: side.segments.map((segment, index) => segment.changed ? (_jsx(Text, { color: wordColor, bold: true, backgroundColor: backgroundColor, children: segment.text }, index)) : (_jsx(Text, { color: segment.color, backgroundColor: backgroundColor, children: segment.text }, index))) }))] }));
342
+ }
343
+ export function SplitDiffView({ diffs, width, maxRows, verbose, }) {
344
+ const [hl, setHl] = React.useState(null);
345
+ React.useEffect(() => {
346
+ let mounted = true;
347
+ void getCliHighlightPromise().then(loaded => {
348
+ if (mounted)
349
+ setHl(loaded);
350
+ });
351
+ return () => { mounted = false; };
352
+ }, []);
353
+ const [themeName] = useTheme();
354
+ // Resolved-palette signature: covers dark→light, light→dark, AND the
355
+ // `auto` base flip where the name never changes (issue #250, P1-2).
356
+ const themeSig = React.useMemo(() => {
357
+ const theme = getTheme(themeName);
358
+ return Object.values(SYNTAX_CLASS_TO_TOKEN)
359
+ .map(key => theme[key] ?? '')
360
+ .join('|');
361
+ }, [themeName]);
362
+ const chTheme = React.useMemo(() => {
363
+ const theme = getTheme(themeName);
364
+ const out = {};
365
+ for (const [tokenClass, tokenKey] of Object.entries(SYNTAX_CLASS_TO_TOKEN)) {
366
+ const value = theme[tokenKey];
367
+ if (value !== undefined)
368
+ out[tokenClass] = chalkFromToken(value);
369
+ }
370
+ return out;
371
+ // themeSig changes exactly when any syntax token's resolved value does.
372
+ // eslint-disable-next-line react-hooks/exhaustive-deps
373
+ }, [themeSig]);
374
+ // Alignment is text-only and capped BEFORE styling: the highlighter and
375
+ // word-diff work lands only on the visible slice (issue #250, P2-8).
376
+ const rows = [];
377
+ let prevPath;
378
+ diffs.forEach((diff, fileIndex) => {
379
+ if (diffs.length > 1) {
380
+ if (diff.path !== prevPath)
381
+ rows.push({ separator: diff.path });
382
+ else
383
+ rows.push({ separator: '⋯' });
384
+ }
385
+ prevPath = diff.path;
386
+ rows.push(...alignFileDiff(fileIndex, diff.oldText, diff.newText));
387
+ });
388
+ const totalRows = rows.length;
389
+ const capped = verbose || totalRows <= maxRows || totalRows - maxRows === 1;
390
+ const visible = capped ? rows : rows.slice(0, maxRows);
391
+ const hidden = totalRows - visible.length;
392
+ const paneWidth = Math.max(20, Math.floor((width - 1) / 2));
393
+ // Whole-hunk highlight per file (multi-line lexer state preserved);
394
+ // only the visible rows merge syntax runs with word flags below.
395
+ const fileSyntax = diffs.map(diff => {
396
+ const language = extname(diff.path).replace(/^\./, '') || undefined;
397
+ return {
398
+ old: highlightLines(expandTabs(diff.oldText ?? ''), language, hl, chTheme, themeSig),
399
+ next: highlightLines(expandTabs(diff.newText), language, hl, chTheme, themeSig),
400
+ };
401
+ });
402
+ return (_jsxs(Box, { flexDirection: "column", width: paneWidth * 2 + 1, backgroundColor: "toolCardBackgroundDim", children: [visible.map((row, index) => {
403
+ if ('separator' in row) {
404
+ return (_jsx(Box, { width: paneWidth * 2 + 1, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: row.separator === '⋯' ? '⋯' : ` ${row.separator}` }) }, index));
405
+ }
406
+ const syntax = fileSyntax[row.fileIndex];
407
+ const oldRuns = row.oldIndex !== undefined ? syntax?.old?.[row.oldIndex] : undefined;
408
+ const newRuns = row.newIndex !== undefined ? syntax?.next?.[row.newIndex] : undefined;
409
+ const oldSide = row.oldWords === undefined
410
+ ? undefined
411
+ : { segments: oldRuns !== undefined ? mergeRuns(oldRuns, row.oldWords) : row.oldWords };
412
+ const newSide = row.newWords === undefined
413
+ ? undefined
414
+ : { segments: newRuns !== undefined ? mergeRuns(newRuns, row.newWords) : row.newWords };
415
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(PaneLine, { side: oldSide, kind: row.kind === 'add' ? 'context' : row.kind, tone: "old", width: paneWidth }), _jsx(Box, { width: 1, flexShrink: 0, backgroundColor: "toolCardBackgroundDim", children: _jsx(Text, { dimColor: true, children: "\u2502" }) }), _jsx(PaneLine, { side: newSide, kind: row.kind === 'del' ? 'context' : row.kind, tone: "new", width: paneWidth, padLeft: true })] }, index));
416
+ }), hidden > 0 && (_jsx(Text, { dimColor: true, children: `… +${hidden} lines (ctrl+o to expand)` }))] }));
417
+ }
@@ -19,6 +19,8 @@ type Props = {
19
19
  * is worth less than mentioning it once.
20
20
  */
21
21
  footnote?: string;
22
+ /** Diff presentation preference; `auto` picks by terminal width. */
23
+ diffLayout?: 'auto' | 'split' | 'unified';
22
24
  };
23
25
  /**
24
26
  * Tool-call card: `● Edit /path` header with a blinking status dot, then the
@@ -26,6 +28,6 @@ type Props = {
26
28
  * output, read content — instead of the raw result dump (mirroring Claude Code's `AssistantToolUseMessage.tsx` + the dsh-tools presentation views the
27
29
  * channel captures per call).
28
30
  */
29
- export declare function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected, isExpanded, footnote, }: Props): React.ReactNode;
31
+ export declare function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected, isExpanded, footnote, diffLayout, }: Props): React.ReactNode;
30
32
  export {};
31
33
  //# sourceMappingURL=AssistantToolUseMessage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantToolUseMessage.d.ts","sourceRoot":"","sources":["../../../../src/components/messages/AssistantToolUseMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,OAAO,KAAK,EAA8C,OAAO,EAAE,MAAM,8BAA8B,CAAA;AAIvG,KAAK,KAAK,GAAG;IACX,IAAI,EAAE,OAAO,CAAA;IACb,4DAA4D;IAC5D,SAAS,EAAE,OAAO,CAAA;IAClB,iEAAiE;IACjE,OAAO,EAAE,OAAO,CAAA;IAChB,wCAAwC;IACxC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,sEAAsE;IACtE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAA;AAsMD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,IAAI,EACJ,SAAS,EACT,OAAO,EACP,UAAkB,EAClB,UAAkB,EAClB,QAAQ,GACT,EAAE,KAAK,GAAG,KAAK,CAAC,SAAS,CAwGzB"}
1
+ {"version":3,"file":"AssistantToolUseMessage.d.ts","sourceRoot":"","sources":["../../../../src/components/messages/AssistantToolUseMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,OAAO,KAAK,EAA8C,OAAO,EAAE,MAAM,8BAA8B,CAAA;AAKvG,KAAK,KAAK,GAAG;IACX,IAAI,EAAE,OAAO,CAAA;IACb,4DAA4D;IAC5D,SAAS,EAAE,OAAO,CAAA;IAClB,iEAAiE;IACjE,OAAO,EAAE,OAAO,CAAA;IAChB,wCAAwC;IACxC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,sEAAsE;IACtE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAA;CAC1C,CAAA;AAyMD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,IAAI,EACJ,SAAS,EACT,OAAO,EACP,UAAkB,EAClB,UAAkB,EAClB,QAAQ,EACR,UAAmB,GACpB,EAAE,KAAK,GAAG,KAAK,CAAC,SAAS,CAoIzB"}
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from '../../ui.js';
2
+ import { Box, Text, useTerminalSize } from '../../ui.js';
3
3
  import { stringWidth } from '../../ink/stringWidth.js';
4
4
  import { useAnimationFrame } from '../../ink/hooks/use-animation-frame.js';
5
5
  import { ToolUseLoader } from '../ToolUseLoader.js';
6
+ import { SplitDiffView } from '../SplitDiffView.js';
6
7
  import { formatDuration } from '../../cc/format.js';
7
8
  /** Tool display names: DSH emits lowercase tool ids (`bash`); Claude Code
8
9
  * shows capitalized names (`Bash`). Map the common ones, fall back to the
@@ -32,8 +33,11 @@ const TEXT_BODY_MAX_LINES = 3;
32
33
  /** Diff bodies cap at the upstream chat row's 8 (dsh-client-ui-tool's
33
34
  * CHAT_DIFF_MAX_LINES) — denser information than log output. */
34
35
  const DIFF_BODY_MAX_LINES = 8;
35
- const GUTTER_FIRST = ' ⎿ ';
36
- const GUTTER_REST = ' ';
36
+ /** Minimum terminal width for the two-pane diff: below this the panes
37
+ * would squeeze under ~50 columns each and the unified view reads better. */
38
+ const SPLIT_DIFF_MIN_COLS = 110;
39
+ const GUTTER_FIRST = ' ⎿ ';
40
+ const GUTTER_REST = ' ';
37
41
  const add = (text) => ({ text, tone: 'add' });
38
42
  const del = (text) => ({ text, tone: 'del' });
39
43
  const dim = (text) => ({ text, tone: 'dim' });
@@ -163,7 +167,7 @@ function HeaderTitle({ name, title, isTerminal, displayArgs }) {
163
167
  * output, read content — instead of the raw result dump (mirroring Claude Code's `AssistantToolUseMessage.tsx` + the dsh-tools presentation views the
164
168
  * channel captures per call).
165
169
  */
166
- export function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected = false, isExpanded = false, footnote, }) {
170
+ export function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected = false, isExpanded = false, footnote, diffLayout = 'auto', }) {
167
171
  const isRunning = tool.status === 'running';
168
172
  const isError = tool.status === 'error';
169
173
  const displayArgs = verbose ? tool.argsFull ?? tool.argsText : tool.argsText;
@@ -188,12 +192,18 @@ export function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected =
188
192
  const elapsedText = elapsedMs !== undefined ? ` · ${formatDuration(elapsedMs)}` : '';
189
193
  // Body lines: the structured view first, raw result text as the fallback
190
194
  // (tools without a presenter, or a folded row awaiting loadOlder).
195
+ // Wide terminals render diffs as a two-pane side-by-side instead: one
196
+ // source line per terminal row (truncate) keeps the panes row-aligned,
197
+ // which the flat add/del line model cannot express.
198
+ const { columns } = useTerminalSize();
199
+ const useSplitDiff = !isError && view?.card === 'diff' &&
200
+ (diffLayout === 'split' || (diffLayout !== 'unified' && columns >= SPLIT_DIFF_MIN_COLS));
191
201
  let body = [];
192
202
  if (isError) {
193
203
  if (tool.errorText)
194
204
  body = [{ text: tool.errorText, tone: 'error' }];
195
205
  }
196
- else {
206
+ else if (!useSplitDiff) {
197
207
  if (view !== undefined)
198
208
  body = viewLines(view);
199
209
  if (body.length === 0 && result) {
@@ -212,7 +222,7 @@ export function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected =
212
222
  ? 'messageActionsBackground'
213
223
  : isExpanded
214
224
  ? 'userMessageBackgroundHover'
215
- : undefined, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", minWidth: minWidth, children: [_jsx(ToolUseLoader, { shouldAnimate: isRunning, isUnresolved: isRunning, isError: isError }), _jsx(HeaderTitle, { name: name, title: headerTitle, isTerminal: headerIsTerminal, displayArgs: displayArgs }), !isRunning && (_jsx(Box, { flexWrap: "nowrap", children: _jsx(Text, { dimColor: true, children: elapsedText }) }))] }), rendered.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 5, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: index === 0 ? GUTTER_FIRST : GUTTER_REST }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: line.tone === 'add'
225
+ : 'toolCardBackgroundDim', children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", minWidth: minWidth, children: [_jsx(ToolUseLoader, { shouldAnimate: isRunning, isUnresolved: isRunning, isError: isError }), _jsx(HeaderTitle, { name: name, title: headerTitle, isTerminal: headerIsTerminal, displayArgs: displayArgs }), !isRunning && (_jsx(Box, { flexWrap: "nowrap", children: _jsx(Text, { dimColor: true, children: elapsedText }) }))] }), useSplitDiff && view?.card === 'diff' ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: GUTTER_FIRST }) }), _jsx(SplitDiffView, { diffs: view.diffs, width: columns - 4, maxRows: DIFF_BODY_MAX_LINES, verbose: verbose })] })) : (rendered.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: index === 0 ? GUTTER_FIRST : GUTTER_REST }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: line.tone === 'add'
216
226
  ? 'diffAddedWord'
217
227
  : line.tone === 'del'
218
228
  ? 'diffRemovedWord'
@@ -220,5 +230,5 @@ export function AssistantToolUseMessage({ tool, addMargin, verbose, isSelected =
220
230
  ? 'error'
221
231
  : line.tone === 'hint'
222
232
  ? 'subtle'
223
- : undefined, dimColor: line.tone === 'dim', wrap: "wrap", children: line.text === '' ? ' ' : line.text }) })] }, index)))] }) }));
233
+ : undefined, dimColor: line.tone === 'dim', wrap: "wrap", children: line.text === '' ? ' ' : line.text }) })] }, index)))), useSplitDiff && footnote !== undefined && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: GUTTER_REST }) }), _jsx(Text, { color: "subtle", children: footnote })] }))] }) }));
224
234
  }
@@ -10,6 +10,9 @@ import { type SessionModeSpec } from '../sessionModes.js';
10
10
  import type { SpinnerMode } from '../components/Spinner/spinnerMode.js';
11
11
  import { type ActivityState } from 'dsh-working-activity/status';
12
12
  import { type TuiWorkspaceCommand, type TuiWorkspaceCommandResult, type TuiWorkspaceTarget } from './workspaces.js';
13
+ import type { TuiSettingsSection } from './settings-sections.js';
14
+ import type { SettingsHost } from './settingsEditor.js';
15
+ import type { TuiSceneDescriptor } from './scenes.js';
13
16
  type ChannelImageBlock = Extract<ContentBlock, {
14
17
  type: 'image';
15
18
  }>;
@@ -217,6 +220,15 @@ export interface LoadedContextSkill {
217
220
  readonly name: string;
218
221
  readonly description: string;
219
222
  }
223
+ /** One skill in the live agent's catalog, for the `/skills` picker (issue #204). */
224
+ export interface SkillInfo {
225
+ readonly name: string;
226
+ readonly description: string;
227
+ /** True when `/name` invokes it (it appears in the `/` menu, issue #86). */
228
+ readonly userInvocable: boolean;
229
+ /** Discovery source bucket (bundled / user-* / project-* / runtime / …). */
230
+ readonly source: string;
231
+ }
220
232
  /** One model-visible tool from the prompt assembly. */
221
233
  export interface LoadedContextTool {
222
234
  readonly name: string;
@@ -304,6 +316,8 @@ export interface Channel {
304
316
  readonly workingActivity: ActivityStatus | undefined;
305
317
  /** Working-activity indicator preset name (`claude`/`moon`/…/`random`). */
306
318
  readonly activityFrames: string | undefined;
319
+ /** Edit/Write diff presentation preference (`auto`/`split`/`unified`). */
320
+ readonly diffLayout: 'auto' | 'split' | 'unified';
307
321
  /** Whether the in-process working-activity line is shown (config.activity). */
308
322
  readonly activityEnabled: boolean;
309
323
  /** Whether the segmented context bar row shows in the status footer
@@ -353,6 +367,22 @@ export interface Channel {
353
367
  * back to sending the line to the model).
354
368
  */
355
369
  runExternalCommand(name: string, rawInput: string): Promise<string | undefined>;
370
+ /**
371
+ * Plugin-registered full-screen scene currently replacing the conversation
372
+ * (the `dsh-tui-scenes` runtime), if any. The chat screen renders its
373
+ * component INSTEAD of the transcript — the same whole-terminal treatment
374
+ * the trajectory scene gets — and hands it the keyboard; `undefined`
375
+ * renders the conversation normally.
376
+ */
377
+ readonly pluginScene: TuiSceneDescriptor | undefined;
378
+ /**
379
+ * Open a registered plugin scene by id. Plugin command handlers usually
380
+ * call the runtime directly (`ctx.tuiScenes.open`); this passthrough lets
381
+ * host-side UI code do the same without touching cordis services.
382
+ */
383
+ openPluginScene(id: string): boolean;
384
+ /** Close the open plugin scene, if any (a no-op otherwise). */
385
+ closePluginScene(): void;
356
386
  /** 侧问(CC /btw):无工具单轮 LLM 调用,复用当前会话上下文;结果不落 session log。 */
357
387
  sideQuestion(question: string, options?: {
358
388
  signal?: AbortSignal;
@@ -459,12 +489,29 @@ export interface Channel {
459
489
  setActivityFrames(name: string): boolean;
460
490
  /** Advertised models across every registered provider route (empty when the LLM service is absent). */
461
491
  listModels(): Promise<readonly LlmModelInfo[]>;
492
+ /** The live agent's full skill catalog for `/skills` (issue #204) — name,
493
+ * description, invocation flags and source bucket. Undefined on a failed
494
+ * or incomplete registry read (the picker shows an error); empty only
495
+ * when no registry is mounted or it genuinely holds nothing. */
496
+ listSkills(): Promise<readonly SkillInfo[] | undefined>;
462
497
  /** Safe credential metadata for `/login`; undefined without the service. */
463
498
  describeCredential(ref: string): Promise<CredentialStatus | undefined>;
464
499
  /** Runtime capabilities for the `/provider` wizard, over the settings /
465
500
  * credentials / llm seams; undefined when the composition lacks them
466
501
  * (bare cordis.yml start without the dsh-base services). */
467
502
  providerSetup(): ProviderSetupHost | undefined;
503
+ /**
504
+ * Runtime capabilities for the `/settings` screen, over the settings /
505
+ * credentials seams; undefined when the composition lacks the settings
506
+ * service (the screen then renders plugin sections as unavailable and
507
+ * namespaces read-only).
508
+ */
509
+ settingsHost(): SettingsHost | undefined;
510
+ /** Plugin-declared settings sections from the `tuiSettingsSections` seam
511
+ * (empty when the seam or every provider is absent). */
512
+ settingsSections(): readonly TuiSettingsSection[];
513
+ /** Subscribe to settings-section register/unregister events. */
514
+ subscribeSettingsSections(listener: () => void): () => void;
468
515
  /** Top-level entries of the session cwd for `@` file completion. */
469
516
  listFiles(): Promise<readonly string[]>;
470
517
  /** Every session the persistence backend stores, classified and unfiltered
@@ -596,6 +643,10 @@ export interface ChannelState {
596
643
  workingActivity: ActivityStatus | undefined;
597
644
  /** Working-activity indicator preset (see the public Channel type). */
598
645
  activityFrames: string | undefined;
646
+ /** Diff presentation preference (see the public Channel type). */
647
+ diffLayout: 'auto' | 'split' | 'unified';
648
+ /** Apply a diff-layout change (see the public Channel type). */
649
+ setDiffLayout(layout: 'auto' | 'split' | 'unified'): void;
599
650
  /** Working-activity display switch (see the public Channel type). */
600
651
  activityEnabled: boolean;
601
652
  /** Context bar row switch (see the public Channel type). */
@@ -623,6 +674,12 @@ export interface ChannelState {
623
674
  commandCompletions(input: string): readonly CommandCompletion[];
624
675
  /** Run a plugin-registered command (see the public Channel type). */
625
676
  runExternalCommand(name: string, rawInput: string): Promise<string | undefined>;
677
+ /** Open plugin scene mirrored from the scenes runtime (see the public Channel type). */
678
+ pluginScene: TuiSceneDescriptor | undefined;
679
+ /** Open a plugin scene by id (see the public Channel type). */
680
+ openPluginScene(id: string): boolean;
681
+ /** Close the open plugin scene (see the public Channel type). */
682
+ closePluginScene(): void;
626
683
  /** Estimated context segments by content type (pi-nano-context style bar). */
627
684
  contextSegments: {
628
685
  system: number;
@@ -687,10 +744,18 @@ export interface ChannelState {
687
744
  /** Switch the working-activity indicator preset (see the public Channel). */
688
745
  setActivityFrames(name: string): boolean;
689
746
  listModels(): Promise<readonly LlmModelInfo[]>;
747
+ /** The live agent's skill catalog for `/skills` (see the public Channel type). */
748
+ listSkills(): Promise<readonly SkillInfo[] | undefined>;
690
749
  /** Safe credential metadata for `/login` (see the public Channel type). */
691
750
  describeCredential(ref: string): Promise<CredentialStatus | undefined>;
692
751
  /** `/provider` wizard capabilities (see the public Channel type). */
693
752
  providerSetup(): ProviderSetupHost | undefined;
753
+ /** `/settings` screen capabilities (see the public Channel type). */
754
+ settingsHost(): SettingsHost | undefined;
755
+ /** Plugin-declared settings sections (see the public Channel type). */
756
+ settingsSections(): readonly TuiSettingsSection[];
757
+ /** Subscribe to settings-section register/unregister events. */
758
+ subscribeSettingsSections(listener: () => void): () => void;
694
759
  listFiles(): Promise<readonly string[]>;
695
760
  listSessions(): Promise<readonly SessionSummary[]>;
696
761
  /** Trailing exchanges of a persisted session (see the public Channel type). */
@@ -746,6 +811,9 @@ export declare function createChannel(ctx: Context, initialAgent: Agent, options
746
811
  /** Indicator preset for the working-activity line (`claude`/`moon`/
747
812
  * `comet`/`dots`/… or `random`); default `claude`. */
748
813
  activityFrames?: string;
814
+ /** Edit/Write diff presentation; default `auto` (side-by-side ≥110
815
+ * columns, unified below). */
816
+ diffLayout?: 'auto' | 'split' | 'unified';
749
817
  /** Show the segmented context bar row in the status footer; default on
750
818
  * (cordis.yml `contextBar: false` hides it, issue #29). */
751
819
  contextBar?: boolean;