initial steps for interrupt

This commit is contained in:
dexhorthy
2025-06-19 12:31:19 -07:00
parent 4954135fa6
commit 30adfed8ce
21 changed files with 430 additions and 656 deletions

View File

@@ -274,6 +274,14 @@ func (s *Session) Kill() error {
return nil return nil
} }
// Interrupt sends a SIGINT signal to the session process
func (s *Session) Interrupt() error {
if s.cmd.Process != nil {
return s.cmd.Process.Signal(syscall.SIGINT)
}
return nil
}
// parseStreamingJSON reads and parses streaming JSON output // parseStreamingJSON reads and parses streaming JSON output
func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) { func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
scanner := bufio.NewScanner(stdout) scanner := bufio.NewScanner(stdout)

View File

@@ -3,6 +3,7 @@ package claudecode
import ( import (
"os/exec" "os/exec"
"sync" "sync"
"syscall"
"time" "time"
) )
@@ -147,6 +148,7 @@ type Session struct {
err error err error
} }
// SetError safely sets the error // SetError safely sets the error
func (s *Session) SetError(err error) { func (s *Session) SetError(err error) {
s.mu.Lock() s.mu.Lock()

View File

@@ -6,6 +6,7 @@
set -e # Exit on any error set -e # Exit on any error
# Function to generate a unique worktree name # Function to generate a unique worktree name
generate_unique_name() { generate_unique_name() {
local adjectives=("swift" "bright" "clever" "smooth" "quick" "clean" "sharp" "neat" "cool" "fast") local adjectives=("swift" "bright" "clever" "smooth" "quick" "clean" "sharp" "neat" "cool" "fast")

View File

@@ -394,3 +394,15 @@ func Connect(socketPath string, maxRetries int, retryDelay time.Duration) (Clien
return nil, fmt.Errorf("failed to connect to daemon after %d attempts: %w", maxRetries+1, lastErr) return nil, fmt.Errorf("failed to connect to daemon after %d attempts: %w", maxRetries+1, lastErr)
} }
// InterruptSession interrupts a running session
func (c *client) InterruptSession(sessionID string) error {
req := rpc.InterruptSessionRequest{
SessionID: sessionID,
}
var resp struct{} // Empty response
if err := c.call("interruptSession", req, &resp); err != nil {
return fmt.Errorf("failed to interrupt session: %w", err)
}
return nil
}

View File

@@ -248,3 +248,40 @@ func TestConnect_WithRetries(t *testing.T) {
assert.Nil(t, client) assert.Nil(t, client)
assert.Contains(t, err.Error(), "failed to connect to daemon after 3 attempts") assert.Contains(t, err.Error(), "failed to connect to daemon after 3 attempts")
} }
func TestClient_InterruptSession(t *testing.T) {
server, socketPath := newMockRPCServer(t)
defer server.stop()
server.setHandler("interruptSession", func(params json.RawMessage) (interface{}, error) {
var req struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(params, &req); err != nil {
return nil, err
}
// Simple validation
if req.SessionID == "" {
return nil, fmt.Errorf("session_id required")
}
return struct{}{}, nil
})
server.start()
time.Sleep(10 * time.Millisecond)
c, err := New(socketPath)
require.NoError(t, err)
defer func() { _ = c.Close() }()
// Test successful interrupt
err = c.InterruptSession("test-123")
assert.NoError(t, err)
// Test missing session ID
err = c.InterruptSession("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "session_id required")
}

View File

@@ -18,6 +18,9 @@ type Client interface {
// ListSessions lists all active sessions // ListSessions lists all active sessions
ListSessions() (*rpc.ListSessionsResponse, error) ListSessions() (*rpc.ListSessionsResponse, error)
// InterruptSession interrupts a running session
InterruptSession(sessionID string) error
// ContinueSession continues an existing completed session with a new query // ContinueSession continues an existing completed session with a new query
ContinueSession(req rpc.ContinueSessionRequest) (*rpc.ContinueSessionResponse, error) ContinueSession(req rpc.ContinueSessionRequest) (*rpc.ContinueSessionResponse, error)

View File

@@ -285,6 +285,42 @@ func (h *SessionHandlers) HandleContinueSession(ctx context.Context, params json
}, nil }, nil
} }
// InterruptSessionRequest is the request for interrupting a session
type InterruptSessionRequest struct {
SessionID string `json:"session_id"`
}
// HandleInterruptSession handles the InterruptSession RPC method
func (h *SessionHandlers) HandleInterruptSession(ctx context.Context, params json.RawMessage) (interface{}, error) {
var req InterruptSessionRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
// Validate required fields
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
// Get session from store
session, err := h.store.GetSession(ctx, req.SessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
// Validate session is running
if session.Status != store.SessionStatusRunning {
return nil, fmt.Errorf("cannot interrupt session with status %s (must be running)", session.Status)
}
// Interrupt session
if err := h.manager.InterruptSession(ctx, req.SessionID); err != nil {
return nil, fmt.Errorf("failed to interrupt session: %w", err)
}
return struct{}{}, nil
}
// Register registers all session handlers with the RPC server // Register registers all session handlers with the RPC server
func (h *SessionHandlers) Register(server *Server) { func (h *SessionHandlers) Register(server *Server) {
server.Register("launchSession", h.HandleLaunchSession) server.Register("launchSession", h.HandleLaunchSession)
@@ -292,4 +328,5 @@ func (h *SessionHandlers) Register(server *Server) {
server.Register("getConversation", h.HandleGetConversation) server.Register("getConversation", h.HandleGetConversation)
server.Register("getSessionState", h.HandleGetSessionState) server.Register("getSessionState", h.HandleGetSessionState)
server.Register("continueSession", h.HandleContinueSession) server.Register("continueSession", h.HandleContinueSession)
server.Register("interruptSession", h.HandleInterruptSession)
} }

View File

@@ -3,6 +3,7 @@ package rpc
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"time" "time"
@@ -237,3 +238,108 @@ func TestHandleGetSessionState(t *testing.T) {
assert.Contains(t, err.Error(), "failed to get session") assert.Contains(t, err.Error(), "failed to get session")
}) })
} }
func TestHandleInterruptSession(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockManager := session.NewMockSessionManager(ctrl)
mockStore := store.NewMockConversationStore(ctrl)
handlers := NewSessionHandlers(mockManager, mockStore)
t.Run("successful interrupt", func(t *testing.T) {
sessionID := "test-123"
// Mock store response
mockStore.EXPECT().
GetSession(gomock.Any(), sessionID).
Return(&store.Session{
ID: sessionID,
Status: store.SessionStatusRunning,
}, nil)
// Mock manager response
mockManager.EXPECT().
InterruptSession(gomock.Any(), sessionID).
Return(nil)
req := InterruptSessionRequest{
SessionID: sessionID,
}
reqJSON, _ := json.Marshal(req)
result, err := handlers.HandleInterruptSession(context.Background(), reqJSON)
require.NoError(t, err)
require.NotNil(t, result)
})
t.Run("missing session ID", func(t *testing.T) {
req := InterruptSessionRequest{}
reqJSON, _ := json.Marshal(req)
_, err := handlers.HandleInterruptSession(context.Background(), reqJSON)
assert.Error(t, err)
assert.Contains(t, err.Error(), "session_id is required")
})
t.Run("session not found", func(t *testing.T) {
sessionID := "nonexistent"
mockStore.EXPECT().
GetSession(gomock.Any(), sessionID).
Return(nil, fmt.Errorf("session not found"))
req := InterruptSessionRequest{
SessionID: sessionID,
}
reqJSON, _ := json.Marshal(req)
_, err := handlers.HandleInterruptSession(context.Background(), reqJSON)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get session")
})
t.Run("session not running", func(t *testing.T) {
sessionID := "completed-123"
mockStore.EXPECT().
GetSession(gomock.Any(), sessionID).
Return(&store.Session{
ID: sessionID,
Status: store.SessionStatusCompleted,
}, nil)
req := InterruptSessionRequest{
SessionID: sessionID,
}
reqJSON, _ := json.Marshal(req)
_, err := handlers.HandleInterruptSession(context.Background(), reqJSON)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot interrupt session with status completed")
})
t.Run("interrupt fails", func(t *testing.T) {
sessionID := "fail-123"
mockStore.EXPECT().
GetSession(gomock.Any(), sessionID).
Return(&store.Session{
ID: sessionID,
Status: store.SessionStatusRunning,
}, nil)
mockManager.EXPECT().
InterruptSession(gomock.Any(), sessionID).
Return(fmt.Errorf("interrupt failed"))
req := InterruptSessionRequest{
SessionID: sessionID,
}
reqJSON, _ := json.Marshal(req)
_, err := handlers.HandleInterruptSession(context.Background(), reqJSON)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to interrupt session")
})
}

View File

@@ -779,3 +779,51 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
Config: config, Config: config,
}, nil }, nil
} }
// InterruptSession interrupts a running session
func (m *Manager) InterruptSession(ctx context.Context, sessionID string) error {
m.mu.Lock()
claudeSession, exists := m.activeProcesses[sessionID]
m.mu.Unlock()
if !exists {
return fmt.Errorf("session not found or not active")
}
// Interrupt the Claude session
if err := claudeSession.Interrupt(); err != nil {
return fmt.Errorf("failed to interrupt Claude session: %w", err)
}
// Update database with interrupted status
status := string(StatusFailed)
errorMsg := "Session interrupted by user"
now := time.Now()
update := store.SessionUpdate{
Status: &status,
ErrorMessage: &errorMsg,
CompletedAt: &now,
LastActivityAt: &now,
}
if err := m.store.UpdateSession(ctx, sessionID, update); err != nil {
slog.Error("failed to update session status after interrupt",
"session_id", sessionID,
"error", err)
// Continue anyway since the session was interrupted
}
// Publish status change event
if m.eventBus != nil {
m.eventBus.Publish(bus.Event{
Type: bus.EventSessionStatusChanged,
Data: map[string]interface{}{
"session_id": sessionID,
"old_status": string(StatusRunning),
"new_status": string(StatusFailed),
"error": errorMsg,
},
})
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
claudecode "github.com/humanlayer/humanlayer/claudecode-go"
"github.com/humanlayer/humanlayer/hld/bus" "github.com/humanlayer/humanlayer/hld/bus"
"github.com/humanlayer/humanlayer/hld/store" "github.com/humanlayer/humanlayer/hld/store"
"go.uber.org/mock/gomock" "go.uber.org/mock/gomock"
@@ -392,3 +393,73 @@ func containsStr(s, substr string) bool {
} }
return false return false
} }
func TestInterruptSession(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockConversationStore(ctrl)
manager, _ := NewManager(nil, mockStore)
// Test interrupting non-existent session
err := manager.InterruptSession(context.Background(), "not-found")
if err == nil {
t.Error("Expected error for non-existent session")
}
if err.Error() != "session not found or not active" {
t.Errorf("Expected 'session not found or not active' error, got: %v", err)
}
// Test interrupting session
sessionID := "test-interrupt"
dbSession := &store.Session{
ID: sessionID,
RunID: "run-interrupt",
Status: store.SessionStatusRunning,
Query: "test query",
CreatedAt: time.Now(),
}
// Expect status update
mockStore.EXPECT().
UpdateSession(gomock.Any(), sessionID, gomock.Any()).
DoAndReturn(func(ctx context.Context, id string, update store.SessionUpdate) error {
if *update.Status != string(StatusFailed) {
t.Errorf("Expected status %s, got %s", StatusFailed, *update.Status)
}
if *update.ErrorMessage != "Session interrupted by user" {
t.Errorf("Expected error message 'Session interrupted by user', got %s", *update.ErrorMessage)
}
if update.CompletedAt == nil {
t.Error("Expected CompletedAt to be set")
}
return nil
})
// Create mock Claude session
mockClaudeSession := &mockClaudeSession{
events: make(chan claudecode.StreamEvent),
result: nil,
err: nil,
}
// Store active process
manager.mu.Lock()
manager.activeProcesses[sessionID] = mockClaudeSession
manager.mu.Unlock()
// Interrupt session
err = manager.InterruptSession(context.Background(), sessionID)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Verify session was removed from active processes
manager.mu.Lock()
_, exists := manager.activeProcesses[sessionID]
manager.mu.Unlock()
if exists {
t.Error("Expected session to be removed from active processes")
}
}

View File

@@ -78,4 +78,7 @@ type SessionManager interface {
// ListSessions returns all sessions from the database // ListSessions returns all sessions from the database
ListSessions() []Info ListSessions() []Info
// InterruptSession interrupts a running session
InterruptSession(ctx context.Context, sessionID string) error
} }

View File

@@ -1,14 +0,0 @@
- cmd+k - global command pallette, search all commands
- / - search all sessions and approvals
- g - go prefix
- g-a go approvals
- g-s go sessions
- g-t go templates
- g-p go pending
commands available through cmd+k:
- all go-to commands (sesions, approvals, templates, etc)
- launch session
- search sessions
- create template

View File

@@ -26,6 +26,7 @@ pub trait DaemonClientTrait: Send + Sync {
async fn deny_function_call(&self, call_id: &str, reason: &str) -> Result<()>; async fn deny_function_call(&self, call_id: &str, reason: &str) -> Result<()>;
async fn respond_to_human_contact(&self, call_id: &str, response: &str) -> Result<()>; async fn respond_to_human_contact(&self, call_id: &str, response: &str) -> Result<()>;
async fn subscribe(&self, req: SubscribeRequest) -> Result<tokio::sync::mpsc::Receiver<EventNotification>>; async fn subscribe(&self, req: SubscribeRequest) -> Result<tokio::sync::mpsc::Receiver<EventNotification>>;
async fn interrupt_session(&self, session_id: &str) -> Result<()>;
} }
pub struct DaemonClient { pub struct DaemonClient {
@@ -123,6 +124,7 @@ impl DaemonClientTrait for DaemonClient {
async fn launch_session(&self, req: LaunchSessionRequest) -> Result<LaunchSessionResponse> { async fn launch_session(&self, req: LaunchSessionRequest) -> Result<LaunchSessionResponse> {
self.send_rpc_request("launchSession", Some(req)).await self.send_rpc_request("launchSession", Some(req)).await
} }
async fn list_sessions(&self) -> Result<ListSessionsResponse> { async fn list_sessions(&self) -> Result<ListSessionsResponse> {
self.send_rpc_request("listSessions", None::<()>).await self.send_rpc_request("listSessions", None::<()>).await
@@ -255,6 +257,13 @@ impl DaemonClientTrait for DaemonClient {
Ok(receiver) Ok(receiver)
} }
async fn interrupt_session(&self, session_id: &str) -> Result<()> {
let req = InterruptSessionRequest {
session_id: session_id.to_string(),
};
self.send_rpc_request("interruptSession", Some(req)).await
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -74,6 +74,16 @@ pub struct LaunchSessionResponse {
pub run_id: String, pub run_id: String,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct InterruptSessionRequest {
pub session_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InterruptSessionResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ListSessionsRequest {} pub struct ListSessionsRequest {}

View File

@@ -222,6 +222,22 @@ async fn subscribe_to_events(
} }
} }
#[tauri::command]
async fn interrupt_session(
state: State<'_, AppState>,
session_id: String,
) -> std::result::Result<(), String> {
let client_guard = state.client.lock().await;
match &*client_guard {
Some(client) => client
.interrupt_session(&session_id)
.await
.map_err(|e| e.to_string()),
None => Err("Not connected to daemon".to_string()),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize tracing // Initialize tracing
@@ -245,6 +261,7 @@ pub fn run() {
deny_function_call, deny_function_call,
respond_to_human_contact, respond_to_human_contact,
subscribe_to_events, subscribe_to_events,
interrupt_session,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -12,6 +12,7 @@ interface StoreState {
setFocusedSession: (session: SessionInfo | null) => void setFocusedSession: (session: SessionInfo | null) => void
focusNextSession: () => void focusNextSession: () => void
focusPreviousSession: () => void focusPreviousSession: () => void
interruptSession: (sessionId: string) => Promise<void>
} }
export const useStore = create<StoreState>(set => ({ export const useStore = create<StoreState>(set => ({
@@ -67,4 +68,12 @@ export const useStore = create<StoreState>(set => ({
// Focus the previous session // Focus the previous session
return { focusedSession: sessions[currentIndex - 1] } return { focusedSession: sessions[currentIndex - 1] }
}), }),
interruptSession: async (sessionId: string) => {
try {
await daemonClient.interruptSession(sessionId)
// The session status will be updated via the subscription
} catch (error) {
console.error('Failed to interrupt session:', error)
}
},
})) }))

View File

@@ -10,6 +10,7 @@ import { useConversation } from '@/hooks/useConversation'
import { Skeleton } from '../ui/skeleton' import { Skeleton } from '../ui/skeleton'
import { Suspense, useEffect, useRef, useState } from 'react' import { Suspense, useEffect, useRef, useState } from 'react'
import { Bot, MessageCircleDashed, Wrench } from 'lucide-react' import { Bot, MessageCircleDashed, Wrench } from 'lucide-react'
import { useStore } from '@/AppStore'
/* I, Sundeep, don't know how I feel about what's going on here. */ /* I, Sundeep, don't know how I feel about what's going on here. */
let starryNight: any | null = null let starryNight: any | null = null
@@ -354,6 +355,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
const [focusedEventId, setFocusedEventId] = useState<number | null>(null) const [focusedEventId, setFocusedEventId] = useState<number | null>(null)
const [expandedEventId, setExpandedEventId] = useState<number | null>(null) const [expandedEventId, setExpandedEventId] = useState<number | null>(null)
const [isWideView, setIsWideView] = useState(false) const [isWideView, setIsWideView] = useState(false)
const interruptSession = useStore(state => state.interruptSession)
// Get events for sidebar access // Get events for sidebar access
const { events } = useConversation(session.id) const { events } = useConversation(session.id)
@@ -387,6 +389,13 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
} }
}) })
// Ctrl+X to interrupt session
useHotkeys('ctrl+x', () => {
if (session.status === 'running' || session.status === 'starting') {
interruptSession(session.id)
}
})
return ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<hgroup className="flex flex-col gap-1"> <hgroup className="flex flex-col gap-1">

View File

@@ -83,6 +83,10 @@ export class DaemonClient {
return unlisten return unlisten
} }
async interruptSession(sessionId: string): Promise<void> {
return await invoke('interrupt_session', { sessionId })
}
} }
// Export a singleton instance // Export a singleton instance

View File

@@ -1,354 +0,0 @@
# Phase 1: Core Command Palette Implementation
## Objective
Build the minimal viable command palette launcher - a full-screen overlay triggered by `Cmd+K` with a single input field that can launch sessions with basic parsing and instant feedback.
## Deliverables
1. **SessionLauncher.tsx** - Full-screen command palette overlay
2. **CommandInput.tsx** - Smart input with basic parsing
3. **useSessionLauncher.ts** - Zustand state management
4. **Global hotkey integration** - Cmd+K trigger
5. **Basic session launch** - Integration with daemon client
## Technical Specs
### SessionLauncher Component
```typescript
interface SessionLauncherProps {
isOpen: boolean;
onClose: () => void;
}
// Features:
// - Full-screen overlay (backdrop blur)
// - Centered modal with monospace font
// - High contrast design
// - Escape key to close
// - Click outside to close
```
### CommandInput Component
```typescript
interface CommandInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
}
// Features:
// - Large input field with monospace font
// - Enter key to submit
// - Real-time character count
// - Focus management
// - Smooth animations
```
### State Management
```typescript
interface LauncherState {
isOpen: boolean;
mode: "command" | "search"; // command = launch sessions, search = find sessions/approvals
query: string;
isLaunching: boolean;
error?: string;
gPrefixMode: boolean;
// Actions
open: (mode?: "command" | "search") => void;
close: () => void;
setQuery: (query: string) => void;
setGPrefixMode: (enabled: boolean) => void;
launchSession: () => Promise<void>;
reset: () => void;
}
```
### Basic Query Parsing
```typescript
interface ParsedQuery {
query: string; // Main text
workingDir?: string; // If query starts with /path
}
// Parse patterns:
// "debug login component" → { query: "debug login component" }
// "/src debug login" → { query: "debug login", workingDir: "/src" }
```
## File Structure
```
humanlayer-wui/src/
├── components/
│ ├── SessionLauncher.tsx # Main overlay component
│ └── CommandInput.tsx # Input field component
├── hooks/
│ └── useSessionLauncher.ts # State management
└── stores/
└── sessionStore.ts # Extended zustand store
```
## Implementation Steps
### Step 1: Create SessionLauncher Component (1 hour)
```typescript
// SessionLauncher.tsx
export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
// Full-screen overlay with backdrop
// Centered modal with session input
// Escape key handling
// Animation on open/close
}
```
**Styling Requirements**:
- Full viewport overlay with backdrop-blur
- Centered modal (max-width: 600px)
- Monospace font family
- High contrast colors (dark background, white text)
- Smooth fade-in/out animations
- Focus trap when open
### Step 2: Create CommandInput Component (1 hour)
```typescript
// CommandInput.tsx
export function CommandInput({ value, onChange, onSubmit }: CommandInputProps) {
// Large input field
// Enter key handling
// Character count display
// Loading state
}
```
**Features**:
- Large text input (48px height minimum)
- Monospace font
- Placeholder text with examples
- Real-time character count
- Loading spinner when launching
- Submit on Enter key
### Step 3: State Management Hook (1 hour)
```typescript
// useSessionLauncher.ts
export const useSessionLauncher = create<LauncherState>((set, get) => ({
isOpen: false,
query: "",
isLaunching: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, query: "", error: undefined }),
setQuery: (query) => set({ query }),
launchSession: async () => {
// Basic session launch logic
// Error handling
// Success navigation
},
}));
```
### Step 4: Global Hotkey Integration (45 minutes)
```typescript
// Add to App.tsx or main component
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd+K - Global command palette
if (e.metaKey && e.key === "k") {
e.preventDefault();
openLauncher("command");
}
// / - Search sessions and approvals
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
e.preventDefault();
openLauncher("search");
}
// G prefix navigation (prepare for Phase 2)
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
e.preventDefault();
setGPrefixMode(true);
setTimeout(() => setGPrefixMode(false), 2000);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Helper to check if user is typing in an input
const isInputFocused = () => {
const active = document.activeElement;
return (
active?.tagName === "INPUT" ||
active?.tagName === "TEXTAREA" ||
active?.contentEditable === "true"
);
};
```
**Enhanced Hotkey Features**:
- `Cmd+K` - Opens command palette in "command" mode (launch sessions)
- `/` - Opens command palette in "search" mode (find sessions/approvals)
- `g` prefix - Sets up for vim-style navigation (Phase 2: g+a = approvals, g+s = sessions)
- Smart input detection to avoid conflicts when user is typing
### Step 5: Session Launch Integration (30 minutes)
```typescript
const launchSession = async () => {
try {
set({ isLaunching: true, error: undefined });
const parsed = parseQuery(get().query);
const response = await daemonClient.launchSession({
query: parsed.query,
working_dir: parsed.workingDir || process.cwd(),
});
// Navigate to new session
navigate(`/session/${response.session_id}`);
// Close launcher
get().close();
} catch (error) {
set({ error: error.message });
} finally {
set({ isLaunching: false });
}
};
```
## UI Design Requirements
### Visual Style
- **Background**: Full-screen overlay with backdrop-blur-sm
- **Modal**: Centered, rounded corners, dark background
- **Typography**: Monospace font (ui-monospace, Monaco, "Cascadia Code")
- **Colors**: High contrast - white text on dark backgrounds
- **Spacing**: Generous padding and margins for breathing room
### Layout Structure
```
┌─────────────────────────────────────────────────┐
│ [OVERLAY] │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ > [INPUT FIELD] │ │
│ │ │ │
│ │ [CHARACTER COUNT] │ │
│ │ │ │
│ │ ↵ Launch ⌘K Close │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
```
### Interaction States
- **Default**: Clean input with placeholder
- **Typing**: Real-time character count
- **Loading**: Spinner + "Launching..." text
- **Error**: Red error message below input
- **Success**: Quick flash before navigation
## Integration Points
### App.tsx Integration
```typescript
function App() {
const { isOpen, close } = useSessionLauncher()
return (
<>
{/* Existing app content */}
<SessionTable />
<SessionDetail />
{/* Command palette overlay */}
<SessionLauncher isOpen={isOpen} onClose={close} />
</>
)
}
```
### SessionTable Button
Add floating action button or header button:
```typescript
<Button
onClick={() => useSessionLauncher.getState().open()}
className="fixed bottom-6 right-6"
>
<Plus className="h-4 w-4" />
</Button>
```
## Testing Requirements
### Unit Tests
- SessionLauncher renders correctly
- CommandInput handles input changes
- Hotkey triggers launcher open
- Session launch calls daemon client
- Error states display properly
### Integration Tests
- End-to-end session creation flow
- Keyboard navigation works
- Mobile responsiveness
- Focus management
## Acceptance Criteria
1.`Cmd+K` opens full-screen command palette
2. ✅ Single input field with monospace font
3. ✅ Enter key launches session with daemon client
4. ✅ Escape key closes launcher
5. ✅ Loading states during session creation
6. ✅ Error handling with user feedback
7. ✅ Navigation to new session on success
8. ✅ Clean, high-contrast design
9. ✅ Smooth animations and interactions
10. ✅ Mobile responsive layout
## Success Metrics
- Launcher opens in <50ms from keypress
- Session launches successfully 95% of the time
- Error messages are clear and actionable
- Interface is intuitive without documentation
- Works on desktop and mobile
## Next Phase Preparation
This implementation should be designed to easily extend with:
- Template parsing (`:debug`, `:review`)
- Model selection (`@claude-opus`)
- Advanced flags (`--max-turns=5`)
- Context detection and suggestions
- Recent session history
Keep the architecture clean and extensible for Phase 2 enhancements.

View File

@@ -1,288 +0,0 @@
# Superhuman Session Launcher
## Vision
A **minimal command palette** that harnesses the web's power while maintaining terminal aesthetics. One input field that intelligently understands context, templates, and intent. Think Linear's command bar meets VS Code's quick open meets superhuman speed.
## Core Philosophy: Minimal Web Superpowers
- **Single Input**: One search box that does everything
- **Context Aware**: Auto-detect git repos, running processes, file types
- **Template Driven**: Smart presets hidden behind simple commands
- **Sub-100ms**: Instant feedback, zero loading states
- **Power User**: Advanced features accessible via keyboard shortcuts
## The Interface
### Primary UI: Command Palette Launcher
```
┌─────────────────────────────────────────────────────────────┐
│ > debug react performance issues in /src/components │
│ │
│ 🎯 Debug React Performance │
│ 📁 /Users/you/project/src/components │
│ 🤖 claude-3-5-sonnet-20241022 │
│ ⚡ approvals: true, max_turns: 20 │
│ │
│ ↵ Launch ⇥ Templates ⌘K Settings ⌘/ Help │
└─────────────────────────────────────────────────────────────┘
```
**Trigger**: `Cmd+K` globally or floating `+` button
**Style**: Full-screen overlay, monospace font, high contrast
**Behavior**: Type to search/filter, instant previews
### Smart Input Parsing
The single input field intelligently parses different patterns:
```bash
# Natural language (default)
> fix the login bug in auth.ts
# Template shortcuts
> :debug react # Expands to debug template
> :review pr # Code review template
> :refactor # Refactoring template
# File/directory focus
> /src/components # Auto-sets working directory
> auth.ts performance # File-specific query
# Advanced options
> fix login @claude-opus --max-turns=5 --no-approvals
# Recent/favorites
> ↑↓ to browse recent sessions
> ⭐ to mark favorites
```
## Technical Implementation
### Core Components (Minimal File Structure)
```
src/components/
├── SessionLauncher.tsx # Main command palette
├── CommandInput.tsx # Smart input with parsing
└── SessionPreview.tsx # Live preview pane
```
### State Management (Zustand Extension)
```typescript
interface LauncherState {
isOpen: boolean;
query: string;
parsedCommand: ParsedCommand;
suggestions: Suggestion[];
recentSessions: RecentSession[];
templates: Template[];
}
interface ParsedCommand {
query: string; // Main query text
model?: string; // @claude-opus, @gpt-4, etc.
workingDir?: string; // /src/components
template?: string; // :debug, :review
maxTurns?: number; // --max-turns=10
approvals?: boolean; // --approvals, --no-approvals
customInstructions?: string; // Additional context
}
```
### Daemon Client Integration
Leveraging the full `LaunchSessionRequest` interface:
```typescript
interface LaunchSessionRequest {
query: string; // ✅ Main input
model?: string; // ✅ Smart model selection
working_dir?: string; // ✅ Auto-detected/specified
max_turns?: number; // ✅ Template defaults
system_prompt?: string; // ✅ Template system prompts
append_system_prompt?: string; // ✅ User customizations
custom_instructions?: string; // ✅ Project-specific context
allowed_tools?: string[]; // ✅ Template restrictions
disallowed_tools?: string[]; // ✅ Security controls
mcp_config?: unknown; // ✅ Advanced MCP settings
permission_prompt_tool?: string; // ✅ Approval tool selection
verbose?: boolean; // ✅ Debug mode
}
```
## Smart Features (Hidden Complexity)
### 1. Context Detection
```typescript
// Auto-detect project context
const detectContext = async (): Promise<SessionContext> => {
return {
gitRepo: await detectGitRepo(), // Current branch, status
packageManager: await detectPackageManager(), // npm, yarn, bun
framework: await detectFramework(), // React, Next.js, etc.
runningProcesses: await getRunningProcesses(), // dev servers
recentFiles: await getMostRecentFiles(), // Recently edited
workingDir: process.cwd(),
};
};
```
### 2. Template System
```typescript
interface Template {
id: string; // 'debug', 'review', 'refactor'
trigger: string; // ':debug'
name: string; // 'Debug Session'
description: string; // 'Debug performance issues'
systemPrompt: string; // Template-specific instructions
allowedTools?: string[]; // Restricted tool access
maxTurns: number; // Default turn limit
model: string; // Preferred model
tags: string[]; // For filtering/search
}
const BUILTIN_TEMPLATES: Template[] = [
{
id: "debug",
trigger: ":debug",
name: "Debug Session",
systemPrompt: "You are debugging code. Focus on finding root causes.",
allowedTools: ["terminal", "file_ops", "browser"],
maxTurns: 20,
model: "claude-3-5-sonnet-20241022",
},
{
id: "review",
trigger: ":review",
name: "Code Review",
systemPrompt: "Review code for bugs, performance, and best practices.",
maxTurns: 10,
model: "claude-3-5-sonnet-20241022",
},
];
```
### 3. Intelligent Suggestions
```typescript
const generateSuggestions = (query: string, context: SessionContext) => {
// Fuzzy match templates
const templateSuggestions = templates.filter(
(t) => fuzzyMatch(query, t.name) || fuzzyMatch(query, t.tags),
);
// Recent session patterns
const recentSuggestions = recentSessions
.filter((s) => fuzzyMatch(query, s.query))
.slice(0, 3);
// File-based suggestions
const fileSuggestions = context.recentFiles
.filter((f) => fuzzyMatch(query, f.name))
.map((f) => `debug ${f.name}`);
return [...templateSuggestions, ...recentSuggestions, ...fileSuggestions];
};
```
## Implementation Phases
### Phase 1: Core Command Palette (4 hours)
**Files**:
- `SessionLauncher.tsx` - Full-screen overlay with search
- `CommandInput.tsx` - Smart input parsing
- `useSessionLauncher.ts` - State management hook
**Features**:
- Global `Cmd+K` hotkey
- Single input with instant preview
- Basic query parsing (templates, model selection)
- Session launch integration
### Phase 2: Smart Context (3 hours)
**Files**:
- `useContextDetection.ts` - Auto-detect project context
- `templates.ts` - Built-in template definitions
**Features**:
- Auto-detect working directory, git status
- Template system with `:shortcut` triggers
- Recent session history
- Smart suggestions based on context
### Phase 3: Advanced Parsing (2 hours)
**Features**:
- Full command parsing (`@model --flags /paths`)
- Real-time validation and error states
- Advanced daemon client options
- Custom instruction handling
### Phase 4: Polish & Performance (3 hours)
**Features**:
- Sub-100ms interactions
- Keyboard navigation perfection
- Mobile-responsive design
- Analytics and error tracking
## Hotkey System
```typescript
// Global hotkeys (always active)
'cmd+k': openLauncher
'cmd+shift+k': openLauncherWithTemplate
'esc': closeLauncher
// Launcher hotkeys (when open)
'enter': launchSession
'cmd+enter': launchWithAdvanced
'tab': nextSuggestion
'shift+tab': prevSuggestion
'cmd+1-9': selectTemplate
'cmd+backspace': clearInput
'cmd+,': openSettings
```
## Success Metrics
1. **Speed**: <100ms from keypress to visual feedback
2. **Adoption**: 80% of sessions launched via command palette
3. **Efficiency**: Average session setup time <5 seconds
4. **Power User**: Advanced features discoverable via keyboard
5. **Minimal**: Single input handles 90% of use cases
## Example Interactions
```bash
# Quick debug session
> debug login component
→ Launches with debug template, /src/components directory
# Specific model and settings
> refactor auth.ts @opus --max-turns=5
→ Uses Claude Opus, limited turns, focuses on auth.ts
# Template shortcut
> :review
→ Expands template picker, shows code review options
# Recent session
> ↑ (previous: "fix api timeout")
→ Reloads previous session configuration
```
This approach gives users **web superpowers** (context detection, visual feedback, smart suggestions) while maintaining **terminal aesthetics** (monospace, keyboard-first, minimal interface). Every advanced feature is accessible but hidden behind the simple command palette interface.

44
specs/hotkey-thoughts.md Normal file
View File

@@ -0,0 +1,44 @@
# Hotkey and Command Specification
## Global Hotkeys
| Hotkey | Description |
|--------|-------------|
| `⌘K` | Open global command palette to search and access all commands |
| `/` | Search across all sessions and approvals |
| `g` | Go-to prefix for quick navigation |
### Go-to Navigation Commands
After pressing `g`, the following keys activate navigation:
| Key | Navigation Target |
|-----|------------------|
| `a` | Approvals page |
| `s` | Sessions page |
| `t` | Templates page |
| `p` | Pending items |
## Command Palette (⌘K)
The command palette provides centralized access to all system commands and features.
### Available Commands
#### Navigation
- All go-to commands (equivalent to `g` prefix hotkeys)
- Go to Sessions
- Go to Approvals
- Go to Templates
- Go to Pending Items
#### Actions
- Launch Session
- Search Sessions
- Create Template
## Implementation Notes
- Command palette should provide fuzzy search across all commands
- Hotkeys should be configurable in the future
- Consider adding tooltips for new users to discover hotkeys
- Ensure hotkeys don't conflict with browser/OS defaults