@mjasnikovs/pi-task 0.17.20 → 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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  **Deterministic spec-orchestration for local models — with bundled web, docs, fetch, and worker sub-agent tools.**
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
- [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
10
+ [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
12
  [![tests](https://img.shields.io/badge/tests-993%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
@@ -177,4 +177,12 @@ Built with [Bun](https://bun.sh), TypeScript (strict), and [TypeBox](https://git
177
177
 
178
178
  ## License
179
179
 
180
- [MIT](./LICENSE) © Edgars Mjasnikovs
180
+ [AGPL-3.0-only](./LICENSE) © Edgars Mjasnikovs
181
+
182
+ Free and open source under the GNU Affero General Public License v3.0: you may
183
+ use, modify, and redistribute it, but any modified version you run — **including
184
+ over a network as a hosted service** — must make its complete source available
185
+ under the same license. Contributions are accepted under the
186
+ [Contributor License Agreement](./CLA.md), which allows dual-licensing;
187
+ for a commercial license that does not carry the AGPL's copyleft obligations,
188
+ contact the author.
@@ -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);