feat: COD-153 add command-k session palette

This commit is contained in:
Saqeb Akhter
2026-07-09 11:08:17 -04:00
committed by Aamer Akhter
parent 48fd2da6ce
commit ad25e234f4
8 changed files with 798 additions and 12 deletions
+1
View File
@@ -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) |
+8
View File
@@ -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
View File
@@ -569,18 +569,47 @@
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">&times;</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">&times;</button>
</div>
<div class="modal-body">
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path&hellip;" 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>
+21
View File
@@ -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;
+299
View File
@@ -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
+211
View File
@@ -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;
+181
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'),
...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');
});
});
+6
View File
@@ -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+\|/);
});
});