/**
* @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking,
* agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking),
* file browser (directory tree, preview), log viewer (floating file streamers),
* image popups (auto-popup for screenshots), mux sessions, monitor panel,
* token statistics, toast notifications, and system stats.
* Includes 19 SSE handlers for tasks, mux, bash tools, subagents, and images.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.sessions)
* @dependency constants.js (escapeHtml, ZINDEX_* constants)
* @dependency subagent-windows.js (openSubagentWindow, closeSubagentWindow)
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
*/
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
// Bounds for the by-id text preview, mirroring what the workspace text preview
// already does server-side (500 lines). The byte cap rides a Range request, so
// a huge log is a partial read rather than a download the viewer throws away.
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
const TEXT_PREVIEW_MAX_LINES = 500;
// A markdown document gets the route's ceiling instead of the 500-line preview
// cap: a rendered README cut mid-way reads as the whole document.
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
// File Viewer text-view prefs: per-device, in their own localStorage keys for
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
// rebuilt from the settings modal on save, so a key toggled from the viewer
// would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered',
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
expanded: 'codeman:filePreviewExpanded',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true, expanded: false };
// Back/forward list of previews opened from one overlay session (links between documents).
const FILE_PREVIEW_HISTORY_MAX = 50;
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
['stillRunning', 'Still Running'],
['idle', 'Idle'],
['informational', 'Informational'],
];
Object.assign(CodemanApp.prototype, {
_addActivityEntry(agentId, entry, maxSize = 50) {
const activity = this.subagentActivity.get(agentId) || [];
activity.push(entry);
if (activity.length > maxSize) activity.shift();
this.subagentActivity.set(agentId, activity);
},
// Tasks
_onTaskCreated(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
},
_onTaskCompleted(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
},
_onTaskFailed(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
},
_onTaskUpdated(data) {
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
},
// Mux (tmux)
_onMuxCreated(data) {
this.muxSessions.push(data);
this.renderMuxSessions();
},
_onMuxKilled(data) {
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
},
_onMuxDied(data) {
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning');
},
_onMuxStatsUpdated(data) {
this.muxSessions = data;
if (document.getElementById('monitorPanel').classList.contains('open')) {
this.renderMuxSessions();
}
},
// Remote auto-reconnect (COD-108)
_onRemoteSessionReconnected(data) {
const id = this.getShortId(data.sessionId);
this.showToast(`Remote session ${id} reconnected`, 'success');
// A successful reattach (the wake flow's own, or the watcher's) means the host is
// back: drop the "unreachable" banner without waiting out the poll interval.
if (this.activeSessionId === data.sessionId) this._pollHostReachability?.(true);
},
_onRemoteReconnectExhausted(data) {
const sessionId = data.sessionId;
const id = this.getShortId(sessionId);
// Auto-reconnect gave up after the bounded backoff. Surface a manual
// "Reconnect" affordance that re-triggers the attach path (force-reload the
// session, which re-runs the create/attach flow against the durable remote).
this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', {
duration: 15000,
action: {
label: 'Reconnect',
onClick: () => {
if (this.sessions && this.sessions.has(sessionId)) {
this.selectSession(sessionId, { forceReload: true });
} else {
this.showToast('Session no longer available', 'warning');
}
},
},
});
},
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
// ⚠️ The `remote:hostWaking` / `remote:hostWakeFailed` HANDLERS live in
// `host-wake-ui.js`, which owns the banner state. They are NOT redefined here:
// both files mix into `CodemanApp.prototype` and `host-wake-ui.js` is loaded
// later, so a second definition would silently shadow the banner update (and the
// toast would never fire — the exact silent no-op `sse-dispatch-table.test.ts`
// exists to prevent, which cannot see shadowing). The toasts are shown from the
// host-wake-ui handlers instead.
// Bash tools
_onBashToolStart(data) {
this.handleBashToolStart(data.sessionId, data.tool);
},
_onBashToolEnd(data) {
this.handleBashToolEnd(data.sessionId, data.tool);
},
_onBashToolsUpdate(data) {
this.handleBashToolsUpdate(data.sessionId, data.tools);
},
// Subagents (Claude Code background agents)
_onSubagentDiscovered(data) {
// Clear all old data for this agentId (in case of ID reuse)
this.subagents.set(data.agentId, data);
this.subagentActivity.set(data.agentId, []);
this.subagentToolResults.delete(data.agentId);
// Close any existing window for this agentId (will be reopened fresh)
if (this.subagentWindows.has(data.agentId)) {
this.forceCloseSubagentWindow(data.agentId);
}
this.renderSubagentPanel();
// Find which Codeman session owns this subagent (direct claudeSessionId match only)
this.findParentSessionForSubagent(data.agentId);
// Subagent windows are no longer auto-opened on discovery.
// Users can open them manually from the monitor panel or via openAllActiveSubagentWindows().
// Ensure connection lines are updated after window is created and DOM settles
requestAnimationFrame(() => {
this.updateConnectionLines();
});
// Notify about new subagent discovery
const parentId = this.subagentParentMap.get(data.agentId);
this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started');
},
_onSubagentUpdated(data) {
const existing = this.subagents.get(data.agentId);
if (existing) {
// Merge updated fields (especially description)
Object.assign(existing, data);
this.subagents.set(data.agentId, existing);
} else {
this.subagents.set(data.agentId, data);
}
this.renderSubagentPanel();
// Update floating window if open (content + header/title)
if (this.subagentWindows.has(data.agentId)) {
this.renderSubagentWindowContent(data.agentId);
this.updateSubagentWindowHeader(data.agentId);
}
},
_onSubagentToolCall(data) {
this._addActivityEntry(data.agentId, { type: 'tool', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
this.renderSubagentPanel();
// Update floating window (debounced — tool_call events fire rapidly)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
},
_onSubagentProgress(data) {
this._addActivityEntry(data.agentId, { type: 'progress', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
},
_onSubagentMessage(data) {
this._addActivityEntry(data.agentId, { type: 'message', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
},
_onSubagentToolResult(data) {
// Store tool result by toolUseId for later lookup (cap at 50 per agent)
if (!this.subagentToolResults.has(data.agentId)) {
this.subagentToolResults.set(data.agentId, new Map());
}
const resultsMap = this.subagentToolResults.get(data.agentId);
resultsMap.set(data.toolUseId, data);
if (resultsMap.size > 50) {
const oldest = resultsMap.keys().next().value;
resultsMap.delete(oldest);
}
// Add to activity stream
this._addActivityEntry(data.agentId, { type: 'tool_result', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
},
async _onSubagentCompleted(data) {
const existing = this.subagents.get(data.agentId);
if (existing) {
existing.status = 'completed';
this.subagents.set(data.agentId, existing);
}
this.renderSubagentPanel();
this.updateSubagentWindows();
// Auto-minimize completed subagent windows
if (this.subagentWindows.has(data.agentId)) {
const windowData = this.subagentWindows.get(data.agentId);
if (windowData && !windowData.minimized) {
await this.closeSubagentWindow(data.agentId); // This minimizes to tab
this.saveSubagentWindowStates(); // Persist the minimized state
}
}
// Notify about subagent completion
const parentId = this.subagentParentMap.get(data.agentId);
this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished');
// Clean up activity/tool data for completed agents after 5 minutes
// This prevents memory leaks from long-running sessions with many subagents
setTimeout(() => {
const agent = this.subagents.get(data.agentId);
// Only clean up if agent is still completed (not restarted)
if (agent?.status === 'completed') {
this.subagentActivity.delete(data.agentId);
this.subagentToolResults.delete(data.agentId);
}
}, 5 * 60 * 1000); // 5 minutes
// Prune stale completed agents from main maps after 30 minutes
// Keeps subagents/subagentParentMap from growing unbounded in 24h sessions
setTimeout(() => {
const agent = this.subagents.get(data.agentId);
if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) {
this.subagents.delete(data.agentId);
this.subagentParentMap.delete(data.agentId);
}
}, 30 * 60 * 1000); // 30 minutes
},
// Images
_onImageDetected(data) {
console.log('[Image Detected]', data);
this.openImagePopup(data);
},
// ═══════════════════════════════════════════════════════════════
// Command Palette (COD-153)
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
// ═══════════════════════════════════════════════════════════════
shouldOpenCommandPaletteFromShortcut(e) {
if (!e) return false;
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
// rebinds), so plain typing exits before any registry work — this runs on
// the document AND xterm keydown hot paths.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
// Registry-aware chord check (COD-157): honors a rebound or disabled
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
// registry isn't available (isolated test harnesses).
const registryAvailable =
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
const palette = registryAvailable
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
: null;
if (palette) {
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
} else {
const key = (e.key || '').toLowerCase();
if (key !== 'k' && e.code !== 'KeyK') return false;
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
// devtools console; matchesShortcutEvent applies the same rule above).
if (e.shiftKey) return false;
}
const target = e.target;
if (!target) return true;
const tagName = (target.tagName || '').toUpperCase();
const className = typeof target.className === 'string' ? target.className : '';
const isXtermHelper =
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
if (isXtermHelper) return true;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
if (target.isContentEditable) return false;
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
return true;
},
/**
* Save what had focus before a search-first overlay takes it.
*
* The Command Palette and the Session Manager both call `search.focus()` on
* open, and both used to close by removing the `active` class and nothing
* else. Hiding the focused input does not hand focus back to anyone — the
* browser drops it on `
` — so after Escape closed the overlay every
* keystroke went nowhere and the user had to click the terminal to type
* again (measured: `document.activeElement` is BODY afterwards and the
* terminal emits no onData at all). Every close path has the same hole, so
* the restore lives in the close functions, not in the global Escape chain.
*
* ⚠️ Deliberately only the save/restore half of {@link FocusTrap}, not the
* whole thing. `FocusTrap.activate()` moves focus to the first focusable
* element, which in both of these overlays is not the search box — adopting
* it wholesale would fix the focus loss by breaking the thing Cmd+K exists
* for, typing a filter the moment it opens.
*/
_rememberOverlayFocus(key) {
this[key] = (typeof document !== 'undefined' && document.activeElement) || null;
},
/**
* Hand focus back to whatever {@link _rememberOverlayFocus} saved.
*
* ⚠️ The terminal fallback is gated on there being an active session: an
* overlay opened from the welcome screen has no terminal to return to, and
* focusing one on a phone summons the on-screen keyboard over a screen that
* has no input on it.
*
* ⚠️ A focus that already left the overlay is kept, not overridden. The
* Session Manager's row menu ("Switch to session", "Open folder") calls
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
* restoring there would pull focus back to the header button that opened the
* modal. Only a focus still inside `modal`, or one dropped on ``, is
* the overlay's to hand back.
*/
_restoreOverlayFocus(key, modal) {
const prev = this[key];
this[key] = null;
const body = typeof document !== 'undefined' ? document.body : null;
const current = typeof document !== 'undefined' ? document.activeElement : null;
if (current && current !== body && modal?.contains?.(current) === false) return;
// `isConnected === false` means the element was removed while the overlay
// was open (a re-render of the tab strip, say); anything else — including
// a stub with no such property — is treated as still focusable.
if (prev && prev !== body && prev.isConnected !== false && typeof prev.focus === 'function') {
prev.focus();
return;
}
// ⚠️ `activeSessionId` alone only covers the welcome screen. On a touch device
// with the keyboard down, focus sits on ``, so focusing the terminal here
// would summon the on-screen keyboard — `selectSession()` deliberately skips the
// focus for exactly that reason, and this would override it. The app's own
// predicate already encodes the rule (true on desktop, on touch only while the
// keyboard is open); the optional call keeps the vm test harness working.
if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) {
this.terminal?.focus?.();
}
},
openCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
if (!modal || !search) return;
this.commandPaletteActiveIndex = 0;
this._resetCommandPaletteAgent();
search.value = '';
modal.classList.add('active');
this._wireCommandPalette();
this.renderCommandPalette();
// BEFORE the steal, not after: `search.focus()` below is what loses the
// caller's focus, so the read has to happen while it is still there.
this._rememberOverlayFocus('_commandPalettePrevFocus');
search.focus();
search.select?.();
},
closeCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
// ⚠️ Bail out when it was not open. The global Escape handler calls this on
// EVERY Escape (app.js), in the CAPTURE phase, so an unconditional restore
// runs before the focused element's own Escape handler and steals focus into
// the terminal: keys typed after Escape in split Pane B land in Pane A, keys
// typed in any text field land in the terminal, and the inline tab rename's
// Escape fires the input's blur (which commits) before its own handler
// (which cancels), turning a cancel into a rename.
if (!modal?.classList?.contains('active')) return;
modal.classList.remove('active');
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
},
_wireCommandPalette() {
if (this._commandPaletteWired) return;
this._commandPaletteWired = true;
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
search?.addEventListener('input', () => {
this.commandPaletteActiveIndex = 0;
this.renderCommandPalette();
});
search?.addEventListener('keydown', async (e) => {
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && this._commandPaletteArrowCyclesAgent(e)) {
e.preventDefault();
this._cycleCommandPaletteAgent(e.key === 'ArrowRight' ? 1 : -1);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveCommandPaletteSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.moveCommandPaletteSelection(-1);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await this.activateCommandPaletteItem();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
modal?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
// Clicking an agent arrow must leave focus in the search box, or the
// Left/Right/Enter keys stop reaching the palette after the first click.
list?.addEventListener?.('mousedown', (e) => {
if (e.target?.closest?.('[data-palette-agent-cycle]')) e.preventDefault?.();
});
list?.addEventListener?.('click', (e) => {
// The agent arrows sit inside the new-session row: they change the agent
// and must not also activate (launch) the row they are drawn in.
const cycle = e.target?.closest?.('[data-palette-agent-cycle]');
if (cycle) {
e.preventDefault?.();
e.stopPropagation?.();
this._cycleCommandPaletteAgent(cycle.dataset.paletteAgentCycle === 'prev' ? -1 : 1);
return;
}
const row = e.target?.closest?.('[data-command-index]');
if (!row) return;
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
void this.activateCommandPaletteItem();
});
},
buildCommandPaletteItems(query = '') {
const needle = query.trim().toLowerCase();
const orderedIds = [
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
];
const seen = new Set();
const sessionItems = [];
for (const sessionId of orderedIds) {
if (seen.has(sessionId)) continue;
seen.add(sessionId);
const session = this.sessions?.get?.(sessionId);
if (!session) continue;
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
if (needle && !haystack.includes(needle)) continue;
sessionItems.push({
id: `session:${sessionId}`,
type: 'session',
sessionId,
title,
subtitle: subtitleParts.join(' · '),
});
}
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
return sessionItems;
},
_buildCommandPaletteNewSessionItem(query = '') {
const caseName = this._commandPaletteCaseName(query);
const agent = this._commandPaletteAgent(caseName);
const mode = agent?.id || this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
return {
id: 'new-session',
type: 'new-session',
caseName,
mode,
title: 'New session',
subtitle: `Run ${agent?.label || labels[mode] || mode} in ${caseName}`,
};
},
/** The case a new session from the palette starts in: the case the query names, else the Run button's case. */
_commandPaletteCaseName(query = '') {
return this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
},
/** The case of the new-session row on screen (the palette's last render), for the agent switch. */
_commandPaletteRowCase() {
const row = (this.commandPaletteItems || []).find((item) => item?.type === 'new-session');
return row ? row.caseName : this._commandPaletteCaseName(document.getElementById('commandPaletteSearch')?.value || '');
},
/**
* The agents the new-session row can start in `caseName`: the server-injected
* CLI catalog's enabled entries that the toolbar Run menu offers for that case,
* in catalog order. An agent entry goes through the Run menu's own rule,
* _runModeAvailableForCase (session-ui.js): the host's CLIs for a local or
* remote case, the container's for a Docker case. A non-agent entry (Shell) is
* never gated, as in the Run menu. The vm test harness may load this file
* without session-ui.js, and then the host's isCliAvailable() decides. The ids
* are DATA from the registry, never a list kept here. Empty on a page rendered
* without the catalog; that and a single entry both leave the row on the Run
* button's mode (see _commandPaletteAgent).
*/
_commandPaletteAgentChoices(caseName = this._commandPaletteRowCase()) {
const catalog = typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
const offered = (id) =>
typeof this._runModeAvailableForCase === 'function'
? this._runModeAvailableForCase(id, caseName)
: (this.isCliAvailable?.(id) ?? true);
return catalog
.filter((cli) => cli && typeof cli.id === 'string' && cli.enabled && (cli.kind !== 'agent' || offered(cli.id)))
.map((cli) => ({ id: cli.id, label: (typeof cli.label === 'string' && cli.label.trim()) || cli.id }));
},
/**
* The agent the new-session row will start in `caseName`: the one picked with
* the switch since the palette opened, else the Run button's mode, else the
* first offered. Null when fewer than two are offered: with one entry there is
* nothing to pick, so the row keeps the Run button's mode as it did before the
* switch existed, and a Run mode the case cannot start fails the way the Run
* button would, instead of the palette quietly starting the one entry offered
* (often Shell).
*/
_commandPaletteAgent(caseName = this._commandPaletteRowCase()) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return null;
const runMode = this.runMode || this._runMode;
return (
choices.find((c) => c.id === this._commandPaletteAgentId) || choices.find((c) => c.id === runMode) || choices[0]
);
},
/**
* Each time the palette opens it forgets the last pick, so the row starts on
* the Run button's mode (see _commandPaletteAgent). That default is resolved
* per render, not here: the query can move the row to another case, which may
* offer the Run button's mode when the first case did not.
*/
_resetCommandPaletteAgent() {
this._commandPaletteAgentId = null;
},
_cycleCommandPaletteAgent(delta) {
const caseName = this._commandPaletteRowCase();
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return;
const at = Math.max(0, choices.findIndex((c) => c.id === this._commandPaletteAgent(caseName)?.id));
this._commandPaletteAgentId = choices[(((at + delta) % choices.length) + choices.length) % choices.length].id;
this.renderCommandPalette();
},
/**
* Left/Right change the agent only while the new-session row is highlighted,
* and the caret is at either query boundary, with no selection. Both arrows
* stay in picker navigation after typing a case name; inside the query they
* still edit text, as do modified arrows and selection changes.
*/
_commandPaletteArrowCyclesAgent(e) {
const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0];
if (active?.type !== 'new-session') return false;
if (this._commandPaletteAgentChoices(active.caseName).length < 2) return false;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
const input = e.target;
const value = typeof input?.value === 'string' ? input.value : '';
const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length;
const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start;
if (start !== end) return false;
return start <= 0 || end >= value.length;
},
_findCommandPaletteCaseMatch(query = '') {
const needle = query.trim().toLowerCase();
if (!needle || !Array.isArray(this.cases)) return null;
const scoreCase = (caseItem) => {
const name = String(caseItem?.name || '').trim();
if (!name) return 0;
const haystack = [
name,
caseItem?.path,
caseItem?.casePath,
caseItem?.workingDir,
caseItem?.remote?.path,
caseItem?.remote?.hostId,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerName === needle) return 100;
if (lowerName.startsWith(needle)) return 90;
if (lowerName.includes(needle)) return 80;
if (haystack.includes(needle)) return 60;
return 0;
};
let best = null;
let bestScore = 0;
for (const caseItem of this.cases) {
const score = scoreCase(caseItem);
if (score > bestScore) {
best = caseItem;
bestScore = score;
}
}
return best?.name || null;
},
renderCommandPalette() {
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
if (!list) return;
const query = search?.value || '';
const items = this.buildCommandPaletteItems(query);
this.commandPaletteItems = items;
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
list.innerHTML = items
.map((item, index) => {
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
const agent = item.type === 'new-session' ? this._renderCommandPaletteAgentPicker(item.mode, item.caseName) : '';
return `
`;
})
.join('');
},
/**
* The new-session row's agent switch: arrows either side of the agent's logo
* and name. Spans, not buttons: the row itself is a