repoburg 1.3.186 → 1.3.188

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.
Files changed (25) hide show
  1. package/agent-pane/backend/src/codex/codex-provider.ts +105 -2
  2. package/agent-pane/backend/src/db/index.ts +24 -0
  3. package/agent-pane/backend/src/db/schema.ts +11 -0
  4. package/agent-pane/backend/src/index.ts +2 -0
  5. package/agent-pane/backend/src/routes/sessions.ts +37 -0
  6. package/agent-pane/backend/src/routes/workspaces.ts +110 -0
  7. package/agent-pane/frontend/src/api/client.ts +12 -0
  8. package/agent-pane/frontend/src/api/sessions.ts +17 -1
  9. package/agent-pane/frontend/src/api/websocket.ts +11 -1
  10. package/agent-pane/frontend/src/api/workspaces.ts +24 -0
  11. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
  12. package/agent-pane/frontend/src/components/CommandPalette.tsx +11 -0
  13. package/agent-pane/frontend/src/components/RenderArea.tsx +61 -48
  14. package/agent-pane/frontend/src/components/SessionTabs.tsx +6 -3
  15. package/agent-pane/frontend/src/components/StatusBar.tsx +13 -5
  16. package/agent-pane/frontend/src/components/Toast.tsx +60 -14
  17. package/agent-pane/frontend/src/components/TurnContent.tsx +104 -12
  18. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +325 -35
  19. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +6 -2
  20. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +15 -2
  21. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +18 -57
  22. package/agent-pane/frontend/src/store/store.ts +159 -16
  23. package/agent-pane/frontend/src/styles/app.css +79 -2
  24. package/agent-pane/frontend/src/types/index.ts +46 -8
  25. package/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  import { Codex, type Thread, type ThreadEvent, type ThreadItem, type ThreadOptions } from '@openai/codex-sdk';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
+ import Database from 'better-sqlite3';
4
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
3
5
  import { getDb } from '../db/index.ts';
4
6
  import { broadcast } from '../ws/index.ts';
5
7
  import { mapItemData, getItemStatus } from './item-mapper.ts';
@@ -213,9 +215,12 @@ function handleEvent(event: ThreadEvent, sessionId: string, turnId: string): voi
213
215
  const usage = event.usage;
214
216
  db.prepare('UPDATE turns SET status = ?, token_usage = ?, updated_at = ? WHERE id = ?')
215
217
  .run('completed', JSON.stringify(usage), now, turnId);
216
- db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
218
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?')
217
219
  .run('active', now, sessionId);
218
- broadcast('turn:completed', { sessionId, turnId, usage });
220
+ broadcast('turn:completed', { sessionId, turnId, usage, hasUnreviewed: true });
221
+
222
+ // Auto-generate session title from first turn (if title is still null)
223
+ syncThreadTitle(sessionId);
219
224
  break;
220
225
  }
221
226
 
@@ -257,4 +262,102 @@ function makeItem(
257
262
  created_at: createdAt,
258
263
  updated_at: updatedAt,
259
264
  };
265
+ }
266
+
267
+ /** Tracks sessions currently generating titles — prevents duplicate throwaway threads. */
268
+ const titleGenerating = new Set<string>();
269
+
270
+ /** Generate a title for the session using a throwaway Codex thread.
271
+ * Only runs once per session — guarded by title === null AND titleGenerating set. */
272
+ function syncThreadTitle(sessionId: string): void {
273
+ const db = getDb();
274
+ const session = db.prepare('SELECT title FROM sessions WHERE id = ?')
275
+ .get(sessionId) as { title: string | null } | undefined;
276
+ if (!session || session.title !== null) return; // Already has a title
277
+ if (titleGenerating.has(sessionId)) return; // Already generating
278
+
279
+ // Get the first turn's user_prompt and agent_response
280
+ const turn = db.prepare('SELECT user_prompt, agent_response FROM turns WHERE session_id = ? ORDER BY created_at ASC LIMIT 1')
281
+ .get(sessionId) as { user_prompt: string; agent_response: string | null } | undefined;
282
+ if (!turn) return;
283
+
284
+ // Mark as generating — prevents concurrent title generation for same session
285
+ titleGenerating.add(sessionId);
286
+
287
+ // Fire-and-forget — don't block the turn:completed handler
288
+ generateTitleAsync(sessionId, turn.user_prompt, turn.agent_response).finally(() => {
289
+ titleGenerating.delete(sessionId);
290
+ });
291
+ }
292
+
293
+ /** Create a throwaway Codex thread, ask for a title, update the session, then clean up. */
294
+ async function generateTitleAsync(sessionId: string, userPrompt: string, agentResponse: string | null): Promise<void> {
295
+ let throwawayThreadId: string | null = null;
296
+ try {
297
+ const codex = new Codex();
298
+ const thread = codex.startThread({ skipGitRepoCheck: true });
299
+ throwawayThreadId = thread.id;
300
+
301
+ const truncatedPrompt = userPrompt.slice(0, 500);
302
+ const truncatedResponse = (agentResponse || '').slice(0, 500);
303
+
304
+ const titlePrompt = `Generate a concise 3-5 word title for this conversation. Reply with only the title, nothing else.\n\nUser asked: "${truncatedPrompt}"\n\nAgent responded: "${truncatedResponse}"`;
305
+
306
+ const result = await thread.run(titlePrompt);
307
+ const title = result.finalResponse.trim().replace(/^["']|["']$/g, '').slice(0, 100);
308
+
309
+ if (title) {
310
+ const db = getDb();
311
+ const now = new Date().toISOString();
312
+ db.prepare('UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?').run(title, now, sessionId);
313
+ broadcast('session:title', { sessionId, title });
314
+ }
315
+ } catch {
316
+ // Silently skip on error — session keeps null title
317
+ } finally {
318
+ // Clean up the throwaway thread
319
+ if (throwawayThreadId) {
320
+ cleanupThrowawayThread(throwawayThreadId);
321
+ }
322
+ }
323
+ }
324
+
325
+ /** Delete throwaway thread from Codex state DB, rollout file, and session_index.jsonl. */
326
+ function cleanupThrowawayThread(threadId: string): void {
327
+ const home = process.env.HOME || process.env.USERPROFILE || '';
328
+
329
+ // 1. Delete from state DB and get rollout path
330
+ try {
331
+ const codexDb = new Database(`${home}/.codex/state_5.sqlite`);
332
+ const row = codexDb.prepare('SELECT rollout_path FROM threads WHERE id = ?')
333
+ .get(threadId) as { rollout_path: string } | undefined;
334
+
335
+ codexDb.prepare('DELETE FROM thread_spawn_edges WHERE child_thread_id = ? OR parent_thread_id = ?').run(threadId, threadId);
336
+ codexDb.prepare('DELETE FROM threads WHERE id = ?').run(threadId);
337
+ codexDb.close();
338
+
339
+ // 2. Delete rollout file
340
+ if (row?.rollout_path) {
341
+ try { unlinkSync(row.rollout_path); } catch { /* file may not exist */ }
342
+ }
343
+ } catch {
344
+ // DB not found — skip
345
+ }
346
+
347
+ // 3. Remove from session_index.jsonl
348
+ try {
349
+ const indexPath = `${home}/.codex/session_index.jsonl`;
350
+ const content = readFileSync(indexPath, 'utf-8');
351
+ const lines = content.trim().split('\n').filter((line) => {
352
+ try {
353
+ const entry = JSON.parse(line) as { id: string };
354
+ return entry.id !== threadId;
355
+ } catch {
356
+ return true; // keep unparseable lines
357
+ }
358
+ });
359
+ writeFileSync(indexPath, lines.join('\n') + '\n');
360
+ } catch {
361
+ // File not found — skip
362
+ }
260
363
  }
@@ -61,7 +61,31 @@ function createTables(database: DB): void {
61
61
  FOREIGN KEY (turn_id) REFERENCES turns(id) ON DELETE CASCADE
62
62
  );
63
63
 
64
+ CREATE TABLE IF NOT EXISTS workspaces (
65
+ id TEXT PRIMARY KEY,
66
+ path TEXT NOT NULL UNIQUE,
67
+ label TEXT,
68
+ deleted INTEGER NOT NULL DEFAULT 0,
69
+ created_at TEXT NOT NULL
70
+ );
71
+
64
72
  CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
65
73
  CREATE INDEX IF NOT EXISTS idx_items_turn_id ON items(turn_id);
74
+ CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(path);
66
75
  `);
76
+
77
+ // Migration: add 'deleted' column to existing workspaces table if missing
78
+ const wsColumns = database.prepare("PRAGMA table_info(workspaces)").all() as { name: string }[];
79
+ if (!wsColumns.some((c) => c.name === 'deleted')) {
80
+ database.exec('ALTER TABLE workspaces ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0');
81
+ }
82
+
83
+ // Migration: add 'lifecycle' + 'has_unreviewed' columns to existing sessions table
84
+ const sessColumns = database.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
85
+ if (!sessColumns.some((c) => c.name === 'lifecycle')) {
86
+ database.exec("ALTER TABLE sessions ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'working'");
87
+ }
88
+ if (!sessColumns.some((c) => c.name === 'has_unreviewed')) {
89
+ database.exec('ALTER TABLE sessions ADD COLUMN has_unreviewed INTEGER NOT NULL DEFAULT 0');
90
+ }
67
91
  }
@@ -22,6 +22,8 @@ export interface Session {
22
22
  external_thread_id: string | null; // Codex thread ID for resume
23
23
  status: SessionStatus;
24
24
  is_running: number; // 0 | 1
25
+ lifecycle: string; // 'working' | 'finished'
26
+ has_unreviewed: number; // 0 | 1
25
27
  created_at: string; // ISO
26
28
  updated_at: string; // ISO
27
29
  }
@@ -59,6 +61,15 @@ export interface Item {
59
61
  updated_at: string;
60
62
  }
61
63
 
64
+ // Workspaces — directory paths for grouping sessions in sidebar
65
+ export interface Workspace {
66
+ id: string;
67
+ path: string; // directory path, e.g. "/Users/me/projects/my-app"
68
+ label: string | null; // optional custom label, null = use basename
69
+ deleted: number; // 0 = active, 1 = soft-deleted (hidden from sidebar)
70
+ created_at: string; // ISO
71
+ }
72
+
62
73
  // Nested session detail for GET /api/sessions/:id
63
74
  export interface SessionDetail extends Session {
64
75
  turns: (Turn & { items: Item[] })[];
@@ -4,6 +4,7 @@ import { initDb } from './db/index.ts';
4
4
  import { initWs, handleUpgrade, getWss } from './ws/index.ts';
5
5
  import { registerSessionRoutes } from './routes/sessions.ts';
6
6
  import { registerTurnRoutes } from './routes/turns.ts';
7
+ import { registerWorkspaceRoutes } from './routes/workspaces.ts';
7
8
 
8
9
  const PORT = 3001;
9
10
 
@@ -20,6 +21,7 @@ async function main(): Promise<void> {
20
21
  // Register routes
21
22
  await registerSessionRoutes(fastify);
22
23
  await registerTurnRoutes(fastify);
24
+ await registerWorkspaceRoutes(fastify);
23
25
 
24
26
  // Initialize WebSocket server (noServer mode)
25
27
  const wss = initWs();
@@ -94,4 +94,41 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
94
94
 
95
95
  return { success: true };
96
96
  });
97
+
98
+ // PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed)
99
+ fastify.patch<{ Params: { id: string }; Body: { title?: string; lifecycle?: string; has_unreviewed?: number } }>(
100
+ '/api/sessions/:id',
101
+ async (request, reply) => {
102
+ const { id } = request.params;
103
+ const { title, lifecycle, has_unreviewed } = request.body || {};
104
+ const db = getDb();
105
+
106
+ const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
107
+ if (!existing) {
108
+ return reply.code(404).send({ error: 'Session not found' });
109
+ }
110
+
111
+ const now = new Date().toISOString();
112
+ const updates: string[] = ['updated_at = ?'];
113
+ const params: (string | number | null)[] = [now];
114
+
115
+ if (title !== undefined) {
116
+ updates.push('title = ?');
117
+ params.push(title);
118
+ }
119
+ if (lifecycle !== undefined) {
120
+ updates.push('lifecycle = ?');
121
+ params.push(lifecycle);
122
+ }
123
+ if (has_unreviewed !== undefined) {
124
+ updates.push('has_unreviewed = ?');
125
+ params.push(has_unreviewed);
126
+ }
127
+
128
+ db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
129
+
130
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
131
+ return session;
132
+ },
133
+ );
97
134
  }
@@ -0,0 +1,110 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { getDb } from '../db/index.ts';
6
+ import type { Workspace } from '../db/schema.ts';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export async function registerWorkspaceRoutes(fastify: FastifyInstance): Promise<void> {
11
+ // GET /api/workspaces — list ALL workspaces (active + soft-deleted)
12
+ fastify.get('/api/workspaces', async () => {
13
+ const rows = getDb()
14
+ .prepare('SELECT * FROM workspaces ORDER BY created_at ASC')
15
+ .all() as Workspace[];
16
+ return rows;
17
+ });
18
+
19
+ // POST /api/workspaces — create workspace (or restore if soft-deleted)
20
+ fastify.post<{ Body: { path: string; label?: string } }>(
21
+ '/api/workspaces',
22
+ async (request, reply) => {
23
+ const { path, label = null } = request.body || {};
24
+ if (!path || !path.trim()) {
25
+ return reply.code(400).send({ error: 'Path is required' });
26
+ }
27
+
28
+ const trimmedPath = path.trim();
29
+ const db = getDb();
30
+
31
+ // Check if workspace with this path already exists
32
+ const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
33
+
34
+ if (existing) {
35
+ if (existing.deleted === 0) {
36
+ return reply.code(409).send({ error: 'Workspace with this path already exists' });
37
+ }
38
+ // Restore soft-deleted workspace
39
+ db.prepare('UPDATE workspaces SET deleted = 0, label = ? WHERE id = ?').run(label, existing.id);
40
+ const restored = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(existing.id) as Workspace;
41
+ return restored;
42
+ }
43
+
44
+ // Create new workspace
45
+ const id = uuidv4();
46
+ const now = new Date().toISOString();
47
+ db.prepare(
48
+ 'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, ?, 0, ?)',
49
+ ).run(id, trimmedPath, label, now);
50
+
51
+ const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id) as Workspace;
52
+ return workspace;
53
+ },
54
+ );
55
+
56
+ // GET /api/pick-directory — open native OS folder picker (macOS only)
57
+ fastify.get('/api/pick-directory', async (_request, reply) => {
58
+ try {
59
+ const { stdout } = await execFileAsync('osascript', [
60
+ '-e',
61
+ 'POSIX path of (choose folder with prompt "Select a workspace directory")',
62
+ ]);
63
+ const path = stdout.trim().replace(/\/$/, '');
64
+ return { path };
65
+ } catch {
66
+ // User cancelled the dialog or osascript not available
67
+ return reply.code(204).send({ cancelled: true });
68
+ }
69
+ });
70
+
71
+ // POST /api/workspaces/hide — soft-delete workspace by path (creates record if not exists)
72
+ fastify.post<{ Body: { path: string } }>(
73
+ '/api/workspaces/hide',
74
+ async (request, reply) => {
75
+ const { path } = request.body || {};
76
+ if (!path || !path.trim()) {
77
+ return reply.code(400).send({ error: 'Path is required' });
78
+ }
79
+ const trimmedPath = path.trim();
80
+ const db = getDb();
81
+
82
+ const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
83
+ if (existing) {
84
+ db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(existing.id);
85
+ } else {
86
+ const id = uuidv4();
87
+ const now = new Date().toISOString();
88
+ db.prepare(
89
+ 'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, NULL, 1, ?)',
90
+ ).run(id, trimmedPath, now);
91
+ }
92
+
93
+ return { success: true };
94
+ },
95
+ );
96
+
97
+ // DELETE /api/workspaces/:id — soft-delete workspace by ID (does NOT delete sessions)
98
+ fastify.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => {
99
+ const { id } = request.params;
100
+ const db = getDb();
101
+
102
+ const existing = db.prepare('SELECT id FROM workspaces WHERE id = ? AND deleted = 0').get(id);
103
+ if (!existing) {
104
+ return reply.code(404).send({ error: 'Workspace not found' });
105
+ }
106
+
107
+ db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(id);
108
+ return { success: true };
109
+ });
110
+ }
@@ -26,4 +26,16 @@ export async function apiDelete<T>(path: string): Promise<T> {
26
26
  throw new Error(`DELETE ${path} failed: ${res.status} ${res.statusText}`);
27
27
  }
28
28
  return res.json() as Promise<T>;
29
+ }
30
+
31
+ export async function apiPatch<T>(path: string, body?: unknown): Promise<T> {
32
+ const res = await fetch(`${API_BASE}${path}`, {
33
+ method: 'PATCH',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: body ? JSON.stringify(body) : undefined,
36
+ });
37
+ if (!res.ok) {
38
+ throw new Error(`PATCH ${path} failed: ${res.status} ${res.statusText}`);
39
+ }
40
+ return res.json() as Promise<T>;
29
41
  }
@@ -1,4 +1,4 @@
1
- import { apiGet, apiPost, apiDelete } from './client.ts';
1
+ import { apiGet, apiPost, apiDelete, apiPatch } from './client.ts';
2
2
  import type { Session, SessionDetail, Turn } from '../types/index.ts';
3
3
 
4
4
  export function fetchSessions(): Promise<Session[]> {
@@ -28,4 +28,20 @@ export function submitPrompt(sessionId: string, userPrompt: string): Promise<Tur
28
28
 
29
29
  export function cancelTurn(sessionId: string): Promise<{ success: boolean }> {
30
30
  return apiPost<{ success: boolean }>(`/sessions/${sessionId}/cancel`);
31
+ }
32
+
33
+ export function renameSession(sessionId: string, title: string): Promise<Session> {
34
+ return apiPatch<Session>(`/sessions/${sessionId}`, { title });
35
+ }
36
+
37
+ export function markFinished(sessionId: string): Promise<Session> {
38
+ return apiPatch<Session>(`/sessions/${sessionId}`, { lifecycle: 'finished' });
39
+ }
40
+
41
+ export function reopenSession(sessionId: string): Promise<Session> {
42
+ return apiPatch<Session>(`/sessions/${sessionId}`, { lifecycle: 'working' });
43
+ }
44
+
45
+ export function clearUnreviewed(sessionId: string): Promise<Session> {
46
+ return apiPatch<Session>(`/sessions/${sessionId}`, { has_unreviewed: 0 });
31
47
  }
@@ -103,6 +103,7 @@ interface TurnCompletedPayload {
103
103
  sessionId: string;
104
104
  turnId: string;
105
105
  usage: TokenUsage;
106
+ hasUnreviewed?: boolean;
106
107
  }
107
108
 
108
109
  interface TurnFailedPayload {
@@ -111,6 +112,11 @@ interface TurnFailedPayload {
111
112
  error: string;
112
113
  }
113
114
 
115
+ interface SessionTitlePayload {
116
+ sessionId: string;
117
+ title: string;
118
+ }
119
+
114
120
  const HANDLERS: Record<string, WsEventHandler> = {
115
121
  'turn:started': (data) => {
116
122
  const p = data as TurnEventPayload;
@@ -130,10 +136,14 @@ const HANDLERS: Record<string, WsEventHandler> = {
130
136
  },
131
137
  'turn:completed': (data) => {
132
138
  const p = data as TurnCompletedPayload;
133
- useStore.getState().onTurnCompleted(p.sessionId, p.turnId, p.usage);
139
+ useStore.getState().onTurnCompleted(p.sessionId, p.turnId, p.usage, p.hasUnreviewed);
134
140
  },
135
141
  'turn:failed': (data) => {
136
142
  const p = data as TurnFailedPayload;
137
143
  useStore.getState().onTurnFailed(p.sessionId, p.turnId, p.error);
138
144
  },
145
+ 'session:title': (data) => {
146
+ const p = data as SessionTitlePayload;
147
+ useStore.getState().onSessionTitle(p.sessionId, p.title);
148
+ },
139
149
  };
@@ -0,0 +1,24 @@
1
+ import { apiGet, apiPost, apiDelete } from './client.ts';
2
+ import type { Workspace } from '../types/index.ts';
3
+
4
+ export function fetchWorkspaces(): Promise<Workspace[]> {
5
+ return apiGet<Workspace[]>('/workspaces');
6
+ }
7
+
8
+ export function createWorkspace(path: string, label?: string): Promise<Workspace> {
9
+ return apiPost<Workspace>('/workspaces', { path, label });
10
+ }
11
+
12
+ export function hideWorkspace(path: string): Promise<{ success: boolean }> {
13
+ return apiPost<{ success: boolean }>('/workspaces/hide', { path });
14
+ }
15
+
16
+ export function deleteWorkspace(id: string): Promise<{ success: boolean }> {
17
+ return apiDelete<{ success: boolean }>(`/workspaces/${id}`);
18
+ }
19
+
20
+ export async function pickDirectory(): Promise<{ path?: string; cancelled?: boolean }> {
21
+ const res = await fetch('/api/pick-directory');
22
+ if (res.status === 204) return { cancelled: true };
23
+ return res.json();
24
+ }
@@ -0,0 +1,50 @@
1
+ import { useState } from 'react';
2
+ import { Wrench, ChevronRight } from 'lucide-react';
3
+ import { MemoItemCard } from './TurnContent.tsx';
4
+ import type { Item } from '../types/index.ts';
5
+
6
+ const TOOL_TYPES = new Set(['command_execution', 'file_change', 'mcp_tool_call', 'web_search']);
7
+
8
+ export function isToolItem(item: Item): boolean {
9
+ return TOOL_TYPES.has(item.type);
10
+ }
11
+
12
+ interface Props {
13
+ items: Item[];
14
+ }
15
+
16
+ export default function CollapsibleToolCalls({ items }: Props) {
17
+ const [expanded, setExpanded] = useState(false);
18
+
19
+ if (items.length === 0) return null;
20
+
21
+ const count = items.length;
22
+
23
+ return (
24
+ <div className="tool-calls">
25
+ <div
26
+ className="tool-calls-header"
27
+ onClick={() => setExpanded((e) => !e)}
28
+ >
29
+ <span className="tool-calls-icon">
30
+ <Wrench size={12} />
31
+ </span>
32
+ <span className="tool-calls-summary">
33
+ {count} tool call{count > 1 ? 's' : ''}
34
+ </span>
35
+ <ChevronRight
36
+ size={14}
37
+ className="tool-calls-chevron"
38
+ style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}
39
+ />
40
+ </div>
41
+ {expanded && (
42
+ <div className="tool-calls-body">
43
+ {items.map((item) => (
44
+ <MemoItemCard key={item.id} item={item} />
45
+ ))}
46
+ </div>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -190,6 +190,17 @@ export default function CommandPalette() {
190
190
  })}
191
191
  </div>
192
192
  )}
193
+ <div className="palette-footer">
194
+ <span className="palette-footer-hint">
195
+ <span className="kbd">↑</span><span className="kbd">↓</span> Navigate
196
+ </span>
197
+ <span className="palette-footer-hint">
198
+ <span className="kbd">↵</span> Select
199
+ </span>
200
+ <span className="palette-footer-hint">
201
+ <span className="kbd">Esc</span> Close
202
+ </span>
203
+ </div>
193
204
  </div>
194
205
  </div>
195
206
  );