mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
initial steps for interrupt
This commit is contained in:
@@ -274,6 +274,14 @@ func (s *Session) Kill() error {
|
||||
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
|
||||
func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
|
||||
@@ -3,6 +3,7 @@ package claudecode
|
||||
import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -147,6 +148,7 @@ type Session struct {
|
||||
err error
|
||||
}
|
||||
|
||||
|
||||
// SetError safely sets the error
|
||||
func (s *Session) SetError(err error) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
|
||||
# Function to generate a unique worktree name
|
||||
generate_unique_name() {
|
||||
local adjectives=("swift" "bright" "clever" "smooth" "quick" "clean" "sharp" "neat" "cool" "fast")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -248,3 +248,40 @@ func TestConnect_WithRetries(t *testing.T) {
|
||||
assert.Nil(t, client)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ type Client interface {
|
||||
// ListSessions lists all active sessions
|
||||
ListSessions() (*rpc.ListSessionsResponse, error)
|
||||
|
||||
// InterruptSession interrupts a running session
|
||||
InterruptSession(sessionID string) error
|
||||
|
||||
// ContinueSession continues an existing completed session with a new query
|
||||
ContinueSession(req rpc.ContinueSessionRequest) (*rpc.ContinueSessionResponse, error)
|
||||
|
||||
|
||||
@@ -285,6 +285,42 @@ func (h *SessionHandlers) HandleContinueSession(ctx context.Context, params json
|
||||
}, 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
|
||||
func (h *SessionHandlers) Register(server *Server) {
|
||||
server.Register("launchSession", h.HandleLaunchSession)
|
||||
@@ -292,4 +328,5 @@ func (h *SessionHandlers) Register(server *Server) {
|
||||
server.Register("getConversation", h.HandleGetConversation)
|
||||
server.Register("getSessionState", h.HandleGetSessionState)
|
||||
server.Register("continueSession", h.HandleContinueSession)
|
||||
server.Register("interruptSession", h.HandleInterruptSession)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package rpc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -237,3 +238,108 @@ func TestHandleGetSessionState(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,3 +779,51 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
|
||||
Config: config,
|
||||
}, 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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
claudecode "github.com/humanlayer/humanlayer/claudecode-go"
|
||||
"github.com/humanlayer/humanlayer/hld/bus"
|
||||
"github.com/humanlayer/humanlayer/hld/store"
|
||||
"go.uber.org/mock/gomock"
|
||||
@@ -392,3 +393,73 @@ func containsStr(s, substr string) bool {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,7 @@ type SessionManager interface {
|
||||
|
||||
// ListSessions returns all sessions from the database
|
||||
ListSessions() []Info
|
||||
|
||||
// InterruptSession interrupts a running session
|
||||
InterruptSession(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -26,6 +26,7 @@ pub trait DaemonClientTrait: Send + Sync {
|
||||
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 subscribe(&self, req: SubscribeRequest) -> Result<tokio::sync::mpsc::Receiver<EventNotification>>;
|
||||
async fn interrupt_session(&self, session_id: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct DaemonClient {
|
||||
@@ -124,6 +125,7 @@ impl DaemonClientTrait for DaemonClient {
|
||||
self.send_rpc_request("launchSession", Some(req)).await
|
||||
}
|
||||
|
||||
|
||||
async fn list_sessions(&self) -> Result<ListSessionsResponse> {
|
||||
self.send_rpc_request("listSessions", None::<()>).await
|
||||
}
|
||||
@@ -255,6 +257,13 @@ impl DaemonClientTrait for DaemonClient {
|
||||
|
||||
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)]
|
||||
|
||||
@@ -74,6 +74,16 @@ pub struct LaunchSessionResponse {
|
||||
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)]
|
||||
pub struct ListSessionsRequest {}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
pub fn run() {
|
||||
// Initialize tracing
|
||||
@@ -245,6 +261,7 @@ pub fn run() {
|
||||
deny_function_call,
|
||||
respond_to_human_contact,
|
||||
subscribe_to_events,
|
||||
interrupt_session,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -12,6 +12,7 @@ interface StoreState {
|
||||
setFocusedSession: (session: SessionInfo | null) => void
|
||||
focusNextSession: () => void
|
||||
focusPreviousSession: () => void
|
||||
interruptSession: (sessionId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useStore = create<StoreState>(set => ({
|
||||
@@ -67,4 +68,12 @@ export const useStore = create<StoreState>(set => ({
|
||||
// Focus the previous session
|
||||
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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useConversation } from '@/hooks/useConversation'
|
||||
import { Skeleton } from '../ui/skeleton'
|
||||
import { Suspense, useEffect, useRef, useState } from '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. */
|
||||
let starryNight: any | null = null
|
||||
@@ -354,6 +355,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
const [focusedEventId, setFocusedEventId] = useState<number | null>(null)
|
||||
const [expandedEventId, setExpandedEventId] = useState<number | null>(null)
|
||||
const [isWideView, setIsWideView] = useState(false)
|
||||
const interruptSession = useStore(state => state.interruptSession)
|
||||
|
||||
// Get events for sidebar access
|
||||
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 (
|
||||
<section className="flex flex-col gap-4">
|
||||
<hgroup className="flex flex-col gap-1">
|
||||
|
||||
@@ -83,6 +83,10 @@ export class DaemonClient {
|
||||
|
||||
return unlisten
|
||||
}
|
||||
|
||||
async interruptSession(sessionId: string): Promise<void> {
|
||||
return await invoke('interrupt_session', { sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
|
||||
@@ -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.
|
||||
@@ -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
44
specs/hotkey-thoughts.md
Normal 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
|
||||
Reference in New Issue
Block a user