@mjasnikovs/pi-task 0.17.21 → 0.17.22

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.
@@ -11,6 +11,8 @@ export interface ToolPart {
11
11
  isError: boolean;
12
12
  /** false while the tool is still running (result not in yet). */
13
13
  done: boolean;
14
+ /** Wall-clock duration once finished, shown dimly in the tool summary. */
15
+ elapsedMs?: number;
14
16
  }
15
17
  export type Part = TextPart | ToolPart;
16
18
  export interface Turn {
@@ -24,6 +24,9 @@ interface SessionState {
24
24
  taskWidget: string[] | null;
25
25
  prompt: PromptMessage | null;
26
26
  context: ContextUsage | null;
27
+ /** tool start timestamps (ms), keyed by toolCallId — kept off the serialized
28
+ * parts so it never reaches the client; used only to compute elapsedMs. */
29
+ toolStarts: Record<string, number>;
27
30
  /** Broadcast sink — swapped in tests via _setSink. */
28
31
  sink: (msg: unknown) => void;
29
32
  }
@@ -20,6 +20,7 @@ function fresh() {
20
20
  taskWidget: null,
21
21
  prompt: null,
22
22
  context: null,
23
+ toolStarts: {},
23
24
  sink: wsBroadcast
24
25
  };
25
26
  }
@@ -76,6 +77,7 @@ export function startTool(toolCallId, toolName, args) {
76
77
  isError: false,
77
78
  done: false
78
79
  });
80
+ s.toolStarts[toolCallId] = Date.now();
79
81
  s.sink({ type: 'tool_start', toolCallId, toolName, args });
80
82
  }
81
83
  export function updateTool(toolCallId, partialResult) {
@@ -85,10 +87,18 @@ export function endTool(toolCallId, toolName, result, isError) {
85
87
  const s = getState();
86
88
  const live = ensureLive(s);
87
89
  const part = live.parts.find((p) => p.kind === 'tool' && p.toolCallId === toolCallId);
90
+ const startedAt = s.toolStarts[toolCallId];
91
+ let elapsedMs;
92
+ if (startedAt != null) {
93
+ elapsedMs = Date.now() - startedAt;
94
+ delete s.toolStarts[toolCallId];
95
+ }
88
96
  if (part) {
89
97
  part.result = result;
90
98
  part.isError = isError;
91
99
  part.done = true;
100
+ if (elapsedMs != null)
101
+ part.elapsedMs = elapsedMs;
92
102
  }
93
103
  else {
94
104
  // tool_end without a matching start (shouldn't happen) — append a done tool.
@@ -102,7 +112,7 @@ export function endTool(toolCallId, toolName, result, isError) {
102
112
  done: true
103
113
  });
104
114
  }
105
- s.sink({ type: 'tool_end', toolCallId, toolName, result, isError });
115
+ s.sink({ type: 'tool_end', toolCallId, toolName, result, isError, elapsedMs });
106
116
  }
107
117
  export function agentEnd(context) {
108
118
  const s = getState();
@@ -0,0 +1,2 @@
1
+ /** Emitted client JS (top-level function declarations + language tables). */
2
+ export declare function highlightModule(): string;
@@ -0,0 +1,119 @@
1
+ // Generalized single-pass syntax highlighter, shipped as a JS string and
2
+ // concatenated into the page by ui.ts. It grew out of the client's original
3
+ // JS-only tokenizer: the string/comment/number/identifier logic is now shared and
4
+ // parameterized by a per-language config (keyword set + comment markers + string
5
+ // flavors). Unknown languages fall back to escaped plain text (unchanged).
6
+ //
7
+ // Depends on escHtml (ui-render.ts) — both land in the same <script>.
8
+ /** Emitted client JS (top-level function declarations + language tables). */
9
+ export function highlightModule() {
10
+ return ` var HL_JS_KW = new Set(('break case catch class const continue debugger default delete do else export '
11
+ + 'extends finally for from function if import in instanceof let new of return static super switch this '
12
+ + 'throw try typeof var void while with yield async await type interface enum implements abstract as declare '
13
+ + 'namespace readonly undefined null true false override satisfies public private protected get set').split(' '));
14
+ var HL_PY_KW = new Set(('def return if elif else for while in not and or import from as class try except finally '
15
+ + 'raise with lambda yield pass break continue global nonlocal assert del None True False async await is print self').split(' '));
16
+ var HL_SH_KW = new Set(('if then else elif fi for while do done case esac in function return echo export local read '
17
+ + 'cd set unset source alias exit test').split(' '));
18
+ var HL_SQL_KW = new Set(('select from where insert into values update set delete create table drop alter add column '
19
+ + 'join left right inner outer full on group by order having limit offset union and or not null is as distinct '
20
+ + 'count sum avg min max primary key foreign references index unique default int integer text varchar char boolean '
21
+ + 'timestamp date time serial begin commit rollback').split(' '));
22
+ var HL_GO_KW = new Set(('func package import var const type struct interface map chan go defer return if else for range '
23
+ + 'switch case default break continue select nil true false make new append len cap string int int64 float64 bool byte rune error').split(' '));
24
+ var HL_RUST_KW = new Set(('fn let mut const static struct enum impl trait pub use mod match if else for while loop return '
25
+ + 'break continue self Self super crate as where move ref dyn async await unsafe true false None Some Ok Err String Vec Option Result Box').split(' '));
26
+ var HL_YAML_KW = new Set('true false null yes no on off'.split(' '));
27
+ var HL_JSON_KW = new Set('true false null'.split(' '));
28
+
29
+ var HL_LANGS = {
30
+ js: {kw: HL_JS_KW, line: '//', block: ['/*', '*/'], tmpl: true},
31
+ ts: {kw: HL_JS_KW, line: '//', block: ['/*', '*/'], tmpl: true},
32
+ python: {kw: HL_PY_KW, line: '#', block: null, triple: true},
33
+ sh: {kw: HL_SH_KW, line: '#', block: null},
34
+ sql: {kw: HL_SQL_KW, line: '--', block: ['/*', '*/'], ci: true},
35
+ go: {kw: HL_GO_KW, line: '//', block: ['/*', '*/'], tmpl: true},
36
+ rust: {kw: HL_RUST_KW, line: '//', block: ['/*', '*/']},
37
+ yaml: {kw: HL_YAML_KW, line: '#', block: null},
38
+ json: {kw: HL_JSON_KW, line: null, block: null},
39
+ css: {kw: null, line: null, block: ['/*', '*/']}
40
+ };
41
+ var HL_ALIAS = {javascript: 'js', jsx: 'js', mjs: 'js', cjs: 'js', node: 'js', typescript: 'ts', tsx: 'ts',
42
+ py: 'python', python3: 'python', bash: 'sh', shell: 'sh', shellscript: 'sh', zsh: 'sh', console: 'sh',
43
+ yml: 'yaml', rs: 'rust', golang: 'go', htm: 'html', xml: 'html', svg: 'html', scss: 'css'};
44
+
45
+ function hlSpan(cls, text) { return '<span class="' + cls + '">' + escHtml(text) + '</span>'; }
46
+
47
+ function tokenize(code, cfg) {
48
+ var BT = String.fromCharCode(96);
49
+ var r = '', i = 0, n = code.length;
50
+ while (i < n) {
51
+ var ch = code[i];
52
+ if (cfg.block && code.startsWith(cfg.block[0], i)) {
53
+ var end = code.indexOf(cfg.block[1], i + cfg.block[0].length);
54
+ var je = end < 0 ? n : end + cfg.block[1].length;
55
+ r += hlSpan('hl-cmt', code.slice(i, je)); i = je; continue;
56
+ }
57
+ if (cfg.line && code.startsWith(cfg.line, i)) {
58
+ var jl = i; while (jl < n && code[jl] !== '\\n') jl++;
59
+ r += hlSpan('hl-cmt', code.slice(i, jl)); i = jl; continue;
60
+ }
61
+ if (ch === '"' || ch === "'" || (cfg.tmpl && ch === BT)) {
62
+ if (cfg.triple && code[i + 1] === ch && code[i + 2] === ch) {
63
+ var close = code.indexOf(ch + ch + ch, i + 3);
64
+ var jt = close < 0 ? n : close + 3;
65
+ r += hlSpan('hl-str', code.slice(i, jt)); i = jt; continue;
66
+ }
67
+ var js = i + 1;
68
+ while (js < n) { if (code[js] === '\\\\') { js += 2; continue; } if (code[js] === ch || code[js] === '\\n') break; js++; }
69
+ if (code[js] === ch) js++;
70
+ r += hlSpan('hl-str', code.slice(i, js)); i = js; continue;
71
+ }
72
+ if (ch >= '0' && ch <= '9') {
73
+ var jn = i;
74
+ if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
75
+ jn += 2; while (jn < n && /[0-9a-fA-F_]/.test(code[jn])) jn++;
76
+ } else {
77
+ while (jn < n && ((code[jn] >= '0' && code[jn] <= '9') || code[jn] === '_')) jn++;
78
+ if (code[jn] === '.') { jn++; while (jn < n && code[jn] >= '0' && code[jn] <= '9') jn++; }
79
+ if (code[jn] === 'e' || code[jn] === 'E') { jn++; if (code[jn] === '+' || code[jn] === '-') jn++; while (jn < n && code[jn] >= '0' && code[jn] <= '9') jn++; }
80
+ if (code[jn] === 'n') jn++;
81
+ }
82
+ r += hlSpan('hl-num', code.slice(i, jn)); i = jn; continue;
83
+ }
84
+ if (/[a-zA-Z_$]/.test(ch)) {
85
+ var jw = i; while (jw < n && /[a-zA-Z0-9_$]/.test(code[jw])) jw++;
86
+ var word = code.slice(i, jw);
87
+ var kwHit = cfg.kw && (cfg.kw.has(word) || (cfg.ci && cfg.kw.has(word.toLowerCase())));
88
+ if (kwHit) r += hlSpan('hl-kw', word);
89
+ else if (code[jw] === '(') r += hlSpan('hl-fn', word);
90
+ else r += escHtml(word);
91
+ i = jw; continue;
92
+ }
93
+ r += escHtml(ch); i++;
94
+ }
95
+ return r;
96
+ }
97
+
98
+ // Markup languages don't fit the identifier tokenizer; highlight tag names and
99
+ // attribute names off the already-escaped text.
100
+ function highlightMarkup(code) {
101
+ var e = escHtml(code);
102
+ // Attribute names first (before any spans exist), then tag names — otherwise
103
+ // the attribute pass would re-match the class="" inside the spans it inserted.
104
+ e = e.replace(/([a-zA-Z-]+)=("|&quot;)/g, function (_, a, q) { return '<span class="hl-fn">' + a + '</span>=' + q; });
105
+ e = e.replace(/(&lt;\\/?)([a-zA-Z][\\w-]*)/g, function (_, p, t) { return p + '<span class="hl-kw">' + t + '</span>'; });
106
+ return e;
107
+ }
108
+
109
+ function syntaxHighlight(code, lang) {
110
+ code = String(code == null ? '' : code);
111
+ lang = (lang || '').toLowerCase();
112
+ lang = HL_ALIAS[lang] || lang;
113
+ if (lang === 'html') return highlightMarkup(code);
114
+ var cfg = HL_LANGS[lang];
115
+ if (!cfg) return escHtml(code);
116
+ return tokenize(code, cfg);
117
+ }
118
+ `;
119
+ }
@@ -0,0 +1,2 @@
1
+ /** Emitted client JS (top-level function declarations). */
2
+ export declare function renderModule(): string;
@@ -0,0 +1,169 @@
1
+ // Pure string→HTML render helpers, shipped to the browser as a JS string and
2
+ // concatenated into the page by ui.ts BEFORE clientScript. Splitting these out
3
+ // of the 900-line client template gives them a testability seam: each emitted
4
+ // function is `new Function`-eval'd in a unit test and asserted on its return
5
+ // value, with no DOM needed. (The shipped fence bug survived because the client
6
+ // JS was never executed by any test — only string-matched.)
7
+ //
8
+ // Exposes (as top-level fn declarations in the page's single <script>):
9
+ // escHtml(s) — HTML-escape & < >
10
+ // parseBlocks(text) — line-based fence scanner (text vs code segments)
11
+ // renderInline(s) — inline markdown (bold/italic/strike/code/links)
12
+ // renderMarkdown(text) — full block-level markdown → HTML string
13
+ // renderMarkdown calls syntaxHighlight (ui-highlight.ts); both land in the same
14
+ // <script>, so declaration order across the concatenated modules doesn't matter.
15
+ /** Emitted client JS (top-level function declarations). */
16
+ export function renderModule() {
17
+ return ` function escHtml(s) {
18
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ }
20
+
21
+ // Split text into ordered {type:'text'|'code', ...} blocks by scanning lines
22
+ // for a fenced code marker at line start. This replaces a RegExp that was built
23
+ // from a string where '[\\\\s\\\\S]' collapsed to '[sS]', so it matched nothing
24
+ // and every code block rendered as raw text. A line scanner also tolerates an
25
+ // unclosed trailing fence, which the regex never could.
26
+ function parseBlocks(text) {
27
+ var FENCE = String.fromCharCode(96, 96, 96);
28
+ var lines = String(text == null ? '' : text).split('\\n');
29
+ var blocks = [];
30
+ var textBuf = [];
31
+ function flushText() {
32
+ if (textBuf.length) { blocks.push({type: 'text', content: textBuf.join('\\n')}); textBuf = []; }
33
+ }
34
+ var i = 0;
35
+ while (i < lines.length) {
36
+ var line = lines[i];
37
+ if (line.slice(0, 3) === FENCE) {
38
+ flushText();
39
+ var lang = line.slice(3).trim();
40
+ var codeLines = [];
41
+ i++;
42
+ while (i < lines.length && lines[i].trim() !== FENCE) { codeLines.push(lines[i]); i++; }
43
+ if (i < lines.length) i++; // consume the closing fence; tolerate EOF (unclosed)
44
+ blocks.push({type: 'code', lang: lang, content: codeLines.join('\\n')});
45
+ } else {
46
+ textBuf.push(line);
47
+ i++;
48
+ }
49
+ }
50
+ flushText();
51
+ return blocks;
52
+ }
53
+
54
+ // Inline formatting on ONE line's worth of text. Code spans are extracted first
55
+ // (and protected with a NUL placeholder) so emphasis inside them is left alone.
56
+ function renderInline(s) {
57
+ s = escHtml(String(s == null ? '' : s));
58
+ var BT = String.fromCharCode(96);
59
+ var codes = [];
60
+ var codeRe = new RegExp(BT + '([^' + BT + ']+)' + BT, 'g');
61
+ s = s.replace(codeRe, function (_, c) { codes.push(c); return '\\u0000' + (codes.length - 1) + '\\u0000'; });
62
+ s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function (_, t, u) {
63
+ var safe = /^(https?:|mailto:)/i.test(u) ? u : '#';
64
+ return '<a href="' + safe + '" target="_blank" rel="noopener">' + t + '</a>';
65
+ });
66
+ s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
67
+ s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
68
+ s = s.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
69
+ s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
70
+ s = s.replace(/\\u0000(\\d+)\\u0000/g, function (_, n) { return '<code class="md-code">' + codes[n] + '</code>'; });
71
+ return s;
72
+ }
73
+
74
+ // A line that begins a block-level construct — used to stop paragraph grouping.
75
+ function mdIsSpecial(l) {
76
+ return /^(#{1,6})\\s+/.test(l)
77
+ || /^\\s*>\\s?/.test(l)
78
+ || /^\\s*([-*+]|\\d+\\.)\\s+/.test(l)
79
+ || /^\\s*([-*_])\\s*(\\1\\s*){2,}$/.test(l);
80
+ }
81
+
82
+ function mdParseList(lines, i) {
83
+ var ordered = /^\\s*\\d+\\.\\s+/.test(lines[i]);
84
+ var tag = ordered ? 'ol' : 'ul';
85
+ var items = '';
86
+ while (i < lines.length && /^\\s*([-*+]|\\d+\\.)\\s+/.test(lines[i])) {
87
+ var content = lines[i].replace(/^\\s*([-*+]|\\d+\\.)\\s+/, '');
88
+ var task = /^\\[([ xX])\\]\\s+(.*)$/.exec(content);
89
+ if (task) {
90
+ var checked = task[1].toLowerCase() === 'x';
91
+ items += '<li class="md-task"><span class="md-check">' + (checked ? '\\u2611' : '\\u2610') + '</span> ' + renderInline(task[2]) + '</li>';
92
+ } else {
93
+ items += '<li>' + renderInline(content) + '</li>';
94
+ }
95
+ i++;
96
+ }
97
+ return {html: '<' + tag + ' class="md-list">' + items + '</' + tag + '>', next: i};
98
+ }
99
+
100
+ function mdParseTable(lines, i) {
101
+ function cells(l) {
102
+ var s = l.trim().replace(/^\\|/, '').replace(/\\|$/, '');
103
+ return s.split('|').map(function (c) { return c.trim(); });
104
+ }
105
+ var head = cells(lines[i]);
106
+ i += 2; // skip the header row and the |---| separator
107
+ var body = '';
108
+ while (i < lines.length && /\\|/.test(lines[i]) && lines[i].trim() !== '') {
109
+ var row = cells(lines[i]);
110
+ var tds = '';
111
+ for (var c = 0; c < row.length; c++) tds += '<td>' + renderInline(row[c]) + '</td>';
112
+ body += '<tr>' + tds + '</tr>';
113
+ i++;
114
+ }
115
+ var ths = '';
116
+ for (var c2 = 0; c2 < head.length; c2++) ths += '<th>' + renderInline(head[c2]) + '</th>';
117
+ return {html: '<table class="md-table"><thead><tr>' + ths + '</tr></thead><tbody>' + body + '</tbody></table>', next: i};
118
+ }
119
+
120
+ // Block-level markdown for one text segment (no fences — those are split off by
121
+ // renderMarkdown first). Headers, hr, blockquotes, tables, lists, paragraphs.
122
+ function renderProse(src) {
123
+ var lines = String(src == null ? '' : src).split('\\n');
124
+ var html = '';
125
+ var i = 0;
126
+ while (i < lines.length) {
127
+ var line = lines[i];
128
+ if (line.trim() === '') { i++; continue; }
129
+ var h = /^(#{1,6})\\s+(.*)$/.exec(line);
130
+ if (h) { var lvl = h[1].length; html += '<h' + lvl + ' class="md-h md-h' + lvl + '">' + renderInline(h[2]) + '</h' + lvl + '>'; i++; continue; }
131
+ if (/^\\s*([-*_])\\s*(\\1\\s*){2,}$/.test(line)) { html += '<hr class="md-hr">'; i++; continue; }
132
+ if (/^\\s*>\\s?/.test(line)) {
133
+ var q = [];
134
+ while (i < lines.length && /^\\s*>\\s?/.test(lines[i])) { q.push(lines[i].replace(/^\\s*>\\s?/, '')); i++; }
135
+ html += '<blockquote class="md-quote">' + renderProse(q.join('\\n')) + '</blockquote>';
136
+ continue;
137
+ }
138
+ if (/\\|/.test(line) && i + 1 < lines.length && /^\\s*\\|?[\\s:|-]*-[\\s:|-]*\\|?\\s*$/.test(lines[i + 1])) {
139
+ var t = mdParseTable(lines, i); html += t.html; i = t.next; continue;
140
+ }
141
+ if (/^\\s*([-*+]|\\d+\\.)\\s+/.test(line)) {
142
+ var lst = mdParseList(lines, i); html += lst.html; i = lst.next; continue;
143
+ }
144
+ var para = [];
145
+ while (i < lines.length && lines[i].trim() !== '' && !mdIsSpecial(lines[i])) { para.push(lines[i]); i++; }
146
+ var inner = [];
147
+ for (var p = 0; p < para.length; p++) inner.push(renderInline(para[p]));
148
+ html += '<p class="md-p">' + inner.join('<br>') + '</p>';
149
+ }
150
+ return html;
151
+ }
152
+
153
+ function renderMarkdown(text) {
154
+ var blocks = parseBlocks(text);
155
+ var out = '';
156
+ for (var k = 0; k < blocks.length; k++) {
157
+ var b = blocks[k];
158
+ if (b.type === 'code') {
159
+ out += '<div class="code-block">'
160
+ + (b.lang ? '<div class="code-lang">' + escHtml(b.lang) + '</div>' : '')
161
+ + '<pre><code>' + syntaxHighlight(b.content, b.lang) + '</code></pre></div>';
162
+ } else {
163
+ out += renderProse(b.content);
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ `;
169
+ }
@@ -56,119 +56,14 @@ export function clientScript(wsUrl) {
56
56
  let reconnectTimer = null;
57
57
  let ws = null;
58
58
 
59
- const BT = String.fromCharCode(96);
60
- const JS_LANGS = new Set(['js','jsx','mjs','cjs','javascript','ts','tsx','typescript']);
61
- const JS_KW = new Set(['break','case','catch','class','const','continue','debugger',
62
- 'default','delete','do','else','export','extends','finally','for','from','function',
63
- 'if','import','in','instanceof','let','new','of','return','static','super','switch',
64
- 'this','throw','try','typeof','var','void','while','with','yield','async','await',
65
- 'type','interface','enum','implements','abstract','as','declare','namespace',
66
- 'readonly','undefined','null','true','false','override','satisfies']);
67
-
68
- function escHtml(s) {
69
- return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
70
- }
71
-
72
- function syntaxHighlight(code, lang) {
73
- if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
74
- let r = '', i = 0;
75
- while (i < code.length) {
76
- const ch = code[i];
77
- // Template literal
78
- if (ch === BT) {
79
- let j = i + 1;
80
- while (j < code.length) {
81
- if (code[j] === '\\\\') { j += 2; continue; }
82
- if (code[j] === BT) { j++; break; }
83
- j++;
84
- }
85
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
86
- i = j; continue;
87
- }
88
- // Single / double quoted string
89
- if (ch === '"' || ch === "'") {
90
- let j = i + 1;
91
- while (j < code.length) {
92
- if (code[j] === '\\\\') { j += 2; continue; }
93
- if (code[j] === ch || code[j] === '\\n') break;
94
- j++;
95
- }
96
- if (code[j] === ch) j++;
97
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
98
- i = j; continue;
99
- }
100
- // Line comment
101
- if (ch === '/' && code[i + 1] === '/') {
102
- let j = i + 2;
103
- while (j < code.length && code[j] !== '\\n') j++;
104
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
105
- i = j; continue;
106
- }
107
- // Block comment
108
- if (ch === '/' && code[i + 1] === '*') {
109
- let j = i + 2;
110
- while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
111
- j += 2;
112
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
113
- i = j; continue;
114
- }
115
- // Number
116
- if (ch >= '0' && ch <= '9') {
117
- let j = i;
118
- if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
119
- j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
120
- } else {
121
- while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
122
- if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
123
- if (code[j] === 'e' || code[j] === 'E') {
124
- j++; if (code[j] === '+' || code[j] === '-') j++;
125
- while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
126
- }
127
- if (code[j] === 'n') j++;
128
- }
129
- r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
130
- i = j; continue;
131
- }
132
- // Identifier / keyword / function call
133
- if (/[a-zA-Z_$]/.test(ch)) {
134
- let j = i;
135
- while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
136
- const word = code.slice(i, j);
137
- if (JS_KW.has(word)) {
138
- r += '<span class="hl-kw">' + word + '</span>';
139
- } else if (code[j] === '(') {
140
- r += '<span class="hl-fn">' + escHtml(word) + '</span>';
141
- } else {
142
- r += escHtml(word);
143
- }
144
- i = j; continue;
145
- }
146
- r += escHtml(ch); i++;
147
- }
148
- return r;
149
- }
150
-
59
+ // Render assistant text as markdown (headers, lists, tables, emphasis, links)
60
+ // with fenced code blocks syntax-highlighted. renderMarkdown/syntaxHighlight are
61
+ // the pure functions from ui-render.ts / ui-highlight.ts, concatenated into this
62
+ // same <script> ahead of clientScript. innerHTML is safe because renderMarkdown
63
+ // escapes every span of literal text and only emits a fixed set of known tags.
151
64
  function setContent(el, text) {
152
- el.innerHTML = '';
153
- const BT3 = BT + BT + BT;
154
- const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
155
- let last = 0, m;
156
- while ((m = re.exec(text)) !== null) {
157
- if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
158
- const lang = m[1].trim();
159
- const code = m[2];
160
- const wrap = document.createElement('div');
161
- wrap.className = 'code-block';
162
- if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
163
- const pre = document.createElement('pre');
164
- const codeEl = document.createElement('code');
165
- codeEl.innerHTML = syntaxHighlight(code, lang);
166
- pre.appendChild(codeEl);
167
- wrap.appendChild(pre);
168
- el.appendChild(wrap);
169
- last = m.index + m[0].length;
170
- }
171
- if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
65
+ el.classList.add('md');
66
+ el.innerHTML = renderMarkdown(text);
172
67
  }
173
68
 
174
69
  const COMMANDS = [
@@ -247,7 +142,10 @@ export function clientScript(wsUrl) {
247
142
  function addBubble(role, text) {
248
143
  const el = document.createElement('div');
249
144
  el.className = 'bubble ' + role;
250
- setContent(el, text);
145
+ // Only assistant text is markdown-rendered; user/error bubbles stay plain so a
146
+ // user's literal *asterisks* or backticks show verbatim.
147
+ if (role === 'assistant') setContent(el, text);
148
+ else el.textContent = text;
251
149
  chatLog.appendChild(el);
252
150
  scrollBottom();
253
151
  return el;
@@ -293,20 +191,50 @@ export function clientScript(wsUrl) {
293
191
  stopSpinIfIdle();
294
192
  }
295
193
 
296
- function addToolCall(toolName, argsStr, isError) {
297
- // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
298
- // that render as the literal "name: undefined" in the collapsed summary.
299
- const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
194
+ // A finished/running tool card. args is the RAW args (object or JSON string)
195
+ // toolSummary/toolBadge/toolDiffHtml (ui-tools.ts) turn it into a readable
196
+ // one-line summary, a +N −M badge, and (for edit/write) an expandable line diff.
197
+ function addToolCall(toolName, args, isError) {
300
198
  const d = document.createElement('details');
301
199
  d.className = 'tool-call' + (isError ? ' error' : '');
302
200
  const s = document.createElement('summary');
303
- s.textContent = label;
201
+ const label = document.createElement('span');
202
+ label.className = 'tool-label';
203
+ label.textContent = toolSummary(toolName, args);
204
+ s.title = label.textContent;
205
+ s.appendChild(label);
206
+ const badge = toolBadge(toolName, args);
207
+ if (badge && (badge.added || badge.removed)) {
208
+ const b = document.createElement('span');
209
+ b.className = 'tool-badge';
210
+ b.textContent = '+' + badge.added + ' \\u2212' + badge.removed;
211
+ s.appendChild(b);
212
+ }
304
213
  d.appendChild(s);
214
+ const diffHtml = toolDiffHtml(toolName, args);
215
+ if (diffHtml) {
216
+ const dv = document.createElement('div');
217
+ dv.className = 'tool-diff';
218
+ dv.innerHTML = diffHtml;
219
+ d.appendChild(dv);
220
+ }
305
221
  chatLog.appendChild(d);
306
222
  scrollBottom();
307
223
  return d;
308
224
  }
309
225
 
226
+ // Dim "· 1.2s" appended to a finished tool summary.
227
+ function appendElapsed(d, elapsedMs) {
228
+ const txt = fmtElapsed(elapsedMs);
229
+ if (!txt) return;
230
+ const s = d.querySelector('summary');
231
+ if (!s || s.querySelector('.tool-elapsed')) return;
232
+ const e = document.createElement('span');
233
+ e.className = 'tool-elapsed';
234
+ e.textContent = '\\u00B7 ' + txt;
235
+ s.appendChild(e);
236
+ }
237
+
310
238
  function setEnabled(on) {
311
239
  const allow = on && activePromptId === null;
312
240
  inputEl.disabled = !allow;
@@ -345,9 +273,9 @@ export function clientScript(wsUrl) {
345
273
 
346
274
  // Render one tool part from the ordered parts list (running or finished).
347
275
  function renderToolPart(p) {
348
- const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
349
- const d = addToolCall(p.toolName, argsStr, p.isError);
276
+ const d = addToolCall(p.toolName, p.args, p.isError);
350
277
  if (p.done) {
278
+ appendElapsed(d, p.elapsedMs);
351
279
  const pre = document.createElement('pre');
352
280
  pre.textContent = toolResultText(p.result);
353
281
  d.appendChild(pre);
@@ -604,8 +532,9 @@ export function clientScript(wsUrl) {
604
532
  activeRecommended = msg.recommended || '';
605
533
  activeRecommended2 = msg.recommended2 || '';
606
534
  if (msg.recommended) {
607
- // Mode A: recommendation(s) present.
608
- promptRecText.textContent = msg.recommended;
535
+ // Mode A: recommendation(s) present. Render markdown in the panel so a
536
+ // recommendation with code/emphasis reads the same as an assistant bubble.
537
+ setContent(promptRecText, msg.recommended);
609
538
  showRecommendation();
610
539
  } else {
611
540
  // Mode B: no recommendation — the user must type an answer (or skip).
@@ -685,8 +614,7 @@ export function clientScript(wsUrl) {
685
614
  break;
686
615
  case 'tool_start': {
687
616
  hideThinking();
688
- const argsStr = typeof msg.args === 'string' ? msg.args : JSON.stringify(msg.args);
689
- const d = addToolCall(msg.toolName, argsStr, false);
617
+ const d = addToolCall(msg.toolName, msg.args, false);
690
618
  const sp = document.createElement('span');
691
619
  sp.className = 'tool-spin spin';
692
620
  d.querySelector('summary').appendChild(sp);
@@ -700,6 +628,7 @@ export function clientScript(wsUrl) {
700
628
  const sp = d.querySelector('.tool-spin');
701
629
  if (sp) { sp.remove(); stopSpinIfIdle(); }
702
630
  if (msg.isError) d.classList.add('error');
631
+ appendElapsed(d, msg.elapsedMs);
703
632
  const pre = document.createElement('pre');
704
633
  pre.textContent = toolResultText(msg.result);
705
634
  d.appendChild(pre);
@@ -1 +1 @@
1
- export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6 \"; }\n .tool-call[open] > summary::before { content: \"\u25BC \"; }\n .tool-call.error > summary { color: var(--red); }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); margin-left: 6px; font-family: ui-monospace, monospace; font-size: 13px; }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n .code-lang {\n background: var(--surface0); color: var(--subtext0);\n font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }";
1
+ export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n .code-lang {\n background: var(--surface0); color: var(--subtext0);\n font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n }";
@@ -93,19 +93,35 @@ export const STYLES = ` :root {
93
93
  .tool-call summary {
94
94
  padding: 6px 10px; color: var(--subtext1); cursor: pointer;
95
95
  user-select: none; list-style: none;
96
- overflow-wrap: anywhere; word-break: break-word;
96
+ display: flex; align-items: center; gap: 8px;
97
97
  }
98
98
  .tool-call summary::-webkit-details-marker { display: none; }
99
- .tool-call summary::before { content: "▶ "; }
100
- .tool-call[open] > summary::before { content: "▼ "; }
99
+ .tool-call summary::before { content: "▶"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }
100
+ .tool-call[open] > summary::before { content: "▼"; }
101
101
  .tool-call.error > summary { color: var(--red); }
102
+ /* The summary is a single line: the label ellipsizes (full text in the title
103
+ tooltip / on expand), badges and timing stay pinned to the right. */
104
+ .tool-label {
105
+ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
106
+ font-family: ui-monospace, monospace;
107
+ }
108
+ .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }
109
+ .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }
102
110
  .tool-call pre {
103
111
  padding: 8px 12px; overflow-y: auto;
104
112
  color: var(--subtext1); font-size: 11px; max-height: 280px;
105
113
  border-top: 1px solid var(--surface0);
106
114
  white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
107
115
  }
108
- .tool-spin { color: var(--mauve); margin-left: 6px; font-family: ui-monospace, monospace; font-size: 13px; }
116
+ .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }
117
+ /* Line diff for edit/write tools (tints derived from --green/--red). */
118
+ .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }
119
+ .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }
120
+ .diff-line { white-space: pre; padding: 0 10px; }
121
+ .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }
122
+ .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
123
+ .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
124
+ .diff-ctx { color: var(--subtext1); }
109
125
  .code-block {
110
126
  background: var(--crust); border: 1px solid var(--surface0);
111
127
  border-radius: 6px; overflow: hidden; margin: 4px 0;
@@ -124,6 +140,35 @@ export const STYLES = ` :root {
124
140
  .hl-cmt { color: var(--subtext0); font-style: italic; }
125
141
  .hl-num { color: var(--blue); }
126
142
  .hl-fn { color: var(--yellow); }
143
+ /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation
144
+ panel (both get the .md class from setContent). Block elements lay themselves
145
+ out, so switch off the container's pre-wrap for these. */
146
+ .md { white-space: normal; }
147
+ .md > :first-child { margin-top: 0; }
148
+ .md > :last-child { margin-bottom: 0; }
149
+ .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }
150
+ .md .md-h1 { font-size: 1.35em; }
151
+ .md .md-h2 { font-size: 1.2em; }
152
+ .md .md-h3 { font-size: 1.08em; }
153
+ .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }
154
+ .md .md-p { margin: 6px 0; }
155
+ .md .md-list { margin: 6px 0; padding-left: 22px; }
156
+ .md .md-list li { margin: 2px 0; }
157
+ .md .md-task { list-style: none; margin-left: -22px; }
158
+ .md .md-check { color: var(--green); }
159
+ .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }
160
+ .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }
161
+ .md a { color: var(--blue); text-decoration: underline; }
162
+ .md strong { color: var(--text); font-weight: 700; }
163
+ .md em { font-style: italic; }
164
+ .md del { color: var(--subtext0); }
165
+ .md .md-code {
166
+ background: var(--crust); border: 1px solid var(--surface0);
167
+ border-radius: 4px; padding: 1px 4px; font-size: 0.92em;
168
+ }
169
+ .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }
170
+ .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }
171
+ .md .md-table th { background: var(--surface0); color: var(--subtext1); }
127
172
  #input-bar {
128
173
  background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
129
174
  display: flex; gap: 8px; flex-shrink: 0;
@@ -217,4 +262,13 @@ export const STYLES = ` :root {
217
262
  #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);
218
263
  border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
219
264
  overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
220
- #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }`;
265
+ #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
266
+ /* Desktop: center the transcript/status/input in a readable column instead of
267
+ hugging the left edge. The scrollbar stays at the true window edge; only the
268
+ content is inset. Mobile (below 960px) is unchanged. */
269
+ @media (min-width: 960px) {
270
+ #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }
271
+ #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }
272
+ #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }
273
+ #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }
274
+ }`;
@@ -0,0 +1,2 @@
1
+ /** Emitted client JS (top-level function declarations). */
2
+ export declare function toolsModule(): string;
@@ -0,0 +1,113 @@
1
+ // Tool-call presentation helpers, shipped as a JS string and concatenated into
2
+ // the page by ui.ts. Turns raw {toolName, args} into a readable one-line summary,
3
+ // a +N −M diff badge, and an expandable line-diff body for edit/write tools —
4
+ // replacing the old "name + JSON.stringify(args) hard-sliced at 64 chars".
5
+ //
6
+ // Depends on escHtml (ui-render.ts) — both land in the same <script>.
7
+ /** Emitted client JS (top-level function declarations). */
8
+ export function toolsModule() {
9
+ return ` // args arrives as an object (live tool_start / snapshot part) or a JSON string.
10
+ function toolArgs(args) {
11
+ if (args && typeof args === 'object') return args;
12
+ if (typeof args === 'string') { try { return JSON.parse(args); } catch (e) { return null; } }
13
+ return null;
14
+ }
15
+
16
+ // A readable one-line summary. Falls back to the old name+JSON form (but with NO
17
+ // hard 64-char slice — the summary line ellipsizes via CSS, full text on hover).
18
+ function toolSummary(toolName, args) {
19
+ var name = (toolName || '').toLowerCase();
20
+ var a = toolArgs(args);
21
+ function s(v) { return v == null ? '' : String(v); }
22
+ if (a) {
23
+ var path = a.path != null ? a.path : a.file_path;
24
+ if (name.indexOf('bash') >= 0 && a.command != null) return '$ ' + s(a.command);
25
+ if (name === 'read' && path != null) return 'read ' + s(path);
26
+ if ((name === 'edit' || name === 'multiedit' || name === 'write' || name === 'str_replace') && path != null) return s(path);
27
+ if (name === 'grep' || name === 'find' || name === 'glob' || name === 'search') {
28
+ var pat = a.pattern != null ? a.pattern : (a.query != null ? a.query : a.glob);
29
+ var loc = a.path != null ? a.path : a.glob;
30
+ return name + ' ' + s(pat) + (loc && loc !== pat ? ' ' + s(loc) : '');
31
+ }
32
+ }
33
+ var argStr = typeof args === 'string' ? args : (args === undefined ? '' : JSON.stringify(args));
34
+ return toolName + (argStr ? ': ' + argStr : '');
35
+ }
36
+
37
+ // Line diff via LCS. Bounded: a huge pair falls back to "all removed / all added"
38
+ // rather than allocating an O(m*n) table that could hang the tab.
39
+ function lcsDiff(oldText, newText) {
40
+ // Treat '' / null as ZERO lines (not one empty line) so a new-file write shows
41
+ // as all-added with no phantom "removed blank line".
42
+ var a = (oldText == null || oldText === '') ? [] : String(oldText).split('\\n');
43
+ var b = (newText == null || newText === '') ? [] : String(newText).split('\\n');
44
+ var m = a.length, n = b.length;
45
+ var rows = [];
46
+ if (m * n > 250000) {
47
+ for (var x = 0; x < m; x++) rows.push({t: '-', s: a[x]});
48
+ for (var y = 0; y < n; y++) rows.push({t: '+', s: b[y]});
49
+ return rows;
50
+ }
51
+ var dp = [];
52
+ for (var i = 0; i <= m; i++) dp.push(new Array(n + 1).fill(0));
53
+ for (var i2 = m - 1; i2 >= 0; i2--)
54
+ for (var j2 = n - 1; j2 >= 0; j2--)
55
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
56
+ var i = 0, j = 0;
57
+ while (i < m && j < n) {
58
+ if (a[i] === b[j]) { rows.push({t: ' ', s: a[i]}); i++; j++; }
59
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { rows.push({t: '-', s: a[i]}); i++; }
60
+ else { rows.push({t: '+', s: b[j]}); j++; }
61
+ }
62
+ while (i < m) { rows.push({t: '-', s: a[i++]}); }
63
+ while (j < n) { rows.push({t: '+', s: b[j++]}); }
64
+ return rows;
65
+ }
66
+
67
+ function diffStats(oldText, newText) {
68
+ var rows = lcsDiff(oldText, newText), add = 0, rem = 0;
69
+ for (var k = 0; k < rows.length; k++) { if (rows[k].t === '+') add++; else if (rows[k].t === '-') rem++; }
70
+ return {added: add, removed: rem};
71
+ }
72
+
73
+ function renderDiff(oldText, newText) {
74
+ var rows = lcsDiff(oldText, newText);
75
+ var html = '<div class="diff">';
76
+ for (var k = 0; k < rows.length; k++) {
77
+ var r = rows[k];
78
+ var cls = r.t === '+' ? 'diff-add' : (r.t === '-' ? 'diff-del' : 'diff-ctx');
79
+ html += '<div class="diff-line ' + cls + '"><span class="diff-sign">' + (r.t === ' ' ? ' ' : r.t) + '</span>' + escHtml(r.s) + '</div>';
80
+ }
81
+ return html + '</div>';
82
+ }
83
+
84
+ // The old/new text for an edit/write tool's diff, or null if not a diffable tool.
85
+ function toolDiffPair(toolName, args) {
86
+ var name = (toolName || '').toLowerCase();
87
+ var a = toolArgs(args);
88
+ if (!a) return null;
89
+ if (name === 'edit' || name === 'multiedit' || name === 'str_replace') {
90
+ if (a.old_string != null || a.new_string != null) return {old: a.old_string, neu: a.new_string};
91
+ }
92
+ if (name === 'write' && a.content != null) return {old: '', neu: a.content};
93
+ return null;
94
+ }
95
+
96
+ function toolBadge(toolName, args) {
97
+ var pair = toolDiffPair(toolName, args);
98
+ return pair ? diffStats(pair.old, pair.neu) : null;
99
+ }
100
+
101
+ function toolDiffHtml(toolName, args) {
102
+ var pair = toolDiffPair(toolName, args);
103
+ return pair ? renderDiff(pair.old, pair.neu) : null;
104
+ }
105
+
106
+ function fmtElapsed(ms) {
107
+ if (ms == null || isNaN(ms)) return '';
108
+ if (ms < 1000) return Math.round(ms) + 'ms';
109
+ var sec = ms / 1000;
110
+ return (sec < 10 ? sec.toFixed(1) : Math.round(sec)) + 's';
111
+ }
112
+ `;
113
+ }
package/dist/remote/ui.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { STYLES } from './ui-styles.js';
2
2
  import { clientScript } from './ui-script.js';
3
+ import { renderModule } from './ui-render.js';
4
+ import { highlightModule } from './ui-highlight.js';
5
+ import { toolsModule } from './ui-tools.js';
3
6
  export function html(wsUrl) {
4
7
  const iconSvg = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">`
5
8
  + `<rect width="180" height="180" rx="38" fill="#1e1e2e"/>`
@@ -61,6 +64,9 @@ ${STYLES}
61
64
  </div>
62
65
  <div id="viewer"><span class="close" id="viewer-close">&#x2715;</span><div id="viewer-body"></div></div>
63
66
  <script>
67
+ ${renderModule()}
68
+ ${highlightModule()}
69
+ ${toolsModule()}
64
70
  ${clientScript(wsUrl)}
65
71
  </script>
66
72
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.21",
3
+ "version": "0.17.22",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",