@jhizzard/termdeck 1.3.0 → 1.5.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.
- package/README.md +1 -1
- package/package.json +3 -5
- package/packages/cli/src/index.js +2 -2
- package/packages/client/public/app.js +645 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +136 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +111 -21
- package/packages/server/src/orchestration-preview.js +1 -1
- package/packages/server/src/session.js +46 -8
- package/packages/server/src/sprint-inject.js +2 -2
|
@@ -25,7 +25,14 @@
|
|
|
25
25
|
// works if the endpoint 404s on an older server during a rolling
|
|
26
26
|
// upgrade — Claude only, anchored binary match.
|
|
27
27
|
agentAdapters: [{ name: 'claude', sessionType: 'claude-code', binary: 'claude', costBand: 'pay-per-token' }],
|
|
28
|
-
focusedId: null
|
|
28
|
+
focusedId: null,
|
|
29
|
+
// Sprint 65 T1 — selected project-filter chip ('' = All). Hydrated from
|
|
30
|
+
// localStorage in init(); a TDZ on PROJECT_FILTER_KEY prevents reading
|
|
31
|
+
// it here in the state literal.
|
|
32
|
+
projectFilter: '',
|
|
33
|
+
// Sprint 65 T1 (c) — global terminal font size (xterm.js default 13).
|
|
34
|
+
// Hydrated from localStorage in init() (same TDZ reason as projectFilter).
|
|
35
|
+
fontSize: 13
|
|
29
36
|
};
|
|
30
37
|
|
|
31
38
|
// ===== API helpers =====
|
|
@@ -33,11 +40,25 @@
|
|
|
33
40
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
34
41
|
if (body) opts.body = JSON.stringify(body);
|
|
35
42
|
const res = await fetch(`${API}${path}`, opts);
|
|
36
|
-
return
|
|
43
|
+
// Success path: unchanged — return the parsed JSON body verbatim.
|
|
44
|
+
if (res.ok) return res.json();
|
|
45
|
+
// Sprint 65 T1 (1.3b) — non-2xx: many callers gate on `.error` and
|
|
46
|
+
// otherwise treat the body as success. A 4xx/5xx body without an
|
|
47
|
+
// `error` key (e.g. the dead-panel shape 410 {ok:false,code,message})
|
|
48
|
+
// would be misreported as delivered. annotateApiFailure() synthesizes a
|
|
49
|
+
// uniform `error` field and surfaces `_httpStatus` for precise callers.
|
|
50
|
+
let data = null;
|
|
51
|
+
try { data = await res.json(); } catch (err) { /* non-JSON error body */ }
|
|
52
|
+
return annotateApiFailure(data, res.status);
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
// ===== Initialize =====
|
|
40
56
|
async function init() {
|
|
57
|
+
// Sprint 65 T1 — hydrate saved client prefs before any panel mounts: the
|
|
58
|
+
// project-filter chip (so a new tile filters right from frame 1) and the
|
|
59
|
+
// terminal font size (so restored panels open at the operator's size).
|
|
60
|
+
state.projectFilter = loadProjectFilter();
|
|
61
|
+
state.fontSize = loadFontSize();
|
|
41
62
|
// Load config
|
|
42
63
|
state.config = await api('GET', '/api/config');
|
|
43
64
|
updateRagIndicator();
|
|
@@ -105,6 +126,14 @@
|
|
|
105
126
|
|
|
106
127
|
updateEmptyState();
|
|
107
128
|
|
|
129
|
+
// Sprint 65 T1 — initial restore complete: subsequent createTerminalPanel
|
|
130
|
+
// calls are user-initiated launches, so arm the born-hidden chip guard.
|
|
131
|
+
_initialLoadComplete = true;
|
|
132
|
+
// Render chips + route the ORCH row now that the restored panels exist.
|
|
133
|
+
refreshDashboardChrome();
|
|
134
|
+
// Sprint 65 T1 (c) — sync the topbar font-size label to the saved size.
|
|
135
|
+
applyFontSizeToAll();
|
|
136
|
+
|
|
108
137
|
// Rumen insights badge + briefing (no-op when server reports enabled:false)
|
|
109
138
|
setupRumen();
|
|
110
139
|
|
|
@@ -409,6 +438,7 @@
|
|
|
409
438
|
<a class="theme-reset" id="theme-reset-${id}" href="javascript:void(0)" onclick="resetTheme('${id}')" title="Revert to project / global default from config.yaml" style="font-size:11px;color:#7aa2f7;text-decoration:none;margin-left:4px;opacity:0.7;cursor:pointer">↺ default</a>
|
|
410
439
|
<button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
|
|
411
440
|
<button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
|
|
441
|
+
<button class="ctrl-btn orch-toggle${isOrchestratorRole(meta.role) ? ' is-orch' : ''}" id="orch-toggle-${id}" type="button" onclick="toggleOrchestratorRole('${id}')" title="${isOrchestratorRole(meta.role) ? 'Unmark this panel as the orchestrator' : 'Mark this panel as the orchestrator — gold border, ORCH badge, pinned row'}">${orchToggleLabel(meta.role)}</button>
|
|
412
442
|
<button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
|
|
413
443
|
<input type="text" class="ctrl-input" id="ai-${id}" placeholder="Ask about this terminal..." onkeydown="if(event.key==='Enter')askAI('${id}', this.value)">
|
|
414
444
|
</div>
|
|
@@ -433,7 +463,9 @@
|
|
|
433
463
|
</div>
|
|
434
464
|
`;
|
|
435
465
|
|
|
436
|
-
|
|
466
|
+
// Sprint 65 T1 (1.2) — orchestrator panels render in the pinned ORCH
|
|
467
|
+
// row above the grid; everything else goes in the grid.
|
|
468
|
+
placePanel(panel, meta);
|
|
437
469
|
|
|
438
470
|
// Sprint 42 T4: drag/drop reorder. Inject identifier is the session
|
|
439
471
|
// UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
|
|
@@ -443,7 +475,7 @@
|
|
|
443
475
|
// Create xterm.js instance
|
|
444
476
|
const terminal = new Terminal({
|
|
445
477
|
fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
446
|
-
fontSize:
|
|
478
|
+
fontSize: state.fontSize,
|
|
447
479
|
lineHeight: 1.3,
|
|
448
480
|
cursorBlink: true,
|
|
449
481
|
cursorStyle: 'bar',
|
|
@@ -499,6 +531,11 @@
|
|
|
499
531
|
refreshPanelIndices();
|
|
500
532
|
renderSwitcher();
|
|
501
533
|
break;
|
|
534
|
+
case 'panel_exited':
|
|
535
|
+
// Sprint 65 T1 (1.3) — T2 broadcasts this when a PTY exits.
|
|
536
|
+
// Primary path for auto-removing the dead tile from the grid.
|
|
537
|
+
handlePanelExited(msg.sessionId, msg.exitCode);
|
|
538
|
+
break;
|
|
502
539
|
case 'status_broadcast':
|
|
503
540
|
updateGlobalStats(msg.sessions);
|
|
504
541
|
break;
|
|
@@ -592,6 +629,11 @@
|
|
|
592
629
|
if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
|
|
593
630
|
refreshAllReplyFormsFor(id);
|
|
594
631
|
refreshPanelIndices();
|
|
632
|
+
// Sprint 65 T1 — a user-launched panel must never be born hidden behind a
|
|
633
|
+
// stale chip filter (T3 20:10 / T4 20:11): switch the filter to its
|
|
634
|
+
// project first, then refresh chrome so the new tile is visible.
|
|
635
|
+
revealNewPanelIfFiltered(meta);
|
|
636
|
+
refreshDashboardChrome();
|
|
595
637
|
|
|
596
638
|
// Handle window resize
|
|
597
639
|
const resizeObserver = new ResizeObserver(() => {
|
|
@@ -611,6 +653,512 @@
|
|
|
611
653
|
return { terminal, ws, fitAddon };
|
|
612
654
|
}
|
|
613
655
|
|
|
656
|
+
// ===== Sprint 65: project-filter chips + ORCH-panel pin + tile lifecycle =====
|
|
657
|
+
// Brad's 2026-05-13 v2 spec (BACKLOG § D.5) — three dashboard-reliability
|
|
658
|
+
// surfaces sharing one client-side lane:
|
|
659
|
+
// 1.1 project-filter chips — a per-project visibility filter above the grid
|
|
660
|
+
// 1.2 ORCH pin — panels with meta.role==='orchestrator' get a dedicated
|
|
661
|
+
// always-visible row + gold/amber treatment, outside the chip filter
|
|
662
|
+
// 1.3 tile auto-removal — dead PTY panels leave the grid instead of
|
|
663
|
+
// lingering as "dead" tiles (Brad's "18 windows, 10 dead codex cli")
|
|
664
|
+
//
|
|
665
|
+
// Design note: TermDeck panels are persistent DOM + live xterm.js + a
|
|
666
|
+
// per-panel WebSocket. They are created once (createTerminalPanel) and
|
|
667
|
+
// never re-rendered per frame — status_broadcast only mutates meta. So the
|
|
668
|
+
// chip row and filter work by toggling classes on existing tiles, never by
|
|
669
|
+
// tearing panels down; ORCH routing is decided at panel-create time and
|
|
670
|
+
// re-checked cheaply on each broadcast.
|
|
671
|
+
|
|
672
|
+
const PROJECT_FILTER_KEY = 'termdeck.dashboard.projectFilter';
|
|
673
|
+
// Belt-and-suspenders thresholds for tile reconciliation (reconcileExitedPanels).
|
|
674
|
+
// ORPHAN_GRACE_MS > the tile-exit grace so the primary panel_exited path
|
|
675
|
+
// always wins when the WS frame is delivered.
|
|
676
|
+
const ORPHAN_GRACE_MS = 5000;
|
|
677
|
+
const STALE_EXITED_MS = 60000;
|
|
678
|
+
let _chromeRefreshScheduled = false;
|
|
679
|
+
// false during init()'s panel-restore loop, true afterward. Gates the
|
|
680
|
+
// born-hidden chip guard so a saved filter is honored on reload but a
|
|
681
|
+
// user-launched panel is always revealed.
|
|
682
|
+
let _initialLoadComplete = false;
|
|
683
|
+
|
|
684
|
+
// --- Pure helpers (no DOM, no globals — unit-tested in
|
|
685
|
+
// tests/dashboard-panels-client.test.js via the vm-extract pattern) ---
|
|
686
|
+
|
|
687
|
+
// Distinct non-null project tags across the given session-meta list, plus
|
|
688
|
+
// whether any panel carries no project at all. `metas` is an array of
|
|
689
|
+
// session.meta objects. Exited panels are excluded — chips count live work.
|
|
690
|
+
function discoverPanelProjects(metas) {
|
|
691
|
+
const projects = [];
|
|
692
|
+
const seen = Object.create(null);
|
|
693
|
+
let hasNullProject = false;
|
|
694
|
+
for (const m of (metas || [])) {
|
|
695
|
+
if (!m || m.status === 'exited') continue;
|
|
696
|
+
const p = m.project;
|
|
697
|
+
if (p === null || p === undefined || p === '') {
|
|
698
|
+
hasNullProject = true;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (!seen[p]) {
|
|
702
|
+
seen[p] = true;
|
|
703
|
+
projects.push(p);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
projects.sort();
|
|
707
|
+
return { projects: projects, hasNullProject: hasNullProject };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Count of live (non-exited) panels for one chip. The "All" chip passes
|
|
711
|
+
// project==='' and counts every live panel; a project chip counts only its
|
|
712
|
+
// own. Orchestrator panels are counted under their project too — the ORCH
|
|
713
|
+
// pin is a placement, not an exclusion from the totals.
|
|
714
|
+
function countPanelsForProject(metas, project) {
|
|
715
|
+
let n = 0;
|
|
716
|
+
for (const m of (metas || [])) {
|
|
717
|
+
if (!m || m.status === 'exited') continue;
|
|
718
|
+
if (project === '' || project === null || project === undefined) { n++; continue; }
|
|
719
|
+
if (m.project === project) n++;
|
|
720
|
+
}
|
|
721
|
+
return n;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Whether a grid tile with the given project should be visible under the
|
|
725
|
+
// current chip selection. The "All" selection ('') shows everything; a
|
|
726
|
+
// project selection shows only exact matches (null-project panels are
|
|
727
|
+
// hidden under any specific-project filter — they surface only under All).
|
|
728
|
+
function isPanelVisibleUnderFilter(panelProject, selectedFilter) {
|
|
729
|
+
if (selectedFilter === '' || selectedFilter === null || selectedFilter === undefined) return true;
|
|
730
|
+
return panelProject === selectedFilter;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Sprint 66 T1 (Task 1.1) — the chip row renders whenever there is at
|
|
734
|
+
// least one project bucket, so the project-filter feature is *discoverable*
|
|
735
|
+
// rather than hidden until a second project shows up. Brad's 2026-05-13 v2
|
|
736
|
+
// spec asked for an always-visible rail; his single-live-panel setup sat
|
|
737
|
+
// below the old ≥2 threshold and saw nothing. With one project the row is
|
|
738
|
+
// [ All ] + that one project chip — harmless, and it advertises the filter.
|
|
739
|
+
// `hasNullProject` is retained in the signature for call-site / test
|
|
740
|
+
// compatibility; with ≥1 project the row shows regardless of it, and with
|
|
741
|
+
// zero projects an All-only row carries no filter value so it stays hidden.
|
|
742
|
+
function shouldShowChipRow(projects, hasNullProject) {
|
|
743
|
+
return (projects || []).length >= 1;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Approach A (Brad's 2026-05-13 spec): orchestrator identity is the
|
|
747
|
+
// explicit meta.role flag, never inferred from cwd.
|
|
748
|
+
function isOrchestratorRole(role) {
|
|
749
|
+
return role === 'orchestrator';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Sprint 66 T1 (Task 1.3) — the binary "mark / unmark orchestrator" toggle.
|
|
753
|
+
// nextRoleForToggle: the role the toggle moves a panel TO, given its
|
|
754
|
+
// current role — orchestrator ⇄ unroled (null). A worker/reviewer/auditor
|
|
755
|
+
// panel is "not orchestrator", so the toggle promotes it to orchestrator;
|
|
756
|
+
// it does NOT preserve a prior non-orch role (the affordance is a binary
|
|
757
|
+
// ORCH switch, not a role-history stack — it matches "mark / unmark as
|
|
758
|
+
// orchestrator"). orchToggleLabel: the toggle button's text for a role.
|
|
759
|
+
// Both pure — unit-tested in tests/dashboard-panels-client.test.js.
|
|
760
|
+
function nextRoleForToggle(currentRole) {
|
|
761
|
+
return currentRole === 'orchestrator' ? null : 'orchestrator';
|
|
762
|
+
}
|
|
763
|
+
function orchToggleLabel(role) {
|
|
764
|
+
return role === 'orchestrator' ? 'unmark orch' : 'mark orch';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Belt-and-suspenders for missed panel_exited frames: panel ids the
|
|
768
|
+
// dashboard still has a tile for, but which no longer appear in the
|
|
769
|
+
// server's broadcast session list. Works whether or not T2 filters exited
|
|
770
|
+
// sessions out of status_broadcast.
|
|
771
|
+
function findOrphanedPanelIds(knownPanelIds, broadcastSessionIds) {
|
|
772
|
+
const live = Object.create(null);
|
|
773
|
+
for (const id of (broadcastSessionIds || [])) live[id] = true;
|
|
774
|
+
const orphaned = [];
|
|
775
|
+
for (const id of (knownPanelIds || [])) {
|
|
776
|
+
if (!live[id]) orphaned.push(id);
|
|
777
|
+
}
|
|
778
|
+
return orphaned;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 1.3b — give a non-2xx API body a uniform failure signal. Many callers
|
|
782
|
+
// gate on `.error`; a 4xx/5xx body that lacks it (e.g. the Sprint 65
|
|
783
|
+
// dead-panel shape 410 {ok:false,code:'panel_exited',message}) would
|
|
784
|
+
// otherwise be misread as success. Pure: takes the parsed body + status,
|
|
785
|
+
// returns the body annotated with `error` + `_httpStatus`.
|
|
786
|
+
function annotateApiFailure(body, httpStatus) {
|
|
787
|
+
const out = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {};
|
|
788
|
+
if (out.error === undefined || out.error === null) {
|
|
789
|
+
out.error = out.message || out.code || ('HTTP ' + httpStatus);
|
|
790
|
+
}
|
|
791
|
+
out._httpStatus = httpStatus;
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// 1.1 born-hidden guard (T3 20:10 / T4 20:11) — a panel the operator just
|
|
796
|
+
// launched must never be hidden by a stale chip filter. Given the active
|
|
797
|
+
// filter and a newly-created panel, returns the filter value that keeps the
|
|
798
|
+
// panel visible, or null when no switch is needed. Pure.
|
|
799
|
+
function filterValueRevealingPanel(currentFilter, panelProject, isOrchPanel, initialLoadComplete) {
|
|
800
|
+
if (initialLoadComplete !== true) return null; // initial restore — honor the saved filter
|
|
801
|
+
if (isOrchPanel === true) return null; // ORCH panels bypass the chip filter
|
|
802
|
+
const proj = panelProject || '';
|
|
803
|
+
if (!currentFilter) return null; // "All" already shows everything
|
|
804
|
+
if (currentFilter === proj) return null; // already visible under this filter
|
|
805
|
+
return proj; // switch the filter to the new panel's project
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// --- localStorage-backed filter persistence (origin-scoped, per-tab) ---
|
|
809
|
+
function loadProjectFilter() {
|
|
810
|
+
try {
|
|
811
|
+
const v = localStorage.getItem(PROJECT_FILTER_KEY);
|
|
812
|
+
return typeof v === 'string' ? v : '';
|
|
813
|
+
} catch (err) {
|
|
814
|
+
console.warn('[client] projectFilter load failed:', err);
|
|
815
|
+
return '';
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function saveProjectFilter(value) {
|
|
819
|
+
try {
|
|
820
|
+
localStorage.setItem(PROJECT_FILTER_KEY, value || '');
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.warn('[client] projectFilter save failed:', err);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// session.meta for every panel currently mounted on THIS dashboard. Skips
|
|
827
|
+
// the _mounting placeholder createTerminalPanel reserves at function entry
|
|
828
|
+
// before the real entry is written.
|
|
829
|
+
function dashboardPanelMetas() {
|
|
830
|
+
const metas = [];
|
|
831
|
+
for (const entry of state.sessions.values()) {
|
|
832
|
+
if (!entry || entry._mounting || !entry.session || !entry.session.meta) continue;
|
|
833
|
+
metas.push(entry.session.meta);
|
|
834
|
+
}
|
|
835
|
+
return metas;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// 1.1 — (re)render the project-filter chip row from the live panel set.
|
|
839
|
+
// Chips are built with createElement (not innerHTML) so project names need
|
|
840
|
+
// no attribute escaping and cannot inject markup.
|
|
841
|
+
function renderProjectChips() {
|
|
842
|
+
const row = document.getElementById('project-chips');
|
|
843
|
+
if (!row) return;
|
|
844
|
+
const metas = dashboardPanelMetas();
|
|
845
|
+
const discovered = discoverPanelProjects(metas);
|
|
846
|
+
const projects = discovered.projects;
|
|
847
|
+
|
|
848
|
+
if (!shouldShowChipRow(projects, discovered.hasNullProject)) {
|
|
849
|
+
row.replaceChildren();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// If the selected project no longer has any live panels, fall back to
|
|
854
|
+
// "All" so the grid never strands empty behind a dead chip.
|
|
855
|
+
if (state.projectFilter && projects.indexOf(state.projectFilter) === -1) {
|
|
856
|
+
state.projectFilter = '';
|
|
857
|
+
saveProjectFilter('');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const chips = [{ project: '', label: 'All' }];
|
|
861
|
+
for (const p of projects) chips.push({ project: p, label: p });
|
|
862
|
+
|
|
863
|
+
const frag = document.createDocumentFragment();
|
|
864
|
+
for (const c of chips) {
|
|
865
|
+
const btn = document.createElement('button');
|
|
866
|
+
btn.type = 'button';
|
|
867
|
+
btn.className = 'project-chip' + (((state.projectFilter || '') === c.project) ? ' active' : '');
|
|
868
|
+
btn.dataset.project = c.project;
|
|
869
|
+
const label = document.createElement('span');
|
|
870
|
+
label.className = 'project-chip-label';
|
|
871
|
+
label.textContent = c.label;
|
|
872
|
+
const count = document.createElement('span');
|
|
873
|
+
count.className = 'project-chip-count';
|
|
874
|
+
count.textContent = '(' + countPanelsForProject(metas, c.project) + ')';
|
|
875
|
+
btn.appendChild(label);
|
|
876
|
+
btn.appendChild(count);
|
|
877
|
+
frag.appendChild(btn);
|
|
878
|
+
}
|
|
879
|
+
row.replaceChildren(frag);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 1.1 — apply the current chip selection to the grid by toggling
|
|
883
|
+
// .panel--filtered-out (display:none) on tiles. PTYs are never torn down —
|
|
884
|
+
// a filtered panel keeps running, just hidden. Orchestrator tiles live
|
|
885
|
+
// outside the grid and are never filtered.
|
|
886
|
+
function applyProjectFilter() {
|
|
887
|
+
for (const entry of state.sessions.values()) {
|
|
888
|
+
if (!entry || entry._mounting || !entry.el || !entry.session) continue;
|
|
889
|
+
if (isOrchestratorRole(entry.session.meta && entry.session.meta.role)) {
|
|
890
|
+
entry.el.classList.remove('panel--filtered-out');
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
const project = entry.session.meta ? entry.session.meta.project : null;
|
|
894
|
+
const visible = isPanelVisibleUnderFilter(project, state.projectFilter || '');
|
|
895
|
+
entry.el.classList.toggle('panel--filtered-out', !visible);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// 1.1 — chip click: single-select, persist, re-render + re-filter + refit.
|
|
900
|
+
function onProjectChipClick(e) {
|
|
901
|
+
const chip = e.target && e.target.closest ? e.target.closest('.project-chip') : null;
|
|
902
|
+
if (!chip) return;
|
|
903
|
+
const project = chip.getAttribute('data-project') || '';
|
|
904
|
+
state.projectFilter = project;
|
|
905
|
+
saveProjectFilter(project);
|
|
906
|
+
renderProjectChips();
|
|
907
|
+
applyProjectFilter();
|
|
908
|
+
requestAnimationFrame(function () { fitAll(); });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 1.1 — born-hidden guard for a freshly-created panel: if an active chip
|
|
912
|
+
// filter would hide it, switch the filter to the panel's project so the
|
|
913
|
+
// operator sees what they just launched. No-op during the initial restore.
|
|
914
|
+
function revealNewPanelIfFiltered(meta) {
|
|
915
|
+
const next = filterValueRevealingPanel(
|
|
916
|
+
state.projectFilter,
|
|
917
|
+
meta && meta.project,
|
|
918
|
+
isOrchestratorRole(meta && meta.role),
|
|
919
|
+
_initialLoadComplete
|
|
920
|
+
);
|
|
921
|
+
if (next === null) return;
|
|
922
|
+
state.projectFilter = next;
|
|
923
|
+
saveProjectFilter(next);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 1.2 — route a freshly-built panel into the ORCH pin row or the grid.
|
|
927
|
+
function placePanel(panel, meta) {
|
|
928
|
+
const orchRow = document.getElementById('orch-pin-row');
|
|
929
|
+
if (orchRow && isOrchestratorRole(meta && meta.role)) {
|
|
930
|
+
panel.classList.add('panel--role-orch');
|
|
931
|
+
orchRow.appendChild(panel);
|
|
932
|
+
} else {
|
|
933
|
+
document.getElementById('termGrid').appendChild(panel);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 1.2 — reconcile: keep every tile in the container its role dictates.
|
|
938
|
+
// Re-evaluates isOrchestratorRole() for every panel on each call, so it is
|
|
939
|
+
// the primary mover whenever a role CHANGES — not merely a placement
|
|
940
|
+
// safety net. Sprint 66 T1 (Task 1.2) made meta.role mutable post-spawn
|
|
941
|
+
// (PATCH /api/sessions/:id {role}); a role flip arrives via status_broadcast,
|
|
942
|
+
// updatePanelMeta() merges it into entry.session.meta, scheduleChromeRefresh()
|
|
943
|
+
// runs this, and the panel moves into / out of the ORCH row carrying the
|
|
944
|
+
// panel--role-orch class (the gold border + "ORCH " badge are pure CSS on
|
|
945
|
+
// that class). Returns true if any tile moved.
|
|
946
|
+
function reconcileOrchRow() {
|
|
947
|
+
const orchRow = document.getElementById('orch-pin-row');
|
|
948
|
+
const grid = document.getElementById('termGrid');
|
|
949
|
+
if (!orchRow || !grid) return false;
|
|
950
|
+
let moved = false;
|
|
951
|
+
for (const entry of state.sessions.values()) {
|
|
952
|
+
if (!entry || entry._mounting || !entry.el || !entry.session) continue;
|
|
953
|
+
const isOrch = isOrchestratorRole(entry.session.meta && entry.session.meta.role);
|
|
954
|
+
const inOrchRow = entry.el.parentElement === orchRow;
|
|
955
|
+
if (isOrch && !inOrchRow) {
|
|
956
|
+
entry.el.classList.add('panel--role-orch');
|
|
957
|
+
entry.el.classList.remove('panel--filtered-out');
|
|
958
|
+
orchRow.appendChild(entry.el);
|
|
959
|
+
moved = true;
|
|
960
|
+
} else if (!isOrch && inOrchRow) {
|
|
961
|
+
entry.el.classList.remove('panel--role-orch');
|
|
962
|
+
grid.appendChild(entry.el);
|
|
963
|
+
moved = true;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return moved;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Single entry point for keeping the dashboard chrome (ORCH row + chips +
|
|
970
|
+
// filter) in sync with the live panel set. Cheap — class toggles and a
|
|
971
|
+
// small chip-row rebuild.
|
|
972
|
+
function refreshDashboardChrome() {
|
|
973
|
+
const moved = reconcileOrchRow();
|
|
974
|
+
renderProjectChips();
|
|
975
|
+
applyProjectFilter();
|
|
976
|
+
if (moved) requestAnimationFrame(function () { fitAll(); });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// status_broadcast fires once per panel WebSocket; coalesce the resulting
|
|
980
|
+
// chrome refreshes into one per animation frame so an 18-panel dashboard
|
|
981
|
+
// doesn't rebuild the chip row 18× per 2s tick (T4's count-thrash concern).
|
|
982
|
+
function scheduleChromeRefresh() {
|
|
983
|
+
if (_chromeRefreshScheduled) return;
|
|
984
|
+
_chromeRefreshScheduled = true;
|
|
985
|
+
requestAnimationFrame(function () {
|
|
986
|
+
_chromeRefreshScheduled = false;
|
|
987
|
+
refreshDashboardChrome();
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// --- 1.3: tile auto-removal on PTY exit ---
|
|
992
|
+
|
|
993
|
+
// Grace window before a dead tile is pulled, so the operator sees the
|
|
994
|
+
// final post-exit lines. Overridable via <body data-tile-exit-grace-ms>
|
|
995
|
+
// for deterministic tests.
|
|
996
|
+
function tileExitGraceMs() {
|
|
997
|
+
try {
|
|
998
|
+
const attr = document.body && document.body.getAttribute('data-tile-exit-grace-ms');
|
|
999
|
+
const n = attr != null ? parseInt(attr, 10) : NaN;
|
|
1000
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
console.warn('[client] tile-exit-grace read failed:', err);
|
|
1003
|
+
}
|
|
1004
|
+
return 3000;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Tear down a panel's DOM + xterm + WebSocket and drop it from state.
|
|
1008
|
+
// Idempotent: a second call for an already-removed id is a no-op. Shared
|
|
1009
|
+
// by handlePanelExited (primary) and reconcileExitedPanels (fallback).
|
|
1010
|
+
function removePanelTile(id) {
|
|
1011
|
+
const entry = state.sessions.get(id);
|
|
1012
|
+
if (!entry) return;
|
|
1013
|
+
try { if (entry.terminal) entry.terminal.dispose(); } catch (err) { console.warn('[client] terminal dispose failed:', err); }
|
|
1014
|
+
try { if (entry.ws) entry.ws.close(); } catch (err) { console.warn('[client] ws close failed:', err); }
|
|
1015
|
+
try { if (entry.el) entry.el.remove(); } catch (err) { console.warn('[client] tile remove failed:', err); }
|
|
1016
|
+
state.sessions.delete(id);
|
|
1017
|
+
updateEmptyState();
|
|
1018
|
+
renderSwitcher();
|
|
1019
|
+
refreshAllReplyFormsFor(id);
|
|
1020
|
+
refreshPanelIndices();
|
|
1021
|
+
refreshDashboardChrome();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// 1.3 primary path — the server broadcast a panel_exited frame (T2 sub-task
|
|
1025
|
+
// 2.4). Dim the tile for a grace window, then remove it. Guarded with
|
|
1026
|
+
// _exitScheduled because panel_exited arrives on every panel's WebSocket.
|
|
1027
|
+
function handlePanelExited(sessionId, exitCode) {
|
|
1028
|
+
const entry = state.sessions.get(sessionId);
|
|
1029
|
+
if (!entry || !entry.el) return;
|
|
1030
|
+
if (entry._exitScheduled) return;
|
|
1031
|
+
entry._exitScheduled = true;
|
|
1032
|
+
entry.el.classList.add('panel--exiting');
|
|
1033
|
+
const statusEl = document.getElementById('status-' + sessionId);
|
|
1034
|
+
if (statusEl && exitCode !== undefined && exitCode !== null) {
|
|
1035
|
+
statusEl.textContent = 'Exited (' + exitCode + ')';
|
|
1036
|
+
}
|
|
1037
|
+
setTimeout(function () { removePanelTile(sessionId); }, tileExitGraceMs());
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 1.3 belt-and-suspenders — covers a missed panel_exited frame. Two
|
|
1041
|
+
// independent checks so the dashboard cannot strand a dead tile forever
|
|
1042
|
+
// regardless of how T2 implements exited-session filtering:
|
|
1043
|
+
// (a) orphaned — a tile whose session id has dropped out of the
|
|
1044
|
+
// broadcast entirely (fires when T2 filters exited from the
|
|
1045
|
+
// broadcast); removed after ORPHAN_GRACE_MS.
|
|
1046
|
+
// (b) stale-exited — a tile still in the broadcast with status 'exited'
|
|
1047
|
+
// and lastActivity older than STALE_EXITED_MS (fires when T2 keeps
|
|
1048
|
+
// exited sessions in the broadcast).
|
|
1049
|
+
function reconcileExitedPanels(broadcastSessions) {
|
|
1050
|
+
const list = Array.isArray(broadcastSessions) ? broadcastSessions : [];
|
|
1051
|
+
const broadcastIds = [];
|
|
1052
|
+
const metaById = Object.create(null);
|
|
1053
|
+
for (const s of list) {
|
|
1054
|
+
if (s && s.id) { broadcastIds.push(s.id); metaById[s.id] = s.meta || {}; }
|
|
1055
|
+
}
|
|
1056
|
+
const knownIds = [];
|
|
1057
|
+
for (const [id, entry] of state.sessions) {
|
|
1058
|
+
if (entry && !entry._mounting && entry.el) knownIds.push(id);
|
|
1059
|
+
}
|
|
1060
|
+
const now = Date.now();
|
|
1061
|
+
|
|
1062
|
+
// (a) orphaned-from-broadcast
|
|
1063
|
+
const orphaned = findOrphanedPanelIds(knownIds, broadcastIds);
|
|
1064
|
+
const orphanSet = Object.create(null);
|
|
1065
|
+
for (const id of orphaned) {
|
|
1066
|
+
orphanSet[id] = true;
|
|
1067
|
+
const entry = state.sessions.get(id);
|
|
1068
|
+
if (!entry) continue;
|
|
1069
|
+
if (!entry._orphanedSince) entry._orphanedSince = now;
|
|
1070
|
+
if (now - entry._orphanedSince >= ORPHAN_GRACE_MS && !entry._exitScheduled) {
|
|
1071
|
+
removePanelTile(id);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// clear the stamp for any panel that is back in the broadcast
|
|
1075
|
+
for (const id of knownIds) {
|
|
1076
|
+
const entry = state.sessions.get(id);
|
|
1077
|
+
if (entry && entry._orphanedSince && !orphanSet[id]) entry._orphanedSince = null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// (b) stale-exited still present in the broadcast
|
|
1081
|
+
for (const id of knownIds) {
|
|
1082
|
+
const meta = metaById[id];
|
|
1083
|
+
if (!meta || meta.status !== 'exited') continue;
|
|
1084
|
+
const entry = state.sessions.get(id);
|
|
1085
|
+
if (!entry || entry._exitScheduled) continue;
|
|
1086
|
+
const last = meta.lastActivity ? new Date(meta.lastActivity).getTime() : 0;
|
|
1087
|
+
if (last && now - last >= STALE_EXITED_MS) {
|
|
1088
|
+
removePanelTile(id);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ===== Sprint 65 T1 (Joshua's 2026-05-16 ask c): terminal font size =====
|
|
1094
|
+
// A single global xterm.js font size, adjusted from the topbar A-/A+
|
|
1095
|
+
// stepper and persisted in localStorage. Applies to every panel (existing
|
|
1096
|
+
// + future) — operators running dense CLI output wanted smaller text. The
|
|
1097
|
+
// BACKLOG 2026-05-16 entry sanctions "per-panel OR global"; global is the
|
|
1098
|
+
// simpler, lower-risk option for this contingent sub-task.
|
|
1099
|
+
|
|
1100
|
+
const FONT_SIZE_KEY = 'termdeck.dashboard.fontSize';
|
|
1101
|
+
|
|
1102
|
+
// Clamp a requested font size to the supported range, coercing non-numbers
|
|
1103
|
+
// to the xterm.js default (13). Pure — unit-tested via the vm-extract path.
|
|
1104
|
+
function clampFontSize(n) {
|
|
1105
|
+
const v = Math.round(Number(n));
|
|
1106
|
+
if (!Number.isFinite(v)) return 13;
|
|
1107
|
+
if (v < 8) return 8;
|
|
1108
|
+
if (v > 22) return 22;
|
|
1109
|
+
return v;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function loadFontSize() {
|
|
1113
|
+
try {
|
|
1114
|
+
const raw = localStorage.getItem(FONT_SIZE_KEY);
|
|
1115
|
+
return raw != null ? clampFontSize(raw) : 13;
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
console.warn('[client] fontSize load failed:', err);
|
|
1118
|
+
return 13;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function saveFontSize(n) {
|
|
1122
|
+
try {
|
|
1123
|
+
localStorage.setItem(FONT_SIZE_KEY, String(n));
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
console.warn('[client] fontSize save failed:', err);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Apply the global font size to every live terminal. Changing fontSize
|
|
1130
|
+
// resizes the character cell but NOT the container, so the per-panel
|
|
1131
|
+
// ResizeObserver does not fire — refit + push the new cols/rows explicitly.
|
|
1132
|
+
function applyFontSizeToAll() {
|
|
1133
|
+
for (const entry of state.sessions.values()) {
|
|
1134
|
+
if (!entry || entry._mounting || !entry.terminal) continue;
|
|
1135
|
+
try {
|
|
1136
|
+
entry.terminal.options.fontSize = state.fontSize;
|
|
1137
|
+
if (entry.fitAddon) entry.fitAddon.fit();
|
|
1138
|
+
if (entry.ws && entry.ws.readyState === WebSocket.OPEN) {
|
|
1139
|
+
entry.ws.send(JSON.stringify({
|
|
1140
|
+
type: 'resize',
|
|
1141
|
+
cols: entry.terminal.cols,
|
|
1142
|
+
rows: entry.terminal.rows,
|
|
1143
|
+
}));
|
|
1144
|
+
}
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
console.warn('[client] fontSize apply failed for a panel:', err);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const label = document.getElementById('fontSizeLabel');
|
|
1150
|
+
if (label) label.textContent = String(state.fontSize);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Topbar A-/A+ stepper handler. delta is +1 or -1.
|
|
1154
|
+
function stepFontSize(delta) {
|
|
1155
|
+
const next = clampFontSize(state.fontSize + delta);
|
|
1156
|
+
if (next === state.fontSize) return;
|
|
1157
|
+
state.fontSize = next;
|
|
1158
|
+
saveFontSize(next);
|
|
1159
|
+
applyFontSizeToAll();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
614
1162
|
// ===== Control dashboard (T1.6) =====
|
|
615
1163
|
async function enterControlMode() {
|
|
616
1164
|
// Pre-warm command history for every open session so the feed is dense.
|
|
@@ -1536,6 +2084,11 @@
|
|
|
1536
2084
|
const p = document.getElementById(`panel-${id}`);
|
|
1537
2085
|
if (p) p.classList.add('exited');
|
|
1538
2086
|
break;
|
|
2087
|
+
case 'panel_exited':
|
|
2088
|
+
// Sprint 65 T1 (1.3) — parity with the main WS handler so the
|
|
2089
|
+
// dead-tile removal still fires for reconnected panels.
|
|
2090
|
+
handlePanelExited(msg.sessionId, msg.exitCode);
|
|
2091
|
+
break;
|
|
1539
2092
|
case 'status_broadcast':
|
|
1540
2093
|
updateGlobalStats(msg.sessions);
|
|
1541
2094
|
break;
|
|
@@ -1621,6 +2174,8 @@
|
|
|
1621
2174
|
renderSwitcher();
|
|
1622
2175
|
refreshAllReplyFormsFor(id);
|
|
1623
2176
|
refreshPanelIndices();
|
|
2177
|
+
// Sprint 65 T1 — closing a panel changes chip counts / may empty a chip.
|
|
2178
|
+
refreshDashboardChrome();
|
|
1624
2179
|
}
|
|
1625
2180
|
|
|
1626
2181
|
function changeTheme(id, themeId) {
|
|
@@ -1649,6 +2204,64 @@
|
|
|
1649
2204
|
if (sel && sel.value !== resolved) sel.value = resolved;
|
|
1650
2205
|
}
|
|
1651
2206
|
|
|
2207
|
+
// Sprint 66 T1 (Task 1.3) — mark / unmark a LIVE panel as the orchestrator
|
|
2208
|
+
// in place. Brad's existing orchestrator panel was spawned with no role and
|
|
2209
|
+
// there was no way to set one short of destroy+recreate via the raw API.
|
|
2210
|
+
// This PATCHes meta.role (the Task 1.2 endpoint); on success the panel
|
|
2211
|
+
// moves into the pinned ORCH row and gains the gold border + "ORCH " badge
|
|
2212
|
+
// with no reload — reconcileOrchRow() (via refreshDashboardChrome) moves it.
|
|
2213
|
+
// Multi-orchestrator is allowed: marking panel B does not unmark panel A
|
|
2214
|
+
// (the ORCH row holds more than one; the operator explicitly unmarks). A
|
|
2215
|
+
// global function — invoked from the Overview-tab button's inline onclick.
|
|
2216
|
+
async function toggleOrchestratorRole(id) {
|
|
2217
|
+
const entry = state.sessions.get(id);
|
|
2218
|
+
if (!entry || entry._mounting || !entry.session) return;
|
|
2219
|
+
const current = entry.session.meta ? entry.session.meta.role : null;
|
|
2220
|
+
const next = nextRoleForToggle(current);
|
|
2221
|
+
const btn = document.getElementById(`orch-toggle-${id}`);
|
|
2222
|
+
if (btn) btn.disabled = true;
|
|
2223
|
+
try {
|
|
2224
|
+
const updated = await api('PATCH', `/api/sessions/${id}`, { role: next });
|
|
2225
|
+
// api() returns the parsed body; a non-2xx body is annotated with
|
|
2226
|
+
// `.error` (annotateApiFailure). The toggle only ever sends a
|
|
2227
|
+
// whitelisted value so a 400 should not occur — but a 404 (panel gone)
|
|
2228
|
+
// or a network failure can, and must not be applied as success.
|
|
2229
|
+
if (updated && updated.error) {
|
|
2230
|
+
console.error('[client] orchestrator-role toggle failed:', updated.error);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
// Apply the authoritative server role from the PATCH response, then
|
|
2234
|
+
// re-route + re-skin the panel. The 2s status_broadcast converges to
|
|
2235
|
+
// the same value (eventually-consistent — same model as changeTheme).
|
|
2236
|
+
if (entry.session.meta) {
|
|
2237
|
+
entry.session.meta.role = (updated && updated.meta) ? updated.meta.role : next;
|
|
2238
|
+
}
|
|
2239
|
+
refreshDashboardChrome();
|
|
2240
|
+
} catch (err) {
|
|
2241
|
+
console.error('[client] orchestrator-role toggle error:', err);
|
|
2242
|
+
} finally {
|
|
2243
|
+
if (btn) btn.disabled = false;
|
|
2244
|
+
syncOrchToggle(id);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Sprint 66 T1 (Task 1.3) — keep a panel's orch-toggle button in sync with
|
|
2249
|
+
// its current meta.role (label, active class, tooltip). Called after a
|
|
2250
|
+
// toggle and on every status_broadcast (updatePanelMeta), so the button is
|
|
2251
|
+
// correct even when the role is changed from another dashboard tab.
|
|
2252
|
+
function syncOrchToggle(id) {
|
|
2253
|
+
const entry = state.sessions.get(id);
|
|
2254
|
+
const btn = document.getElementById(`orch-toggle-${id}`);
|
|
2255
|
+
if (!entry || entry._mounting || !entry.session || !btn) return;
|
|
2256
|
+
const role = entry.session.meta ? entry.session.meta.role : null;
|
|
2257
|
+
const isOrch = isOrchestratorRole(role);
|
|
2258
|
+
btn.textContent = orchToggleLabel(role);
|
|
2259
|
+
btn.classList.toggle('is-orch', isOrch);
|
|
2260
|
+
btn.title = isOrch
|
|
2261
|
+
? 'Unmark this panel as the orchestrator'
|
|
2262
|
+
: 'Mark this panel as the orchestrator — gold border, ORCH badge, pinned row';
|
|
2263
|
+
}
|
|
2264
|
+
|
|
1652
2265
|
async function askAI(id, question) {
|
|
1653
2266
|
if (!question.trim()) return;
|
|
1654
2267
|
const entry = state.sessions.get(id);
|
|
@@ -2877,6 +3490,11 @@
|
|
|
2877
3490
|
entry.session.meta = { ...entry.session.meta, ...meta };
|
|
2878
3491
|
}
|
|
2879
3492
|
|
|
3493
|
+
// Sprint 66 T1 (Task 1.3) — re-sync the orch-toggle button from the just-
|
|
3494
|
+
// merged role, so a role changed from another dashboard tab is reflected
|
|
3495
|
+
// here too (the per-tab toggle path syncs in its own finally block).
|
|
3496
|
+
syncOrchToggle(id);
|
|
3497
|
+
|
|
2880
3498
|
const dot = document.getElementById(`dot-${id}`);
|
|
2881
3499
|
const status = document.getElementById(`status-${id}`);
|
|
2882
3500
|
const metaLast = document.getElementById(`meta-last-${id}`);
|
|
@@ -2954,6 +3572,10 @@
|
|
|
2954
3572
|
document.getElementById('stat-thinking').textContent = thinking;
|
|
2955
3573
|
document.getElementById('stat-idle').textContent = idle;
|
|
2956
3574
|
renderSwitcher();
|
|
3575
|
+
// Sprint 65 T1 — reconcile dead tiles against this broadcast, then
|
|
3576
|
+
// refresh chips + ORCH-row chrome (coalesced to one rebuild per frame).
|
|
3577
|
+
reconcileExitedPanels(sessions);
|
|
3578
|
+
scheduleChromeRefresh();
|
|
2957
3579
|
}
|
|
2958
3580
|
|
|
2959
3581
|
function updateEmptyState() {
|
|
@@ -4246,6 +4868,17 @@
|
|
|
4246
4868
|
btn.addEventListener('click', () => setLayout(btn.dataset.layout));
|
|
4247
4869
|
});
|
|
4248
4870
|
|
|
4871
|
+
// Sprint 65 T1 (1.1) — project-filter chip clicks (delegated; chips are
|
|
4872
|
+
// rebuilt on every status_broadcast so a per-chip listener would go stale).
|
|
4873
|
+
const projectChipsRow = document.getElementById('project-chips');
|
|
4874
|
+
if (projectChipsRow) projectChipsRow.addEventListener('click', onProjectChipClick);
|
|
4875
|
+
|
|
4876
|
+
// Sprint 65 T1 (c) — topbar terminal font-size stepper.
|
|
4877
|
+
const fontDecBtn = document.getElementById('btn-font-dec');
|
|
4878
|
+
const fontIncBtn = document.getElementById('btn-font-inc');
|
|
4879
|
+
if (fontDecBtn) fontDecBtn.addEventListener('click', () => stepFontSize(-1));
|
|
4880
|
+
if (fontIncBtn) fontIncBtn.addEventListener('click', () => stepFontSize(1));
|
|
4881
|
+
|
|
4249
4882
|
document.getElementById('promptLaunch').addEventListener('click', launchTerminal);
|
|
4250
4883
|
document.getElementById('promptInput').addEventListener('keydown', (e) => {
|
|
4251
4884
|
if (e.key === 'Enter') launchTerminal();
|
|
@@ -4362,11 +4995,15 @@
|
|
|
4362
4995
|
document.getElementById('promptInput').focus();
|
|
4363
4996
|
}
|
|
4364
4997
|
}
|
|
4365
|
-
// Ctrl+Shift+1-
|
|
4366
|
-
|
|
4998
|
+
// Ctrl+Shift+1-9,0 OR Cmd+Shift+1-9,0 → layout switch (Mac friendly).
|
|
4999
|
+
// Sprint 65 T1 (1.4) — keys 1-9 map to indices 0-8, key 0 maps to index
|
|
5000
|
+
// 9. Keys 1-7 keep their pre-Sprint-65 layouts (muscle memory); 8/9/0 are
|
|
5001
|
+
// the new dense presets. Topbar buttons cover every preset incl. 2x5/5x2/3x4.
|
|
5002
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '0' && e.key <= '9') {
|
|
4367
5003
|
e.preventDefault();
|
|
4368
|
-
const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch'];
|
|
4369
|
-
|
|
5004
|
+
const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch', '1x2', '4x3', '4x4'];
|
|
5005
|
+
const idx = e.key === '0' ? 9 : parseInt(e.key, 10) - 1;
|
|
5006
|
+
if (layouts[idx]) setLayout(layouts[idx]);
|
|
4370
5007
|
}
|
|
4371
5008
|
// Ctrl+Shift+] / [ → cycle between terminals
|
|
4372
5009
|
if (e.ctrlKey && e.shiftKey && (e.key === ']' || e.key === '[')) {
|