mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
feat: COD-153 add command-k session palette
This commit is contained in:
committed by
Aamer Akhter
parent
48fd2da6ce
commit
ad25e234f4
@@ -482,6 +482,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd+K` | Find open session or start a new one |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
|
||||
@@ -804,11 +804,19 @@ class CodemanApp {
|
||||
// Don't intercept keys during CJK IME composition
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
|
||||
e.preventDefault();
|
||||
this.openCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||
if (e.key === 'Escape') {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
}
|
||||
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
|
||||
+71
-12
@@ -569,18 +569,47 @@
|
||||
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
</div>
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Tabs</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Panels</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>/</kbd></div><div>Show Help</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1963,6 +1992,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Palette Modal -->
|
||||
<div class="modal command-palette-modal" id="commandPaletteModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
|
||||
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="command-palette-input-row">
|
||||
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
|
||||
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
|
||||
<div id="commandPaletteList" class="command-palette-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Manager Modal -->
|
||||
<div class="modal" id="sessionManagerModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
|
||||
<div class="modal-content session-manager-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Sessions</h3>
|
||||
<button class="modal-close" onclick="app.closeSessionManager()" aria-label="Close session manager">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path…" autocomplete="off" maxlength="200">
|
||||
<div id="sessionManagerList" class="session-manager-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
|
||||
@@ -198,6 +198,27 @@ html.mobile-init .file-browser-panel {
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.command-palette-modal {
|
||||
padding: 10vh 0.75rem 0;
|
||||
}
|
||||
|
||||
.command-palette-shell {
|
||||
width: 100%;
|
||||
max-height: 74vh;
|
||||
}
|
||||
|
||||
.command-palette-input-row {
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.command-palette-input-row kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.modal-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@@ -250,6 +250,305 @@ Object.assign(CodemanApp.prototype, {
|
||||
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;
|
||||
if ((e.key || '').toLowerCase() !== 'k') return false;
|
||||
if (!(e.metaKey || e.ctrlKey) || e.altKey) return false;
|
||||
|
||||
const target = e.target;
|
||||
if (!target) return true;
|
||||
const tagName = (target.tagName || '').toUpperCase();
|
||||
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;
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
if (!modal || !search) return;
|
||||
|
||||
this.closeMobileHeaderUtilities?.();
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
search.value = '';
|
||||
modal.classList.add('active');
|
||||
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
_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 === '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();
|
||||
}
|
||||
});
|
||||
|
||||
list?.addEventListener?.('click', (e) => {
|
||||
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 = 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());
|
||||
return sessionItems;
|
||||
},
|
||||
|
||||
_buildCommandPaletteNewSessionItem() {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
|
||||
const caseName = document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
type: 'new-session',
|
||||
title: 'New session',
|
||||
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
|
||||
};
|
||||
},
|
||||
|
||||
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' ? '+' : '›';
|
||||
return `
|
||||
<button class="command-palette-item${active}" type="button" data-command-index="${index}">
|
||||
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
|
||||
<span class="command-palette-text">
|
||||
<span class="command-palette-title">${escapeHtml(item.title)}</span>
|
||||
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
moveCommandPaletteSelection(delta) {
|
||||
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
|
||||
if (!items.length) return;
|
||||
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
|
||||
this.renderCommandPalette();
|
||||
},
|
||||
|
||||
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
|
||||
const item = (this.commandPaletteItems || [])[index];
|
||||
if (!item) return;
|
||||
|
||||
this.closeCommandPalette();
|
||||
if (item.type === 'session' && item.sessionId) {
|
||||
await this.selectSession(item.sessionId);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'new-session') {
|
||||
await this.run();
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Manager Modal (COD-121)
|
||||
// Persistent, header-reachable session list (GET /api/sessions/unified)
|
||||
// reachable mid-session, with a server-side search box. Reuses the
|
||||
// unit-2 history item renderer so clicking resumes/switches sessions.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
// Escape closes the modal even while focus is in the search input. A
|
||||
// modal-scoped listener is robust regardless of the global Escape chain
|
||||
// (which runs other close handlers first and can short-circuit). Wire once.
|
||||
if (!this._sessionManagerEscWired) {
|
||||
this._sessionManagerEscWired = true;
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.closeSessionManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cases are loaded so item subtitles can show "#caseName" labels.
|
||||
// Mirror loadHistorySessions(): prefer already-loaded this.cases.
|
||||
if (!Array.isArray(this.cases) || this.cases.length === 0) {
|
||||
try {
|
||||
const r = await fetch('/api/cases');
|
||||
const d = r.ok ? await r.json() : null;
|
||||
this.cases = d?.data || [];
|
||||
} catch {
|
||||
this.cases = this.cases || [];
|
||||
}
|
||||
}
|
||||
|
||||
const search = document.getElementById('sessionManagerSearch');
|
||||
if (search) {
|
||||
// Wire the debounced search input once (lazy — the element exists by
|
||||
// the time the modal is first opened, and mixin methods are bound).
|
||||
if (!this._sessionManagerSearchWired) {
|
||||
this._sessionManagerSearchWired = true;
|
||||
search.addEventListener('input', () => {
|
||||
const value = search.value.trim();
|
||||
this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200);
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
},
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-121: live-refresh the unified session list when sessions change
|
||||
* (created/updated/deleted via SSE). Only touches surfaces that are currently
|
||||
* showing — the open Session Manager modal and/or the visible welcome list —
|
||||
* and is debounced so an event burst collapses into one re-fetch. The current
|
||||
* search query is preserved.
|
||||
*/
|
||||
_onSessionListMaybeChanged() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
this._debouncedCall(
|
||||
'sessionManagerRefresh',
|
||||
() => this._loadSessionManagerList(this._sessionManagerQuery || ''),
|
||||
400
|
||||
);
|
||||
}
|
||||
const welcome = document.getElementById('welcomeOverlay');
|
||||
if (welcome && welcome.classList.contains('visible')) {
|
||||
this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
async _loadSessionManagerList(q = '') {
|
||||
this._sessionManagerQuery = q;
|
||||
const list = document.getElementById('sessionManagerList');
|
||||
if (!list) return;
|
||||
try {
|
||||
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const sessions = data.data?.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'empty-message';
|
||||
empty.textContent = q ? 'No sessions match your search' : 'No sessions found';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const s of sessions) {
|
||||
const item = this._buildHistoryItem(s, this.cases, { showViewAll: false });
|
||||
// COD-130: scope the modal-close to the main (resume) row only, in the
|
||||
// bubble phase. The ⋯ kebab button calls stopPropagation(), so clicking
|
||||
// it (or its menu) no longer closes the Session Manager modal.
|
||||
item.querySelector('.history-item-main')?.addEventListener('click', () => this.closeSessionManager());
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[_loadSessionManagerList]', err);
|
||||
list.replaceChildren();
|
||||
const errLine = document.createElement('p');
|
||||
errLine.className = 'empty-message';
|
||||
errLine.textContent = 'Failed to load sessions';
|
||||
list.appendChild(errLine);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Away Digest Modal
|
||||
|
||||
@@ -5525,6 +5525,217 @@ kbd {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette (COD-153) */
|
||||
.command-palette-modal {
|
||||
align-items: flex-start;
|
||||
padding-top: min(16vh, 120px);
|
||||
}
|
||||
|
||||
.command-palette-shell {
|
||||
position: relative;
|
||||
width: min(92vw, 620px);
|
||||
max-height: min(70vh, 620px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: rgba(19, 19, 22, 0.97);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.command-palette-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.command-palette-search-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-palette-search {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.command-palette-search:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.command-palette-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.command-palette-input-row kbd {
|
||||
padding: 0.15rem 0.35rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.command-palette-label {
|
||||
padding: 0.55rem 0.8rem 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-palette-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-palette-item:hover,
|
||||
.command-palette-item.active {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
}
|
||||
|
||||
.command-palette-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.command-palette-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-palette-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Session Manager modal (COD-121) */
|
||||
.session-manager-modal {
|
||||
max-width: 720px;
|
||||
width: min(92vw, 720px);
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-manager-modal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#sessionManagerSearch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.session-manager-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* COD-130: per-row ⋯ kebab context menu (appended to <body>, fixed-positioned).
|
||||
z-index must clear the Session Manager modal (.modal is z-index: 1000). */
|
||||
.session-row-menu {
|
||||
position: fixed;
|
||||
z-index: 10010;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: rgba(22, 22, 28, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.session-row-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-smooth);
|
||||
}
|
||||
|
||||
.session-row-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-row-menu-sublabel {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
const makeClassList = () => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
toggle: (name: string, force?: boolean) => {
|
||||
const shouldAdd = force ?? !classes.has(name);
|
||||
if (shouldAdd) classes.add(name);
|
||||
else classes.delete(name);
|
||||
return shouldAdd;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
elements.commandPaletteModal = {
|
||||
classList: makeClassList(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`modal:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
elements.commandPaletteSearch = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`search:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
elements.commandPaletteList = {
|
||||
innerHTML: '',
|
||||
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
||||
listeners[`list:${event}`] = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
},
|
||||
console,
|
||||
escapeHtml: (value: string) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '''),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.sessions = new Map([
|
||||
[
|
||||
'sess-alpha',
|
||||
{
|
||||
id: 'sess-alpha',
|
||||
name: 'Alpha API cleanup',
|
||||
workingDir: '/repo/api',
|
||||
mode: 'codex',
|
||||
status: 'busy',
|
||||
},
|
||||
],
|
||||
[
|
||||
'sess-beta',
|
||||
{
|
||||
id: 'sess-beta',
|
||||
name: 'Billing prompt polish',
|
||||
workingDir: '/repo/billing',
|
||||
mode: 'claude',
|
||||
status: 'idle',
|
||||
},
|
||||
],
|
||||
]);
|
||||
app.sessionOrder = ['sess-beta', 'sess-alpha'];
|
||||
app.selectSession = vi.fn();
|
||||
app.run = vi.fn();
|
||||
app.closeMobileHeaderUtilities = vi.fn();
|
||||
|
||||
return { app, elements, listeners };
|
||||
}
|
||||
|
||||
describe('Command-K session palette', () => {
|
||||
it('recognizes Cmd/Ctrl-K outside text-entry contexts only', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', metaKey: true, ctrlKey: false, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'K', metaKey: false, ctrlKey: true, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
target: { tagName: 'INPUT', isContentEditable: false },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({
|
||||
key: 'k',
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
target: { tagName: 'DIV', isContentEditable: true },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('opens and focuses the palette search box', () => {
|
||||
const { app, elements } = loadPaletteHarness();
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(elements.commandPaletteModal.classList.contains('active')).toBe(true);
|
||||
expect(elements.commandPaletteSearch.focus).toHaveBeenCalledTimes(1);
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('Alpha API cleanup');
|
||||
});
|
||||
|
||||
it('filters currently open sessions and always includes a new-session action', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
const results = app.buildCommandPaletteItems('bill');
|
||||
|
||||
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session']);
|
||||
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
|
||||
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
|
||||
});
|
||||
|
||||
it('activates the highlighted session result', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activates the new-session result through the current run path', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('does-not-match');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes Enter from the palette search to the current result', async () => {
|
||||
const { app, listeners } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
await listeners['search:keydown'](event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
});
|
||||
});
|
||||
@@ -34,4 +34,10 @@ describe('keyboard shortcuts', () => {
|
||||
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
|
||||
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
|
||||
});
|
||||
|
||||
it('documents the Command-K open-session palette in help and README', () => {
|
||||
expect(appSource).toContain('this.openCommandPalette()');
|
||||
expect(helpHtml).toContain('<kbd>Ctrl/Cmd</kbd>+<kbd>K</kbd>');
|
||||
expect(readme).toMatch(/\| `Ctrl\/Cmd\+K`\s+\| Find open session or start a new one\s+\|/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user