@jhizzard/termdeck 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -472,6 +472,14 @@
472
472
  // /api/sessions/:id/input, and reply-form targets are unaffected.
473
473
  setupPanelDragDrop(panel);
474
474
 
475
+ // Sprint 72 T3 — web-chat (Grok) panels render a live screencast canvas +
476
+ // an inject input box instead of an xterm. Branch AFTER the shared chrome
477
+ // (header/meta/drawer) is built, then return; the entire xterm path below
478
+ // stays byte-identical for every other panel type.
479
+ if (meta.type === 'web-chat') {
480
+ return mountWebChatPanel(id, sessionData, panel);
481
+ }
482
+
475
483
  // Create xterm.js instance
476
484
  const terminal = new Terminal({
477
485
  fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
@@ -653,6 +661,297 @@
653
661
  return { terminal, ws, fitAddon };
654
662
  }
655
663
 
664
+ // ===== Sprint 72 T3: web-chat (Grok) canvas panel =====
665
+ // A web-chat panel mirrors a real, logged-in headful grok.com tab: T1's CDP
666
+ // screencast frames paint to a <canvas>; an input box injects a prompt into
667
+ // the live composer (T2 routes {type:'input'} → grok.inject); and canvas
668
+ // mouse/wheel events forward back to the tab ({type:'web-chat-input'} →
669
+ // handle.sendInput) so the human can drive it in-deck. The xterm render path
670
+ // (createTerminalPanel, above) is untouched for every non-web-chat panel.
671
+
672
+ function ensureWebChatStyles() {
673
+ if (document.getElementById('wc-styles')) return;
674
+ const style = document.createElement('style');
675
+ style.id = 'wc-styles';
676
+ style.textContent = [
677
+ '.panel-terminal.web-chat-terminal{display:flex;flex-direction:column;height:100%;min-height:0;background:#000;}',
678
+ '.web-chat-stage{flex:1;min-height:0;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#000;}',
679
+ '.web-chat-canvas{display:block;max-width:100%;max-height:100%;outline:none;}',
680
+ '.web-chat-input-bar{display:flex;gap:6px;padding:6px;border-top:1px solid #1f2335;background:#16161e;}',
681
+ '.web-chat-input{flex:1;min-width:0;padding:6px 8px;border:1px solid #1f2335;border-radius:6px;background:#1a1b26;color:#c0caf5;font:13px/1.3 inherit;}',
682
+ '.web-chat-input:focus{outline:none;border-color:#7aa2f7;}',
683
+ '.web-chat-send{padding:6px 14px;border:0;border-radius:6px;background:#7aa2f7;color:#0b0b14;font-weight:600;cursor:pointer;}',
684
+ '.web-chat-send:hover{background:#8fb3ff;}',
685
+ ].join('');
686
+ document.head.appendChild(style);
687
+ }
688
+
689
+ // Build the two WS frames that drive T2's server-side two-stage assembler
690
+ // (routeWebChatInput): (1) the prompt wrapped in bracketed-paste markers —
691
+ // buffered server-side after the markers are stripped — then (2) a lone CR,
692
+ // the submit signal that fires grok.inject on the accumulated text. The
693
+ // wrapper is what makes a MULTI-LINE prompt safe: the server submits on a
694
+ // TRAILING CR, so a naive `text + '\r'` would submit early on an embedded
695
+ // newline. Same shape as the 4+1 orchestrator inject. Pure (vm-extract tested).
696
+ function webChatSubmitFrames(text) {
697
+ return [
698
+ { type: 'input', data: '\x1b[200~' + String(text) + '\x1b[201~' },
699
+ { type: 'input', data: '\r' },
700
+ ];
701
+ }
702
+
703
+ function mountWebChatPanel(id, sessionData, panel) {
704
+ const meta = sessionData.meta;
705
+ ensureWebChatStyles();
706
+
707
+ const container = document.getElementById(`term-${id}`);
708
+ container.classList.add('web-chat-terminal');
709
+ container.innerHTML =
710
+ `<div class="web-chat-stage" id="wc-stage-${id}">` +
711
+ `<canvas class="web-chat-canvas" id="wc-canvas-${id}" tabindex="0"></canvas>` +
712
+ `</div>` +
713
+ `<form class="web-chat-input-bar" id="wc-form-${id}" autocomplete="off">` +
714
+ `<input type="text" class="web-chat-input" id="wc-input-${id}" placeholder="Message Grok… (Enter sends into the live session)">` +
715
+ `<button type="submit" class="web-chat-send" id="wc-send-${id}">send</button>` +
716
+ `</form>`;
717
+
718
+ const stage = document.getElementById(`wc-stage-${id}`);
719
+ const canvas = document.getElementById(`wc-canvas-${id}`);
720
+ const ctx = canvas.getContext('2d');
721
+ const inputEl = document.getElementById(`wc-input-${id}`);
722
+ const formEl = document.getElementById(`wc-form-${id}`);
723
+
724
+ let lastImg = null, lastDevW = 0, lastDevH = 0, map = null;
725
+
726
+ // Paint one screencast frame. T1's frame-channel shape exposes `dataUrl`
727
+ // (drops straight into Image.src) + deviceWidth/deviceHeight (the page
728
+ // viewport in CSS px, needed to map clicks back to page coordinates).
729
+ function paintFrame(frame) {
730
+ if (!frame) return;
731
+ const url = frame.dataUrl || (frame.data ? `data:image/${frame.format || 'jpeg'};base64,${frame.data}` : null);
732
+ if (!url) return;
733
+ const img = new Image();
734
+ img.onload = () => {
735
+ lastImg = img;
736
+ lastDevW = frame.deviceWidth || img.naturalWidth;
737
+ lastDevH = frame.deviceHeight || img.naturalHeight;
738
+ drawLastFrame();
739
+ };
740
+ img.src = url;
741
+ }
742
+
743
+ // Letterbox the last frame into the canvas, tracking the transform so
744
+ // pointer events can be mapped back to page coordinates.
745
+ function drawLastFrame() {
746
+ if (!lastImg) return;
747
+ const rect = stage.getBoundingClientRect();
748
+ const dpr = window.devicePixelRatio || 1;
749
+ const cw = Math.max(1, Math.round(rect.width)), ch = Math.max(1, Math.round(rect.height));
750
+ if (canvas.width !== Math.round(cw * dpr) || canvas.height !== Math.round(ch * dpr)) {
751
+ canvas.width = Math.round(cw * dpr);
752
+ canvas.height = Math.round(ch * dpr);
753
+ canvas.style.width = cw + 'px';
754
+ canvas.style.height = ch + 'px';
755
+ }
756
+ const iw = lastImg.naturalWidth, ih = lastImg.naturalHeight;
757
+ const scale = Math.min(canvas.width / iw, canvas.height / ih);
758
+ const dw = iw * scale, dh = ih * scale;
759
+ const dx = (canvas.width - dw) / 2, dy = (canvas.height - dh) / 2;
760
+ ctx.fillStyle = '#000';
761
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
762
+ ctx.drawImage(lastImg, dx, dy, dw, dh);
763
+ map = { dx, dy, scale, iw, ih, dpr };
764
+ }
765
+
766
+ // canvas client px → page CSS px (or null if the click hit the letterbox).
767
+ function mapToPage(clientX, clientY) {
768
+ if (!map || !lastDevW || !lastDevH) return null;
769
+ const rect = canvas.getBoundingClientRect();
770
+ const px = (clientX - rect.left) * map.dpr, py = (clientY - rect.top) * map.dpr;
771
+ const ix = (px - map.dx) / map.scale, iy = (py - map.dy) / map.scale;
772
+ if (ix < 0 || iy < 0 || ix > map.iw || iy > map.ih) return null;
773
+ return { x: Math.round(ix * (lastDevW / map.iw)), y: Math.round(iy * (lastDevH / map.ih)) };
774
+ }
775
+
776
+ function liveWs() { const e = state.sessions.get(id); return e && e.ws; }
777
+ // Submit a composer prompt via T2's two-stage assembler (bracketed-paste
778
+ // body buffered, then a lone CR fires grok.inject). NOT a single raw
779
+ // {type:'input',data:text} — that has no submit sentinel and buffers
780
+ // server-side forever (T4 FINDING 13:30).
781
+ function sendComposerSubmit(text) {
782
+ const ws = liveWs();
783
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
784
+ for (const frame of webChatSubmitFrames(text)) ws.send(JSON.stringify(frame));
785
+ return true;
786
+ }
787
+ function sendCdpEvent(event) {
788
+ const ws = liveWs();
789
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'web-chat-input', event }));
790
+ }
791
+
792
+ // Input box → submit the prompt into the live composer via the two-stage
793
+ // assembler. Clearing the field is gated on the WS send so a prompt is
794
+ // never silently lost when the socket is down.
795
+ formEl.addEventListener('submit', (e) => {
796
+ e.preventDefault();
797
+ const text = inputEl.value;
798
+ if (!text.trim()) return;
799
+ if (sendComposerSubmit(text)) inputEl.value = '';
800
+ });
801
+ inputEl.addEventListener('focus', () => { panel.classList.add('active-input'); state.focusedId = id; });
802
+ inputEl.addEventListener('blur', () => { panel.classList.remove('active-input'); });
803
+
804
+ // Canvas → forward clicks + wheel to the live tab so the human can drive
805
+ // it in-deck. (Text typing stays in the input box for v1; full keyboard
806
+ // forwarding is a future enhancement to avoid hijacking deck shortcuts.)
807
+ canvas.addEventListener('mousedown', (e) => {
808
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
809
+ canvas.focus(); state.focusedId = id;
810
+ sendCdpEvent({ kind: 'mouse', type: 'mousePressed', x: pt.x, y: pt.y, button: 'left', clickCount: 1 });
811
+ });
812
+ canvas.addEventListener('mouseup', (e) => {
813
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
814
+ sendCdpEvent({ kind: 'mouse', type: 'mouseReleased', x: pt.x, y: pt.y, button: 'left', clickCount: 1 });
815
+ });
816
+ canvas.addEventListener('wheel', (e) => {
817
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
818
+ e.preventDefault();
819
+ sendCdpEvent({ kind: 'mouse', type: 'mouseWheel', x: pt.x, y: pt.y, deltaX: e.deltaX, deltaY: e.deltaY });
820
+ }, { passive: false });
821
+
822
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
823
+ ws.onmessage = (event) => handleWebChatWsMessage(id, event, paintFrame);
824
+ ws.onclose = (event) => {
825
+ const e2 = state.sessions.get(id); if (!e2) return;
826
+ if (event.code === 4000 || event.code === 4001) return;
827
+ const p = document.getElementById(`panel-${id}`);
828
+ if (p && p.classList.contains('exited')) return;
829
+ const delay = Math.min(1000 * Math.pow(2, (e2._reconnectAttempts || 0)), 10000);
830
+ e2._reconnectAttempts = (e2._reconnectAttempts || 0) + 1;
831
+ if (e2._reconnectAttempts <= 5) setTimeout(() => reconnectWebChat(id), delay);
832
+ else updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
833
+ };
834
+
835
+ // Re-letterbox on panel resize (fitAll() also calls our fitAddon.fit()).
836
+ const resizeObserver = new ResizeObserver(() => { try { drawLastFrame(); } catch (err) { /* ignore */ } });
837
+ resizeObserver.observe(stage);
838
+
839
+ state.sessions.set(id, {
840
+ session: sessionData,
841
+ ws,
842
+ el: panel,
843
+ isWebChat: true,
844
+ canvas, ctx, inputEl,
845
+ paintFrame, drawLastFrame,
846
+ // No xterm. A no-op-ish fitAddon keeps fitAll()/drawer-resize loops safe
847
+ // AND re-letterboxes the canvas on a global re-fit.
848
+ fitAddon: { fit() { drawLastFrame(); } },
849
+ activeTab: 'overview',
850
+ drawerOpen: false,
851
+ commandHistory: [],
852
+ commandsLoaded: false,
853
+ memoryHits: [],
854
+ statusLog: [],
855
+ webChatLog: [],
856
+ lastKnownStatus: meta.status,
857
+ });
858
+
859
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
860
+ setupDrawerListeners(id);
861
+ renderOverviewTab(id);
862
+ renderSwitcher();
863
+ const replyBtn = document.getElementById(`reply-btn-${id}`);
864
+ if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
865
+ refreshAllReplyFormsFor(id);
866
+ refreshPanelIndices();
867
+ revealNewPanelIfFiltered(meta);
868
+ refreshDashboardChrome();
869
+ requestAnimationFrame(() => drawLastFrame());
870
+
871
+ return { ws, canvas };
872
+ }
873
+
874
+ // Shared WS dispatch for web-chat panels (used by both initial mount and
875
+ // reconnect). Mirrors the xterm handler's non-output cases exactly (same
876
+ // downstream fns) but routes screencast frames → canvas and grok text → an
877
+ // in-memory transcript instead of writing to an xterm.
878
+ function handleWebChatWsMessage(id, event, paintFrame) {
879
+ let msg;
880
+ try { msg = JSON.parse(event.data); }
881
+ catch (err) { console.error('[client] web-chat ws parse failed:', err); return; }
882
+ const entry = state.sessions.get(id);
883
+ switch (msg.type) {
884
+ case 'web-chat-frame':
885
+ if (paintFrame) paintFrame(msg.frame);
886
+ else if (entry && entry.paintFrame) entry.paintFrame(msg.frame);
887
+ break;
888
+ case 'output':
889
+ // Grok response text. The canvas is the visual; keep a transcript for
890
+ // potential future drawer rendering. Never write to a (nonexistent) xterm.
891
+ if (entry) { (entry.webChatLog = entry.webChatLog || []).push(msg.data); }
892
+ break;
893
+ case 'meta':
894
+ updatePanelMeta(id, msg.session.meta);
895
+ break;
896
+ case 'proactive_memory':
897
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
898
+ break;
899
+ case 'exit': {
900
+ updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
901
+ const p = document.getElementById(`panel-${id}`);
902
+ if (p) p.classList.add('exited');
903
+ refreshAllReplyFormsFor(id);
904
+ refreshPanelIndices();
905
+ renderSwitcher();
906
+ break;
907
+ }
908
+ case 'panel_exited':
909
+ handlePanelExited(msg.sessionId, msg.exitCode);
910
+ break;
911
+ case 'status_broadcast':
912
+ updateGlobalStats(msg.sessions);
913
+ break;
914
+ case 'config_changed':
915
+ if (msg.config) {
916
+ state.config = { ...state.config, ...msg.config };
917
+ if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
918
+ if (typeof updateRagIndicator === 'function') updateRagIndicator();
919
+ }
920
+ break;
921
+ case 'projects_changed':
922
+ if (msg.projects && state.config) {
923
+ state.config.projects = msg.projects;
924
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
925
+ }
926
+ break;
927
+ }
928
+ }
929
+
930
+ function reconnectWebChat(id) {
931
+ const entry = state.sessions.get(id);
932
+ if (!entry) return;
933
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
934
+ ws.onmessage = (event) => handleWebChatWsMessage(id, event, entry.paintFrame);
935
+ ws.onopen = () => {
936
+ entry._reconnectAttempts = 0;
937
+ entry.ws = ws;
938
+ updatePanelMeta(id, { status: 'active', statusDetail: 'Reconnected' });
939
+ };
940
+ ws.onclose = (event) => {
941
+ const p = document.getElementById(`panel-${id}`);
942
+ if (p && p.classList.contains('exited')) return;
943
+ if (event.code === 4001) {
944
+ updatePanelMeta(id, { status: 'exited', statusDetail: 'Session ended' });
945
+ if (p) p.classList.add('exited');
946
+ return;
947
+ }
948
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
949
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
950
+ if (entry._reconnectAttempts <= 5) setTimeout(() => reconnectWebChat(id), delay);
951
+ else updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
952
+ };
953
+ }
954
+
656
955
  // ===== Sprint 65: project-filter chips + ORCH-panel pin + tile lifecycle =====
657
956
  // Brad's 2026-05-13 v2 spec (BACKLOG § D.5) — three dashboard-reliability
658
957
  // surfaces sharing one client-side lane:
@@ -1738,8 +2037,10 @@
1738
2037
  entry.el.classList.add('primary');
1739
2038
  }
1740
2039
 
1741
- // Focus the xterm textarea (without stealing pointer)
1742
- try { entry.terminal.focus(); } catch (err) { /* ignore */ }
2040
+ // Focus the xterm textarea (without stealing pointer); web-chat panels
2041
+ // focus their inject input instead.
2042
+ if (entry.terminal) { try { entry.terminal.focus(); } catch (err) { /* ignore */ } }
2043
+ else if (entry.inputEl) { try { entry.inputEl.focus(); } catch (err) { /* ignore */ } }
1743
2044
  state.focusedId = id;
1744
2045
 
1745
2046
  // Flash the panel border briefly
@@ -1833,7 +2134,7 @@
1833
2134
  setTimeout(() => {
1834
2135
  try { entry.fitAddon.fit(); } catch (err) { /* ignore */ }
1835
2136
  const ws = entry.ws;
1836
- if (ws && ws.readyState === WebSocket.OPEN) {
2137
+ if (ws && ws.readyState === WebSocket.OPEN && entry.terminal) { // PTY resize only; web-chat has no cols/rows
1837
2138
  ws.send(JSON.stringify({
1838
2139
  type: 'resize',
1839
2140
  cols: entry.terminal.cols,
@@ -2054,6 +2355,8 @@
2054
2355
  const entry = state.sessions.get(id);
2055
2356
  if (entry && entry.terminal) {
2056
2357
  try { entry.terminal.focus(); } catch (err) { /* ignore */ }
2358
+ } else if (entry && entry.inputEl) {
2359
+ try { entry.inputEl.focus(); } catch (err) { /* ignore */ } // web-chat inject box
2057
2360
  }
2058
2361
 
2059
2362
  // Re-fit all visible terminals
@@ -2164,9 +2467,9 @@
2164
2467
 
2165
2468
  const entry = state.sessions.get(id);
2166
2469
  if (entry) {
2167
- entry.terminal.dispose();
2168
- entry.ws.close();
2169
- entry.el.remove();
2470
+ if (entry.terminal) entry.terminal.dispose(); // web-chat panels have no xterm
2471
+ if (entry.ws) entry.ws.close();
2472
+ if (entry.el) entry.el.remove();
2170
2473
  state.sessions.delete(id);
2171
2474
  }
2172
2475
 
@@ -2183,7 +2486,7 @@
2183
2486
  if (!entry) return;
2184
2487
 
2185
2488
  const themeObj = getThemeObject(themeId);
2186
- entry.terminal.options.theme = themeObj;
2489
+ if (entry.terminal) entry.terminal.options.theme = themeObj; // no xterm on web-chat panels
2187
2490
 
2188
2491
  // Persist to server (writes to sessions.theme_override server-side)
2189
2492
  api('PATCH', `/api/sessions/${id}`, { theme: themeId });
@@ -2199,7 +2502,7 @@
2199
2502
  const updated = await api('PATCH', '/api/sessions/' + id, { theme: null });
2200
2503
  const resolved = updated && updated.meta && updated.meta.theme;
2201
2504
  if (!resolved) return;
2202
- entry.terminal.options.theme = getThemeObject(resolved);
2505
+ if (entry.terminal) entry.terminal.options.theme = getThemeObject(resolved);
2203
2506
  const sel = document.getElementById('theme-' + id);
2204
2507
  if (sel && sel.value !== resolved) sel.value = resolved;
2205
2508
  }
@@ -2267,9 +2570,14 @@
2267
2570
  const entry = state.sessions.get(id);
2268
2571
  if (!entry) return;
2269
2572
 
2573
+ // Sprint 72 T3 — web-chat panels have no xterm; route mnestra output to a
2574
+ // no-op writer so askAI still caches Memory-tab hits without crashing on a
2575
+ // missing terminal. (xterm panels render inline exactly as before.)
2576
+ const tw = (s) => { if (entry.terminal) { try { entry.terminal.write(s); } catch (e) { /* ignore */ } } };
2577
+
2270
2578
  // Early return if AI queries are not available
2271
2579
  if (!state.config.aiQueryAvailable) {
2272
- entry.terminal.write(
2580
+ tw(
2273
2581
  '\r\n\x1b[33m[mnestra] AI queries are not available.\x1b[0m\r\n' +
2274
2582
  '\x1b[33mTo enable, add the following to ~/.termdeck/config.yaml:\x1b[0m\r\n' +
2275
2583
  '\x1b[90m rag:\r\n' +
@@ -2292,7 +2600,7 @@
2292
2600
  });
2293
2601
 
2294
2602
  if (result.error) {
2295
- entry.terminal.write(`\r\n\x1b[33m[mnestra] ${result.error}\x1b[0m\r\n`);
2603
+ tw(`\r\n\x1b[33m[mnestra] ${result.error}\x1b[0m\r\n`);
2296
2604
  } else if (result.memories && result.memories.length > 0) {
2297
2605
  // Cache hits for the Memory tab
2298
2606
  if (!entry.memoryHits) entry.memoryHits = [];
@@ -2307,7 +2615,7 @@
2307
2615
  if (entry.drawerOpen && entry.activeTab === 'memory') {
2308
2616
  renderMemoryTab(id);
2309
2617
  }
2310
- const cols = entry.terminal.cols || 80;
2618
+ const cols = (entry.terminal && entry.terminal.cols) || 80;
2311
2619
  const wrap = (text, indent) => {
2312
2620
  const maxW = cols - indent - 2;
2313
2621
  const words = text.split(/\s+/);
@@ -2325,23 +2633,23 @@
2325
2633
  return lines;
2326
2634
  };
2327
2635
 
2328
- entry.terminal.write(`\r\n\x1b[36m━━━ Mnestra: ${result.total} memories found ━━━\x1b[0m\r\n`);
2636
+ tw(`\r\n\x1b[36m━━━ Mnestra: ${result.total} memories found ━━━\x1b[0m\r\n`);
2329
2637
  for (const m of result.memories) {
2330
2638
  const score = m.similarity ? `${(m.similarity * 100).toFixed(0)}%` : '';
2331
2639
  const proj = m.project ? m.project : '';
2332
- entry.terminal.write(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
2640
+ tw(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
2333
2641
  const contentLines = wrap(m.content || '(empty)', 2);
2334
2642
  for (const cl of contentLines) {
2335
- entry.terminal.write(`${cl}\r\n`);
2643
+ tw(`${cl}\r\n`);
2336
2644
  }
2337
2645
  }
2338
- entry.terminal.write(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
2646
+ tw(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
2339
2647
  } else {
2340
- entry.terminal.write(`\r\n\x1b[33m[mnestra] No relevant memories found.\x1b[0m\r\n`);
2648
+ tw(`\r\n\x1b[33m[mnestra] No relevant memories found.\x1b[0m\r\n`);
2341
2649
  }
2342
2650
  } catch (err) {
2343
2651
  console.error('[client] AI query failed:', err);
2344
- entry.terminal.write(`\r\n\x1b[31m[mnestra] Query failed: ${err.message}\x1b[0m\r\n`);
2652
+ tw(`\r\n\x1b[31m[mnestra] Query failed: ${err.message}\x1b[0m\r\n`);
2345
2653
  }
2346
2654
 
2347
2655
  inputEl.value = '';
@@ -3377,6 +3685,12 @@
3377
3685
 
3378
3686
  // ===== Layout =====
3379
3687
  function setLayout(layout) {
3688
+ // Sprint 67 T3: legacy `orch` layout retired (superseded by the role-tagged
3689
+ // ORCH-pin row from Sprint 65). Redirect any stale callers to `4x2` so the
3690
+ // grid still renders cleanly if `orch` arrives from older code paths.
3691
+ if (layout === 'orch') {
3692
+ layout = '4x2';
3693
+ }
3380
3694
  const wasControl = state.layout === 'control';
3381
3695
  // Only persist "real" grid layouts as state.layout; the control view is
3382
3696
  // an overlay, not a target to restore to when the user hits Escape.
@@ -3385,15 +3699,7 @@
3385
3699
  }
3386
3700
  const grid = document.getElementById('termGrid');
3387
3701
  grid.className = `grid-container layout-${layout}`;
3388
-
3389
- // Orchestrator layout: set column count based on worker panels (total - 1)
3390
- if (layout === 'orch') {
3391
- const panelCount = grid.querySelectorAll('.term-panel').length;
3392
- const workerCount = Math.max(0, panelCount - 1);
3393
- grid.setAttribute('data-orch-cols', String(workerCount || panelCount));
3394
- } else {
3395
- grid.removeAttribute('data-orch-cols');
3396
- }
3702
+ grid.removeAttribute('data-orch-cols');
3397
3703
 
3398
3704
  // Remove focus/half states
3399
3705
  document.querySelectorAll('.term-panel').forEach(p => {
@@ -3449,6 +3755,7 @@
3449
3755
  'codex': 'Codex CLI',
3450
3756
  'gemini': 'Gemini CLI',
3451
3757
  'grok': 'Grok CLI',
3758
+ 'web-chat': 'Grok (web)',
3452
3759
  'python-server': 'Python Server',
3453
3760
  'one-shot': 'One-shot'
3454
3761
  };
@@ -3544,7 +3851,7 @@
3544
3851
  if (themeSelect && themeSelect.value !== meta.theme) {
3545
3852
  themeSelect.value = meta.theme;
3546
3853
  const entry = state.sessions.get(id);
3547
- if (entry) {
3854
+ if (entry && entry.terminal) { // web-chat panels have no xterm to theme
3548
3855
  entry.terminal.options.theme = getThemeObject(meta.theme);
3549
3856
  }
3550
3857
  }
@@ -3685,7 +3992,7 @@
3685
3992
  {
3686
3993
  target: '.topbar-center',
3687
3994
  title: 'Layout modes',
3688
- body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (4 workers across the top + 1 full-width orchestrator across the bottom, for 4+1 sprints), plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+7</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>7</kbd>) do the same.`,
3995
+ body: `Preset grid layouts — <kbd>1x1</kbd> through <kbd>4x4</kbd> plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+9</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>9</kbd>) cycle through them. Orchestrator panels are pinned to a dedicated row above the grid via <strong>meta.role</strong>; no special "orch" layout needed.`,
3689
3996
  },
3690
3997
  {
3691
3998
  target: '#termSwitcher',
@@ -5001,7 +5308,10 @@
5001
5308
  // the new dense presets. Topbar buttons cover every preset incl. 2x5/5x2/3x4.
5002
5309
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '0' && e.key <= '9') {
5003
5310
  e.preventDefault();
5004
- const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch', '1x2', '4x3', '4x4'];
5311
+ // Sprint 67 T3: index 6 (was `orch`, key 7) is now null — the legacy
5312
+ // orch layout is retired in favor of the role-tagged ORCH-pin row.
5313
+ // Keep the slot to preserve muscle memory on keys 8/9/0.
5314
+ const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', null, '1x2', '4x3', '4x4'];
5005
5315
  const idx = e.key === '0' ? 9 : parseInt(e.key, 10) - 1;
5006
5316
  if (layouts[idx]) setLayout(layouts[idx]);
5007
5317
  }
@@ -5016,7 +5326,8 @@
5016
5326
  : (curIdx - 1 + ids.length) % ids.length;
5017
5327
  const entry = state.sessions.get(ids[next]);
5018
5328
  if (entry) {
5019
- entry.terminal.focus();
5329
+ if (entry.terminal) entry.terminal.focus();
5330
+ else if (entry.inputEl) entry.inputEl.focus(); // web-chat: focus the inject box
5020
5331
  state.focusedId = ids[next];
5021
5332
  }
5022
5333
  }
@@ -47,7 +47,6 @@
47
47
  <button class="layout-btn" data-layout="4x3" title="12 panels — 4 cols × 3 rows">4x3</button>
48
48
  <button class="layout-btn" data-layout="3x4" title="12 panels — 3 cols × 4 rows">3x4</button>
49
49
  <button class="layout-btn" data-layout="4x4" title="16 panels">4x4</button>
50
- <button class="layout-btn" data-layout="orch" title="Orchestrator: 4 workers across top, 1 full-width orchestrator across bottom">orch</button>
51
50
  <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
52
51
  </div>
53
52
  </div>
@@ -329,27 +329,6 @@
329
329
  .grid-container.layout-3x4 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: repeat(4, 1fr); }
330
330
  .grid-container.layout-4x4 { grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr); }
331
331
 
332
- /* Orchestrator: workers across the top (60%), one full-width orchestrator
333
- panel across the bottom (40%). The last panel is always the orchestrator.
334
- JS sets a data-orch-cols attribute on the grid to match the worker count. */
335
- /* Orchestrator: 2x2 workers on top (60%), full-width orchestrator bottom (40%) */
336
- .grid-container.layout-orch {
337
- grid-template-columns: repeat(2, 1fr);
338
- grid-template-rows: 2fr 2fr 2fr;
339
- }
340
- .grid-container.layout-orch .term-panel:last-child {
341
- grid-column: 1 / -1;
342
- grid-row: 3;
343
- }
344
- /* Single panel: fill entire grid */
345
- .grid-container.layout-orch[data-orch-cols="0"] {
346
- grid-template-rows: 1fr;
347
- grid-template-columns: 1fr;
348
- }
349
- .grid-container.layout-orch[data-orch-cols="0"] .term-panel:last-child {
350
- grid-row: 1;
351
- }
352
-
353
332
  /* Focus mode: single terminal fills the grid */
354
333
  .grid-container.layout-focus { grid-template-columns: 1fr; grid-template-rows: 1fr; }
355
334
  .grid-container.layout-focus .term-panel:not(.focused) { display: none; }
@@ -3036,17 +3015,9 @@
3036
3015
  }
3037
3016
  }
3038
3017
 
3039
- /* Very narrow viewports (rare — sub-1024 widths): also collapse 2x4
3040
- and orch to something sane. 2x4 already uses 2 columns, so we just
3041
- let it scroll; orch falls back to a single-column stack. */
3018
+ /* Very narrow viewports (rare — sub-1024 widths): let dense grids scroll
3019
+ rather than truncate panels. */
3042
3020
  @media (max-width: 900px) {
3043
- .grid-container.layout-orch {
3044
- grid-template-columns: 1fr;
3045
- grid-template-rows: repeat(var(--orch-worker-rows, 3), 1fr) 1.2fr;
3046
- }
3047
- .grid-container.layout-orch .term-panel:last-child {
3048
- grid-column: 1;
3049
- }
3050
3021
  .grid-container { overflow: auto; }
3051
3022
  }
3052
3023