cli-jaw 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +8 -4
  2. package/dist/bin/commands/doctor.js +48 -0
  3. package/dist/bin/commands/doctor.js.map +1 -1
  4. package/dist/bin/commands/init.js +2 -1
  5. package/dist/bin/commands/init.js.map +1 -1
  6. package/dist/bin/postinstall.js +66 -1
  7. package/dist/bin/postinstall.js.map +1 -1
  8. package/dist/server.js +8 -3
  9. package/dist/server.js.map +1 -1
  10. package/dist/src/agent/spawn.js +2 -5
  11. package/dist/src/agent/spawn.js.map +1 -1
  12. package/dist/src/orchestrator/parser.js +2 -1
  13. package/dist/src/orchestrator/parser.js.map +1 -1
  14. package/dist/src/orchestrator/pipeline.js +5 -96
  15. package/dist/src/orchestrator/pipeline.js.map +1 -1
  16. package/dist/src/orchestrator/state-machine.js +11 -14
  17. package/dist/src/orchestrator/state-machine.js.map +1 -1
  18. package/dist/src/prompt/builder.js +5 -15
  19. package/dist/src/prompt/builder.js.map +1 -1
  20. package/dist/src/prompt/templates/a1-system.md +33 -34
  21. package/dist/src/prompt/templates/employee.md +5 -3
  22. package/dist/src/prompt/templates/orchestration.md +13 -37
  23. package/package.json +7 -1
  24. package/public/css/chat.css +12 -0
  25. package/public/dist/assets/employees-B11suLXa.js +39 -0
  26. package/public/dist/assets/index-C3xIEYRH.css +1 -0
  27. package/public/dist/assets/index-CMUmeewA.js +49 -0
  28. package/public/dist/assets/render-C5gpc065.js +25 -0
  29. package/public/dist/assets/{settings-jLH2evC4.js → settings-BbG1hQmA.js} +14 -14
  30. package/public/dist/assets/settings-tOEsbIIs.js +1 -0
  31. package/public/dist/assets/skills-DL7wTirZ.js +12 -0
  32. package/public/dist/assets/skills-kOx6rq1X.js +1 -0
  33. package/public/dist/assets/slash-commands-C5vUKzNP.js +1 -0
  34. package/public/dist/assets/{slash-commands-3SMle5Qk.js → slash-commands-D_tV86er.js} +1 -1
  35. package/public/dist/assets/ui-ByJAyywC.js +1 -0
  36. package/public/dist/assets/ui-ORW7tzea.js +131 -0
  37. package/public/dist/assets/ws-CDrAtY9-.js +2 -0
  38. package/public/dist/index.html +2 -2
  39. package/public/js/features/chat.ts +3 -1
  40. package/public/js/render.ts +44 -15
  41. package/public/js/streaming-render.ts +33 -10
  42. package/public/js/ui.ts +45 -13
  43. package/public/js/virtual-scroll.ts +50 -16
  44. package/public/js/ws.ts +2 -1
  45. package/scripts/ensure-native-modules.cjs +54 -0
  46. package/scripts/install-officecli.ps1 +96 -0
  47. package/scripts/install-officecli.sh +37 -3
  48. package/scripts/install-wsl.sh +113 -27
  49. package/public/dist/assets/employees-C2iuXw4k.js +0 -39
  50. package/public/dist/assets/index-BkF1Onqv.js +0 -49
  51. package/public/dist/assets/index-D6ci1wCN.css +0 -1
  52. package/public/dist/assets/render-HNw-fqbv.js +0 -25
  53. package/public/dist/assets/settings-GMJkuozJ.js +0 -1
  54. package/public/dist/assets/skills-CbtCvg1l.js +0 -1
  55. package/public/dist/assets/skills-DfGrAT8o.js +0 -12
  56. package/public/dist/assets/slash-commands-AojbjNCT.js +0 -1
  57. package/public/dist/assets/ui-5JOnDmC_.js +0 -131
  58. package/public/dist/assets/ui-CaWuOlEV.js +0 -1
  59. package/public/dist/assets/ws-CF5lzgW9.js +0 -2
@@ -288,10 +288,18 @@ export function shieldMath(text: string): { text: string; blocks: MathBlock[] }
288
288
  return { text: processed, blocks };
289
289
  }
290
290
 
291
- export function unshieldMath(html: string, blocks: MathBlock[]): string {
291
+ export function unshieldMath(html: string, blocks: MathBlock[], isStreaming = false): string {
292
292
  return html.replace(/\x00MATH-(\d+)\x00/g, (_, i) => {
293
293
  const block = blocks[Number(i)];
294
294
  if (!block) return `<code title="math placeholder error">[math error]</code>`;
295
+
296
+ // During streaming: lightweight placeholder, defer KaTeX to finalize
297
+ if (isStreaming) {
298
+ return block.displayMode
299
+ ? `<div class="math-placeholder">${escapeHtml(block.tex)}</div>`
300
+ : `<code class="math-placeholder">${escapeHtml(block.tex)}</code>`;
301
+ }
302
+
295
303
  try {
296
304
  return katex.renderToString(block.tex, {
297
305
  displayMode: block.displayMode,
@@ -397,8 +405,9 @@ async function renderSingleMermaid(el: HTMLElement): Promise<void> {
397
405
  }
398
406
  }
399
407
 
400
- async function renderMermaidBlocks(): Promise<void> {
401
- const pending = document.querySelectorAll('.mermaid-pending');
408
+ async function renderMermaidBlocks(scope?: HTMLElement | Document): Promise<void> {
409
+ const root = scope || document;
410
+ const pending = root.querySelectorAll('.mermaid-pending');
402
411
  if (!pending.length) return;
403
412
  ensureMermaidObserver();
404
413
  for (const el of pending) {
@@ -453,8 +462,9 @@ function ensureMarked(): boolean {
453
462
  }
454
463
 
455
464
  // ── Rehighlight all code blocks ──
456
- export function rehighlightAll(): void {
457
- document.querySelectorAll('.code-block pre code, .code-block-wrapper pre code').forEach(el => {
465
+ export function rehighlightAll(scope?: HTMLElement | Document): void {
466
+ const root = scope || document;
467
+ root.querySelectorAll('.code-block pre code, .code-block-wrapper pre code').forEach(el => {
458
468
  if ((el as HTMLElement).dataset.highlighted === 'yes') return;
459
469
  const lang = [...el.classList].find(c => c.startsWith('language-'))?.replace('language-', '');
460
470
  const raw = el.textContent || '';
@@ -676,14 +686,14 @@ function unshieldSvgBlocks(html: string, blocks: SvgBlock[]): string {
676
686
 
677
687
  // ── Diagram Zoom Overlay ──
678
688
 
679
- export function bindDiagramZoom(): void {
680
- document.querySelectorAll('.diagram-zoom-btn').forEach(btn => {
689
+ export function bindDiagramZoom(scope?: HTMLElement | Document): void {
690
+ const root = scope || document;
691
+ root.querySelectorAll('.diagram-zoom-btn').forEach(btn => {
681
692
  if ((btn as HTMLElement).dataset.bound) return;
682
693
  (btn as HTMLElement).dataset.bound = '1';
683
694
  btn.addEventListener('click', () => {
684
695
  const container = btn.closest('.diagram-container');
685
696
  if (!container) return;
686
- // Clone without zoom button to prevent nesting
687
697
  const clone = container.cloneNode(true) as HTMLElement;
688
698
  clone.querySelectorAll('.diagram-zoom-btn, .diagram-copy-btn, .diagram-save-btn').forEach(b => b.remove());
689
699
  openDiagramOverlay(clone.innerHTML);
@@ -773,7 +783,7 @@ export function renderMarkdown(text: string, isStreaming = false): string {
773
783
  html = html.replace(/<table/g, '<div class="table-wrapper"><table').replace(/<\/table>/g, '</table></div>');
774
784
 
775
785
  // 6. Unshield math
776
- html = unshieldMath(html, mathBlocks);
786
+ html = unshieldMath(html, mathBlocks, isStreaming);
777
787
 
778
788
  // 7. Sanitize
779
789
  html = sanitizeHtml(html);
@@ -781,15 +791,34 @@ export function renderMarkdown(text: string, isStreaming = false): string {
781
791
  // 8. Unshield SVGs (after sanitize — SVGs sanitized individually in renderSvgBlock)
782
792
  html = unshieldSvgBlocks(html, svgBlocks);
783
793
 
784
- // 9. Post-render async tasks
785
- requestAnimationFrame(() => {
786
- renderMermaidBlocks();
787
- rehighlightAll();
788
- bindDiagramZoom();
789
- });
794
+ // 9. Post-render async tasks — skip during streaming (deferred to finalize)
795
+ if (!isStreaming) {
796
+ schedulePostRender();
797
+ }
790
798
 
791
799
  ensureCopyDelegation();
792
800
  ensureDiagramActionDelegation();
793
801
 
794
802
  return html;
795
803
  }
804
+
805
+ // ── Batched post-render scheduler ──
806
+ // Coalesces multiple renderMarkdown() calls into a single post-render pass.
807
+ let postRenderRAF: number | null = null;
808
+
809
+ function schedulePostRender(): void {
810
+ if (postRenderRAF) return;
811
+ postRenderRAF = requestAnimationFrame(() => {
812
+ postRenderRAF = null;
813
+ renderMermaidBlocks();
814
+ rehighlightAll();
815
+ bindDiagramZoom();
816
+ });
817
+ }
818
+
819
+ export function cancelPostRender(): void {
820
+ if (postRenderRAF) {
821
+ cancelAnimationFrame(postRenderRAF);
822
+ postRenderRAF = null;
823
+ }
824
+ }
@@ -1,11 +1,14 @@
1
1
  // ── Streaming rAF Renderer ──
2
- // Phase 3/8: throttled markdown rendering during agent streaming
2
+ // Throttled markdown rendering during agent streaming.
3
3
  // Prevents flicker by batching renders to 1 per animation frame.
4
+ // Array buffer avoids O(n) string copy per chunk.
4
5
 
5
6
  import { renderMarkdown } from './render.js';
6
7
 
7
8
  export interface StreamState {
9
+ chunks: string[];
8
10
  fullText: string;
11
+ textDirty: boolean;
9
12
  element: HTMLElement;
10
13
  pendingRAF: number | null;
11
14
  isFinalized: boolean;
@@ -16,26 +19,39 @@ const FULL_RENDER_THRESHOLD = 2000;
16
19
  const THROTTLE_MS = 80; // ~12fps — was 32ms (30fps), reduced to avoid blocking input
17
20
 
18
21
  export function createStreamRenderer(el: HTMLElement): StreamState {
19
- return { fullText: '', element: el, pendingRAF: null, isFinalized: false, lastRenderTime: 0 };
22
+ return {
23
+ chunks: [], fullText: '', textDirty: false,
24
+ element: el, pendingRAF: null, isFinalized: false, lastRenderTime: 0,
25
+ };
26
+ }
27
+
28
+ function getFullText(ss: StreamState): string {
29
+ if (ss.textDirty) {
30
+ ss.fullText = ss.chunks.join('');
31
+ ss.textDirty = false;
32
+ }
33
+ return ss.fullText;
20
34
  }
21
35
 
22
36
  export function appendChunk(ss: StreamState, chunk: string): void {
23
- ss.fullText += chunk;
37
+ ss.chunks.push(chunk);
38
+ ss.textDirty = true;
39
+
24
40
  if (!ss.pendingRAF && !ss.isFinalized) {
25
41
  ss.pendingRAF = requestAnimationFrame(() => {
26
42
  ss.pendingRAF = null;
27
43
  if (ss.isFinalized) return;
28
44
  const now = performance.now();
29
- if (ss.fullText.length < FULL_RENDER_THRESHOLD || now - ss.lastRenderTime > THROTTLE_MS) {
30
- ss.element.innerHTML = renderMarkdown(ss.fullText, true) +
45
+ const text = getFullText(ss);
46
+ if (text.length < FULL_RENDER_THRESHOLD || now - ss.lastRenderTime > THROTTLE_MS) {
47
+ ss.element.innerHTML = renderMarkdown(text, true) +
31
48
  '<span class="stream-cursor" aria-hidden="true"></span>';
32
49
  ss.lastRenderTime = now;
33
50
  } else {
34
- // Throttled — schedule trailing render to ensure latest text paints
35
51
  ss.pendingRAF = requestAnimationFrame(() => {
36
52
  ss.pendingRAF = null;
37
53
  if (ss.isFinalized) return;
38
- ss.element.innerHTML = renderMarkdown(ss.fullText, true) +
54
+ ss.element.innerHTML = renderMarkdown(getFullText(ss), true) +
39
55
  '<span class="stream-cursor" aria-hidden="true"></span>';
40
56
  ss.lastRenderTime = performance.now();
41
57
  });
@@ -44,12 +60,19 @@ export function appendChunk(ss: StreamState, chunk: string): void {
44
60
  }
45
61
  }
46
62
 
47
- export function finalizeStream(ss: StreamState): string {
63
+ /**
64
+ * Finalize streaming session. Returns accumulated text.
65
+ * @param skipRender true when caller (finalizeAgent) will do its own full render.
66
+ */
67
+ export function finalizeStream(ss: StreamState, skipRender = false): string {
48
68
  ss.isFinalized = true;
49
69
  if (ss.pendingRAF) {
50
70
  cancelAnimationFrame(ss.pendingRAF);
51
71
  ss.pendingRAF = null;
52
72
  }
53
- ss.element.innerHTML = renderMarkdown(ss.fullText);
54
- return ss.fullText;
73
+ const text = getFullText(ss);
74
+ if (!skipRender) {
75
+ ss.element.innerHTML = renderMarkdown(text);
76
+ }
77
+ return text;
55
78
  }
package/public/js/ui.ts CHANGED
@@ -85,8 +85,7 @@ export function updateQueueBadge(count: number): void {
85
85
  function showSkeleton(): void {
86
86
  const container = document.getElementById('chatMessages');
87
87
  if (!container || container.querySelector('.skeleton-msg')) return;
88
- const vs = getVirtualScroll();
89
- if (vs.active) vs.flushToDOM();
88
+ // No flushToDOM — skeleton goes directly into container as overlay
90
89
  hideEmptyState();
91
90
  const skel = document.createElement('div');
92
91
  skel.className = 'skeleton-msg';
@@ -236,7 +235,7 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
236
235
  }
237
236
  const content = (state.currentAgentDiv as HTMLElement)?.querySelector('.msg-content');
238
237
  // Live stream is preview-only; agent_done text stays authoritative.
239
- const streamedText = currentStream ? finalizeStream(currentStream) : '';
238
+ const streamedText = currentStream ? finalizeStream(currentStream, true) : '';
240
239
  const finalText = text || streamedText;
241
240
  currentStream = null;
242
241
  // Skip static tool HTML when process block already shows tool summary
@@ -244,6 +243,13 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
244
243
  if (content) content.innerHTML = toolHtml + renderMarkdown(finalText);
245
244
  if (content) content.setAttribute('data-raw', stripOrchestration(finalText));
246
245
  if (content) activateWidgets(content as HTMLElement);
246
+
247
+ // Promote streaming div from real DOM into VS if active
248
+ const vs = getVirtualScroll();
249
+ if (vs.active && state.currentAgentDiv && state.currentAgentDiv.isConnected) {
250
+ vs.appendLiveItem(state.currentAgentDiv);
251
+ state.currentAgentDiv.remove();
252
+ }
247
253
  }
248
254
  currentStream = null;
249
255
  state.currentAgentDiv = null;
@@ -257,7 +263,6 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
257
263
  export function addMessage(role: string, text: string, cli?: string | null): HTMLDivElement {
258
264
  const container = document.getElementById('chatMessages');
259
265
  const vs = getVirtualScroll();
260
- if (vs.active) vs.flushToDOM();
261
266
  hideEmptyState();
262
267
  removeSkeleton();
263
268
 
@@ -274,8 +279,17 @@ export function addMessage(role: string, text: string, cli?: string | null): HTM
274
279
  }
275
280
  const contentEl = div.querySelector('.msg-content');
276
281
  if (contentEl) contentEl.setAttribute('data-raw', stripOrchestration(text));
277
- container?.appendChild(div);
278
- activateWidgets(div);
282
+
283
+ // Streaming placeholder (agent + empty text) must stay in real DOM
284
+ // so state.currentAgentDiv reference remains valid during streaming.
285
+ const isStreamingPlaceholder = role === 'agent' && !text;
286
+
287
+ if (vs.active && !isStreamingPlaceholder) {
288
+ vs.appendLiveItem(div);
289
+ } else {
290
+ container?.appendChild(div);
291
+ activateWidgets(div);
292
+ }
279
293
  scrollToBottom();
280
294
  return div;
281
295
  }
@@ -337,22 +351,40 @@ export async function loadMessages(): Promise<void> {
337
351
  if (chatEl) chatEl.innerHTML = '';
338
352
 
339
353
  if (msgs.length >= VS_THRESHOLD) {
354
+ // Phase 2: lazy render — store skeleton HTML, render on viewport entry
340
355
  for (const m of msgs) {
341
356
  const role = m.role === 'assistant' ? 'agent' : m.role;
342
- const rendered = renderMarkdown(m.content);
357
+ const rawContent = stripOrchestration(m.content);
343
358
  const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
344
359
  const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
345
360
  const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
361
+ // Skeleton placeholder — lazy-pending class triggers render on viewport entry
362
+ const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
346
363
  const html = role === 'agent'
347
- ? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content" data-raw="${escapeHtml(stripOrchestration(m.content))}">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
348
- : `<div class="msg msg-${role}"><div class="msg-label">${label}</div><div class="msg-content" data-raw="${escapeHtml(stripOrchestration(m.content))}">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div>`;
364
+ ? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
365
+ : `<div class="msg msg-${role}"><div class="msg-label">${label}</div><div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div>`;
349
366
  vs.addItem(crypto.randomUUID(), html);
350
367
  }
368
+
369
+ // Register lazy render callback
370
+ vs.onLazyRender = (targets: HTMLElement[]) => {
371
+ for (const el of targets) {
372
+ if (!el.classList.contains('lazy-pending')) continue;
373
+ const raw = el.getAttribute('data-raw') || '';
374
+ el.innerHTML = raw ? renderMarkdown(raw) : '';
375
+ el.classList.remove('lazy-pending');
376
+ activateWidgets(el);
377
+
378
+ // Persist rendered HTML back into VS cache
379
+ const msgEl = el.closest('[data-vs-idx]') as HTMLElement | null;
380
+ if (msgEl) {
381
+ const idx = Number(msgEl.dataset.vsIdx);
382
+ vs.updateItemHtml(idx, msgEl.outerHTML);
383
+ }
384
+ }
385
+ };
386
+
351
387
  vs.scrollToBottom();
352
- requestAnimationFrame(() => {
353
- const chatElRef = document.getElementById('chatMessages');
354
- if (chatElRef) activateWidgets(chatElRef);
355
- });
356
388
  } else {
357
389
  msgs.forEach(m => {
358
390
  const div = addMessage(m.role === 'assistant' ? 'agent' : m.role, m.content, m.cli);
@@ -2,7 +2,7 @@
2
2
  // Activates at THRESHOLD messages to prevent DOM bloat
3
3
  // Below threshold: standard DOM append (zero overhead)
4
4
 
5
- const THRESHOLD = 200;
5
+ const THRESHOLD = 80;
6
6
  const BUFFER = 5;
7
7
  const EST_HEIGHT = 80;
8
8
 
@@ -12,6 +12,8 @@ export interface VirtualItem {
12
12
  height: number;
13
13
  }
14
14
 
15
+ export type LazyRenderCallback = (targets: HTMLElement[]) => void;
16
+
15
17
  export class VirtualScroll {
16
18
  private items: VirtualItem[] = [];
17
19
  private container: HTMLElement;
@@ -19,10 +21,14 @@ export class VirtualScroll {
19
21
  private spacerBottom: HTMLDivElement;
20
22
  private viewport: HTMLDivElement;
21
23
  private _active = false;
24
+ private _totalHeight = 0;
22
25
  private rafId: number | null = null;
23
26
  private firstVisible = 0;
24
27
  private lastVisible = 0;
25
28
 
29
+ /** Called after render() mounts items in viewport — for lazy rendering */
30
+ onLazyRender: LazyRenderCallback | null = null;
31
+
26
32
  constructor(containerId: string) {
27
33
  this.container = document.getElementById(containerId)!;
28
34
  this.spacerTop = document.createElement('div');
@@ -37,22 +43,23 @@ export class VirtualScroll {
37
43
  get count(): number { return this.items.length; }
38
44
 
39
45
  /** Flush all virtual items to real DOM and deactivate VS.
40
- * Called before live message append to prevent spacer/DOM conflicts. */
46
+ * Called on conversation clear or explicit reset. */
41
47
  flushToDOM(): void {
42
48
  if (!this._active) return;
43
49
  this.container.removeEventListener('scroll', this.scrollHandler);
44
50
  if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
45
- // Render all items as real DOM nodes
46
51
  this.container.innerHTML = this.items.map(it => it.html).join('');
47
52
  this._active = false;
48
53
  this.firstVisible = 0;
49
54
  this.lastVisible = 0;
50
- // Release items to free memory (DOM now owns the content)
51
55
  this.items = [];
56
+ this._totalHeight = 0;
52
57
  }
53
58
 
54
59
  addItem(id: string, html: string): void {
55
- this.items.push({ id, html, height: EST_HEIGHT });
60
+ const item: VirtualItem = { id, html, height: EST_HEIGHT };
61
+ this.items.push(item);
62
+ this._totalHeight += EST_HEIGHT;
56
63
  if (!this._active && this.items.length >= THRESHOLD) {
57
64
  this.activate();
58
65
  }
@@ -61,18 +68,38 @@ export class VirtualScroll {
61
68
  }
62
69
  }
63
70
 
71
+ /** Append a live DOM element while keeping VS active.
72
+ * Serializes to HTML for virtual storage. */
73
+ appendLiveItem(div: HTMLElement): void {
74
+ if (!this._active) return;
75
+ const html = div.outerHTML;
76
+ const id = crypto.randomUUID();
77
+ const item: VirtualItem = { id, html, height: EST_HEIGHT };
78
+ this.items.push(item);
79
+ this._totalHeight += EST_HEIGHT;
80
+ this.scheduleRender();
81
+ this.scrollToBottom();
82
+ }
83
+
84
+ /** Update cached HTML for a specific item index (used by lazy render). */
85
+ updateItemHtml(idx: number, html: string): void {
86
+ if (this.items[idx]) {
87
+ this.items[idx].html = html;
88
+ }
89
+ }
90
+
64
91
  private scrollHandler = () => this.scheduleRender();
65
92
 
66
93
  private activate(): void {
67
94
  this._active = true;
68
- // Measure existing DOM nodes
95
+ this._totalHeight = 0;
69
96
  const existing = this.container.querySelectorAll('.msg');
70
97
  existing.forEach((el, i) => {
71
98
  if (this.items[i]) {
72
99
  this.items[i].height = el.getBoundingClientRect().height;
100
+ this._totalHeight += this.items[i].height;
73
101
  }
74
102
  });
75
- // Replace DOM with virtual structure
76
103
  this.container.innerHTML = '';
77
104
  this.container.append(this.spacerTop, this.viewport, this.spacerBottom);
78
105
  this.container.addEventListener('scroll', this.scrollHandler, { passive: true });
@@ -91,7 +118,6 @@ export class VirtualScroll {
91
118
  const scrollTop = this.container.scrollTop;
92
119
  const viewHeight = this.container.clientHeight;
93
120
 
94
- // Binary-ish search for start index
95
121
  let accum = 0;
96
122
  let startIdx = 0;
97
123
  for (let i = 0; i < this.items.length; i++) {
@@ -112,12 +138,10 @@ export class VirtualScroll {
112
138
  }
113
139
  const last = Math.min(this.items.length - 1, endIdx + BUFFER);
114
140
 
115
- // Skip re-render if range unchanged
116
141
  if (first === this.firstVisible && last === this.lastVisible) return;
117
142
  this.firstVisible = first;
118
143
  this.lastVisible = last;
119
144
 
120
- // Compute spacer heights
121
145
  let topSpace = 0;
122
146
  for (let i = 0; i < first; i++) topSpace += this.items[i].height;
123
147
  let bottomSpace = 0;
@@ -126,7 +150,6 @@ export class VirtualScroll {
126
150
  this.spacerTop.style.height = `${topSpace}px`;
127
151
  this.spacerBottom.style.height = `${bottomSpace}px`;
128
152
 
129
- // Render visible items
130
153
  const frag = document.createDocumentFragment();
131
154
  for (let i = first; i <= last; i++) {
132
155
  const item = this.items[i];
@@ -141,26 +164,36 @@ export class VirtualScroll {
141
164
  this.viewport.innerHTML = '';
142
165
  this.viewport.appendChild(frag);
143
166
 
144
- // Re-measure rendered heights
167
+ // Re-measure rendered heights and update totalHeight
145
168
  this.viewport.querySelectorAll('[data-vs-idx]').forEach(el => {
146
169
  const idx = Number((el as HTMLElement).dataset.vsIdx);
147
170
  if (this.items[idx]) {
148
- this.items[idx].height = el.getBoundingClientRect().height;
171
+ const oldH = this.items[idx].height;
172
+ const newH = el.getBoundingClientRect().height;
173
+ this.items[idx].height = newH;
174
+ this._totalHeight += (newH - oldH);
149
175
  }
150
176
  });
177
+
178
+ // Fire lazy render callback for newly visible items
179
+ if (this.onLazyRender) {
180
+ const lazyTargets = this.viewport.querySelectorAll<HTMLElement>('.lazy-pending');
181
+ if (lazyTargets.length > 0) {
182
+ this.onLazyRender(Array.from(lazyTargets));
183
+ }
184
+ }
151
185
  }
152
186
 
153
187
  scrollToBottom(): void {
154
- const total = this.items.reduce((sum, it) => sum + it.height, 0);
155
- this.container.scrollTop = total;
188
+ this.container.scrollTop = this._totalHeight;
156
189
  this.scheduleRender();
157
190
  }
158
191
 
159
192
  clear(): void {
160
193
  this.items = [];
194
+ this._totalHeight = 0;
161
195
  if (this._active) {
162
196
  this.container.removeEventListener('scroll', this.scrollHandler);
163
- // Restore normal DOM structure
164
197
  this.viewport.innerHTML = '';
165
198
  this.spacerTop.style.height = '0';
166
199
  this.spacerBottom.style.height = '0';
@@ -169,6 +202,7 @@ export class VirtualScroll {
169
202
  this._active = false;
170
203
  this.firstVisible = 0;
171
204
  this.lastVisible = 0;
205
+ this.onLazyRender = null;
172
206
  if (this.rafId) {
173
207
  cancelAnimationFrame(this.rafId);
174
208
  this.rafId = null;
package/public/js/ws.ts CHANGED
@@ -4,7 +4,7 @@ import { setStatus, updateQueueBadge, addSystemMsg, appendAgentText, finalizeAge
4
4
  import { t, getLang } from './features/i18n.js';
5
5
  import { getVirtualScroll } from './virtual-scroll.js';
6
6
  import { ICONS, emojiToIcon } from './icons.js';
7
- import { escapeHtml } from './render.js';
7
+ import { escapeHtml, cancelPostRender } from './render.js';
8
8
  import type { OrcStateName } from './state.js';
9
9
 
10
10
  const ROADMAP_PHASES = ['P', 'A', 'B', 'C'] as const;
@@ -258,6 +258,7 @@ export function connect(): void {
258
258
  } else if (msg.type === 'orchestrate_done') {
259
259
  finalizeAgent(msg.text || '');
260
260
  } else if (msg.type === 'clear') {
261
+ cancelPostRender();
261
262
  cleanupToolActivity();
262
263
  getVirtualScroll().clear();
263
264
  const el = document.getElementById('chatMessages');
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-native-modules.cjs
4
+ *
5
+ * Verifies that native addons needed for local runtime are loadable.
6
+ * If better-sqlite3 exists but was built against a different Node ABI,
7
+ * rebuild it in-place before build/test steps continue.
8
+ */
9
+ const { execFileSync } = require('child_process');
10
+
11
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
12
+
13
+ function loadBetterSqlite3() {
14
+ try {
15
+ require('better-sqlite3');
16
+ return null;
17
+ } catch (error) {
18
+ return error;
19
+ }
20
+ }
21
+
22
+ function isRecoverableNativeMismatch(error) {
23
+ const text = String(error && (error.stack || error.message || error));
24
+ return (
25
+ text.includes('better_sqlite3.node') ||
26
+ text.includes('better-sqlite3') ||
27
+ text.includes('NODE_MODULE_VERSION') ||
28
+ text.includes('ERR_DLOPEN_FAILED')
29
+ );
30
+ }
31
+
32
+ const firstError = loadBetterSqlite3();
33
+ if (!firstError) process.exit(0);
34
+
35
+ if (!isRecoverableNativeMismatch(firstError)) {
36
+ console.error('[jaw:native] better-sqlite3 load failed with a non-recoverable error.');
37
+ console.error(firstError);
38
+ process.exit(1);
39
+ }
40
+
41
+ console.warn('[jaw:native] better-sqlite3 load failed — rebuilding native module for current Node runtime...');
42
+ execFileSync(npmBin, ['rebuild', 'better-sqlite3'], {
43
+ stdio: 'inherit',
44
+ cwd: process.cwd(),
45
+ });
46
+
47
+ const secondError = loadBetterSqlite3();
48
+ if (secondError) {
49
+ console.error('[jaw:native] better-sqlite3 is still not loadable after rebuild.');
50
+ console.error(secondError);
51
+ process.exit(1);
52
+ }
53
+
54
+ console.log('[jaw:native] better-sqlite3 is ready for this Node runtime.');
@@ -0,0 +1,96 @@
1
+ param(
2
+ [switch]$Force,
3
+ [switch]$Update,
4
+ [switch]$Upstream,
5
+ [string]$Repo = $env:OFFICECLI_REPO
6
+ )
7
+
8
+ $ErrorActionPreference = "Stop"
9
+
10
+ function Write-Info($msg) { Write-Host "▸ $msg" -ForegroundColor Cyan }
11
+ function Write-Ok($msg) { Write-Host "✔ $msg" -ForegroundColor Green }
12
+ function Write-Warn($msg) { Write-Host "⚠ $msg" -ForegroundColor Yellow }
13
+ function Fail($msg) { Write-Host "✖ $msg" -ForegroundColor Red; exit 1 }
14
+
15
+ if ($Upstream -and $env:OFFICECLI_REPO) {
16
+ Fail "--upstream cannot be combined with OFFICECLI_REPO"
17
+ }
18
+
19
+ if (-not $Repo) {
20
+ $Repo = "lidge-jun/OfficeCLI"
21
+ }
22
+ if ($Upstream) {
23
+ $Repo = "iOfficeAI/OfficeCLI"
24
+ }
25
+
26
+ $installDir = Join-Path $env:LOCALAPPDATA "OfficeCli"
27
+ $targetBin = Join-Path $installDir "officecli.exe"
28
+ $downloadBin = Join-Path $installDir "officecli.download.exe"
29
+
30
+ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
31
+ switch ($arch) {
32
+ "x64" { $asset = "officecli-win-x64.exe" }
33
+ "arm64" { $asset = "officecli-win-arm64.exe" }
34
+ default { Fail "Unsupported Windows architecture: $arch" }
35
+ }
36
+
37
+ function Normalize-Version([string]$version) {
38
+ if (-not $version) { return "" }
39
+ return $version.Trim().TrimStart('v')
40
+ }
41
+
42
+ function Get-LatestTag([string]$repoName) {
43
+ $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoName/releases/latest" -Headers @{ "User-Agent" = "cli-jaw-postinstall" }
44
+ return [string]$release.tag_name
45
+ }
46
+
47
+ Write-Info "Platform: win32/$arch -> $asset"
48
+
49
+ if ((Test-Path $targetBin) -and -not $Force -and -not $Update) {
50
+ try {
51
+ $current = & $targetBin --version 2>$null
52
+ Write-Ok "officecli already installed: v$($current.Trim())"
53
+ Write-Host " Use -Force to reinstall or -Update to refresh only when outdated"
54
+ exit 0
55
+ } catch {
56
+ Write-Warn "Existing officecli is not executable; reinstalling"
57
+ }
58
+ }
59
+
60
+ if ((Test-Path $targetBin) -and -not $Force -and $Update) {
61
+ $current = ""
62
+ $latest = ""
63
+ try { $current = & $targetBin --version 2>$null } catch { }
64
+ try { $latest = Get-LatestTag $Repo } catch { }
65
+ if ($current -and $latest -and (Normalize-Version $current) -eq (Normalize-Version $latest)) {
66
+ Write-Ok "officecli already up to date: v$(Normalize-Version $current)"
67
+ exit 0
68
+ }
69
+ if ($current -and $latest) {
70
+ Write-Info "Updating officecli v$(Normalize-Version $current) -> v$(Normalize-Version $latest)"
71
+ } else {
72
+ Write-Warn "Could not compare installed version with latest release; reinstalling"
73
+ }
74
+ }
75
+
76
+ New-Item -ItemType Directory -Force -Path $installDir | Out-Null
77
+ $downloadUrl = "https://github.com/$Repo/releases/latest/download/$asset"
78
+
79
+ Write-Info "Downloading officecli from $Repo..."
80
+ Invoke-WebRequest -Uri $downloadUrl -OutFile $downloadBin
81
+
82
+ try {
83
+ $version = (& $downloadBin --version 2>$null).Trim()
84
+ } catch {
85
+ Remove-Item -Force $downloadBin -ErrorAction SilentlyContinue
86
+ Fail "Binary exists but won't execute"
87
+ }
88
+
89
+ Move-Item -Force $downloadBin $targetBin
90
+ Write-Ok "officecli v$version installed -> $targetBin"
91
+
92
+ $pathEntries = (($env:PATH -split ';') | ForEach-Object { $_.Trim() }) | Where-Object { $_ }
93
+ if (-not ($pathEntries -contains $installDir)) {
94
+ Write-Warn "officecli is not on PATH. Add this location if you want to run it directly:"
95
+ Write-Host " $installDir"
96
+ }