From b06b8104a519e7c3a798651c1980f3fc59284786 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 23 Oct 2025 21:40:19 +0100 Subject: [PATCH] Add task specifications for Phase 5 advanced input features - 015-keyboard-shortcuts.md (completed) - 020-command-palette.md (completed) - 021-file-attachments.md (next: @mentions, drag-drop, chips) - 022-long-paste-handling.md (summarize long pastes) - 023-symbol-attachments.md (LSP integration) - 024-agent-attachments.md (agent context) - 025-image-clipboard-support.md (image handling) --- tasks/todo/015-keyboard-shortcuts.md | 849 ++++++++++++++++++++++ tasks/todo/020-command-palette.md | 217 ++++++ tasks/todo/021-file-attachments.md | 40 + tasks/todo/022-long-paste-handling.md | 29 + tasks/todo/023-symbol-attachments.md | 37 + tasks/todo/024-agent-attachments.md | 31 + tasks/todo/025-image-clipboard-support.md | 31 + 7 files changed, 1234 insertions(+) create mode 100644 tasks/todo/015-keyboard-shortcuts.md create mode 100644 tasks/todo/020-command-palette.md create mode 100644 tasks/todo/021-file-attachments.md create mode 100644 tasks/todo/022-long-paste-handling.md create mode 100644 tasks/todo/023-symbol-attachments.md create mode 100644 tasks/todo/024-agent-attachments.md create mode 100644 tasks/todo/025-image-clipboard-support.md diff --git a/tasks/todo/015-keyboard-shortcuts.md b/tasks/todo/015-keyboard-shortcuts.md new file mode 100644 index 00000000..7660eb2e --- /dev/null +++ b/tasks/todo/015-keyboard-shortcuts.md @@ -0,0 +1,849 @@ +# Task 015: Keyboard Shortcuts + +## Goal + +Implement comprehensive keyboard shortcuts for efficient keyboard-first navigation, inspired by the TUI's keyboard system but adapted for desktop multi-instance/multi-session workflow. + +## Prerequisites + +- ✅ 001-013 completed +- ✅ All core UI components built +- ✅ Message stream, prompt input, tabs working + +## Decisions Made + +1. **Tab Navigation**: Use `Cmd/Ctrl+[/]` for instances, `Cmd/Ctrl+Shift+[/]` for sessions +2. **Clear Input**: Use `Cmd/Ctrl+K` (common in Slack, Discord, VS Code) +3. **Escape Behavior**: Context-dependent (blur when idle, interrupt when busy) +4. **Message History**: Per-instance, stored in IndexedDB (embedded local database) +5. **Agent Cycling**: Include Tab/Shift+Tab for agent cycling, add model selector focus shortcut +6. **Leader Key**: Skip it - use standard Cmd/Ctrl patterns +7. **Platform**: Cmd on macOS, Ctrl elsewhere (standard cross-platform pattern) +8. **View Controls**: Not needed for MVP +9. **Help Dialog**: Not needed - inline hints instead + +## Key Principles + +### Smart Inline Hints + +Instead of a help dialog, show shortcuts contextually: + +- Display hints next to actions they affect +- Keep hints subtle (small text, muted color) +- Use platform-specific symbols (⌘ on Mac, Ctrl elsewhere) +- Examples already in app: "Enter to send • Shift+Enter for new line" + +### Modular Architecture + +Build shortcuts in a centralized, configurable system: + +- Single source of truth for all shortcuts +- Easy to extend for future customization +- Clear separation between shortcut definition and handler logic +- Registry pattern for discoverability + +## Shortcuts to Implement + +### Navigation (Tabs) + +**Already Implemented:** + +- [x] `Cmd/Ctrl+1-9` - Switch to instance tab by index +- [x] `Cmd/Ctrl+N` - New instance (select folder) +- [x] `Cmd/Ctrl+T` - New session in active instance +- [x] `Cmd/Ctrl+W` - Close active **parent** session (only) + +**To Implement:** + +- [ ] `Cmd/Ctrl+[` - Previous instance tab +- [ ] `Cmd/Ctrl+]` - Next instance tab +- [ ] `Cmd/Ctrl+Shift+[` - Previous session tab +- [ ] `Cmd/Ctrl+Shift+]` - Next session tab +- [ ] `Cmd/Ctrl+Shift+L` - Switch to Logs tab + +### Input Management + +**Already Implemented:** + +- [x] `Enter` - Send message +- [x] `Shift+Enter` - New line + +**To Implement:** + +- [ ] `Cmd/Ctrl+K` - Clear input +- [ ] `Cmd/Ctrl+L` - Focus prompt input +- [ ] `Up Arrow` - Previous message in history (when at start of input) +- [ ] `Down Arrow` - Next message in history (when in history mode) +- [ ] `Escape` - Context-dependent: + - When idle: Blur input / close modals + - When busy: Interrupt session (requires confirmation) + +### Agent/Model Selection + +**To Implement:** + +- [ ] `Tab` - Cycle to next agent (when input empty or not focused) +- [ ] `Shift+Tab` - Cycle to previous agent +- [ ] `Cmd/Ctrl+M` - Focus model selector dropdown + +### Message Navigation + +**To Implement:** + +- [ ] `PgUp` - Scroll messages up +- [ ] `PgDown` - Scroll messages down +- [ ] `Home` - Jump to first message +- [ ] `End` - Jump to last message + +## Architecture Design + +### 1. Centralized Keyboard Registry + +```typescript +// src/lib/keyboard-registry.ts + +export interface KeyboardShortcut { + id: string + key: string + modifiers: { + ctrl?: boolean + meta?: boolean + shift?: boolean + alt?: boolean + } + handler: () => void + description: string + context?: "global" | "input" | "messages" // Where it works + condition?: () => boolean // Runtime condition check +} + +class KeyboardRegistry { + private shortcuts = new Map() + + register(shortcut: KeyboardShortcut) { + this.shortcuts.set(shortcut.id, shortcut) + } + + unregister(id: string) { + this.shortcuts.delete(id) + } + + findMatch(event: KeyboardEvent): KeyboardShortcut | null { + for (const shortcut of this.shortcuts.values()) { + if (this.matches(event, shortcut)) { + // Check context + if (shortcut.context === "input" && !this.isInputFocused()) continue + if (shortcut.context === "messages" && this.isInputFocused()) continue + + // Check runtime condition + if (shortcut.condition && !shortcut.condition()) continue + + return shortcut + } + } + return null + } + + private matches(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean { + const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase() + const ctrlMatch = event.ctrlKey === !!shortcut.modifiers.ctrl + const metaMatch = event.metaKey === !!shortcut.modifiers.meta + const shiftMatch = event.shiftKey === !!shortcut.modifiers.shift + const altMatch = event.altKey === !!shortcut.modifiers.alt + + return keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch + } + + private isInputFocused(): boolean { + const active = document.activeElement + return active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable") + } + + getByContext(context: string): KeyboardShortcut[] { + return Array.from(this.shortcuts.values()).filter((s) => !s.context || s.context === context) + } +} + +export const keyboardRegistry = new KeyboardRegistry() +``` + +### 2. Cross-Platform Key Helper + +```typescript +// src/lib/keyboard-utils.ts + +export const isMac = () => navigator.platform.includes("Mac") + +export const modKey = (event?: KeyboardEvent) => { + if (!event) return isMac() ? "metaKey" : "ctrlKey" + return isMac() ? event.metaKey : event.ctrlKey +} + +export const modKeyPressed = (event: KeyboardEvent) => { + return isMac() ? event.metaKey : event.ctrlKey +} + +export const formatShortcut = (shortcut: KeyboardShortcut): string => { + const parts: string[] = [] + + if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) { + parts.push(isMac() ? "⌘" : "Ctrl") + } + if (shortcut.modifiers.shift) { + parts.push(isMac() ? "⇧" : "Shift") + } + if (shortcut.modifiers.alt) { + parts.push(isMac() ? "⌥" : "Alt") + } + + parts.push(shortcut.key.toUpperCase()) + + return parts.join(isMac() ? "" : "+") +} +``` + +### 3. IndexedDB Storage Layer + +```typescript +// src/lib/db.ts + +const DB_NAME = "opencode-client" +const DB_VERSION = 1 +const HISTORY_STORE = "message-history" + +let db: IDBDatabase | null = null + +async function getDB(): Promise { + if (db) return db + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + db = request.result + resolve(db) + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // Create object stores + if (!db.objectStoreNames.contains(HISTORY_STORE)) { + db.createObjectStore(HISTORY_STORE) + } + } + }) +} + +export async function saveHistory(instanceId: string, history: string[]): Promise { + const database = await getDB() + return new Promise((resolve, reject) => { + const tx = database.transaction(HISTORY_STORE, "readwrite") + const store = tx.objectStore(HISTORY_STORE) + const request = store.put(history, instanceId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +} + +export async function loadHistory(instanceId: string): Promise { + try { + const database = await getDB() + return new Promise((resolve, reject) => { + const tx = database.transaction(HISTORY_STORE, "readonly") + const store = tx.objectStore(HISTORY_STORE) + const request = store.get(instanceId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result || []) + }) + } catch (error) { + console.warn("Failed to load history from IndexedDB:", error) + return [] + } +} + +export async function deleteHistory(instanceId: string): Promise { + const database = await getDB() + return new Promise((resolve, reject) => { + const tx = database.transaction(HISTORY_STORE, "readwrite") + const store = tx.objectStore(HISTORY_STORE) + const request = store.delete(instanceId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +} +``` + +### 4. Message History Management + +Per-instance storage using IndexedDB (persists across app restarts): + +```typescript +// src/stores/message-history.ts + +import { saveHistory, loadHistory, deleteHistory } from "../lib/db" + +const MAX_HISTORY = 100 + +// In-memory cache +const instanceHistories = new Map() +const historyLoaded = new Set() + +export async function addToHistory(instanceId: string, text: string): Promise { + // Ensure history is loaded + await ensureHistoryLoaded(instanceId) + + const history = instanceHistories.get(instanceId) || [] + + // Add to front (newest first) + history.unshift(text) + + // Limit to MAX_HISTORY + if (history.length > MAX_HISTORY) { + history.length = MAX_HISTORY + } + + // Update cache and persist + instanceHistories.set(instanceId, history) + + // Persist to IndexedDB (async, don't wait) + saveHistory(instanceId, history).catch((err) => { + console.warn("Failed to persist message history:", err) + }) +} + +export async function getHistory(instanceId: string): Promise { + await ensureHistoryLoaded(instanceId) + return instanceHistories.get(instanceId) || [] +} + +export async function clearHistory(instanceId: string): Promise { + // Manually clear history (not called on instance stop) + instanceHistories.delete(instanceId) + historyLoaded.delete(instanceId) + await deleteHistory(instanceId) +} + +async function ensureHistoryLoaded(instanceId: string): Promise { + if (historyLoaded.has(instanceId)) { + return + } + + try { + const history = await loadHistory(instanceId) + instanceHistories.set(instanceId, history) + historyLoaded.add(instanceId) + } catch (error) { + console.warn("Failed to load history:", error) + instanceHistories.set(instanceId, []) + historyLoaded.add(instanceId) + } +} +``` + +### 4. Inline Hint Component + +```typescript +// src/components/keyboard-hint.tsx + +import { Component } from 'solid-js' +import { formatShortcut, type KeyboardShortcut } from '../lib/keyboard-utils' + +const KeyboardHint: Component<{ + shortcuts: KeyboardShortcut[] + separator?: string +}> = (props) => { + return ( + + {props.shortcuts.map((shortcut, i) => ( + <> + {i > 0 && {props.separator || '•'}} + {formatShortcut(shortcut)} + {shortcut.description} + + ))} + + ) +} + +export default KeyboardHint +``` + +## Implementation Steps + +### Step 1: Create Keyboard Infrastructure + +1. Create `src/lib/keyboard-registry.ts` - Central registry +2. Create `src/lib/keyboard-utils.ts` - Platform helpers +3. Create `src/lib/db.ts` - IndexedDB storage layer +4. Create `src/stores/message-history.ts` - History management +5. Create `src/components/keyboard-hint.tsx` - Inline hints component + +### Step 2: Register Navigation Shortcuts + +```typescript +// src/lib/shortcuts/navigation.ts + +import { keyboardRegistry } from "../keyboard-registry" +import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" +import { getSessions, activeSessionId, setActiveSession } from "../../stores/sessions" + +export function registerNavigationShortcuts() { + // Instance navigation + keyboardRegistry.register({ + id: "instance-prev", + key: "[", + modifiers: { ctrl: true, meta: true }, + handler: () => { + const ids = Array.from(instances().keys()) + const current = ids.indexOf(activeInstanceId() || "") + const prev = current === 0 ? ids.length - 1 : current - 1 + if (ids[prev]) setActiveInstanceId(ids[prev]) + }, + description: "previous instance", + context: "global", + }) + + keyboardRegistry.register({ + id: "instance-next", + key: "]", + modifiers: { ctrl: true, meta: true }, + handler: () => { + const ids = Array.from(instances().keys()) + const current = ids.indexOf(activeInstanceId() || "") + const next = (current + 1) % ids.length + if (ids[next]) setActiveInstanceId(ids[next]) + }, + description: "next instance", + context: "global", + }) + + // Session navigation + keyboardRegistry.register({ + id: "session-prev", + key: "[", + modifiers: { ctrl: true, meta: true, shift: true }, + handler: () => { + const instanceId = activeInstanceId() + if (!instanceId) return + + const sessions = getSessions(instanceId) + const ids = sessions.map((s) => s.id).concat(["logs"]) + const current = ids.indexOf(activeSessionId().get(instanceId) || "") + const prev = current === 0 ? ids.length - 1 : current - 1 + if (ids[prev]) setActiveSession(instanceId, ids[prev]) + }, + description: "previous session", + context: "global", + }) + + keyboardRegistry.register({ + id: "session-next", + key: "]", + modifiers: { ctrl: true, meta: true, shift: true }, + handler: () => { + const instanceId = activeInstanceId() + if (!instanceId) return + + const sessions = getSessions(instanceId) + const ids = sessions.map((s) => s.id).concat(["logs"]) + const current = ids.indexOf(activeSessionId().get(instanceId) || "") + const next = (current + 1) % ids.length + if (ids[next]) setActiveSession(instanceId, ids[next]) + }, + description: "next session", + context: "global", + }) + + // Logs tab + keyboardRegistry.register({ + id: "switch-to-logs", + key: "l", + modifiers: { ctrl: true, meta: true, shift: true }, + handler: () => { + const instanceId = activeInstanceId() + if (instanceId) setActiveSession(instanceId, "logs") + }, + description: "logs tab", + context: "global", + }) +} +``` + +### Step 3: Register Input Shortcuts + +```typescript +// src/lib/shortcuts/input.ts + +export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) { + keyboardRegistry.register({ + id: "clear-input", + key: "k", + modifiers: { ctrl: true, meta: true }, + handler: clearInput, + description: "clear input", + context: "global", + }) + + keyboardRegistry.register({ + id: "focus-input", + key: "l", + modifiers: { ctrl: true, meta: true }, + handler: focusInput, + description: "focus input", + context: "global", + }) +} +``` + +### Step 4: Update PromptInput with History Navigation + +```typescript +// src/components/prompt-input.tsx + +import { createSignal, onMount } from 'solid-js' +import { addToHistory, getHistory } from '../stores/message-history' + +const PromptInput: Component = (props) => { + const [input, setInput] = createSignal('') + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [history, setHistory] = createSignal([]) + + let textareaRef: HTMLTextAreaElement | undefined + + // Load history on mount + onMount(async () => { + const loaded = await getHistory(props.instanceId) + setHistory(loaded) + }) + + async function handleKeyDown(e: KeyboardEvent) { + const textarea = textareaRef + if (!textarea) return + + const atStart = textarea.selectionStart === 0 + const currentHistory = history() + + // Up arrow - navigate to older message + if (e.key === 'ArrowUp' && atStart && currentHistory.length > 0) { + e.preventDefault() + const newIndex = Math.min(historyIndex() + 1, currentHistory.length - 1) + setHistoryIndex(newIndex) + setInput(currentHistory[newIndex]) + } + + // Down arrow - navigate to newer message + if (e.key === 'ArrowDown' && historyIndex() >= 0) { + e.preventDefault() + const newIndex = historyIndex() - 1 + if (newIndex >= 0) { + setHistoryIndex(newIndex) + setInput(currentHistory[newIndex]) + } else { + setHistoryIndex(-1) + setInput('') + } + } + } + + async function handleSend() { + const text = input().trim() + if (!text) return + + // Add to history (async, per instance) + await addToHistory(props.instanceId, text) + + // Reload history for next navigation + const updated = await getHistory(props.instanceId) + setHistory(updated) + setHistoryIndex(-1) + + await props.onSend(text) + setInput('') + } + + return ( +
+