Files
NomadArch/tasks/done/015-keyboard-shortcuts.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb):
- .github/ workflows and issue templates
- .gitignore (proper exclusions)
- .opencode/agent/web_developer.md
- AGENTS.md, BUILD.md, PROGRESS.md
- dev-docs/ (9 architecture/implementation docs)
- docs/screenshots/ (4 UI screenshots)
- images/ (CodeNomad icons)
- package-lock.json (dependency lockfile)
- tasks/ (25+ project task files)

Also restored original source files that were modified:
- packages/ui/src/App.tsx
- packages/ui/src/lib/logger.ts
- packages/ui/src/stores/instances.ts
- packages/server/src/server/routes/workspaces.ts
- packages/server/src/workspaces/manager.ts
- packages/server/src/workspaces/runtime.ts
- packages/server/package.json

Kept new additions:
- Install-*.bat/.sh (enhanced installers)
- Launch-*.bat/.sh (new launchers)
- README.md (SEO optimized with GLM 4.7)
2025-12-23 13:03:48 +04:00

22 KiB
Raw Blame History

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:

  • Cmd/Ctrl+1-9 - Switch to instance tab by index
  • Cmd/Ctrl+N - New instance (select folder)
  • Cmd/Ctrl+T - New session in active instance
  • 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:

  • Enter - Send message
  • 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

// 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<string, KeyboardShortcut>()

  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

// 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

// 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<IDBDatabase> {
  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<void> {
  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<string[]> {
  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<void> {
  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):

// src/stores/message-history.ts

import { saveHistory, loadHistory, deleteHistory } from "../lib/db"

const MAX_HISTORY = 100

// In-memory cache
const instanceHistories = new Map<string, string[]>()
const historyLoaded = new Set<string>()

export async function addToHistory(instanceId: string, text: string): Promise<void> {
  // 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<string[]> {
  await ensureHistoryLoaded(instanceId)
  return instanceHistories.get(instanceId) || []
}

export async function clearHistory(instanceId: string): Promise<void> {
  // Manually clear history (not called on instance stop)
  instanceHistories.delete(instanceId)
  historyLoaded.delete(instanceId)
  await deleteHistory(instanceId)
}

async function ensureHistoryLoaded(instanceId: string): Promise<void> {
  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

// 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 (
    <span class="text-xs text-gray-500 dark:text-gray-400">
      {props.shortcuts.map((shortcut, i) => (
        <>
          {i > 0 && <span class="mx-1">{props.separator || '•'}</span>}
          <kbd class="font-mono">{formatShortcut(shortcut)}</kbd>
          <span class="ml-1">{shortcut.description}</span>
        </>
      ))}
    </span>
  )
}

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

// 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

// 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

// src/components/prompt-input.tsx

import { createSignal, onMount } from 'solid-js'
import { addToHistory, getHistory } from '../stores/message-history'

const PromptInput: Component<Props> = (props) => {
  const [input, setInput] = createSignal('')
  const [historyIndex, setHistoryIndex] = createSignal(-1)
  const [history, setHistory] = createSignal<string[]>([])

  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 (
    <div class="prompt-input">
      <textarea
        ref={textareaRef}
        value={input()}
        onInput={(e) => setInput(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Type your message..."
      />

      <KeyboardHint shortcuts={[
        { description: 'to send', key: 'Enter', modifiers: {} },
        { description: 'for new line', key: 'Enter', modifiers: { shift: true } }
      ]} />
    </div>
  )
}

Step 5: Agent Cycling

// src/lib/shortcuts/agent.ts

export function registerAgentShortcuts(
  cycleAgent: () => void,
  cycleAgentReverse: () => void,
  focusModelSelector: () => void,
) {
  keyboardRegistry.register({
    id: "agent-next",
    key: "Tab",
    modifiers: {},
    handler: cycleAgent,
    description: "next agent",
    context: "global",
    condition: () => !isInputFocused(), // Only when not typing
  })

  keyboardRegistry.register({
    id: "agent-prev",
    key: "Tab",
    modifiers: { shift: true },
    handler: cycleAgentReverse,
    description: "previous agent",
    context: "global",
    condition: () => !isInputFocused(),
  })

  keyboardRegistry.register({
    id: "focus-model",
    key: "m",
    modifiers: { ctrl: true, meta: true },
    handler: focusModelSelector,
    description: "focus model",
    context: "global",
  })
}

Step 6: Escape Key Context Handling

// src/lib/shortcuts/escape.ts

export function registerEscapeShortcut(
  isSessionBusy: () => boolean,
  interruptSession: () => void,
  blurInput: () => void,
  closeModal: () => void,
) {
  keyboardRegistry.register({
    id: "escape",
    key: "Escape",
    modifiers: {},
    handler: () => {
      // Priority 1: Close modal if open
      if (hasOpenModal()) {
        closeModal()
        return
      }

      // Priority 2: Interrupt if session is busy
      if (isSessionBusy()) {
        interruptSession()
        return
      }

      // Priority 3: Blur input
      blurInput()
    },
    description: "cancel/close",
    context: "global",
  })
}

Step 7: Setup Global Listener in App

// src/App.tsx

import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
import { registerEscapeShortcut } from "./lib/shortcuts/escape"
import { keyboardRegistry } from "./lib/keyboard-registry"

onMount(() => {
  // Register all shortcuts
  registerNavigationShortcuts()
  registerInputShortcuts(
    () => setInput(""),
    () => document.querySelector("textarea")?.focus(),
  )
  registerAgentShortcuts(handleCycleAgent, handleCycleAgentReverse, () =>
    document.querySelector("[data-model-selector]")?.focus(),
  )
  registerEscapeShortcut(
    () => activeInstance()?.status === "streaming",
    handleInterrupt,
    () => document.activeElement?.blur(),
    hideModal,
  )

  // Global keydown handler
  const handleKeyDown = (e: KeyboardEvent) => {
    const shortcut = keyboardRegistry.findMatch(e)
    if (shortcut) {
      e.preventDefault()
      shortcut.handler()
    }
  }

  window.addEventListener("keydown", handleKeyDown)

  onCleanup(() => {
    window.removeEventListener("keydown", handleKeyDown)
  })
})

Step 8: Add Inline Hints Throughout UI

In PromptInput:

<KeyboardHint
  shortcuts={[getShortcut("enter-to-send"), getShortcut("shift-enter-newline"), getShortcut("cmd-k-clear")]}
/>

In Instance Tabs:

<KeyboardHint shortcuts={[getShortcut("cmd-1-9"), getShortcut("cmd-brackets")]} />

In Agent Selector:

<KeyboardHint shortcuts={[getShortcut("tab-cycle")]} />

Where to Show Hints

  1. Prompt Input Area (bottom)

    • Enter/Shift+Enter (already shown)
    • Add: Cmd+K to clear, ↑↓ for history
  2. Instance Tabs (subtle tooltip or header)

    • Cmd+1-9, Cmd+[/]
  3. Session Tabs (same as instance)

    • Cmd+Shift+[/]
  4. Agent/Model Selectors (placeholder or label)

    • Tab/Shift+Tab, Cmd+M
  5. Empty State (when no messages)

    • Common shortcuts overview

Testing Checklist

Navigation

  • Cmd/Ctrl+[ / ] cycles instance tabs
  • Cmd/Ctrl+Shift+[ / ] cycles session tabs
  • Cmd/Ctrl+1-9 jumps to instance
  • Cmd/Ctrl+T creates new session
  • Cmd/Ctrl+W closes parent session only
  • Cmd/Ctrl+Shift+L switches to logs

Input

  • Cmd/Ctrl+K clears input
  • Cmd/Ctrl+L focuses input
  • Up arrow loads previous message (when at start)
  • Down arrow navigates forward in history
  • History is per-instance
  • History persists in IndexedDB across app restarts
  • History limited to 100 entries (not 50)
  • History loads on component mount
  • History NOT cleared when instance stops

Agent/Model

  • Tab cycles agents (when not in input)
  • Shift+Tab cycles agents backward
  • Cmd/Ctrl+M focuses model selector

Context Behavior

  • Escape closes modals first
  • Escape interrupts when busy
  • Escape blurs input when idle
  • Shortcuts don't fire in wrong context

Cross-Platform

  • Works with Cmd on macOS
  • Works with Ctrl on Windows
  • Works with Ctrl on Linux
  • Hints show correct keys per platform

Inline Hints

  • Hints visible but not intrusive
  • Correct platform symbols shown
  • Hints appear in relevant locations
  • No excessive screen space used

Dependencies

Requires:

  • Tasks 001-013 completed

Blocks:

  • None (final MVP task)

Estimated Time

4-5 hours

Success Criteria

Task complete when:

  • All shortcuts implemented and working
  • Message history per-instance, persisted in IndexedDB
  • History stores 100 most recent prompts
  • History persists across app restarts and instance stops
  • Agent cycling with Tab/Shift+Tab
  • Context-aware Escape behavior
  • Inline hints shown throughout UI
  • Cross-platform (Cmd/Ctrl) working
  • Modular registry system for future customization
  • Can navigate entire app efficiently with keyboard

Notes on History Storage

Why per-instance (folder path)?

  • User opens same project folder multiple times → same history
  • More intuitive: history tied to project, not ephemeral instance
  • Survives instance restarts without losing context

Why 100 entries?

  • More generous than TUI's 50
  • ~20KB per instance (100 × ~200 chars)
  • Plenty for typical usage patterns
  • Can increase later if needed

Cleanup Strategy:

  • No automatic cleanup (history persists indefinitely)
  • Could add manual "Clear History" option in future
  • IndexedDB handles storage efficiently