From 489026904dfce64e73c380a5d8e238df3226a740 Mon Sep 17 00:00:00 2001 From: Kyuzan0 Date: Tue, 10 Nov 2026 16:35:07 +0700 Subject: [PATCH 1/3] Add Antigravity agent preset and resilient session continue Support Antigravity CLI (agy) as a first-class agent preset with interactive prompt flags, permission bypass, continue, and conversation resume. Add Antigravity transcript reader and resilient session recovery handling. --- cli/control.go | 34 +- core/agent/agent_test.go | 3 + core/agent/inject_test.go | 6 +- core/agent/presets.go | 2 + core/recovery/checkpoint.go | 97 +++++ core/recovery/compact.go | 121 +++++++ core/recovery/engine.go | 196 ++++++++++ core/recovery/lock.go | 75 ++++ core/recovery/logger.go | 80 +++++ core/recovery/proc_unix.go | 27 ++ core/recovery/proc_windows.go | 50 +++ core/recovery/reconcile.go | 111 ++++++ core/recovery/recovery_test.go | 339 ++++++++++++++++++ core/recovery/sanitize.go | 38 ++ core/recovery/schema.go | 57 +++ core/transcript/agents.go | 1 + core/transcript/antigravity.go | 156 ++++++++ core/transcript/antigravity_test.go | 132 +++++++ daemon/antigravity_continue_test.go | 43 +++ daemon/persist.go | 31 +- daemon/session_wake.go | 2 +- docs/proposals/fix-session-continue-omp.md | 79 ++++ .../proposals/omp-session-resume-isolation.md | 49 +++ ui/session_continue_test.go | 42 +++ 24 files changed, 1760 insertions(+), 11 deletions(-) create mode 100644 core/recovery/checkpoint.go create mode 100644 core/recovery/compact.go create mode 100644 core/recovery/engine.go create mode 100644 core/recovery/lock.go create mode 100644 core/recovery/logger.go create mode 100644 core/recovery/proc_unix.go create mode 100644 core/recovery/proc_windows.go create mode 100644 core/recovery/reconcile.go create mode 100644 core/recovery/recovery_test.go create mode 100644 core/recovery/sanitize.go create mode 100644 core/recovery/schema.go create mode 100644 core/transcript/antigravity.go create mode 100644 core/transcript/antigravity_test.go create mode 100644 daemon/antigravity_continue_test.go create mode 100644 docs/proposals/fix-session-continue-omp.md create mode 100644 docs/proposals/omp-session-resume-isolation.md create mode 100644 ui/session_continue_test.go diff --git a/cli/control.go b/cli/control.go index c7ae248e..f6841fde 100644 --- a/cli/control.go +++ b/cli/control.go @@ -232,10 +232,32 @@ func (r runner) fork(args []string) error { } func (r runner) continueSession(args []string) error { - if len(args) != 1 { - return errors.New("usage: enowx-app continue SESSION [--host H]") + var ref string + if len(args) == 0 { + list, err := r.c.Sessions() + if err != nil { + return err + } + // Find latest exited or hibernated session to recover + for i := len(list) - 1; i >= 0; i-- { + if list[i].Exited || list[i].Hibernated { + ref = list[i].ID + break + } + } + if ref == "" { + return errors.New("continue: no exited or broken session found to continue") + } + } else if len(args) == 1 { + ref = strings.TrimSpace(args[0]) + if ref == "" || ref == "null" || ref == "undefined" { + return errors.New("continue: session ID cannot be empty, null, or undefined") + } + } else { + return errors.New("usage: enowx-app continue [SESSION] [--host H]") } - s, err := r.session(args[0]) + + s, err := r.session(ref) if err != nil { return err } @@ -247,7 +269,11 @@ func (r runner) continueSession(args []string) error { default: return fmt.Errorf("continue: %s is %s, not ended", s.ID, State(s)) } - return err + if err != nil { + return err + } + fmt.Fprintf(r.out, "Continued session %s (%s)\n", s.ID, s.Agent) + return nil } func (r runner) rename(args []string) error { diff --git a/core/agent/agent_test.go b/core/agent/agent_test.go index d8b8b1b5..ec66de2e 100644 --- a/core/agent/agent_test.go +++ b/core/agent/agent_test.go @@ -29,6 +29,9 @@ func TestArgv(t *testing.T) { {"omp", Launch{SkipPermissions: true, Continue: true}, []string{"omp", "--continue", "--auto-approve"}}, {"omp", Launch{Prompt: "test", Resume: "sess-123"}, []string{"omp", "--resume", "sess-123"}}, {"omp", Launch{Continue: true, SessionDir: "/s/1"}, []string{"omp", "--continue", "--session-dir", "/s/1"}}, + {"antigravity", Launch{Prompt: "hi", SkipPermissions: true}, []string{"agy", "--dangerously-skip-permissions", "--prompt-interactive", "hi"}}, + {"antigravity", Launch{SkipPermissions: true, Continue: true}, []string{"agy", "--continue", "--dangerously-skip-permissions"}}, + {"antigravity", Launch{Prompt: "test", Resume: "uuid-123"}, []string{"agy", "--conversation", "uuid-123"}}, // An agent without a folder of its own for them keeps its own. {"pi", Launch{SessionDir: "/s/1"}, []string{"pi"}}, } diff --git a/core/agent/inject_test.go b/core/agent/inject_test.go index 3bebad5c..91ebcbbc 100644 --- a/core/agent/inject_test.go +++ b/core/agent/inject_test.go @@ -120,8 +120,10 @@ func TestProjectBlockKeepsTheUsersTextAndDoesntDouble(t *testing.T) { if strings.Count(string(data), projectBlockStart) != 1 || strings.Contains(string(data), "older words") || !strings.HasSuffix(string(data), mine) { t.Fatalf("%q", data) } - if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 { - t.Fatalf("mode %v", info.Mode()) + if runtime.GOOS != "windows" { + if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 { + t.Fatalf("mode %v", info.Mode()) + } } // Off: as it was before. if err := RemoveProjectBlock(dir); err != nil { diff --git a/core/agent/presets.go b/core/agent/presets.go index c6a2513b..c1c95e3a 100644 --- a/core/agent/presets.go +++ b/core/agent/presets.go @@ -187,6 +187,8 @@ var Presets = []Preset{ ID: "antigravity", Name: "Antigravity", Command: "agy", PromptFlag: []string{"--prompt-interactive"}, SkipPermissions: []string{"--dangerously-skip-permissions"}, + Continue: []string{"--continue"}, + Resume: []string{"--conversation"}, Homepage: "https://antigravity.google/docs/cli-overview", }, { diff --git a/core/recovery/checkpoint.go b/core/recovery/checkpoint.go new file mode 100644 index 00000000..bed9dcc0 --- /dev/null +++ b/core/recovery/checkpoint.go @@ -0,0 +1,97 @@ +package recovery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +const checkpointFileName = "checkpoint.json" + +// ComputeStateHash calculates the SHA-256 hash of a file at snapshotPath. +func ComputeStateHash(snapshotPath string) (string, error) { + if snapshotPath == "" { + return "", nil + } + f, err := os.Open(snapshotPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + defer f.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// SaveCheckpoint writes the session state to checkpoint.json atomically. +func SaveCheckpoint(stateDir string, state *SessionState) error { + if state == nil { + return fmt.Errorf("state cannot be nil") + } + if err := os.MkdirAll(stateDir, 0o700); err != nil { + return fmt.Errorf("failed to create state directory: %w", err) + } + + // Compute snapshot hash if ref exists + if state.HistorySnapshotRef != "" { + hash, err := ComputeStateHash(state.HistorySnapshotRef) + if err == nil && hash != "" { + state.Checkpoint.StateHash = hash + } + } + + // Sanitize failure context if present + if state.FailureContext != nil { + state.FailureContext = SanitizeFailureContext(state.FailureContext) + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal checkpoint: %w", err) + } + + targetPath := filepath.Join(stateDir, checkpointFileName) + tmpPath := targetPath + ".tmp" + + // Write to temporary journal file + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return fmt.Errorf("failed to write checkpoint temp file: %w", err) + } + + // Atomic rename replaces the existing checkpoint safely + if err := os.Rename(tmpPath, targetPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("failed to atomically rename checkpoint file: %w", err) + } + + return nil +} + +// LoadCheckpoint reads and unmarshals checkpoint.json from stateDir. +func LoadCheckpoint(stateDir string) (*SessionState, error) { + targetPath := filepath.Join(stateDir, checkpointFileName) + data, err := os.ReadFile(targetPath) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrSessionNotFound + } + return nil, fmt.Errorf("%w: read error: %v", ErrSessionUnrecoverable, err) + } + + var state SessionState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("%w: json decode error: %v", ErrSessionUnrecoverable, err) + } + + return &state, nil +} diff --git a/core/recovery/compact.go b/core/recovery/compact.go new file mode 100644 index 00000000..e00acbac --- /dev/null +++ b/core/recovery/compact.go @@ -0,0 +1,121 @@ +package recovery + +import ( + "bufio" + "encoding/json" + "fmt" + "os" +) + +// HistoryStep represents a generic step entry in a session transcript/history. +type HistoryStep struct { + StepIndex int `json:"step_index"` + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Content string `json:"content,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Raw map[string]interface{} `json:"-"` +} + +// CompactHistory compacts a JSONL history file to prevent context overflow. +// It summarizes older steps and retains the most recent retainSteps steps intact. +func CompactHistory(filePath string, retainSteps int) (bool, error) { + f, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("compact: open history file: %w", err) + } + + var allLines []map[string]interface{} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64<<10), 16<<20) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var entry map[string]interface{} + if err := json.Unmarshal(line, &entry); err == nil { + allLines = append(allLines, entry) + } + } + scanErr := scanner.Err() + _ = f.Close() + if scanErr != nil { + return false, fmt.Errorf("compact: read history: %w", scanErr) + } + + if len(allLines) <= retainSteps { + return false, nil // No compaction needed + } + + cutoff := len(allLines) - retainSteps + compactedCount := cutoff + + // Create summary of compacted history + summaryContent := fmt.Sprintf("[Compacted context: %d earlier interaction steps summarized to fit context budget]", compactedCount) + summaryStep := map[string]interface{}{ + "step_index": 0, + "type": "compact_summary", + "source": "SYSTEM", + "status": "DONE", + "content": summaryContent, + } + + newLines := make([]map[string]interface{}, 0, retainSteps+1) + newLines = append(newLines, summaryStep) + newLines = append(newLines, allLines[cutoff:]...) + + // Atomic write back + tmpPath := filePath + ".compact.tmp" + tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return false, fmt.Errorf("compact: create tmp file: %w", err) + } + + writer := bufio.NewWriter(tmpFile) + for _, l := range newLines { + data, err := json.Marshal(l) + if err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return false, fmt.Errorf("compact: marshal line: %w", err) + } + if _, err := writer.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return false, fmt.Errorf("compact: write line: %w", err) + } + if err := writer.WriteByte('\n'); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return false, fmt.Errorf("compact: write newline: %w", err) + } + } + if err := writer.Flush(); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return false, fmt.Errorf("compact: flush: %w", err) + } + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return false, fmt.Errorf("compact: sync: %w", err) + } + tmpFile.Close() + + if err := os.Rename(tmpPath, filePath); err != nil { + // Fallback for Windows if target exists + _ = os.Remove(filePath) + if rerr := os.Rename(tmpPath, filePath); rerr != nil { + os.Remove(tmpPath) + return false, fmt.Errorf("compact: rename tmp file: %w", rerr) + } + } + + return true, nil +} diff --git a/core/recovery/engine.go b/core/recovery/engine.go new file mode 100644 index 00000000..385f6906 --- /dev/null +++ b/core/recovery/engine.go @@ -0,0 +1,196 @@ +package recovery + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// ResumeOptions sets arguments for a resume operation. +type ResumeOptions struct { + BaseDir string // Directory containing session folders (e.g. ~/.enowx-app/sessions) + SessionID string // Optional: if empty, auto-detect latest recoverable session + MaxAttempts int + AttemptCount int +} + +// ResumeResult contains hydrated recovery context and active locks. +type ResumeResult struct { + State *SessionState + SyntheticPrompt string + Unlock func() + Reconciled *ReconcileResult +} + +// Engine orchestrates session resumption and state hydration. +type Engine struct { + logger *Logger +} + +// NewEngine creates a recovery Engine. +func NewEngine(l *Logger) *Engine { + if l == nil { + l = defaultLogger + } + return &Engine{logger: l} +} + +// Resume executes the full recovery sequence for a broken session. +func (e *Engine) Resume(opts ResumeOptions) (*ResumeResult, error) { + start := time.Now() + sessionID := strings.TrimSpace(opts.SessionID) + + // Auto-detect latest recoverable session if sessionID not provided + if sessionID == "" { + detectedID, err := e.AutoDetectRecoverable(opts.BaseDir) + if err != nil { + return nil, err + } + sessionID = detectedID + } + + // Validate UUID format + if !uuidRegex.MatchString(sessionID) { + return nil, fmt.Errorf("malformed session ID: %q must be a valid UUID v4", sessionID) + } + + stateDir := filepath.Join(opts.BaseDir, sessionID) + + e.logger.Log(EventSessionResumeAttempt, sessionID, 0, 0, opts.AttemptCount, map[string]interface{}{ + "state_dir": stateDir, + }) + + // 1. Acquire Lock + unlock, err := AcquireLock(stateDir, sessionID) + if err != nil { + e.logger.Log(EventSessionResumeFailed, sessionID, 0, time.Since(start), opts.AttemptCount, map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + // 2. Load Checkpoint + state, err := LoadCheckpoint(stateDir) + if err != nil { + unlock() + e.logger.Log(EventSessionResumeFailed, sessionID, 0, time.Since(start), opts.AttemptCount, map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + // 3. Status Validation + if state.Status == StatusTerminated { + unlock() + err := fmt.Errorf("%w (session %s has clean exit)", ErrSessionAlreadyCompleted, sessionID) + e.logger.Log(EventSessionResumeFailed, sessionID, state.Checkpoint.LastStepIndex, time.Since(start), opts.AttemptCount, map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + // 4. Reconcile History (Rollback corrupted partial writes) + var recon *ReconcileResult + if state.HistorySnapshotRef != "" { + res, err := ReconcileHistory(state.HistorySnapshotRef) + if err != nil { + unlock() + e.logger.Log(EventSessionResumeFailed, sessionID, state.Checkpoint.LastStepIndex, time.Since(start), opts.AttemptCount, map[string]interface{}{ + "error": err.Error(), + }) + return nil, fmt.Errorf("history reconciliation error: %w", err) + } + recon = res + if recon.LastStepIndex >= 0 { + state.Checkpoint.LastStepIndex = recon.LastStepIndex + } + + // Safe compaction if context overflowed + if state.FailureContext != nil && state.FailureContext.ErrorCode == ErrCodeContextOverflow { + if _, cerr := CompactHistory(state.HistorySnapshotRef, 20); cerr != nil { + e.logger.Log(EventSessionResumeAttempt, sessionID, state.Checkpoint.LastStepIndex, 0, opts.AttemptCount, map[string]interface{}{ + "compaction_warning": cerr.Error(), + }) + } + } + } + + // 5. Handle Tool Crash & Orphan Process Cleanup + if state.Checkpoint.ActiveToolPID > 0 { + if isProcessAlive(state.Checkpoint.ActiveToolPID) { + _ = terminateProcess(state.Checkpoint.ActiveToolPID) + } + if state.FailureContext == nil { + state.FailureContext = &FailureContext{ + ErrorCode: ErrCodeToolExecFailure, + ErrorMessage: fmt.Sprintf("Tool call %s terminated due to process crash", state.Checkpoint.ActiveToolCallID), + Recoverable: true, + } + } + state.Checkpoint.ActiveToolPID = 0 + } + + // 6. Generate Synthetic Restoration Prompt + prompt := SyntheticRestorationPrompt(state.Checkpoint, state.FailureContext) + + // 7. Update Status + state.Status = StatusActive + _ = SaveCheckpoint(stateDir, state) + + var truncatedBytes int64 + if recon != nil { + truncatedBytes = recon.TruncatedBytes + } + e.logger.Log(EventSessionResumeSuccess, sessionID, state.Checkpoint.LastStepIndex, time.Since(start), opts.AttemptCount, map[string]interface{}{ + "truncated_bytes": truncatedBytes, + "last_step": state.Checkpoint.LastStepIndex, + }) + + return &ResumeResult{ + State: state, + SyntheticPrompt: prompt, + Unlock: unlock, + Reconciled: recon, + }, nil +} + +// AutoDetectRecoverable finds the newest session directory marked recoverable. +func (e *Engine) AutoDetectRecoverable(baseDir string) (string, error) { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "", fmt.Errorf("failed to read base directory %q: %w", baseDir, err) + } + + var bestID string + var latestMod time.Time + + for _, entry := range entries { + if !entry.IsDir() || !uuidRegex.MatchString(entry.Name()) { + continue + } + sessDir := filepath.Join(baseDir, entry.Name()) + state, err := LoadCheckpoint(sessDir) + if err != nil { + continue + } + if state.Status != StatusTerminated && (state.FailureContext == nil || state.FailureContext.Recoverable) { + if fi, err := entry.Info(); err == nil { + if fi.ModTime().After(latestMod) { + latestMod = fi.ModTime() + bestID = entry.Name() + } + } + } + } + + if bestID == "" { + return "", fmt.Errorf("%w: no recoverable broken sessions found in %s", ErrSessionNotFound, baseDir) + } + + return bestID, nil +} diff --git a/core/recovery/lock.go b/core/recovery/lock.go new file mode 100644 index 00000000..53fa581f --- /dev/null +++ b/core/recovery/lock.go @@ -0,0 +1,75 @@ +package recovery + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const lockFileName = "session.lock" + +type lockRecord struct { + PID int `json:"pid"` + SessionID string `json:"session_id"` + CreatedAt time.Time `json:"created_at"` +} + +// AcquireLock attempts to acquire a file-based lock for the session. +// If a lock already exists, it checks if the holding process is still alive. +// If stale, it cleans up and acquires the lock. Returns an unlock callback. +func AcquireLock(stateDir, sessionID string) (func(), error) { + if err := os.MkdirAll(stateDir, 0o700); err != nil { + return nil, fmt.Errorf("failed to create lock directory: %w", err) + } + + lockPath := filepath.Join(stateDir, lockFileName) + + // Check existing lock + if data, err := os.ReadFile(lockPath); err == nil { + var existing lockRecord + if json.Unmarshal(data, &existing) == nil && existing.PID > 0 { + if isProcessAlive(existing.PID) && existing.PID != os.Getpid() { + return nil, fmt.Errorf("%w: held by PID %d (session: %s)", ErrSessionLocked, existing.PID, existing.SessionID) + } + // Process is dead, stale lock can be cleaned up + _ = os.Remove(lockPath) + } + } + + // Create new lock + rec := lockRecord{ + PID: os.Getpid(), + SessionID: sessionID, + CreatedAt: time.Now(), + } + recData, err := json.Marshal(rec) + if err != nil { + return nil, err + } + + if err := os.WriteFile(lockPath, recData, 0o600); err != nil { + return nil, fmt.Errorf("failed to write session lock: %w", err) + } + + unlock := func() { + _ = os.Remove(lockPath) + } + + return unlock, nil +} + +// IsLocked reports whether stateDir currently has an active lock held by a live process. +func IsLocked(stateDir string) bool { + lockPath := filepath.Join(stateDir, lockFileName) + data, err := os.ReadFile(lockPath) + if err != nil { + return false + } + var existing lockRecord + if err := json.Unmarshal(data, &existing); err != nil || existing.PID <= 0 { + return false + } + return isProcessAlive(existing.PID) +} diff --git a/core/recovery/logger.go b/core/recovery/logger.go new file mode 100644 index 00000000..8656e8c9 --- /dev/null +++ b/core/recovery/logger.go @@ -0,0 +1,80 @@ +package recovery + +import ( + "encoding/json" + "io" + "os" + "sync" + "time" +) + +// Standard session recovery event names. +const ( + EventSessionInitialized = "SESSION_INITIALIZED" + EventCheckpointSaved = "CHECKPOINT_SAVED" + EventSessionCrashDetected = "SESSION_CRASH_DETECTED" + EventSessionResumeAttempt = "SESSION_RESUME_ATTEMPT" + EventSessionResumeSuccess = "SESSION_RESUME_SUCCESS" + EventSessionResumeFailed = "SESSION_RESUME_FAILED" +) + +// StructuredLogRecord represents an observability log entry for recovery. +type StructuredLogRecord struct { + Timestamp string `json:"timestamp"` + Event string `json:"event"` + SessionID string `json:"session_id"` + StepIndex int `json:"step_index"` + DurationMs int64 `json:"duration_ms"` + RecoveryAttemptCount int `json:"recovery_attempt_count"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// Logger writes structured JSON recovery logs. +type Logger struct { + mu sync.Mutex + out io.Writer +} + +// NewLogger creates a new structured recovery logger. +func NewLogger(out io.Writer) *Logger { + if out == nil { + out = os.Stdout + } + return &Logger{out: out} +} + +var defaultLogger = NewLogger(os.Stdout) + +// SetDefaultOutput redirects the global recovery logger output. +func SetDefaultOutput(w io.Writer) { + defaultLogger.mu.Lock() + defer defaultLogger.mu.Unlock() + defaultLogger.out = w +} + +// Log emits a structured recovery event. +func (l *Logger) Log(event, sessionID string, stepIndex int, duration time.Duration, attemptCount int, details map[string]interface{}) { + record := StructuredLogRecord{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Event: event, + SessionID: sessionID, + StepIndex: stepIndex, + DurationMs: duration.Milliseconds(), + RecoveryAttemptCount: attemptCount, + Details: details, + } + + data, err := json.Marshal(record) + if err != nil { + return + } + + l.mu.Lock() + defer l.mu.Unlock() + _, _ = l.out.Write(append(data, '\n')) +} + +// EmitLog logs an event using the default recovery logger. +func EmitLog(event, sessionID string, stepIndex int, duration time.Duration, attemptCount int, details map[string]interface{}) { + defaultLogger.Log(event, sessionID, stepIndex, duration, attemptCount, details) +} diff --git a/core/recovery/proc_unix.go b/core/recovery/proc_unix.go new file mode 100644 index 00000000..f36c63a0 --- /dev/null +++ b/core/recovery/proc_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package recovery + +import ( + "os" + "syscall" +) + +func isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func terminateProcess(pid int) error { + if pid <= 0 { + return nil + } + p, err := os.FindProcess(pid) + if err != nil { + return err + } + return p.Kill() +} diff --git a/core/recovery/proc_windows.go b/core/recovery/proc_windows.go new file mode 100644 index 00000000..6ca226c4 --- /dev/null +++ b/core/recovery/proc_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package recovery + +import ( + "os" + "syscall" + "unsafe" +) + +const ( + processQueryLimitedInformation = 0x1000 + stillActive = 259 +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procGetExitCode = kernel32.NewProc("GetExitCodeProcess") + procCloseHandle = kernel32.NewProc("CloseHandle") +) + +func isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + h, _, _ := procOpenProcess.Call(uintptr(processQueryLimitedInformation), 0, uintptr(pid)) + if h == 0 { + return false + } + defer procCloseHandle.Call(h) + + var exitCode uint32 + r, _, _ := procGetExitCode.Call(h, uintptr(unsafe.Pointer(&exitCode))) + if r == 0 { + return false + } + return exitCode == stillActive +} + +func terminateProcess(pid int) error { + if pid <= 0 { + return nil + } + p, err := os.FindProcess(pid) + if err != nil { + return err + } + return p.Kill() +} diff --git a/core/recovery/reconcile.go b/core/recovery/reconcile.go new file mode 100644 index 00000000..75deb39e --- /dev/null +++ b/core/recovery/reconcile.go @@ -0,0 +1,111 @@ +package recovery + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" +) + +// ReconcileResult reports the outcome of history validation and rollback. +type ReconcileResult struct { + TotalLines int + ValidSteps int + TruncatedBytes int64 + LastStepIndex int + Recovered bool +} + +// ReconcileHistory inspects a JSONL history file, validates line-by-line JSON integrity, +// and truncates any partial or corrupted trailing line resulting from a crash or sudden termination. +func ReconcileHistory(filePath string) (*ReconcileResult, error) { + f, err := os.OpenFile(filePath, os.O_RDWR, 0o600) + if err != nil { + if os.IsNotExist(err) { + return &ReconcileResult{}, nil + } + return nil, fmt.Errorf("failed to open history file: %w", err) + } + defer f.Close() + + reader := bufio.NewReader(f) + var validOffset int64 + var currentOffset int64 + var totalLines int + var validSteps int + var lastStepIndex int = -1 + + for { + line, isPrefix, readErr := reader.ReadLine() + if readErr != nil { + if readErr == io.EOF { + break + } + return nil, fmt.Errorf("error reading history: %w", readErr) + } + + lineLen := int64(len(line)) + 1 // +1 for newline delimiter + if isPrefix { + // Incomplete long buffer line, mark corruption + break + } + + // Verify valid JSON + var raw map[string]interface{} + if err := json.Unmarshal(line, &raw); err == nil { + totalLines++ + validOffset = currentOffset + lineLen + if idx, ok := raw["step_index"].(float64); ok { + stepIdx := int(idx) + if stepIdx > lastStepIndex { + lastStepIndex = stepIdx + validSteps++ + } + } + } else { + // Line is corrupted / partial write at EOF + break + } + + currentOffset += lineLen + } + + fi, err := f.Stat() + if err != nil { + return nil, err + } + + result := &ReconcileResult{ + TotalLines: totalLines, + ValidSteps: validSteps, + LastStepIndex: lastStepIndex, + } + + if fi.Size() > validOffset { + // Truncate corrupted trailing data + result.TruncatedBytes = fi.Size() - validOffset + if err := f.Truncate(validOffset); err != nil { + return nil, fmt.Errorf("failed to truncate corrupted trailing history: %w", err) + } + result.Recovered = true + } + + return result, nil +} + +// SyntheticRestorationPrompt creates an injection prompt notifying the agent of checkpoint resumption. +func SyntheticRestorationPrompt(cp Checkpoint, failure *FailureContext) string { + var reason string + if failure != nil && failure.ErrorCode != "" { + reason = fmt.Sprintf("Interruption reason: %s.", failure.ErrorCode) + if failure.ErrorMessage != "" { + reason += fmt.Sprintf(" (%s)", SanitizeText(failure.ErrorMessage)) + } + } else { + reason = "Interrupted by system disconnect." + } + + return fmt.Sprintf("[System: Resuming session execution from Checkpoint Step #%d. %s Workspace state and history integrity verified. Please continue task from last successful transaction.]", + cp.LastStepIndex, reason) +} diff --git a/core/recovery/recovery_test.go b/core/recovery/recovery_test.go new file mode 100644 index 00000000..2110dea5 --- /dev/null +++ b/core/recovery/recovery_test.go @@ -0,0 +1,339 @@ +package recovery + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCrashSimulationAndRecovery(t *testing.T) { + tempBase := t.TempDir() + sessionID := "11111111-2222-3333-4444-555555555555" + stateDir := filepath.Join(tempBase, sessionID) + _ = os.MkdirAll(stateDir, 0o700) + + logPath := filepath.Join(stateDir, "transcript.jsonl") + + // 1. Simulate initial multi-step execution up to step 2 + historyContent := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"Start task"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"Step 1 completed"} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"Step 2 completed"} +` + if err := os.WriteFile(logPath, []byte(historyContent), 0o600); err != nil { + t.Fatal(err) + } + + state := &SessionState{ + SessionID: sessionID, + ConversationID: sessionID, + Status: StatusActive, + HistorySnapshotRef: logPath, + Checkpoint: Checkpoint{ + LastStepIndex: 2, + TimestampEpochMs: time.Now().UnixMilli(), + }, + } + if err := SaveCheckpoint(stateDir, state); err != nil { + t.Fatalf("failed to save initial checkpoint: %v", err) + } + + // 2. Simulate crash during step 3 execution: + // A stale lock was left by a deceased process PID 9999999 + staleLock := lockRecord{ + PID: 9999999, // Dead PID + SessionID: sessionID, + CreatedAt: time.Now().Add(-10 * time.Minute), + } + staleLockData, _ := json.Marshal(staleLock) + _ = os.WriteFile(filepath.Join(stateDir, lockFileName), staleLockData, 0o600) + + // Step 3 wrote a partial trailing line before crashing + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, _ = f.WriteString(`{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"IN_PROG`) // Corrupted line + _ = f.Close() + + // Update state to Error with failure context + state.Status = StatusError + state.FailureContext = &FailureContext{ + ErrorCode: ErrCodeAbruptDisconnect, + ErrorMessage: "process terminated abruptly by SIGKILL with key sk-abcdef1234567890abcdef", + Recoverable: true, + } + if err := SaveCheckpoint(stateDir, state); err != nil { + t.Fatal(err) + } + + // 3. Execute Resume via Recovery Engine + var logBuf bytes.Buffer + logger := NewLogger(&logBuf) + engine := NewEngine(logger) + + result, err := engine.Resume(ResumeOptions{ + BaseDir: tempBase, + SessionID: sessionID, + }) + if err != nil { + t.Fatalf("Resume failed: %v", err) + } + defer result.Unlock() + + // Verify history was reconciled and truncated + if !result.Reconciled.Recovered || result.Reconciled.TruncatedBytes <= 0 { + t.Fatalf("expected corrupted partial line to be truncated, result: %+v", result.Reconciled) + } + if result.State.Checkpoint.LastStepIndex != 2 { + t.Fatalf("expected LastStepIndex 2, got %d", result.State.Checkpoint.LastStepIndex) + } + + // Verify synthetic restoration prompt + if !strings.Contains(result.SyntheticPrompt, "Checkpoint Step #2") { + t.Errorf("synthetic prompt missing step reference: %s", result.SyntheticPrompt) + } + + // Verify credentials were redacted + if strings.Contains(result.SyntheticPrompt, "sk-abcdef") { + t.Errorf("synthetic prompt leaked secret key: %s", result.SyntheticPrompt) + } + + // Verify state status updated to active + if result.State.Status != StatusActive { + t.Errorf("expected status %s, got %s", StatusActive, result.State.Status) + } + + // Verify structured log emission + logOutput := logBuf.String() + if !strings.Contains(logOutput, EventSessionResumeAttempt) || !strings.Contains(logOutput, EventSessionResumeSuccess) { + t.Errorf("missing expected structured logs in: %s", logOutput) + } +} + +func TestCorruptedHistoryRecovery(t *testing.T) { + tempFile := filepath.Join(t.TempDir(), "corrupt_history.jsonl") + + validContent := `{"step_index":0,"data":"valid"} +{"step_index":1,"data":"valid"} +` + corruptSuffix := `{"step_index":2,"incomplete_` + + if err := os.WriteFile(tempFile, []byte(validContent+corruptSuffix), 0o600); err != nil { + t.Fatal(err) + } + + res, err := ReconcileHistory(tempFile) + if err != nil { + t.Fatalf("ReconcileHistory failed: %v", err) + } + + if !res.Recovered { + t.Fatal("expected Recovered to be true") + } + if res.LastStepIndex != 1 { + t.Fatalf("expected LastStepIndex 1, got %d", res.LastStepIndex) + } + if res.ValidSteps != 2 { + t.Fatalf("expected 2 valid steps, got %d", res.ValidSteps) + } + + // Check file content after truncation + afterBytes, err := os.ReadFile(tempFile) + if err != nil { + t.Fatal(err) + } + if string(afterBytes) != validContent { + t.Fatalf("file content mismatch:\ngot: %q\nwant: %q", string(afterBytes), validContent) + } +} + +func TestLockingAndConcurrency(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "lock_test") + sessionID := "22222222-3333-4444-5555-666666666666" + + // 1. Acquire first lock + unlock1, err := AcquireLock(stateDir, sessionID) + if err != nil { + t.Fatalf("initial AcquireLock failed: %v", err) + } + + // 2. Second acquire by another instance should fail with ErrSessionLocked + // Note: in our lock implementation, if existing PID == current PID it succeeds, so simulate another live PID + lockFile := filepath.Join(stateDir, lockFileName) + otherLock := lockRecord{ + PID: os.Getpid(), // current live PID + SessionID: sessionID, + CreatedAt: time.Now(), + } + otherData, _ := json.Marshal(otherLock) + _ = os.WriteFile(lockFile, otherData, 0o600) + + if !IsLocked(stateDir) { + t.Fatal("expected IsLocked to be true") + } + + unlock1() + if IsLocked(stateDir) { + t.Fatal("expected IsLocked to be false after unlock") + } +} + +func TestTerminatedSessionRejection(t *testing.T) { + tempBase := t.TempDir() + sessionID := "33333333-4444-5555-6665-777777777777" + stateDir := filepath.Join(tempBase, sessionID) + + state := &SessionState{ + SessionID: sessionID, + ConversationID: sessionID, + Status: StatusTerminated, // Clean exit + Checkpoint: Checkpoint{ + LastStepIndex: 10, + }, + } + if err := SaveCheckpoint(stateDir, state); err != nil { + t.Fatal(err) + } + + engine := NewEngine(nil) + _, err := engine.Resume(ResumeOptions{ + BaseDir: tempBase, + SessionID: sessionID, + }) + + if err == nil || !errors.Is(err, ErrSessionAlreadyCompleted) { + t.Fatalf("expected ErrSessionAlreadyCompleted, got: %v", err) + } +} + +func TestAutoDetectLatestRecoverable(t *testing.T) { + tempBase := t.TempDir() + + // Session 1: Terminated (clean exit) + s1 := "aaaaaaaa-1111-1111-1111-111111111111" + _ = SaveCheckpoint(filepath.Join(tempBase, s1), &SessionState{ + SessionID: s1, Status: StatusTerminated, + }) + + // Session 2: Recoverable error + s2 := "bbbbbbbb-2222-2222-2222-222222222222" + _ = SaveCheckpoint(filepath.Join(tempBase, s2), &SessionState{ + SessionID: s2, Status: StatusError, + FailureContext: &FailureContext{Recoverable: true}, + }) + + engine := NewEngine(nil) + detected, err := engine.AutoDetectRecoverable(tempBase) + if err != nil { + t.Fatalf("AutoDetectRecoverable failed: %v", err) + } + + if detected != s2 { + t.Fatalf("expected detected session %s, got %s", s2, detected) + } +} + +func TestSanitizeSecrets(t *testing.T) { + raw := "Error connecting to AI: Bearer my-secret-token-12345678, api_key: AIzaSyD1234567890123456789012345678901, sk-abcdef1234567890abcdef" + clean := SanitizeText(raw) + + if strings.Contains(clean, "my-secret-token") || strings.Contains(clean, "AIzaSy") || strings.Contains(clean, "sk-abcdef") { + t.Fatalf("SanitizeText failed to redact secrets: %s", clean) + } +} + +func TestCompactionOnContextOverflow(t *testing.T) { + tempFile := filepath.Join(t.TempDir(), "history_overflow.jsonl") + + // Generate 30 steps + var buf bytes.Buffer + for i := 0; i < 30; i++ { + line := map[string]interface{}{ + "step_index": i, + "source": "MODEL", + "type": "PLANNER_RESPONSE", + "content": "step data", + } + data, _ := json.Marshal(line) + buf.Write(data) + buf.WriteByte('\n') + } + if err := os.WriteFile(tempFile, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + compacted, err := CompactHistory(tempFile, 10) + if err != nil { + t.Fatalf("CompactHistory failed: %v", err) + } + if !compacted { + t.Fatal("expected CompactHistory to return true") + } + + // Verify line count + readData, err := os.ReadFile(tempFile) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(readData)), "\n") + // 1 summary step + 10 retained steps = 11 lines + if len(lines) != 11 { + t.Fatalf("expected 11 lines after compaction, got %d", len(lines)) + } + if !strings.Contains(lines[0], "compact_summary") { + t.Fatalf("expected first line to be compact_summary, got: %s", lines[0]) + } +} + +func TestToolCrashOrphanProcessHandling(t *testing.T) { + tempBase := t.TempDir() + sessionID := "44444444-5555-6666-7777-888888888888" + stateDir := filepath.Join(tempBase, sessionID) + _ = os.MkdirAll(stateDir, 0o700) + + logPath := filepath.Join(stateDir, "transcript.jsonl") + _ = os.WriteFile(logPath, []byte(`{"step_index":0,"data":"init"}`+"\n"), 0o600) + + state := &SessionState{ + SessionID: sessionID, + ConversationID: sessionID, + Status: StatusError, + HistorySnapshotRef: logPath, + Checkpoint: Checkpoint{ + LastStepIndex: 0, + ActiveToolPID: 9999999, // Dead PID representing orphaned child process + ActiveToolCallID: "call_tool_read_file", + }, + FailureContext: &FailureContext{ + ErrorCode: ErrCodeToolExecFailure, + Recoverable: true, + }, + } + if err := SaveCheckpoint(stateDir, state); err != nil { + t.Fatal(err) + } + + engine := NewEngine(nil) + res, err := engine.Resume(ResumeOptions{ + BaseDir: tempBase, + SessionID: sessionID, + }) + if err != nil { + t.Fatalf("Resume failed: %v", err) + } + defer res.Unlock() + + // Verify ActiveToolPID was cleared + if res.State.Checkpoint.ActiveToolPID != 0 { + t.Fatalf("expected ActiveToolPID to be reset to 0, got %d", res.State.Checkpoint.ActiveToolPID) + } + // Verify synthetic prompt notifies of failure + if !strings.Contains(res.SyntheticPrompt, ErrCodeToolExecFailure) { + t.Errorf("expected synthetic prompt to reference %s, got: %s", ErrCodeToolExecFailure, res.SyntheticPrompt) + } +} diff --git a/core/recovery/sanitize.go b/core/recovery/sanitize.go new file mode 100644 index 00000000..c3ab289e --- /dev/null +++ b/core/recovery/sanitize.go @@ -0,0 +1,38 @@ +package recovery + +import ( + "regexp" +) + +var ( + // Patterns for sensitive secrets: API keys, tokens, auth headers. + apiKeyRegex = regexp.MustCompile(`(?i)(api[_-]?key|secret|token|password|bearer|auth|gemini|openai|anthropic)[\s:=]+["']?([a-zA-Z0-9_\-\.]{8,})["']?`) + jwtRegex = regexp.MustCompile(`ey[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`) + skKeyRegex = regexp.MustCompile(`(sk-[a-zA-Z0-9]{20,}|AIzaSy[a-zA-Z0-9_-]{33}|ghp_[a-zA-Z0-9]{36})`) + credValueRegex = regexp.MustCompile(`(?i)(bearer\s+)[a-zA-Z0-9_\-\.]+`) +) + +// SanitizeText removes API keys, credentials, and tokens from a string. +func SanitizeText(text string) string { + if text == "" { + return "" + } + s := apiKeyRegex.ReplaceAllString(text, `$1=[REDACTED]`) + s = jwtRegex.ReplaceAllString(s, `[REDACTED_JWT]`) + s = skKeyRegex.ReplaceAllString(s, `[REDACTED_KEY]`) + s = credValueRegex.ReplaceAllString(s, `${1}[REDACTED_TOKEN]`) + return s +} + +// SanitizeFailureContext returns a sanitized copy of FailureContext. +func SanitizeFailureContext(fc *FailureContext) *FailureContext { + if fc == nil { + return nil + } + return &FailureContext{ + ErrorCode: fc.ErrorCode, + ErrorMessage: SanitizeText(fc.ErrorMessage), + StackTrace: SanitizeText(fc.StackTrace), + Recoverable: fc.Recoverable, + } +} diff --git a/core/recovery/schema.go b/core/recovery/schema.go new file mode 100644 index 00000000..f9a9770a --- /dev/null +++ b/core/recovery/schema.go @@ -0,0 +1,57 @@ +package recovery + +import ( + "errors" +) + +// Standard session statuses. +const ( + StatusActive = "active" + StatusPaused = "paused" + StatusError = "error" + StatusTerminated = "terminated" +) + +// Standard failure error codes. +const ( + ErrCodeAbruptDisconnect = "SESSION_ABRUPT_DISCONNECT" + ErrCodeToolExecFailure = "TOOL_EXEC_FAILURE" + ErrCodeContextOverflow = "CONTEXT_OVERFLOW" +) + +// Standard recovery error definitions. +var ( + ErrSessionNotFound = errors.New("ERR_SESSION_NOT_FOUND: session ID not found") + ErrSessionLocked = errors.New("ERR_SESSION_LOCKED: session is currently active or locked by another instance") + ErrSessionUnrecoverable = errors.New("ERR_SESSION_UNRECOVERABLE: checkpoint metadata corrupted or invalid") + ErrSessionAlreadyCompleted = errors.New("ERR_SESSION_ALREADY_COMPLETED: session completed normally and cannot be resumed") +) + +// Checkpoint records the transactional state boundary of an agent execution. +type Checkpoint struct { + LastStepIndex int `json:"last_step_index"` + LastSuccessfulToolCallID string `json:"last_successful_tool_call_id,omitempty"` + ActiveToolCallID string `json:"active_tool_call_id,omitempty"` + ActiveToolPID int `json:"active_tool_pid,omitempty"` + TimestampEpochMs int64 `json:"timestamp_epoch_ms"` + StateHash string `json:"state_hash"` + UncommittedChanges bool `json:"uncommitted_changes"` +} + +// FailureContext provides error details and recovery suitability. +type FailureContext struct { + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + StackTrace string `json:"stack_trace,omitempty"` + Recoverable bool `json:"recoverable"` +} + +// SessionState is the persistent metadata schema for a resilient session. +type SessionState struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + Status string `json:"status"` + Checkpoint Checkpoint `json:"checkpoint"` + FailureContext *FailureContext `json:"failure_context,omitempty"` + HistorySnapshotRef string `json:"history_snapshot_ref,omitempty"` +} diff --git a/core/transcript/agents.go b/core/transcript/agents.go index 2fb69045..9ce5bcc2 100644 --- a/core/transcript/agents.go +++ b/core/transcript/agents.go @@ -30,6 +30,7 @@ var readers = map[string]struct { "claude-agent-teams": {claudeFound, NewClaudeReader, claudeID}, "codex": {CodexFiles, NewCodexReader, codexID}, "omp": {OMPFiles, NewOMPReader, OMPConversationID}, + "antigravity": {AntigravityFiles, NewAntigravityReader, AntigravityConversationID}, record.AgentID: {PlatformFiles, NewPlatformReader, claudeID}, } diff --git a/core/transcript/antigravity.go b/core/transcript/antigravity.go new file mode 100644 index 00000000..17256bf0 --- /dev/null +++ b/core/transcript/antigravity.go @@ -0,0 +1,156 @@ +package transcript + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/enowx/enowx/proto" +) + +var ( + antigravityUUIDRegex = regexp.MustCompile(`([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})`) + userReqOpen = "" + userReqClose = "" +) + +// AntigravityFiles returns the transcripts Antigravity keeps for sessions in dir, newest first. +func AntigravityFiles(home, dir string) []Found { + cleanDir := filepath.Clean(dir) + var all []Found + seen := map[string]bool{} + + // 1. First check history.jsonl which maps workspace to conversationId + historyPath := filepath.Join(home, ".gemini", "antigravity-cli", "history.jsonl") + if f, err := os.Open(historyPath); err == nil { + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var rec struct { + Workspace string `json:"workspace"` + ConversationID string `json:"conversationId"` + } + if json.Unmarshal(scanner.Bytes(), &rec) == nil && rec.ConversationID != "" { + if strings.EqualFold(filepath.Clean(rec.Workspace), cleanDir) { + tPath := filepath.Join(home, ".gemini", "antigravity-cli", "brain", rec.ConversationID, ".system_generated", "logs", "transcript.jsonl") + if info, err := os.Stat(tPath); err == nil && !seen[tPath] { + seen[tPath] = true + all = append(all, Found{Path: tPath, Changed: info.ModTime()}) + } + } + } + } + _ = f.Close() + } + + // 2. Direct scan of brain/ directory as fallback + brainDir := filepath.Join(home, ".gemini", "antigravity-cli", "brain") + if entries, err := os.ReadDir(brainDir); err == nil { + for _, e := range entries { + if !e.IsDir() || !antigravityUUIDRegex.MatchString(e.Name()) { + continue + } + tPath := filepath.Join(brainDir, e.Name(), ".system_generated", "logs", "transcript.jsonl") + if seen[tPath] { + continue + } + if info, err := os.Stat(tPath); err == nil { + seen[tPath] = true + all = append(all, Found{Path: tPath, Changed: info.ModTime()}) + } + } + } + + slices.SortFunc(all, func(a, b Found) int { + return b.Changed.Compare(a.Changed) + }) + return all +} + +// AntigravityConversationID extracts the conversation UUID from an Antigravity transcript path. +func AntigravityConversationID(path string) string { + if m := antigravityUUIDRegex.FindStringSubmatch(path); len(m) > 1 { + return m[1] + } + return "" +} + +// NewAntigravityReader creates a reader following an Antigravity transcript.jsonl. +func NewAntigravityReader(path string) *Reader { + return newReader(path, &antigravityParser{}) +} + +type antigravityParser struct{} + +func (*antigravityParser) fresh() parser { return &antigravityParser{} } + +type antigravityToolCall struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` +} + +type antigravityRecord struct { + StepIndex int `json:"step_index"` + Source string `json:"source"` + Type string `json:"type"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + Content string `json:"content"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + ToolCalls []antigravityToolCall `json:"tool_calls"` +} + +func (p *antigravityParser) parse(line []byte, r *Reader) { + var rec antigravityRecord + if json.Unmarshal(line, &rec) != nil { + return + } + + if rec.InputTokens > 0 || rec.OutputTokens > 0 { + r.Context = rec.InputTokens + rec.OutputTokens + } + + switch rec.Type { + case "USER_INPUT": + content := extractUserContent(rec.Content) + if content != "" { + r.add(proto.TranscriptEntry{ + Kind: proto.TranscriptUser, + Time: rec.CreatedAt, + Text: content, + }) + } + case "PLANNER_RESPONSE": + if rec.Content != "" { + r.add(proto.TranscriptEntry{ + Kind: proto.TranscriptAssistant, + Time: rec.CreatedAt, + Text: rec.Content, + }) + } + for _, tc := range rec.ToolCalls { + r.add(proto.TranscriptEntry{ + Kind: proto.TranscriptTool, + Time: rec.CreatedAt, + Text: tc.Name, + Detail: string(tc.Args), + }) + } + } +} + +func extractUserContent(raw string) string { + raw = strings.TrimSpace(raw) + start := strings.Index(raw, userReqOpen) + end := strings.Index(raw, userReqClose) + if start >= 0 && end > start { + return strings.TrimSpace(raw[start+len(userReqOpen) : end]) + } + return raw +} diff --git a/core/transcript/antigravity_test.go b/core/transcript/antigravity_test.go new file mode 100644 index 00000000..2b97c47d --- /dev/null +++ b/core/transcript/antigravity_test.go @@ -0,0 +1,132 @@ +package transcript + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +func TestAntigravityConversationID(t *testing.T) { + cases := []struct { + path string + want string + }{ + { + path: "C:\\Users\\test\\.gemini\\antigravity-cli\\brain\\95f17248-01cd-432c-8dcd-ac35f2180110\\.system_generated\\logs\\transcript.jsonl", + want: "95f17248-01cd-432c-8dcd-ac35f2180110", + }, + { + path: "/home/user/.gemini/antigravity-cli/brain/12345678-1234-1234-1234-123456789abc/.system_generated/logs/transcript.jsonl", + want: "12345678-1234-1234-1234-123456789abc", + }, + { + path: "invalid/path/without/uuid.jsonl", + want: "", + }, + } + + for _, tc := range cases { + got := AntigravityConversationID(tc.path) + if got != tc.want { + t.Errorf("AntigravityConversationID(%q) = %q, want %q", tc.path, got, tc.want) + } + } +} + +func TestAntigravityReaderParsing(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "transcript.jsonl") + + content := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-11-10T03:30:38Z","content":"\ngit pull\n\n\ninfo\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-11-10T03:30:39Z","input_tokens":100,"output_tokens":50,"tool_calls":[{"name":"run_command","args":{"CommandLine":"git pull"}}]} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-11-10T03:30:44Z","content":"Pull completed successfully"} +` + if err := os.WriteFile(logPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + reader := NewAntigravityReader(logPath) + if err := reader.Update(); err != nil { + t.Fatalf("Update failed: %v", err) + } + entries, _ := reader.Since(0) + + if len(entries) < 3 { + t.Fatalf("expected at least 3 entries, got %d: %+v", len(entries), entries) + } + + // Verify User input extraction + if entries[0].Kind != proto.TranscriptUser || entries[0].Text != "git pull" { + t.Errorf("entry 0: kind=%v text=%q", entries[0].Kind, entries[0].Text) + } + + // Verify Assistant Tool Call + if entries[1].Kind != proto.TranscriptTool || entries[1].Text != "run_command" { + t.Errorf("entry 1: kind=%v text=%q", entries[1].Kind, entries[1].Text) + } + + // Verify Assistant Message + if entries[2].Kind != proto.TranscriptAssistant || entries[2].Text != "Pull completed successfully" { + t.Errorf("entry 2: kind=%v text=%q", entries[2].Kind, entries[2].Text) + } + + // Verify Token calculation + if reader.Context != 150 { + t.Errorf("reader.Context = %d, want 150", reader.Context) + } +} + +func TestAntigravityReaderToleratesCorruptedTrailingLine(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "transcript.jsonl") + + // Corrupted line at the end (simulating abrupt kill mid-write) + content := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-11-10T03:30:38Z","content":"\ncheck status\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DO +` + if err := os.WriteFile(logPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + reader := NewAntigravityReader(logPath) + if err := reader.Update(); err != nil { + t.Fatalf("Update failed: %v", err) + } + entries, _ := reader.Since(0) + + // Valid first line should be parsed without panic or failure + if len(entries) != 1 { + t.Fatalf("expected 1 valid entry despite corrupted EOF line, got %d", len(entries)) + } + if entries[0].Text != "check status" { + t.Errorf("expected text 'check status', got %q", entries[0].Text) + } +} + +func TestAntigravityFilesDiscovery(t *testing.T) { + home := t.TempDir() + geminiDir := filepath.Join(home, ".gemini", "antigravity-cli") + _ = os.MkdirAll(geminiDir, 0o700) + + convoID := "88888888-4444-4444-4444-121212121212" + brainLogDir := filepath.Join(geminiDir, "brain", convoID, ".system_generated", "logs") + _ = os.MkdirAll(brainLogDir, 0o700) + tFile := filepath.Join(brainLogDir, "transcript.jsonl") + _ = os.WriteFile(tFile, []byte("{}\n"), 0o600) + + ws := filepath.Join(home, "myproject") + history := `{"workspace":"` + ws + `","conversationId":"` + convoID + `","timestamp":` + time.Now().Format("20060102") + `} +` + _ = os.WriteFile(filepath.Join(geminiDir, "history.jsonl"), []byte(history), 0o600) + + found := AntigravityFiles(home, ws) + if len(found) != 1 { + t.Fatalf("expected 1 found transcript, got %d", len(found)) + } + if found[0].Path != tFile { + t.Errorf("found path = %q, want %q", found[0].Path, tFile) + } +} diff --git a/daemon/antigravity_continue_test.go b/daemon/antigravity_continue_test.go new file mode 100644 index 00000000..83726800 --- /dev/null +++ b/daemon/antigravity_continue_test.go @@ -0,0 +1,43 @@ +package daemon + +import ( + "testing" + "time" + + "github.com/enowx/enowx/core/session" +) + +func TestContinueAntigravityOlderSession(t *testing.T) { + srv := startServer(t) + + // Simulate an older Antigravity session loaded from sessions.json where Relaunch was false + id := "test-ag-older-session" + spec := session.Spec{ + ID: id, + Dir: t.TempDir(), + Created: time.Now().Add(-1 * time.Hour), + Command: []string{"agy", "--dangerously-skip-permissions"}, + Exited: true, + Meta: session.Meta{ + Agent: "antigravity", + Relaunch: false, // older session before continue flag was added + SkipPermissions: true, + }, + } + sess, err := srv.sessions.Create(spec) + if err != nil { + t.Fatalf("Create session failed: %v", err) + } + + // resumeSessionIn should succeed and not return "this session's command can't be continued" + next, err := srv.resumeSessionIn(sess.ID(), "", "") + if err != nil { + t.Fatalf("resumeSessionIn failed: %v", err) + } + if next == nil { + t.Fatal("expected relaunched session, got nil") + } + if !next.Meta().Relaunch { + t.Errorf("expected Relaunch to be true on recovered session") + } +} diff --git a/daemon/persist.go b/daemon/persist.go index 54416540..2145e998 100644 --- a/daemon/persist.go +++ b/daemon/persist.go @@ -266,17 +266,23 @@ func equalRecords(a, b record) bool { // continue their last conversation), the others as they ended. func (s *Server) restore() { for _, r := range s.persist.saved() { + relaunch := r.Relaunch + if !relaunch && r.Agent != "" { + if preset, ok := agent.Find(r.Agent); ok && len(preset.Continue) > 0 { + relaunch = true + } + } spec := session.Spec{ ID: r.ID, Dir: r.Dir, Created: r.Created, History: s.persist.loadHistory(r.ID), Env: s.hostEnvFor(r.Member), Meta: session.Meta{ FinishedAt: r.Finished, Name: r.Name, Prompt: r.Prompt, Project: r.Project, Agent: r.Agent, Branch: r.Branch, - Worktree: r.Worktree, SkipPermissions: r.Skip, SkipUnheard: r.Unheard, Model: r.Model, Effort: r.Effort, Mode: r.Mode, ModelStarted: r.Started, ModelMoved: r.Moved, Conversation: r.Convo, ConvoSince: r.Since, Relaunch: r.Relaunch, Restart: r.Restart, Restored: true, + Worktree: r.Worktree, SkipPermissions: r.Skip, SkipUnheard: r.Unheard, Model: r.Model, Effort: r.Effort, Mode: r.Mode, ModelStarted: r.Started, ModelMoved: r.Moved, Conversation: r.Convo, ConvoSince: r.Since, Relaunch: relaunch, Restart: r.Restart, Restored: true, Budget: time.Duration(r.Budget) * time.Second, Parent: r.Parent, Note: r.Note, Group: r.Group, Account: r.Account, Config: r.Config, Member: r.Member, Private: r.Private, NotesFor: r.NotesFor, CanvasFor: r.Canvas, GOA: r.GOA, GOAWorker: r.GOAWorker, Features: r.Features, Creds: r.Creds, Hibernated: r.Hibernated, HibernatedByHand: r.HibernatedByHand, HibernatedAt: r.HibernatedAt, NoAutoHibernate: r.NoAutoHibernate, Rules: r.Rules, }, } - if r.Relaunch { + if spec.Meta.Relaunch { keepBypass(&spec.Meta) } for _, what := range r.Lost { @@ -435,7 +441,11 @@ func (s *Server) resumeSessionIn(id, convo, mode string) (*session.Session, erro return nil, fmt.Errorf("it ended right after starting %d times in a row, so enowX won't start it again by itself: %s", quickExitsMax, info.ExitWhy) } if !meta.Relaunch { - return nil, errors.New("this session's command can't be continued: start it again in a new session") + if preset, ok := agent.Find(meta.Agent); ok && len(preset.Continue) > 0 { + meta.Relaunch = true + } else { + return nil, errors.New("this session's command can't be continued: start it again in a new session") + } } command, restart := meta.Restart, meta.Restart run, err := s.configRunFor(id, meta) // its config, as it starts again @@ -519,7 +529,20 @@ func (s *Server) sessionConversation(sess *session.Session) string { if preset, ok := agent.Find(meta.Agent); !ok || len(preset.Resume) == 0 || !transcript.Supports(meta.Agent) { return "" } - return transcript.ConversationID(meta.Agent, s.transcriptPath(sess.ID())) + convo := transcript.ConversationID(meta.Agent, s.transcriptPath(sess.ID())) + if convo != "" { + return convo + } + if meta.Conversation != "" { + return meta.Conversation + } + if home, err := os.UserHomeDir(); err == nil { + found := transcript.Find(meta.Agent, home, sess.Info().Dir) + if len(found) > 0 { + return transcript.ConversationID(meta.Agent, found[0].Path) + } + } + return "" } // agentEnv is what meta's agent runs with when started again: its diff --git a/daemon/session_wake.go b/daemon/session_wake.go index b4ef43ea..0ec511bf 100644 --- a/daemon/session_wake.go +++ b/daemon/session_wake.go @@ -77,7 +77,7 @@ func (w *sessionWakes) finish(id string, l *wakeLine, err error) { // nor a program enowX can't continue. func comesBack(meta session.Meta) bool { preset, ok := agent.Find(meta.Agent) - return ok && len(preset.Continue) > 0 && meta.Relaunch + return ok && len(preset.Continue) > 0 } // wakeThen runs send once session id's agent can take it, and says whether it had to diff --git a/docs/proposals/fix-session-continue-omp.md b/docs/proposals/fix-session-continue-omp.md new file mode 100644 index 00000000..0b98a5fb --- /dev/null +++ b/docs/proposals/fix-session-continue-omp.md @@ -0,0 +1,79 @@ +# Proposal: Perbaikan Fitur "Continue Session" pada enowX + +## 1. Ringkasan Masalah +Pada UI enowX, ketika sebuah session agen (khususnya **OMP**) telah berakhir (`stopped`/`done`/`exited`), muncul tombol **Continue** di toolbar sesi dan opsi **Continue** di menu konteks. +Namun ketika tombol tersebut diklik, muncul error pada status bar: +```text +session.continue: this session's command can't be continued: start it again in a new session +``` + +--- + +## 2. Root Cause Analysis (Penyebab Utama) + +### A. Definisi Preset Agent (`core/agent/presets.go`) +Di `core/agent/presets.go`, entri untuk `omp` hanya terdaftar dasar tanpa konfigurasi sesi lanjutan: +```go +{ + ID: "omp", Name: "OMP", Command: "omp", + Homepage: "https://omp.sh", +}, +``` +Padahal CLI `omp` memiliki opsi bawaan untuk melanjutkan sesi: +- `-c, --continue` : Continue previous session +- `-r, --resume=` : Resume a session by ID prefix or path +- `--auto-approve` : Auto-approve all tool calls (skip approval prompts) + +### B. Mekanisme Relaunch di Daemon (`daemon/create.go` & `daemon/persist.go`) +1. Ketika sesi dibuat (`daemon/create.go:286`), flag `relaunch` ditentukan oleh: + ```go + relaunch: len(preset.Continue) > 0, + ``` + Karena preset `omp` tidak memiliki `Continue`, maka `meta.Relaunch` disimpan bernilai `false`. +2. Ketika user menekan tombol **Continue**, daemon menjalankan method `session.continue` (`daemon/persist.go:317`): + ```go + if !meta.Relaunch { + return nil, errors.New("this session's command can't be continued: start it again in a new session") + } + ``` + Daemon menolak mengeksekusi relaunch karena `!meta.Relaunch`. + +### C. Mismatch Logika di UI (`ui/session.go`) +Fungsi `canContinue` di `ui/session.go`: +```go +func canContinue(s proto.SessionInfo) bool { + return s.Exited && s.Agent != "" && s.Agent != agent.Custom +} +``` +UI mengasumsikan bahwa **semua** sesi agent yang exited dapat di-continue, tanpa memeriksa apakah preset agen tersebut sebenarnya memiliki kapabilitas continue (`len(preset.Continue) > 0`). Hal ini menimbulkan inkonsistensi: tombol Continue ditampilkan, tetapi pasti gagal saat diklik jika agen tidak mendukung continue. + +--- + +## 3. Rencana Solusi (Action Plan) + +### Rung 1: Dukung `omp` di Preset (`core/agent/presets.go` & `docs/agents.md`) +Menambahkan opsi continue, resume, dan skip permissions pada preset OMP: +```go +{ + ID: "omp", Name: "OMP", Command: "omp", + SkipPermissions: []string{"--auto-approve"}, + Continue: []string{"--continue"}, + Resume: []string{"--resume"}, + Homepage: "https://omp.sh", +}, +``` + +### Rung 2: Perbaiki Validasi UI `canContinue` (`ui/session.go`) +Pastikan UI hanya menampilkan tombol/menu Continue untuk: +1. Shell (`s.Agent == agent.Shell`) +2. Agen yang terdaftar di preset dan memiliki `len(preset.Continue) > 0` + +Dengan begitu: +- Sesi `omp` sekarang **bisa di-continue secara mulus**. +- Agen lain yang memang tidak memiliki fitur resume/continue tidak akan memunculkan tombol yang berujung error bagi user. + +### Rung 3: Verifikasi & Test +1. Jalankan unit test `core/agent`: `go test ./core/agent` +2. Jalankan unit test `daemon`: `go test ./daemon` +3. Jalankan unit test `ui`: `go test ./ui` +4. Pastikan `go test ./proto` tetap valid. diff --git a/docs/proposals/omp-session-resume-isolation.md b/docs/proposals/omp-session-resume-isolation.md new file mode 100644 index 00000000..1b5e2bc2 --- /dev/null +++ b/docs/proposals/omp-session-resume-isolation.md @@ -0,0 +1,49 @@ +# Proposal Perbaikan: Isolasi Percakapan OMP (Resume by Conversation ID) + +## 1. Masalah yang Terjadi +Ketika pengguna memiliki beberapa sesi OMP di direktori yang sama (misalnya proyek non-git seperti `Research`), menekan tombol **Continue** pada sesi mana pun menyebabkan sesi tersebut memuat percakapan yang **sama persis** dengan sesi terakhir. + +### Penyebab Akar: +1. `omp` (dan `claude`) secara bawaan menyimpan sesi percakapan per direktori kerja (`CWD`). +2. Perintah `omp --continue` hanya melanjutkan sesi percakapan dengan timestamp paling akhir di direktori tersebut. +3. enowX sebelumnya hanya mengeksekusi `omp --continue` generik saat me-relaunch sesi, tanpa menyertakan ID percakapan spesifik (`--resume `). + +--- + +## 2. Rencana Arsitektur & Perbaikan (Opsi 1: Seperti Claude Code) + +### A. Komponen 1: Pembaca Transcript OMP (`core/transcript/omp.go`) +1. **Pencarian File (`OMPFiles`)**: + - `omp` menyimpan riwayat sesi di: + `~/.omp/agent/sessions/----/_.jsonl` + - Setiap file `.jsonl` memiliki baris session: + `{"type":"session","id":"","cwd":"..."}` + - `OMPFiles(home, dir string) []Found` mencari dan mengembalikan daftar file percakapan OMP di direktori `dir`, diurutkan berdasarkan `ModTime` terbaru. +2. **Parser Percakapan (`ompParser`)**: + - Membaca baris-baris JSONL OMP: + - `session`: mengekstrak session UUID. + - `model_change`: memperbarui model aktif (`r.Model`). + - `message`: mengekstrak role `user` dan `assistant` (termasuk blok teks, thinking, dan tool calls). +3. **Pendaftaran di `core/transcript/agents.go`**: + - Daftarkan `"omp": {ompFound, NewOMPReader}` sehingga `transcript.Supports("omp")` bernilai `true`. + - Ini sekaligus mengaktifkan fitur **Chat View** untuk OMP di UI enowX! + +### B. Komponen 2: Pelacakan ID Percakapan & Continue Sesi (`daemon/persist.go`) +1. Ketika sesi di-continue di `daemon/persist.go` (`continueSession`): + - Daemon memeriksa apakah sesi memiliki transcript file yang sudah diklaim via `s.transcriptPath(id)`. + - Jika ada transcript file OMP (atau Claude), daemon mengekstrak ID percakapan UUID dari file tersebut. + - Perintah relaunch disesuaikan dari `omp --continue` menjadi: + ```bash + omp --resume --auto-approve ... + ``` +2. Jika belum ada transcript file atau ID tidak ditemukan, fallback tetap ke `omp --continue`. + +--- + +## 3. Dampak & Keuntungan +1. **Isolasi 100%**: Sesi 1 akan resume percakapan Sesi 1; Sesi 2 akan resume percakapan Sesi 2, meskipun berada di folder fisik yang sama persis tanpa Git worktree. +2. **Efisiensi & Standar enowX**: + - Tidak ada codebloat; memanfaatkan pola yang sudah matang di `core/transcript` (arsitektur sama dengan `claude` dan `codex`). + - Kode tetap ringkas dan mengikuti prinsip Go standar repository ini. +3. **Bonus Nilai Tambah**: + - Sesi OMP kini mendukung **Chat View UI** di samping Terminal view. diff --git a/ui/session_continue_test.go b/ui/session_continue_test.go new file mode 100644 index 00000000..60d44486 --- /dev/null +++ b/ui/session_continue_test.go @@ -0,0 +1,42 @@ +package ui + +import ( + "testing" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/proto" +) + +func TestCanContinueAntigravity(t *testing.T) { + // Exited Antigravity session should be eligible for continue + sess := proto.SessionInfo{ + ID: "sess-ag-123", + Agent: "antigravity", + Exited: true, + ExitCode: 1, + ExitWhy: "process terminated abruptly", + } + if !canContinue(sess) { + t.Errorf("expected canContinue to be true for exited antigravity session") + } + + // Active session cannot be continued + activeSess := proto.SessionInfo{ + ID: "sess-ag-456", + Agent: "antigravity", + Exited: false, + } + if canContinue(activeSess) { + t.Errorf("expected canContinue to be false for active session") + } + + // Custom command session cannot be continued via agent continue + customSess := proto.SessionInfo{ + ID: "sess-ag-789", + Agent: agent.Custom, + Exited: true, + } + if canContinue(customSess) { + t.Errorf("expected canContinue to be false for custom command") + } +} From 4e7ed41258b3ec4e6c79e82fa6b6f0494393d029 Mon Sep 17 00:00:00 2001 From: Kyuzan0 Date: Tue, 10 Nov 2026 16:35:38 +0700 Subject: [PATCH 2/3] Add Windows 11 Fluent theme, window controls styling, and shortcuts manager Implement Windows 11 Fluent Design System theme tokens, palette, and styling. Add window control buttons style toggle (Windows/macOS style) in Appearance settings. Add keyboard shortcuts manager dialog for customizable shortcuts. --- ui/app.go | 22 +- ui/appearance.go | 28 +- ui/appearance_fluent_test.go | 139 ++++++++++ ui/appearance_style.go | 2 +- ui/appearance_windowcontrols.go | 46 ++++ ui/palette.go | 28 +- ui/settings_sections.go | 41 ++- ui/shortcuts.go | 437 ++++++++++++++++++++++++++++++++ ui/shortcuts_dialog.go | 178 +++++++++++++ ui/shortcuts_test.go | 227 +++++++++++++++++ ui/state.go | 6 + ui/theme/fluent_palette.go | 40 +++ ui/theme/fluent_test.go | 124 +++++++++ ui/theme/fluent_tokens.go | 267 +++++++++++++++++++ ui/theme/light.go | 26 ++ ui/theme/palette.go | 3 +- ui/theme/style.go | 2 +- ui/theme/style_fluent.go | 130 ++++++++++ ui/titlebar.go | 10 +- ui/view.go | 27 +- ui/widget/controls.go | 17 +- ui/windowbuttons.go | 110 +++++++- ui/windowbuttons_test.go | 74 ++++++ 23 files changed, 1933 insertions(+), 51 deletions(-) create mode 100644 ui/appearance_fluent_test.go create mode 100644 ui/appearance_windowcontrols.go create mode 100644 ui/shortcuts.go create mode 100644 ui/shortcuts_dialog.go create mode 100644 ui/shortcuts_test.go create mode 100644 ui/theme/fluent_palette.go create mode 100644 ui/theme/fluent_test.go create mode 100644 ui/theme/fluent_tokens.go create mode 100644 ui/theme/style_fluent.go diff --git a/ui/app.go b/ui/app.go index 07bfff97..a96068a7 100644 --- a/ui/app.go +++ b/ui/app.go @@ -75,6 +75,7 @@ type App struct { watchdog *watchdog.Watch // the main thread's, once the window is open diag *diag.Log // what happened, for later (diagnostics.go); nil in tests diagPage string // the page last logged + diagTheme string // the theme last logged daemonBehind *daemonBehind // a daemon left at another version, to replace (daemon_behind.go) replaceDaemon string // the host whose daemon the next connection replaces, asked for sessionToasts []*sessionToast // what notifications are about (sessiontoast.go) @@ -242,16 +243,17 @@ type App struct { // portOffers are ports sessions just opened, offered to open. portOffers []portOffer // conflicts are the conflict radar's last look, by project path. - conflicts map[string][]proto.Conflict - budget *budgetForm // setting a session's budget - agentLimit *agentsForm // setting the host's limit of agents at once - usage usageview.Meter - usageShown bool // the Resource usage page was shown last frame - tok tokensState // token usage: the Tokens tab, sessions' badges (tokens.go) - goa goaApp // GOD of Agents mode (goa.go) - blurred bool // the window isn't the focused one - unfocused atomic.Bool // the same, for goroutines - winCtl windowControl // what the window buttons act on, when not win (tests) + conflicts map[string][]proto.Conflict + budget *budgetForm // setting a session's budget + agentLimit *agentsForm // setting the host's limit of agents at once + shortcutsForm *shortcutDialogState + usage usageview.Meter + usageShown bool // the Resource usage page was shown last frame + tok tokensState // token usage: the Tokens tab, sessions' badges (tokens.go) + goa goaApp // GOD of Agents mode (goa.go) + blurred bool // the window isn't the focused one + unfocused atomic.Bool // the same, for goroutines + winCtl windowControl // what the window buttons act on, when not win (tests) // retrying counts the tries to connect again to retryHost after its // connection dropped; 0 when not retrying. retrying int diff --git a/ui/appearance.go b/ui/appearance.go index 2f90521f..a3b49ef8 100644 --- a/ui/appearance.go +++ b/ui/appearance.go @@ -2,6 +2,7 @@ package ui import ( "strings" + "time" "github.com/egoist/mygo/ui" @@ -12,8 +13,30 @@ import ( // applyTheme sets the palette the state names, in every view and // terminal at once. func (a *App) applyTheme() { + began := time.Now() + prevTheme := a.diagTheme + if prevTheme == "" { + prevTheme = "default" + } + theme.UseStyle(theme.StyleByID(a.state.UIStyle)) - a.showPalette(a.state.palette(a.lightAppearance())) + pal := a.state.palette(a.lightAppearance()) + a.showPalette(pal) + + newTheme := pal.ID + if a.state.UIStyle != "" { + newTheme = a.state.UIStyle + ":" + pal.ID + } + tookMs := time.Since(began).Milliseconds() + if a.diag != nil { + a.diag.Event("theme_changed", + "previousTheme", prevTheme, + "newTheme", newTheme, + "isDarkMode", !a.lightAppearance(), + "resolutionTimeMs", tookMs, + ) + } + a.diagTheme = newTheme } // showPalette has every view and terminal take p. @@ -49,8 +72,9 @@ func (a *App) appearancePage(c *ui.Context) { a.backgroundCard(c) ui.Grid(c).FillWidth().Grow(1).Columns(2).Gap(theme.S3).AlignItems(ui.Stretch).Children(func() { widget.Card(c).Children(func() { a.appearanceMode(c) }) - widget.Card(c).Children(func() { a.accentPicker(c) }) + widget.Card(c).Children(func() { a.windowControlStyleCard(c) }) }) + widget.Card(c).Children(func() { a.accentPicker(c) }) }) widget.Card(c).Grow(5).Basis(0).Children(func() { a.themePreview(c) }) }) diff --git a/ui/appearance_fluent_test.go b/ui/appearance_fluent_test.go new file mode 100644 index 00000000..5cbd17a9 --- /dev/null +++ b/ui/appearance_fluent_test.go @@ -0,0 +1,139 @@ +package ui + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/core/diag" + "github.com/enowx/enowx/ui/theme" +) + +func TestWindows11FluentStyleIntegration(t *testing.T) { + app := &App{} + app.state = appState{ + UIStyle: "windows-11", + Theme: "windows-11", + ThemeMode: "dark", + } + + app.applyTheme() + + if style := theme.CurrentStyle(); style.ID != "windows-11" { + t.Fatalf("expected active style 'windows-11', got %q", style.ID) + } else { + if style.RadiusControl != 8 { + t.Errorf("expected RadiusControl=8, got %.0f", style.RadiusControl) + } + if style.RadiusCard != 12 { + t.Errorf("expected RadiusCard=12, got %.0f", style.RadiusCard) + } + if style.RadiusPanel != 12 { + t.Errorf("expected RadiusPanel=12, got %.0f", style.RadiusPanel) + } + if style.Family != theme.FluentFontFamily { + t.Errorf("expected Family=%q, got %q", theme.FluentFontFamily, style.Family) + } + } + + curPal := theme.Current() + if curPal.ID != "windows-11" { + t.Fatalf("expected active palette 'windows-11', got %q", curPal.ID) + } + if theme.Hex(curPal.Background) != "#202020" { + t.Errorf("expected dark background #202020, got %s", theme.Hex(curPal.Background)) + } + if theme.Hex(curPal.Accent) != "#60CDFF" { + t.Errorf("expected dark accent #60CDFF, got %s", theme.Hex(curPal.Accent)) + } + + // Switch to light mode + app.setAppearanceMode("light") + lightPal := theme.Current() + if !lightPal.Light { + t.Fatalf("expected light palette, got Light=false") + } + if theme.Hex(lightPal.Background) != "#F3F3F3" { + t.Errorf("expected light background #F3F3F3, got %s", theme.Hex(lightPal.Background)) + } + if theme.Hex(lightPal.Accent) != "#0067C0" { + t.Errorf("expected light accent #0067C0, got %s", theme.Hex(lightPal.Accent)) + } +} + +func TestWindows11StatePersistenceAndFallback(t *testing.T) { + dir := t.TempDir() + stateFile := filepath.Join(dir, "state.json") + + // 1. Save valid Windows 11 state + st := appState{ + UIStyle: "windows-11", + Theme: "windows-11", + ThemeMode: "light", + } + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stateFile, data, 0o600); err != nil { + t.Fatal(err) + } + + loaded := loadState(stateFile) + if loaded.UIStyle != "windows-11" || loaded.Theme != "windows-11" || loaded.ThemeMode != "light" { + t.Fatalf("failed to restore Windows 11 state: %+v", loaded) + } + + // 2. Fallback on corrupt or invalid theme / style IDs + corruptSt := appState{ + UIStyle: "unknown-corrupt-style", + Theme: "unknown-corrupt-theme", + } + s := theme.StyleByID(corruptSt.UIStyle) + if s.ID != theme.LiquidGlass.ID { + t.Errorf("expected fallback to LiquidGlass on unknown style, got %q", s.ID) + } + p := theme.Lookup(corruptSt.Theme, ui.Color{}) + if p.ID != theme.Default.ID { + t.Errorf("expected fallback to Default on unknown palette, got %q", p.ID) + } +} + +func TestThemeChangedDiagnosticsLogging(t *testing.T) { + dir := t.TempDir() + log := diag.Open(dir, "test.log") + defer log.Close() + + app := &App{ + diag: log, + } + app.state = appState{ + UIStyle: "windows-11", + Theme: "windows-11", + ThemeMode: "dark", + } + + app.applyTheme() + log.Flush() + + // Read log file content to verify theme_changed event was written + entries, err := os.ReadDir(dir) + if err != nil || len(entries) == 0 { + t.Fatalf("no log file generated in %s", dir) + } + logBytes, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + logContent := string(logBytes) + if !strings.Contains(logContent, "theme_changed") { + t.Errorf("log missing 'theme_changed' event: %s", logContent) + } + if !strings.Contains(logContent, "windows-11") { + t.Errorf("log missing 'windows-11' theme identifier: %s", logContent) + } +} diff --git a/ui/appearance_style.go b/ui/appearance_style.go index e0dbcb36..5e1d3752 100644 --- a/ui/appearance_style.go +++ b/ui/appearance_style.go @@ -12,7 +12,7 @@ import ( func (a *App) stylePicker(c *ui.Context) { ui.Column(c.Key("style-picker")).FillWidth().Gap(theme.S2).Children(func() { ui.Text(c, "Style").FontSize(theme.TextUI).FontWeight(500) - ui.Grid(c).FillWidth().Columns(4).Gap(theme.S3).Children(func() { + ui.Grid(c).FillWidth().Columns(len(theme.Styles)).Gap(theme.S3).Children(func() { current := theme.StyleByID(a.state.UIStyle).ID for _, s := range theme.Styles { if a.styleCard(c.Key(s.ID), s, s.ID == current) && s.ID != current { diff --git a/ui/appearance_windowcontrols.go b/ui/appearance_windowcontrols.go new file mode 100644 index 00000000..9b53dcc4 --- /dev/null +++ b/ui/appearance_windowcontrols.go @@ -0,0 +1,46 @@ +package ui + +import ( + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/ui/icon" + "github.com/enowx/enowx/ui/theme" + "github.com/enowx/enowx/ui/widget" +) + +func (a *App) windowControlStyleCard(c *ui.Context) { + widget.CardTitle(c, nil, "Window controls", nil) + selected := 0 + if a.isWindowsControlStyle() { + selected = 1 + } + ui.Row(c).FillWidth().Children(func() { + if picked := widget.Segmented(c.Key("window-controls-picker"), []string{"Mac style", "Windows style"}, selected, nil); picked >= 0 { + if picked == 1 { + a.setWindowControlStyle(WindowControlStyleWindows) + } else { + a.setWindowControlStyle(WindowControlStyleMac) + } + } + }) + ui.Text(c, "Choose between macOS traffic lights on the left or Windows caption buttons on the right."). + FontSize(theme.TextLabel).TextColor(theme.TextSoft) + + // Visual preview of selected control style + ui.Row(c).Height(32).AlignItems(ui.Center).Gap(theme.S3).Children(func() { + ui.Text(c, "Preview:").FontSize(theme.TextLabel).TextColor(theme.TextSoft) + if a.isWindowsControlStyle() { + ui.Row(c).Height(24).Radius(theme.RadiusSmall).Background(theme.Panel).BorderColor(theme.Border).BorderWidth(1, 1, 1, 1).Clip().Children(func() { + ui.Row(c).Size(28, 24).Center().Children(func() { ui.Icon(c, icon.Minus).FontSize(9).TextColor(theme.TextSoft) }) + ui.Row(c).Size(28, 24).Center().Children(func() { ui.Icon(c, icon.Square).FontSize(9).TextColor(theme.TextSoft) }) + ui.Row(c).Size(28, 24).Center().Background(ui.Hex("#E81123")).Children(func() { ui.Icon(c, icon.Close).FontSize(9).TextColor(ui.Hex("#FFFFFF")) }) + }) + } else { + ui.Row(c).Height(24).PaddingX(theme.S2).Radius(theme.RadiusSmall).Background(theme.Panel).BorderColor(theme.Border).BorderWidth(1, 1, 1, 1).AlignItems(ui.Center).Gap(theme.S2).Children(func() { + ui.Box(c).Size(10, 10).Radius(theme.RadiusFull).Background(windowDots[0]) + ui.Box(c).Size(10, 10).Radius(theme.RadiusFull).Background(windowDots[1]) + ui.Box(c).Size(10, 10).Radius(theme.RadiusFull).Background(windowDots[2]) + }) + } + }) +} diff --git a/ui/palette.go b/ui/palette.go index 7dbccb62..269aff0b 100644 --- a/ui/palette.go +++ b/ui/palette.go @@ -44,7 +44,7 @@ func (a *App) commands() []command { var out []command p, hasProject := a.currentProject() if hasProject { - out = append(out, command{"new", "New session", modKey + "N", icon.Plus, func() { a.openNewSession(p) }}) + out = append(out, command{"new", "New session", a.shortcutLabel(ShortcutNewSession), icon.Plus, func() { a.openNewSession(p) }}) } out = append(out, command{"sessions", "Sessions", "Page", icon.Terminal, func() { if a.goaOn() { @@ -79,14 +79,14 @@ func (a *App) commands() []command { } out = append(out, command{"add-host", "Add host", "A VPS or another device", icon.Server, a.openAddHost}, - command{"open", "Open folder", modKey + "O", icon.FolderPlus, a.openFolder}, + command{"open", "Open folder", a.shortcutLabel(ShortcutOpenFolder), icon.FolderPlus, a.openFolder}, command{"clone", "Clone from Git", "", icon.Git, a.openClone}, - command{"goto-file", "Go to file…", modKey + "P", icon.File, a.openQuickOpen}, + command{"goto-file", "Go to file…", a.shortcutLabel(ShortcutQuickOpen), icon.File, a.openQuickOpen}, ) if hasProject && a.client != nil { out = append(out, command{"history", "Past conversations", p.Name, icon.Chat, func() { a.openHistory(p) }}) } - out = append(out, command{"rail", "Expand or collapse sidebar", modKey + "B", icon.Sidebar, func() { a.state.RailWide = !a.state.RailWide }}) + out = append(out, command{"rail", "Expand or collapse sidebar", a.shortcutLabel(ShortcutSidebar), icon.Sidebar, func() { a.state.RailWide = !a.state.RailWide }}) out = append(out, a.sessionCommands()...) out = append(out, a.runPaletteCommands()...) if hasProject && a.client != nil { @@ -105,7 +105,7 @@ func (a *App) commands() []command { command{"quit-stop", "Quit and stop all agents", "On this device", icon.Stop, a.quitAndStopAgents}, command{"diagnostics", "Save diagnostics for a bug report", "This device's logs and " + a.host.Name + "'s, as a zip", icon.Download, a.saveDiagnostics}, command{"updates", "Check for updates", "enowX " + version.String, icon.ArrowUp, func() { a.checkForUpdates(true) }}, - command{"sessions-panel", "Show or hide the sidebar", modKey + "⇧S", icon.Sidebar, a.toggleSessionsPanel}, + command{"sessions-panel", "Show or hide the sidebar", a.shortcutLabel(ShortcutSessionsPanel), icon.Sidebar, a.toggleSessionsPanel}, command{"floating", "Floating control", floatingKey + " · Over any app", icon.Floating, a.toggleFloating}, ) for _, h := range a.allHosts() { @@ -135,9 +135,9 @@ func (a *App) sessionCommands() []command { label = "Show one session at a time" } out = append(out, - command{"split", label, modKey + "⇧L", icon.Split, func() { a.setSplit(!a.splitOn()) }}, - command{"next", "Next session", modKey + "⇧]", icon.ChevronRight, func() { a.stepSession(1) }}, - command{"previous", "Previous session", modKey + "⇧[", icon.ChevronRight, func() { a.stepSession(-1) }}, + command{"split", label, a.shortcutLabel(ShortcutSplitView), icon.Split, func() { a.setSplit(!a.splitOn()) }}, + command{"next", "Next session", a.shortcutLabel(ShortcutNextSession), icon.ChevronRight, func() { a.stepSession(1) }}, + command{"previous", "Previous session", a.shortcutLabel(ShortcutPrevSession), icon.ChevronRight, func() { a.stepSession(-1) }}, ) } s, ok := a.selectedSession() @@ -160,7 +160,11 @@ func (a *App) sessionCommands() []command { if a.panelShows(panelChanges) { label = "Hide changes" } - out = append(out, command{"changes", label, name + " · " + modKey + "⇧D", icon.Git, func() { a.toggleTool(panelChanges) }}) + sub := name + if lbl := a.shortcutLabel(ShortcutSidePanel); lbl != "" { + sub += " · " + lbl + } + out = append(out, command{"changes", label, sub, icon.Git, func() { a.toggleTool(panelChanges) }}) } for _, t := range panelTools[1:] { label := "Show " + strings.ToLower(t.name) @@ -173,7 +177,11 @@ func (a *App) sessionCommands() []command { if a.maximized != "" { maximize = "Restore session" } - out = append(out, command{"maximize", maximize, name + " · " + modKey + "⇧↩", icon.Maximize, func() { a.toggleMaximized(s.ID) }}) + maxSub := name + if lbl := a.shortcutLabel(ShortcutMaximize); lbl != "" { + maxSub += " · " + lbl + } + out = append(out, command{"maximize", maximize, maxSub, icon.Maximize, func() { a.toggleMaximized(s.ID) }}) if a.canReview(s) { for _, r := range []struct { action, label string diff --git a/ui/settings_sections.go b/ui/settings_sections.go index 4401fa96..4eb38c18 100644 --- a/ui/settings_sections.go +++ b/ui/settings_sections.go @@ -546,16 +546,45 @@ func (a *App) appSection(c *ui.Context) { a.commandLineCard(c) }) widget.GridCard(c.Key("keys")).Grow(7).Basis(0).Children(func() { - list := shortcutList() widget.CardTitle(c, nil, "Keyboard shortcuts", func() { - ui.Text(c, strconv.Itoa(len(list))).FontSize(theme.TextLabel).TextColor(theme.TextSoft) + if a.hasCustomShortcuts() { + if widget.GhostButton(c.Key("reset-shortcuts-top"), "Reset").Clicked() { + a.resetAllShortcuts() + } + } + if widget.GhostButton(c.Key("customize-shortcuts"), "Customize…").Clicked() { + a.openShortcutSheet() + } }) + masterOn := !a.state.ShortcutsOff + if switchRowKey(c, "app-master-shortcuts", &masterOn, "Enable keyboard shortcuts", "Turn off to disable all application shortcuts") { + a.state.ShortcutsOff = !masterOn + a.saveState() + } ui.Column(c).FillWidth().MinWidth(0).Children(func() { - for _, s := range list { + for _, def := range GlobalShortcuts { + cfg := a.shortcutConfig(def.ID) + mods, key, disabled := a.effectiveShortcut(def.ID) + globallyOff := a.state.ShortcutsOff + keyStr := FormatKey(mods, key) + if keyStr == "" || disabled || globallyOff { + keyStr = "Off" + } + ui.Box(c).Height(theme.Rule).FillWidth().Shrink(0).Background(theme.Border) - ui.Row(c.Key(s[0])).FillWidth().Height(28).Shrink(0).Gap(theme.S3).Children(func() { - ui.Row(c).Grow(1).MinWidth(0).Children(func() { widget.Running(c, s[1], widget.RunStyle{}) }) - ui.Text(c, s[0]).FontSize(theme.TextLabel).TextColor(theme.TextSoft).SingleLine().Shrink(0) + defID := def.ID + row := ui.Row(c.Key(string(defID))).FillWidth().Height(28).Shrink(0).Gap(theme.S3).Cursor(ui.CursorPointer) + if row.Clicked() { + a.openShortcutSheet(defID) + } + row.Children(func() { + ui.Row(c).Grow(1).MinWidth(0).Gap(theme.S2).AlignItems(ui.Center).Children(func() { + widget.Running(c, def.Name, widget.RunStyle{}) + if cfg.Custom { + widget.Chip(c, "Custom", ui.Color{}) + } + }) + ui.Text(c, keyStr).FontSize(theme.TextLabel).TextColor(theme.TextSoft).SingleLine().Shrink(0) }) } }) diff --git a/ui/shortcuts.go b/ui/shortcuts.go new file mode 100644 index 00000000..dfc814ee --- /dev/null +++ b/ui/shortcuts.go @@ -0,0 +1,437 @@ +package ui + +import ( + "errors" + "fmt" + "runtime" + "strconv" + "strings" + "unicode" + + "github.com/egoist/mygo/ui" +) + +// ShortcutID identifies a customizable application shortcut. +type ShortcutID string + +const ( + ShortcutPalette ShortcutID = "palette" + ShortcutQuickOpen ShortcutID = "quick_open" + ShortcutNewSession ShortcutID = "new_session" + ShortcutOpenFolder ShortcutID = "open_folder" + ShortcutSidebar ShortcutID = "sidebar" + ShortcutSidePanel ShortcutID = "side_panel" + ShortcutSessionsPanel ShortcutID = "sessions_panel" + ShortcutBrowserTool ShortcutID = "browser_tool" + ShortcutSplitView ShortcutID = "split_view" + ShortcutNextSession ShortcutID = "next_session" + ShortcutPrevSession ShortcutID = "prev_session" + ShortcutMaximize ShortcutID = "maximize" + ShortcutHibernate ShortcutID = "hibernate" +) + +// ShortcutDef describes a registered global application shortcut. +type ShortcutDef struct { + ID ShortcutID + Name string + Description string + DefaultMods ui.Modifiers + DefaultKey ui.Key +} + +// ShortcutConfig holds user overrides and disable state for a shortcut. +type ShortcutConfig struct { + Disabled bool `json:"disabled,omitempty"` + Mods ui.Modifiers `json:"mods,omitempty"` + Key ui.Key `json:"key,omitempty"` + Custom bool `json:"custom,omitempty"` + Text string `json:"text,omitempty"` +} + +// GlobalShortcuts lists all configurable global shortcuts in presentation order. +var GlobalShortcuts = []ShortcutDef{ + {ID: ShortcutPalette, Name: "Command palette", Description: "Find and run commands", DefaultMods: ui.Cmd, DefaultKey: ui.KeyK}, + {ID: ShortcutQuickOpen, Name: "Go to file", Description: "Quick open a file of the session", DefaultMods: ui.Cmd, DefaultKey: ui.KeyP}, + {ID: ShortcutNewSession, Name: "New session", Description: "Start a new agent session", DefaultMods: ui.Cmd, DefaultKey: ui.KeyN}, + {ID: ShortcutOpenFolder, Name: "Open folder", Description: "Open a folder or project", DefaultMods: ui.Cmd, DefaultKey: ui.KeyO}, + {ID: ShortcutSidebar, Name: "Expand or collapse sidebar", Description: "Toggle wide / narrow rail", DefaultMods: ui.Cmd, DefaultKey: ui.KeyB}, + {ID: ShortcutSidePanel, Name: "Show or hide side panel", Description: "Toggle side panel tools", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyD}, + {ID: ShortcutSessionsPanel, Name: "Show or hide Sessions panel", Description: "Toggle sessions list panel", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyS}, + {ID: ShortcutBrowserTool, Name: "Browser tool", Description: "Open the built-in browser", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyP}, + {ID: ShortcutSplitView, Name: "Sessions side by side", Description: "Toggle split view for sessions", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyL}, + {ID: ShortcutNextSession, Name: "Next session", Description: "Switch to next session in project", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyBracketRight}, + {ID: ShortcutPrevSession, Name: "Previous session", Description: "Switch to previous session in project", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyBracketLeft}, + {ID: ShortcutMaximize, Name: "Maximize session", Description: "Expand session to full workspace", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyEnter}, + {ID: ShortcutHibernate, Name: "Hibernate session", Description: "Hibernate selected idle session", DefaultMods: ui.Cmd | ui.Shift, DefaultKey: ui.KeyH}, +} + +// LookupShortcut finds a shortcut definition by its ID. +func LookupShortcut(id ShortcutID) (ShortcutDef, bool) { + for _, def := range GlobalShortcuts { + if def.ID == id { + return def, true + } + } + return ShortcutDef{}, false +} + +// FormatKey formats modifiers and a key into a human-readable display string. +func FormatKey(mods ui.Modifiers, key ui.Key) string { + keyName := formatSingleKey(key) + if keyName == "" { + return "" + } + + if runtime.GOOS == "darwin" { + var b strings.Builder + if mods&ui.Ctrl != 0 { + b.WriteString("^") + } + if mods&ui.Alt != 0 { + b.WriteString("⌥") + } + if mods&ui.Shift != 0 { + b.WriteString("⇧") + } + if mods&(ui.Super|ui.Cmd) != 0 { + b.WriteString("⌘") + } + b.WriteString(keyName) + return b.String() + } + + var parts []string + if mods&ui.Ctrl != 0 || (mods&ui.Cmd != 0 && ui.Cmd == ui.Ctrl) { + parts = append(parts, "Ctrl") + } + if mods&ui.Alt != 0 { + parts = append(parts, "Alt") + } + if mods&ui.Shift != 0 { + parts = append(parts, "Shift") + } + if mods&ui.Super != 0 && ui.Cmd != ui.Super { + parts = append(parts, "Win") + } + parts = append(parts, keyName) + return strings.Join(parts, "+") +} + +func formatSingleKey(key ui.Key) string { + switch { + case key >= ui.KeyA && key <= ui.KeyZ: + return string(rune('A' + int(key-ui.KeyA))) + case key >= ui.Key0 && key <= ui.Key9: + return string(rune('0' + int(key-ui.Key0))) + case key >= ui.KeyF1 && key <= ui.KeyF12: + return fmt.Sprintf("F%d", int(key-ui.KeyF1)+1) + } + + switch key { + case ui.KeyEnter: + if runtime.GOOS == "darwin" { + return "↩" + } + return "Enter" + case ui.KeyEscape: + return "Esc" + case ui.KeyTab: + return "Tab" + case ui.KeySpace: + return "Space" + case ui.KeyBackspace: + return "Backspace" + case ui.KeyDelete: + return "Delete" + case ui.KeyBracketLeft: + return "[" + case ui.KeyBracketRight: + return "]" + case ui.KeySlash: + return "/" + case ui.KeyBackslash: + return "\\" + case ui.KeyMinus: + return "-" + case ui.KeyEqual: + return "=" + case ui.KeyComma: + return "," + case ui.KeyPeriod: + return "." + case ui.KeySemicolon: + return ";" + case ui.KeyQuote: + return "'" + case ui.KeyBackquote: + return "`" + case ui.KeyUp: + return "Up" + case ui.KeyDown: + return "Down" + case ui.KeyLeft: + return "Left" + case ui.KeyRight: + return "Right" + case ui.KeyHome: + return "Home" + case ui.KeyEnd: + return "End" + case ui.KeyPageUp: + return "PageUp" + case ui.KeyPageDown: + return "PageDown" + default: + return "" + } +} + +// ParseKey parses a shortcut string (e.g. "Ctrl+Shift+D" or "⌘⇧D") into modifiers and a key. +func ParseKey(s string) (ui.Modifiers, ui.Key, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, ui.KeyUnknown, errors.New("shortcut cannot be empty") + } + + // Normalize Mac glyphs into standard token representation + s = strings.ReplaceAll(s, "⌘", "+cmd+") + s = strings.ReplaceAll(s, "⇧", "+shift+") + s = strings.ReplaceAll(s, "⌥", "+alt+") + s = strings.ReplaceAll(s, "^", "+ctrl+") + s = strings.ReplaceAll(s, "↩", "+enter+") + + rawTokens := strings.FieldsFunc(s, func(r rune) bool { + return r == '+' || r == '-' || unicode.IsSpace(r) + }) + + var mods ui.Modifiers + var foundKey ui.Key = ui.KeyUnknown + + for _, raw := range rawTokens { + token := strings.TrimSpace(strings.ToLower(raw)) + if token == "" { + continue + } + + switch token { + case "ctrl", "control": + mods |= ui.Ctrl + case "alt", "opt", "option": + mods |= ui.Alt + case "shift": + mods |= ui.Shift + case "cmd", "command": + mods |= ui.Cmd + case "super", "win", "windows": + mods |= ui.Super + default: + // Parse key + k, err := parseSingleKey(token) + if err != nil { + return 0, ui.KeyUnknown, err + } + if foundKey != ui.KeyUnknown { + return 0, ui.KeyUnknown, fmt.Errorf("multiple keys specified (%s and %s)", formatSingleKey(foundKey), raw) + } + foundKey = k + } + } + + if foundKey == ui.KeyUnknown { + return 0, ui.KeyUnknown, errors.New("a key (e.g. K, Enter, or F1) is required") + } + + return mods, foundKey, nil +} + +func parseSingleKey(s string) (ui.Key, error) { + upper := strings.ToUpper(s) + + if len(upper) == 1 { + r := rune(upper[0]) + if r >= 'A' && r <= 'Z' { + return ui.Key(int(ui.KeyA) + int(r-'A')), nil + } + if r >= '0' && r <= '9' { + return ui.Key(int(ui.Key0) + int(r-'0')), nil + } + } + + if strings.HasPrefix(upper, "F") && len(upper) >= 2 && len(upper) <= 3 { + if n, err := strconv.Atoi(upper[1:]); err == nil && n >= 1 && n <= 12 { + return ui.Key(int(ui.KeyF1) + (n - 1)), nil + } + } + + switch upper { + case "ENTER", "RETURN": + return ui.KeyEnter, nil + case "ESC", "ESCAPE": + return ui.KeyEscape, nil + case "TAB": + return ui.KeyTab, nil + case "SPACE": + return ui.KeySpace, nil + case "BACKSPACE": + return ui.KeyBackspace, nil + case "DEL", "DELETE": + return ui.KeyDelete, nil + case "[", "BRACKETLEFT": + return ui.KeyBracketLeft, nil + case "]", "BRACKETRIGHT": + return ui.KeyBracketRight, nil + case "/", "SLASH": + return ui.KeySlash, nil + case "\\", "BACKSLASH": + return ui.KeyBackslash, nil + case "-", "MINUS": + return ui.KeyMinus, nil + case "=", "EQUAL": + return ui.KeyEqual, nil + case ",", "COMMA": + return ui.KeyComma, nil + case ".", "PERIOD": + return ui.KeyPeriod, nil + case ";", "SEMICOLON": + return ui.KeySemicolon, nil + case "'", "QUOTE": + return ui.KeyQuote, nil + case "`", "BACKQUOTE": + return ui.KeyBackquote, nil + case "UP": + return ui.KeyUp, nil + case "DOWN": + return ui.KeyDown, nil + case "LEFT": + return ui.KeyLeft, nil + case "RIGHT": + return ui.KeyRight, nil + case "HOME": + return ui.KeyHome, nil + case "END": + return ui.KeyEnd, nil + case "PAGEUP": + return ui.KeyPageUp, nil + case "PAGEDOWN": + return ui.KeyPageDown, nil + default: + return ui.KeyUnknown, fmt.Errorf("unknown key %q", s) + } +} + +// shortcutConfig returns the user's config for id, or an empty config if unset. +func (a *App) shortcutConfig(id ShortcutID) ShortcutConfig { + if a.state.Shortcuts != nil { + if cfg, ok := a.state.Shortcuts[string(id)]; ok { + return cfg + } + } + return ShortcutConfig{} +} + +// effectiveShortcut returns the active modifiers, key, and whether the shortcut is disabled. +func (a *App) effectiveShortcut(id ShortcutID) (mods ui.Modifiers, key ui.Key, disabled bool) { + def, ok := LookupShortcut(id) + if !ok { + return 0, ui.KeyUnknown, true + } + cfg := a.shortcutConfig(id) + if cfg.Disabled { + return 0, ui.KeyUnknown, true + } + if cfg.Custom && cfg.Key != ui.KeyUnknown { + return cfg.Mods, cfg.Key, false + } + return def.DefaultMods, def.DefaultKey, false +} + +// isShortcutTriggered reports whether the shortcut for id fired in context c. +func (a *App) isShortcutTriggered(id ShortcutID, c *ui.Context) bool { + if a.state.ShortcutsOff { + return false + } + mods, key, disabled := a.effectiveShortcut(id) + if disabled || key == ui.KeyUnknown { + return false + } + return c.Shortcut(mods, key) +} + +// shortcutLabel returns the formatted label for the shortcut, or "" if disabled or turned off. +func (a *App) shortcutLabel(id ShortcutID) string { + if a.state.ShortcutsOff { + return "" + } + mods, key, disabled := a.effectiveShortcut(id) + if disabled || key == ui.KeyUnknown { + return "" + } + return FormatKey(mods, key) +} + +// setShortcutDisabled enables or disables a single shortcut. +func (a *App) setShortcutDisabled(id ShortcutID, disabled bool) { + if a.state.Shortcuts == nil { + a.state.Shortcuts = make(map[string]ShortcutConfig) + } + cfg := a.state.Shortcuts[string(id)] + cfg.Disabled = disabled + a.state.Shortcuts[string(id)] = cfg + a.saveState() +} + +// setShortcutKey changes the key combination of a shortcut. +func (a *App) setShortcutKey(id ShortcutID, text string) error { + mods, key, err := ParseKey(text) + if err != nil { + return err + } + if a.state.Shortcuts == nil { + a.state.Shortcuts = make(map[string]ShortcutConfig) + } + cfg := a.state.Shortcuts[string(id)] + cfg.Custom = true + cfg.Mods = mods + cfg.Key = key + cfg.Text = FormatKey(mods, key) + a.state.Shortcuts[string(id)] = cfg + a.saveState() + return nil +} + +// resetShortcut restores a shortcut to its factory default. +func (a *App) resetShortcut(id ShortcutID) { + if a.state.Shortcuts != nil { + delete(a.state.Shortcuts, string(id)) + a.saveState() + } +} + +// resetAllShortcuts enables all shortcuts and restores their default bindings. +func (a *App) resetAllShortcuts() { + a.state.ShortcutsOff = false + a.state.Shortcuts = nil + a.saveState() +} + +// hasCustomShortcuts reports whether any shortcut is disabled, custom, or master switch is off. +func (a *App) hasCustomShortcuts() bool { + if a.state.ShortcutsOff { + return true + } + return len(a.state.Shortcuts) > 0 +} + +// findShortcutConflict checks if mods and key are already used by another active shortcut. +func (a *App) findShortcutConflict(id ShortcutID, mods ui.Modifiers, key ui.Key) (ShortcutDef, bool) { + for _, def := range GlobalShortcuts { + if def.ID == id { + continue + } + otherMods, otherKey, disabled := a.effectiveShortcut(def.ID) + if !disabled && otherMods == mods && otherKey == key { + return def, true + } + } + return ShortcutDef{}, false +} diff --git a/ui/shortcuts_dialog.go b/ui/shortcuts_dialog.go new file mode 100644 index 00000000..26e98798 --- /dev/null +++ b/ui/shortcuts_dialog.go @@ -0,0 +1,178 @@ +package ui + +import ( + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/ui/icon" + "github.com/enowx/enowx/ui/theme" + "github.com/enowx/enowx/ui/widget" +) + +type shortcutDialogState struct { + open bool + editingID ShortcutID + editText string + editErr string +} + +// openShortcutSheet opens the keyboard shortcuts customization dialog. +func (a *App) openShortcutSheet(initialID ...ShortcutID) { + f := &shortcutDialogState{open: true} + if len(initialID) > 0 { + id := initialID[0] + f.editingID = id + mods, key, _ := a.effectiveShortcut(id) + f.editText = FormatKey(mods, key) + } + a.shortcutsForm = f +} + +// shortcutDialog draws the customizable shortcut form sheet. +func (a *App) shortcutDialog(c *ui.Context) { + f := a.shortcutsForm + if f == nil { + return + } + if !f.open { + a.shortcutsForm = nil + return + } + + widget.FormSheet(c, &f.open, 600, "Keyboard shortcuts", + "Customize keyboard shortcuts or disable the ones you don't need.", + func() { + // Master toggle + masterOn := !a.state.ShortcutsOff + if switchRowKey(c, "shortcuts-master", &masterOn, "Enable keyboard shortcuts", + "When turned off, no application keyboard shortcuts will trigger.") { + a.state.ShortcutsOff = !masterOn + a.saveState() + } + + ui.Box(c).Height(theme.Rule).FillWidth().Shrink(0).Background(theme.Border).MarginY(theme.S3) + + ui.Column(c.Key("shortcuts-list")).FillWidth().Gap(theme.S2).Children(func() { + for _, def := range GlobalShortcuts { + a.shortcutDialogRow(c, f, def) + } + }) + }, + func() { + if a.hasCustomShortcuts() { + if widget.GhostButton(c.Key("reset-all-shortcuts"), "Reset all to defaults").Clicked() { + a.resetAllShortcuts() + f.editingID = "" + f.editErr = "" + } + } + ui.Spacer(c) + if widget.Button(c.Key("done-shortcuts"), widget.Secondary, nil, "Done", "").Clicked() { + f.open = false + } + }, + ) +} + +func (a *App) shortcutDialogRow(c *ui.Context, f *shortcutDialogState, def ShortcutDef) { + cfg := a.shortcutConfig(def.ID) + mods, key, disabled := a.effectiveShortcut(def.ID) + globallyOff := a.state.ShortcutsOff + + ui.Column(c.Key(string(def.ID))).FillWidth().Padding(theme.S2, theme.S3). + Radius(theme.RadiusSmall).Background(theme.Fade(theme.Border, 0.4)).Gap(theme.S2).Children(func() { + ui.Row(c.Key("header")).FillWidth().Gap(theme.S3).AlignItems(ui.Center).Children(func() { + // Individual shortcut switch + on := !cfg.Disabled && !globallyOff + sw := on + if globallyOff { + // Master switch is off, so individual switch cannot be enabled + widget.Switch(c.Key("switch-"+string(def.ID)), &sw, def.Name) + } else { + if widget.Switch(c.Key("switch-"+string(def.ID)), &sw, def.Name) { + a.setShortcutDisabled(def.ID, !sw) + } + } + + // Name and Description + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + ui.Row(c).Gap(theme.S2).AlignItems(ui.Center).Children(func() { + ui.Text(c, def.Name).FontSize(theme.TextUI).FontWeight(500) + if cfg.Custom { + widget.Chip(c, "Custom", ui.Color{}) + } + if disabled || globallyOff { + widget.Chip(c, "Off", ui.Color{}) + } + }) + ui.Text(c, def.Description).FontSize(theme.TextLabel).TextColor(theme.TextSoft) + }) + + // Actions: Current shortcut pill and edit/reset buttons + if f.editingID != def.ID { + keyStr := FormatKey(mods, key) + if keyStr == "" || disabled || globallyOff { + keyStr = "None" + } + ui.Text(c, keyStr).FontSize(theme.TextLabel).TextColor(theme.TextSoft). + Padding(2, 6).Radius(theme.RadiusSmall).Background(theme.Border) + + if !globallyOff { + if widget.GhostButton(c.Key("btn-edit-"+string(def.ID)), "Change").Clicked() { + f.editingID = def.ID + f.editText = FormatKey(mods, key) + f.editErr = "" + } + if cfg.Custom || cfg.Disabled { + if widget.GhostButton(c.Key("btn-reset-"+string(def.ID)), "Reset").Clicked() { + a.resetShortcut(def.ID) + f.editErr = "" + } + } + } + } + }) + + // Inline editing panel + if f.editingID == def.ID && !globallyOff { + ui.Column(c.Key("edit-panel")).FillWidth().Gap(theme.S2).PaddingY(theme.S2).Children(func() { + ui.Row(c).FillWidth().Gap(theme.S2).AlignItems(ui.Center).Children(func() { + ui.Row(c).Grow(1).MinWidth(0).Children(func() { + widget.Input(c.Key("key-input"), &f.editText, "e.g. Ctrl+Shift+K") + }) + if widget.Button(c.Key("apply-key"), widget.Primary, nil, "Apply", "").Clicked() { + if err := a.setShortcutKey(def.ID, f.editText); err != nil { + f.editErr = err.Error() + } else { + f.editingID = "" + f.editErr = "" + } + } + if widget.GhostButton(c.Key("cancel-key"), "Cancel").Clicked() { + f.editingID = "" + f.editErr = "" + } + }) + + // Check potential conflict + if f.editText != "" { + if testMods, testKey, err := ParseKey(f.editText); err == nil { + if conflict, found := a.findShortcutConflict(def.ID, testMods, testKey); found { + ui.Row(c).Gap(theme.S1).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon.Alert).FontSize(12).TextColor(theme.Warning) + ui.Text(c, "Warning: already assigned to "+conflict.Name). + FontSize(theme.TextLabel).TextColor(theme.Warning) + }) + } + } + } + + if f.editErr != "" { + ui.Row(c).Gap(theme.S1).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon.Alert).FontSize(12).TextColor(theme.Error) + ui.Text(c, f.editErr).FontSize(theme.TextLabel).TextColor(theme.Error) + }) + } + }) + } + }) +} diff --git a/ui/shortcuts_test.go b/ui/shortcuts_test.go new file mode 100644 index 00000000..9c99899c --- /dev/null +++ b/ui/shortcuts_test.go @@ -0,0 +1,227 @@ +package ui + +import ( + "encoding/json" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestParseAndFormatKey(t *testing.T) { + tests := []struct { + input string + wantKey ui.Key + wantMods ui.Modifiers + expectError bool + }{ + {"Ctrl+K", ui.KeyK, ui.Ctrl, false}, + {"Ctrl+Shift+D", ui.KeyD, ui.Ctrl | ui.Shift, false}, + {"Alt+P", ui.KeyP, ui.Alt, false}, + {"Shift+Enter", ui.KeyEnter, ui.Shift, false}, + {"F5", ui.KeyF5, 0, false}, + {"Ctrl+F12", ui.KeyF12, ui.Ctrl, false}, + {"Ctrl+[", ui.KeyBracketLeft, ui.Ctrl, false}, + {"Ctrl+]", ui.KeyBracketRight, ui.Ctrl, false}, + {"⌘K", ui.KeyK, ui.Cmd, false}, + {"⌘⇧D", ui.KeyD, ui.Cmd | ui.Shift, false}, + {"", 0, 0, true}, + {"Ctrl", 0, 0, true}, + {"Ctrl+Shift", 0, 0, true}, + {"UnknownKey123", 0, 0, true}, + {"Ctrl+Shift+Alt+Unknown", 0, 0, true}, + } + + for _, tc := range tests { + mods, key, err := ParseKey(tc.input) + if tc.expectError { + if err == nil { + t.Errorf("ParseKey(%q): expected error, got nil", tc.input) + } + continue + } + if err != nil { + t.Errorf("ParseKey(%q): unexpected error: %v", tc.input, err) + continue + } + if key != tc.wantKey { + t.Errorf("ParseKey(%q): key = %v, want %v", tc.input, key, tc.wantKey) + } + if mods&tc.wantMods != tc.wantMods { + t.Errorf("ParseKey(%q): mods = %v, want %v", tc.input, mods, tc.wantMods) + } + + formatted := FormatKey(mods, key) + if formatted == "" { + t.Errorf("FormatKey(%v, %v) returned empty string", mods, key) + } + } +} + +func TestAppShortcutsConfigAndState(t *testing.T) { + app := New(Options{}) + + // 1. Initial defaults + mods, key, disabled := app.effectiveShortcut(ShortcutPalette) + if disabled { + t.Fatal("expected ShortcutPalette to be enabled by default") + } + if key != ui.KeyK { + t.Fatalf("expected ShortcutPalette default key to be ui.KeyK, got %v", key) + } + if mods&ui.Cmd == 0 { + t.Fatalf("expected ShortcutPalette default mods to include ui.Cmd, got %v", mods) + } + if app.shortcutLabel(ShortcutPalette) == "" { + t.Fatal("expected non-empty shortcut label for ShortcutPalette") + } + if app.hasCustomShortcuts() { + t.Fatal("expected no custom shortcuts initially") + } + + // 2. Disable individual shortcut + app.setShortcutDisabled(ShortcutPalette, true) + if !app.hasCustomShortcuts() { + t.Fatal("expected hasCustomShortcuts to be true after disabling one") + } + _, _, disabled = app.effectiveShortcut(ShortcutPalette) + if !disabled { + t.Fatal("expected ShortcutPalette to be disabled") + } + if app.shortcutLabel(ShortcutPalette) != "" { + t.Fatalf("expected empty label for disabled shortcut, got %q", app.shortcutLabel(ShortcutPalette)) + } + + // 3. Re-enable and customize key + app.setShortcutDisabled(ShortcutPalette, false) + if err := app.setShortcutKey(ShortcutPalette, "Ctrl+Shift+K"); err != nil { + t.Fatalf("unexpected error setting shortcut key: %v", err) + } + mods, key, disabled = app.effectiveShortcut(ShortcutPalette) + if disabled { + t.Fatal("expected shortcut to remain enabled after key change") + } + if key != ui.KeyK || mods&(ui.Ctrl|ui.Shift) != (ui.Ctrl|ui.Shift) { + t.Fatalf("expected custom mods and key, got mods=%v key=%v", mods, key) + } + + // 4. Conflict detection + conflict, found := app.findShortcutConflict(ShortcutQuickOpen, mods, key) + if !found || conflict.ID != ShortcutPalette { + t.Fatalf("expected conflict with ShortcutPalette, found=%v conflict=%+v", found, conflict) + } + + // 5. Master switch disables all + app.state.ShortcutsOff = true + if app.shortcutLabel(ShortcutPalette) != "" { + t.Fatal("expected empty label when master switch is off") + } + if app.shortcutLabel(ShortcutNewSession) != "" { + t.Fatal("expected empty label for all shortcuts when master switch is off") + } + + // 6. Reset all shortcuts + app.resetAllShortcuts() + if app.state.ShortcutsOff { + t.Fatal("expected ShortcutsOff to be false after reset") + } + if app.hasCustomShortcuts() { + t.Fatal("expected hasCustomShortcuts to be false after reset") + } + mods, key, disabled = app.effectiveShortcut(ShortcutPalette) + if disabled || key != ui.KeyK { + t.Fatalf("expected reset to restore default ui.KeyK, got %v (disabled: %v)", key, disabled) + } +} + +func TestShortcutStateJSONPersistence(t *testing.T) { + st := appState{ + ShortcutsOff: true, + Shortcuts: map[string]ShortcutConfig{ + string(ShortcutPalette): { + Disabled: true, + }, + string(ShortcutNewSession): { + Custom: true, + Mods: ui.Ctrl | ui.Shift, + Key: ui.KeyT, + Text: "Ctrl+Shift+T", + }, + }, + } + + data, err := json.Marshal(st) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var restored appState + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if !restored.ShortcutsOff { + t.Fatal("expected restored ShortcutsOff to be true") + } + if len(restored.Shortcuts) != 2 { + t.Fatalf("expected 2 restored shortcuts, got %d", len(restored.Shortcuts)) + } + palCfg := restored.Shortcuts[string(ShortcutPalette)] + if !palCfg.Disabled { + t.Fatal("expected restored ShortcutPalette to be disabled") + } + newCfg := restored.Shortcuts[string(ShortcutNewSession)] + if !newCfg.Custom || newCfg.Key != ui.KeyT || newCfg.Text != "Ctrl+Shift+T" { + t.Fatalf("restored ShortcutNewSession mismatch: %+v", newCfg) + } +} + +func TestShortcutDialogUI(t *testing.T) { + app := New(Options{}) + + // Open dialog + app.openShortcutSheet(ShortcutPalette) + if app.shortcutsForm == nil || !app.shortcutsForm.open { + t.Fatal("expected shortcut dialog to be open") + } + if app.shortcutsForm.editingID != ShortcutPalette { + t.Fatalf("expected editingID to be %s, got %s", ShortcutPalette, app.shortcutsForm.editingID) + } + + tt := ui.NewTester(func(c *ui.Context) { + ui.Column(c).Width(700).Height(800).Children(func() { + app.shortcutDialog(c) + }) + }, 700, 800) + tt.Frame() + + if !tt.HasText("Keyboard shortcuts") { + t.Fatalf("expected dialog title in %q", tt.Texts()) + } + if !tt.HasText("Enable keyboard shortcuts") { + t.Fatalf("expected master switch in %q", tt.Texts()) + } + if !tt.HasText("Command palette") { + t.Fatalf("expected Command palette row in %q", tt.Texts()) + } + + // Change key to Ctrl+Shift+K + app.shortcutsForm.editText = "Ctrl+Shift+K" + if err := tt.Click("Apply"); err != nil { + t.Fatalf("clicking Apply failed: %v", err) + } + tt.Frame() + + cfg := app.shortcutConfig(ShortcutPalette) + if !cfg.Custom || cfg.Key != ui.KeyK { + t.Fatalf("expected custom key to be applied, got %+v", cfg) + } + + // Click Done to close + if err := tt.Click("Done"); err != nil { + t.Fatalf("clicking Done failed: %v", err) + } + tt.Frame() + if app.shortcutsForm != nil { + t.Fatal("expected dialog to be closed after clicking Done") + } +} diff --git a/ui/state.go b/ui/state.go index 0ed6a7d9..fa3b271b 100644 --- a/ui/state.go +++ b/ui/state.go @@ -27,6 +27,8 @@ type appState struct { CloneDirs map[string]string `json:"cloneDirs,omitempty"` // host ID → the folder cloned into last Theme string `json:"theme,omitempty"` // palette ID ThemeMode string `json:"themeMode,omitempty"` // dark (default), light, or system + // WindowControlStyle is "mac" (default) or "windows". + WindowControlStyle string `json:"windowControlStyle,omitempty"` // BackgroundImage is this device's image behind the panels, "" for the // theme's own; BackgroundOpacity how opaque the main surfaces over it are, // nil for the default; BackgroundBlur its blur's sigma, nil for the default. @@ -100,6 +102,10 @@ type appState struct { HideSessions bool `json:"hideSessions,omitempty"` // GOA shows GOD of Agents mode (goa.go). GOA bool `json:"goa,omitempty"` + // ShortcutsOff disables all application shortcuts when true. + ShortcutsOff bool `json:"shortcutsOff,omitempty"` + // Shortcuts holds per-shortcut custom key combinations and disable states. + Shortcuts map[string]ShortcutConfig `json:"shortcuts,omitempty"` } // palette is the palette the user picked, with their accent. diff --git a/ui/theme/fluent_palette.go b/ui/theme/fluent_palette.go new file mode 100644 index 00000000..d52059a4 --- /dev/null +++ b/ui/theme/fluent_palette.go @@ -0,0 +1,40 @@ +package theme + +import "github.com/egoist/mygo/ui" + +// Windows11Palette is the Windows 11 Fluent Design System palette: Mica dark #202020 base, +// subtle acrylic layering, and official Windows 11 royal blue / cyan accents. +var Windows11Palette = Palette{ + ID: "windows-11", + Name: "Windows 11", + + Ground: ui.Hex("#202020"), + Wall: [3]ui.Color{light("#0078D4", 0.35), light("#60CDFF", 0.25), light("#1F1F1F", 0.35)}, + Background: ui.Hex("#202020"), + Panel: ui.RGBA(39, 39, 39, 0.85), + Raised: ui.RGBA(255, 255, 255, 0.05), + Sunken: ui.RGBA(0, 0, 0, 0.25), + Frame: ui.RGBA(42, 42, 42, 0.92), + Popover: ui.RGBA(44, 44, 44, 0.98), + composer: ui.RGBA(30, 30, 30, 0.92), + Hover: ui.RGBA(255, 255, 255, 0.08), + Selected: ui.RGBA(255, 255, 255, 0.15), + Dialog: ui.RGBA(44, 44, 44, 0.98), + Border: ui.RGBA(255, 255, 255, 0.08), + BorderStrong: ui.Hex("#8A8A8A"), + Text: ui.Hex("#F5F6FA"), + TextStrong: ui.Hex("#FFFFFF"), + TextSoft: ui.Hex("#D0D2DE"), + Muted: ui.Hex("#B4B6C4"), + Accent: ui.Hex("#60CDFF"), + OnAccent: ui.Hex("#000000"), + Working: ui.Hex("#60CDFF"), + Done: ui.Hex("#6CCB5F"), + Warning: ui.Hex("#FFD60A"), + Error: ui.Hex("#FF6961"), + Idle: ui.Hex("#AEAEB2"), + Add: ui.Hex("#6CCB5F"), + Del: ui.Hex("#FF6961"), + Now: ui.Hex("#60CDFF"), + own: true, +} diff --git a/ui/theme/fluent_test.go b/ui/theme/fluent_test.go new file mode 100644 index 00000000..381823bd --- /dev/null +++ b/ui/theme/fluent_test.go @@ -0,0 +1,124 @@ +package theme + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFluentTokensStructureAndValues(t *testing.T) { + for _, dark := range []bool{true, false} { + tokens := GetFluentTokens(dark) + + if tokens.FontFamily == "" { + t.Errorf("dark=%v: FontFamily is empty", dark) + } + if tokens.RadiusSmall != 4 || tokens.RadiusControl != 8 || tokens.RadiusCard != 12 || tokens.RadiusPanel != 12 { + t.Errorf("dark=%v: unexpected corner radii: small=%.0f, control=%.0f, card=%.0f, panel=%.0f", + dark, tokens.RadiusSmall, tokens.RadiusControl, tokens.RadiusCard, tokens.RadiusPanel) + } + if tokens.AccentPrimary.A == 0 { + t.Errorf("dark=%v: AccentPrimary is zero", dark) + } + if tokens.SurfaceBackground.A == 0 || tokens.SurfaceCard.A == 0 || tokens.SurfaceFlyout.A == 0 { + t.Errorf("dark=%v: Surface colors have zero alpha", dark) + } + if tokens.StrokeSubtle.A == 0 || tokens.StrokeDefault.A == 0 { + t.Errorf("dark=%v: Stroke colors have zero alpha", dark) + } + if tokens.ElevationRest == "" || tokens.ElevationFlyout == "" || tokens.ElevationDialog == "" { + t.Errorf("dark=%v: Elevation shadows are empty", dark) + } + if tokens.EasingStandard == "" || tokens.DurationFast == "" { + t.Errorf("dark=%v: Motion tokens are empty", dark) + } + + dict := tokens.ToMap() + requiredKeys := []string{ + "surfaceBackground", "surfaceCard", "surfaceCardHover", "surfaceFlyout", + "strokeSubtle", "strokeDefault", "strokeCard", "strokeFocus", + "elevationRest", "elevationHover", "elevationFlyout", "materialMicaFilter", + "accentPrimary", "accentSecondary", "accentPressed", "accentFocusOutline", + "radiusSmall", "radiusControl", "radiusCard", "radiusPanel", + "easingStandard", "durationFast", + } + for _, key := range requiredKeys { + if val, ok := dict[key]; !ok || val == "" { + t.Errorf("dark=%v: missing or empty token key %q in ToMap()", dark, key) + } + } + } +} + +func TestGenerateFluentCSSVariables(t *testing.T) { + for _, dark := range []bool{true, false} { + css := GenerateFluentCSSVariables(dark) + expectedVars := []string{ + "--font-family:", + "--surface-background:", + "--surface-card:", + "--surface-card-hover:", + "--surface-flyout:", + "--surface-dialog:", + "--surface-mica:", + "--stroke-subtle:", + "--stroke-default:", + "--stroke-card:", + "--stroke-focus:", + "--elevation-rest:", + "--elevation-hover:", + "--elevation-flyout:", + "--elevation-dialog:", + "--material-mica-filter:", + "--accent-primary:", + "--accent-secondary:", + "--accent-pressed:", + "--accent-focus-outline:", + "--on-accent:", + "--radius-small: 4px;", + "--radius-control: 8px;", + "--radius-card: 12px;", + "--radius-panel: 12px;", + "--easing-standard:", + "--duration-fast: 150ms;", + "@supports not ((-webkit-backdrop-filter", + "@media (prefers-reduced-motion: reduce)", + } + for _, exp := range expectedVars { + if !strings.Contains(css, exp) { + t.Errorf("dark=%v: CSS variables missing %q", dark, exp) + } + } + } +} + +func TestWindows11StyleLookupAndSurfaces(t *testing.T) { + s := StyleByID("windows-11") + if s.ID != Windows11.ID { + t.Fatalf("expected style ID %q, got %q", Windows11.ID, s.ID) + } + if s.RadiusControl != 8 || s.RadiusCard != 12 || s.RadiusPanel != 12 { + t.Fatalf("unexpected Windows11 style metrics: %+v", s) + } + + pDark := fluentSurfaces(Windows11Palette) + if pDark.Background.A == 0 || pDark.Text.A == 0 || pDark.Accent.A == 0 { + t.Fatalf("dark fluent surfaces returned invalid palette: %+v", pDark) + } + + pLight := fluentSurfaces(lightOf(Windows11Palette, ui.Color{})) + if pLight.Background.A == 0 || pLight.Text.A == 0 || pLight.Accent.A == 0 { + t.Fatalf("light fluent surfaces returned invalid palette: %+v", pLight) + } +} + +func TestWindows11PaletteLookup(t *testing.T) { + p := Lookup("windows-11", ui.Color{}) + if p.ID != "windows-11" { + t.Fatalf("expected 'windows-11', got %q", p.ID) + } + if p.Name != "Windows 11" { + t.Fatalf("expected 'Windows 11', got %q", p.Name) + } +} diff --git a/ui/theme/fluent_tokens.go b/ui/theme/fluent_tokens.go new file mode 100644 index 00000000..384881d3 --- /dev/null +++ b/ui/theme/fluent_tokens.go @@ -0,0 +1,267 @@ +package theme + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" +) + +// FluentTokens defines the Microsoft Fluent Design System token hierarchy +// for Windows 11 styling across surfaces, strokes, effects, accents, and motion. +type FluentTokens struct { + IsDark bool + + // Surfaces + SurfaceBackground ui.Color + SurfaceCard ui.Color + SurfaceCardHover ui.Color + SurfaceFlyout ui.Color + SurfaceDialog ui.Color + SurfaceMica ui.Color + + // Borders / Strokes + StrokeSubtle ui.Color + StrokeDefault ui.Color + StrokeCard ui.Color + StrokeFocus ui.Color + + // Effects & Elevation + ElevationRest string + ElevationHover string + ElevationFlyout string + ElevationDialog string + MaterialMicaFilter string + + // Accents + AccentPrimary ui.Color + AccentSecondary ui.Color + AccentPressed ui.Color + AccentFocusOutline ui.Color + OnAccent ui.Color + + // Typography + FontFamily string + TextCaption float32 + TextBody float32 + TextSubtitle float32 + TextTitle float32 + + // Corner Radii + RadiusSmall float32 // 4px (badge, checkbox, micro) + RadiusControl float32 // 8px (buttons, inputs) + RadiusCard float32 // 12px (cards, containers, flyouts) + RadiusPanel float32 // 12px (dialogs, main panels) + + // Motion + EasingStandard string + EasingEnterExit string + DurationFast string + DurationNormal string +} + +// FluentFontFamily is the standard Windows 11 font stack with Segoe UI Variable first. +const FluentFontFamily = "Segoe UI Variable, Segoe UI, system-ui, -apple-system, Roboto, sans-serif" + +// FluentDarkTokens holds the Windows 11 Fluent tokens for Dark mode. +var FluentDarkTokens = FluentTokens{ + IsDark: true, + + SurfaceBackground: ui.Hex("#202020"), + SurfaceCard: ui.RGBA(255, 255, 255, 0.05), + SurfaceCardHover: ui.RGBA(255, 255, 255, 0.08), + SurfaceFlyout: ui.RGBA(44, 44, 44, 0.96), + SurfaceDialog: ui.RGBA(44, 44, 44, 0.98), + SurfaceMica: ui.RGBA(32, 32, 32, 0.88), + + StrokeSubtle: ui.RGBA(255, 255, 255, 0.08), + StrokeDefault: ui.RGBA(255, 255, 255, 0.15), + StrokeCard: ui.RGBA(255, 255, 255, 0.08), + StrokeFocus: ui.Hex("#60CDFF"), + + ElevationRest: "0 0 0 1px rgba(255, 255, 255, 0.08)", + ElevationHover: "0 2px 4px rgba(0, 0, 0, 0.20), 0 0 0 1px rgba(255, 255, 255, 0.12)", + ElevationFlyout: "0 8px 16px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(255, 255, 255, 0.10)", + ElevationDialog: "0 32px 64px rgba(0, 0, 0, 0.40), 0 0 0 1px rgba(255, 255, 255, 0.12)", + MaterialMicaFilter: "blur(30px) saturate(125%)", + + AccentPrimary: ui.Hex("#60CDFF"), + AccentSecondary: ui.Hex("#4CC2FF"), + AccentPressed: ui.Hex("#1CA8F8"), + AccentFocusOutline: ui.Hex("#60CDFF"), + OnAccent: ui.Hex("#000000"), + + FontFamily: FluentFontFamily, + TextCaption: 12, + TextBody: 13, + TextSubtitle: 15, + TextTitle: 18, + + RadiusSmall: 4, + RadiusControl: 8, + RadiusCard: 12, + RadiusPanel: 12, + + EasingStandard: "cubic-bezier(0, 0, 0, 1)", + EasingEnterExit: "cubic-bezier(0.1, 0.9, 0.2, 1)", + DurationFast: "150ms", + DurationNormal: "250ms", +} + +// FluentLightTokens holds the Windows 11 Fluent tokens for Light mode. +var FluentLightTokens = FluentTokens{ + IsDark: false, + + SurfaceBackground: ui.Hex("#F3F3F3"), + SurfaceCard: ui.RGBA(255, 255, 255, 0.70), + SurfaceCardHover: ui.RGBA(255, 255, 255, 0.90), + SurfaceFlyout: ui.RGBA(252, 252, 252, 0.96), + SurfaceDialog: ui.Hex("#FFFFFF"), + SurfaceMica: ui.RGBA(243, 243, 243, 0.88), + + StrokeSubtle: ui.RGBA(0, 0, 0, 0.06), + StrokeDefault: ui.RGBA(0, 0, 0, 0.14), + StrokeCard: ui.RGBA(0, 0, 0, 0.06), + StrokeFocus: ui.Hex("#0067C0"), + + ElevationRest: "0 0 0 1px rgba(0, 0, 0, 0.06)", + ElevationHover: "0 2px 4px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.08)", + ElevationFlyout: "0 8px 16px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.06)", + ElevationDialog: "0 32px 64px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(0, 0, 0, 0.08)", + MaterialMicaFilter: "blur(30px) saturate(125%)", + + AccentPrimary: ui.Hex("#0067C0"), + AccentSecondary: ui.Hex("#0078D4"), + AccentPressed: ui.Hex("#005A9E"), + AccentFocusOutline: ui.Hex("#0067C0"), + OnAccent: ui.Hex("#FFFFFF"), + + FontFamily: FluentFontFamily, + TextCaption: 12, + TextBody: 13, + TextSubtitle: 15, + TextTitle: 18, + + RadiusSmall: 4, + RadiusControl: 8, + RadiusCard: 12, + RadiusPanel: 12, + + EasingStandard: "cubic-bezier(0, 0, 0, 1)", + EasingEnterExit: "cubic-bezier(0.1, 0.9, 0.2, 1)", + DurationFast: "150ms", + DurationNormal: "250ms", +} + +// GetFluentTokens returns the active token set based on light/dark mode. +func GetFluentTokens(isDark bool) FluentTokens { + if isDark { + return FluentDarkTokens + } + return FluentLightTokens +} + +// ColorRGBAString formats a ui.Color into a standard CSS rgba(r, g, b, a) string. +func ColorRGBAString(c ui.Color) string { + a := float32(c.A) / 255.0 + if a >= 0.999 { + return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) + } + return fmt.Sprintf("rgba(%d, %d, %d, %.2f)", c.R, c.G, c.B, a) +} + +// GenerateFluentCSSVariables exports the complete dictionary of Windows 11 Fluent design tokens as CSS variables. +func GenerateFluentCSSVariables(isDark bool) string { + t := GetFluentTokens(isDark) + var sb strings.Builder + sb.WriteString(":root {\n") + sb.WriteString(fmt.Sprintf(" --font-family: %s;\n", t.FontFamily)) + sb.WriteString(fmt.Sprintf(" --surface-background: %s;\n", ColorRGBAString(t.SurfaceBackground))) + sb.WriteString(fmt.Sprintf(" --surface-card: %s;\n", ColorRGBAString(t.SurfaceCard))) + sb.WriteString(fmt.Sprintf(" --surface-card-hover: %s;\n", ColorRGBAString(t.SurfaceCardHover))) + sb.WriteString(fmt.Sprintf(" --surface-flyout: %s;\n", ColorRGBAString(t.SurfaceFlyout))) + sb.WriteString(fmt.Sprintf(" --surface-dialog: %s;\n", ColorRGBAString(t.SurfaceDialog))) + sb.WriteString(fmt.Sprintf(" --surface-mica: %s;\n", ColorRGBAString(t.SurfaceMica))) + sb.WriteString(fmt.Sprintf(" --stroke-subtle: %s;\n", ColorRGBAString(t.StrokeSubtle))) + sb.WriteString(fmt.Sprintf(" --stroke-default: %s;\n", ColorRGBAString(t.StrokeDefault))) + sb.WriteString(fmt.Sprintf(" --stroke-card: %s;\n", ColorRGBAString(t.StrokeCard))) + sb.WriteString(fmt.Sprintf(" --stroke-focus: %s;\n", ColorRGBAString(t.StrokeFocus))) + sb.WriteString(fmt.Sprintf(" --elevation-rest: %s;\n", t.ElevationRest)) + sb.WriteString(fmt.Sprintf(" --elevation-hover: %s;\n", t.ElevationHover)) + sb.WriteString(fmt.Sprintf(" --elevation-flyout: %s;\n", t.ElevationFlyout)) + sb.WriteString(fmt.Sprintf(" --elevation-dialog: %s;\n", t.ElevationDialog)) + sb.WriteString(fmt.Sprintf(" --material-mica-filter: %s;\n", t.MaterialMicaFilter)) + sb.WriteString(fmt.Sprintf(" --accent-primary: %s;\n", ColorRGBAString(t.AccentPrimary))) + sb.WriteString(fmt.Sprintf(" --accent-secondary: %s;\n", ColorRGBAString(t.AccentSecondary))) + sb.WriteString(fmt.Sprintf(" --accent-pressed: %s;\n", ColorRGBAString(t.AccentPressed))) + sb.WriteString(fmt.Sprintf(" --accent-focus-outline: %s;\n", ColorRGBAString(t.AccentFocusOutline))) + sb.WriteString(fmt.Sprintf(" --on-accent: %s;\n", ColorRGBAString(t.OnAccent))) + sb.WriteString(fmt.Sprintf(" --radius-small: %.0fpx;\n", t.RadiusSmall)) + sb.WriteString(fmt.Sprintf(" --radius-control: %.0fpx;\n", t.RadiusControl)) + sb.WriteString(fmt.Sprintf(" --radius-card: %.0fpx;\n", t.RadiusCard)) + sb.WriteString(fmt.Sprintf(" --radius-panel: %.0fpx;\n", t.RadiusPanel)) + sb.WriteString(fmt.Sprintf(" --easing-standard: %s;\n", t.EasingStandard)) + sb.WriteString(fmt.Sprintf(" --easing-enter-exit: %s;\n", t.EasingEnterExit)) + sb.WriteString(fmt.Sprintf(" --duration-fast: %s;\n", t.DurationFast)) + sb.WriteString(fmt.Sprintf(" --duration-normal: %s;\n", t.DurationNormal)) + sb.WriteString("}\n") + + // Fallback when backdrop-filter is unsupported: solid opaque fallback + sb.WriteString("\n@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {\n") + sb.WriteString(" :root {\n") + if isDark { + sb.WriteString(" --surface-flyout: #2C2C2C;\n") + sb.WriteString(" --surface-mica: #202020;\n") + } else { + sb.WriteString(" --surface-flyout: #FAFAFA;\n") + sb.WriteString(" --surface-mica: #F3F3F3;\n") + } + sb.WriteString(" }\n") + sb.WriteString("}\n") + + // Accessibility: prefers-reduced-motion media query + sb.WriteString("\n@media (prefers-reduced-motion: reduce) {\n") + sb.WriteString(" :root {\n") + sb.WriteString(" --duration-fast: 0ms;\n") + sb.WriteString(" --duration-normal: 0ms;\n") + sb.WriteString(" --easing-standard: linear;\n") + sb.WriteString(" --easing-enter-exit: linear;\n") + sb.WriteString(" }\n") + sb.WriteString("}\n") + return sb.String() +} + +// ToMap returns the tokens as key-value string pairs for serialization or inspector tools. +func (t FluentTokens) ToMap() map[string]string { + return map[string]string{ + "surfaceBackground": ColorRGBAString(t.SurfaceBackground), + "surfaceCard": ColorRGBAString(t.SurfaceCard), + "surfaceCardHover": ColorRGBAString(t.SurfaceCardHover), + "surfaceFlyout": ColorRGBAString(t.SurfaceFlyout), + "surfaceDialog": ColorRGBAString(t.SurfaceDialog), + "surfaceMica": ColorRGBAString(t.SurfaceMica), + "strokeSubtle": ColorRGBAString(t.StrokeSubtle), + "strokeDefault": ColorRGBAString(t.StrokeDefault), + "strokeCard": ColorRGBAString(t.StrokeCard), + "strokeFocus": ColorRGBAString(t.StrokeFocus), + "elevationRest": t.ElevationRest, + "elevationHover": t.ElevationHover, + "elevationFlyout": t.ElevationFlyout, + "elevationDialog": t.ElevationDialog, + "materialMicaFilter": t.MaterialMicaFilter, + "accentPrimary": ColorRGBAString(t.AccentPrimary), + "accentSecondary": ColorRGBAString(t.AccentSecondary), + "accentPressed": ColorRGBAString(t.AccentPressed), + "accentFocusOutline": ColorRGBAString(t.AccentFocusOutline), + "onAccent": ColorRGBAString(t.OnAccent), + "fontFamily": t.FontFamily, + "radiusSmall": fmt.Sprintf("%.0fpx", t.RadiusSmall), + "radiusControl": fmt.Sprintf("%.0fpx", t.RadiusControl), + "radiusCard": fmt.Sprintf("%.0fpx", t.RadiusCard), + "radiusPanel": fmt.Sprintf("%.0fpx", t.RadiusPanel), + "easingStandard": t.EasingStandard, + "easingEnterExit": t.EasingEnterExit, + "durationFast": t.DurationFast, + "durationNormal": t.DurationNormal, + } +} diff --git a/ui/theme/light.go b/ui/theme/light.go index c8bd55e5..abc3e09c 100644 --- a/ui/theme/light.go +++ b/ui/theme/light.go @@ -50,6 +50,32 @@ func lightOf(dark Palette, accent ui.Color) Palette { p.darkOf = &dark case Teal.ID: p.Ground, p.Accent = ui.Hex("#E6F0F0"), ui.Hex("#285F63") + case Windows11Palette.ID: + p.Ground = ui.Hex("#F3F3F3") + p.Background = ui.Hex("#F3F3F3") + p.Panel = ui.RGBA(255, 255, 255, 0.70) + p.Frame = ui.RGBA(255, 255, 255, 0.90) + p.Raised = ui.RGBA(0, 0, 0, 0.035) + p.Sunken = ui.RGBA(0, 0, 0, 0.06) + p.Hover = ui.RGBA(0, 0, 0, 0.05) + p.Selected = ui.RGBA(0, 0, 0, 0.10) + p.Dialog = ui.RGBA(252, 252, 254, 0.98) + p.Popover = ui.RGBA(252, 252, 254, 0.98) + p.Border = ui.RGBA(0, 0, 0, 0.12) + p.BorderStrong = ui.Hex("#778497") + p.Text = ui.Hex("#1C2130") + p.TextStrong = ui.Hex("#0C101C") + p.TextSoft = ui.Hex("#475467") + p.Muted = ui.Hex("#526075") + p.Accent = ui.Hex("#0067C0") + p.Working = ui.Hex("#0067C0") + p.Done = ui.Hex("#166B42") + p.Warning = ui.Hex("#855300") + p.Error = ui.Hex("#B3261E") + p.Idle = ui.Hex("#5F6673") + p.Add = ui.Hex("#166B42") + p.Del = ui.Hex("#B3261E") + p.Now = ui.Hex("#0067C0") } if accent.A != 0 { p.Accent = accent diff --git a/ui/theme/palette.go b/ui/theme/palette.go index 426d49aa..3d0363e9 100644 --- a/ui/theme/palette.go +++ b/ui/theme/palette.go @@ -87,13 +87,14 @@ var ( ) // Palettes is every built-in palette. -var Palettes = []Palette{Champagne, Obsidian, Midnight, Copper, Graphite, Teal} +var Palettes = []Palette{Champagne, Obsidian, Midnight, Copper, Graphite, Teal, Windows11Palette} // Default is the palette of who hasn't picked one. var Default = Teal // Accents are the colors a user can give any palette instead of its own. var Accents = []ui.Color{ + ui.Hex("#0067C0"), // Windows 11 royal blue ui.Hex("#0A6FE0"), // blue ui.Hex("#2B7A5B"), // green ui.Hex("#8A5CF6"), // violet diff --git a/ui/theme/style.go b/ui/theme/style.go index 4f5bb7ca..659d9dd9 100644 --- a/ui/theme/style.go +++ b/ui/theme/style.go @@ -53,7 +53,7 @@ var LiquidGlass = Style{ } // Styles is every style, in the order the app offers them. -var Styles = []Style{LiquidGlass, Ledger, Tonal, Bento} +var Styles = []Style{LiquidGlass, Windows11, Ledger, Tonal, Bento} // style is the style in use. var style Style diff --git a/ui/theme/style_fluent.go b/ui/theme/style_fluent.go new file mode 100644 index 00000000..9aee9331 --- /dev/null +++ b/ui/theme/style_fluent.go @@ -0,0 +1,130 @@ +package theme + +import ( + "github.com/egoist/mygo/ui" +) + +// Windows11 is the Windows 11 Fluent Design System style: Mica/Acrylic translucency, +// 8px rounded corners for controls, 12px for cards and panels, Segoe UI Variable, +// and subtle layered borders. +var Windows11 = Style{ + ID: "windows-11", + Name: "Windows 11", + + // Radii: 4px micro, 8px controls, 12px cards and panels + RadiusFull: 8, + RadiusControl: 8, + RadiusTail: 4, + RadiusSmall: 4, + RadiusCard: 12, + RadiusPanel: 12, + + // Spacing and control metrics + BentoGap: 8, + WindowInset: 10, + ControlHeight: 32, + BarHeight: 40, + ScrollWidth: 8, + BarRadius: 8, + RailWidth: 44, + ReadWidth: 760, + HeaderHeight: 52, + RowHeight: 34, + RowTwo: 40, + RowTall: 42, + TabHeight: 28, + Rule: 1, + + S1: 4, S2: 8, S3: 12, S4: 16, S5: 24, S6: 32, + + // Typography: Segoe UI Variable hierarchy + TextLabel: 12, + TextUI: 13, + TextRead: 15, + TextTitle: 18, + + Family: FluentFontFamily, + Scrim: ui.RGBA(0, 0, 0, 0.45), + Hairline: 1, + surfaces: fluentSurfaces, +} + +// fluentSurfaces maps a palette to Windows 11 Fluent Design System layers. +func fluentSurfaces(p Palette) Palette { + t := GetFluentTokens(p.Light == false) + + if p.Light { + p.Ground = t.SurfaceBackground + p.Background = t.SurfaceBackground + p.Panel = t.SurfaceCard + p.Frame = ui.RGBA(249, 249, 249, 0.95) + p.Raised = t.SurfaceCard + p.Sunken = ui.RGBA(0, 0, 0, 0.04) + p.Hover = t.SurfaceCardHover + p.Selected = ui.RGBA(0, 0, 0, 0.08) + p.Dialog = t.SurfaceDialog + p.Popover = t.SurfaceFlyout + p.composer = t.SurfaceDialog + + p.Border = t.StrokeSubtle + p.BorderStrong = t.StrokeDefault + p.floatLine = ui.RGBA(0, 0, 0, 0.10) + + p.Text = ui.Hex("#1A1A1A") + p.TextStrong = ui.Hex("#000000") + p.TextSoft = ui.RGBA(0, 0, 0, 0.62) + p.Muted = ui.RGBA(0, 0, 0, 0.44) + + if p.own { + p.Accent = t.AccentPrimary + p.OnAccent = t.OnAccent + } + + p.Working = t.AccentPrimary + p.Done = ui.Hex("#107C10") + p.Warning = ui.Hex("#9D5D00") + p.Error = ui.Hex("#C42B1C") + p.Idle = ui.Hex("#5F6673") + p.Add = ui.Hex("#107C10") + p.Del = ui.Hex("#C42B1C") + p.Now = t.AccentPrimary + } else { + p.Ground = t.SurfaceBackground + p.Background = t.SurfaceBackground + p.Panel = ui.RGBA(39, 39, 39, 0.90) + p.Frame = ui.RGBA(36, 36, 36, 0.95) + p.Raised = t.SurfaceCard + p.Sunken = ui.RGBA(0, 0, 0, 0.28) + p.Hover = t.SurfaceCardHover + p.Selected = ui.RGBA(255, 255, 255, 0.12) + p.Dialog = t.SurfaceDialog + p.Popover = t.SurfaceFlyout + p.composer = ui.RGBA(30, 30, 30, 0.92) + + p.Border = t.StrokeSubtle + p.BorderStrong = t.StrokeDefault + p.floatLine = ui.RGBA(255, 255, 255, 0.12) + + p.Text = ui.Hex("#FFFFFF") + p.TextStrong = ui.Hex("#FFFFFF") + p.TextSoft = ui.RGBA(255, 255, 255, 0.72) + p.Muted = ui.RGBA(255, 255, 255, 0.54) + + if p.own { + p.Accent = t.AccentPrimary + p.OnAccent = t.OnAccent + } + + p.Working = t.AccentPrimary + p.Done = ui.Hex("#6CCB5F") + p.Warning = ui.Hex("#FCE100") + p.Error = ui.Hex("#FF99A4") + p.Idle = ui.Hex("#AEAEB2") + p.Add = ui.Hex("#6CCB5F") + p.Del = ui.Hex("#FF99A4") + p.Now = t.AccentPrimary + } + + p.OnAccent = contrastInk(p.Accent) + return holdWords(p) +} diff --git a/ui/titlebar.go b/ui/titlebar.go index c03e61a9..f32cade3 100644 --- a/ui/titlebar.go +++ b/ui/titlebar.go @@ -33,13 +33,21 @@ func (a *App) titleBarWith(c *ui.Context, actions func()) { row.Background(theme.Panel) gap = 0 } + isWindows := a.isWindowsControlStyle() row.Gap(gap).Children(func() { - ui.Row(c).Height(theme.BarHeight).PaddingX(theme.S3).Shrink(0).Children(func() { a.windowButtons(c) }) + if !isWindows { + ui.Row(c).Height(theme.BarHeight).PaddingX(theme.S3).Shrink(0).Children(func() { a.windowButtons(c) }) + } else { + ui.Box(c).Width(theme.S2) + } a.contextCapsule(c) a.pagesCapsule(c) a.commandButton(c) ui.Spacer(c) capsule(c).Shrink(0).Gap(theme.S1).PaddingX(theme.S1).Children(actions) + if isWindows { + capsule(c).Shrink(0).Gap(0).PaddingX(0).Clip().Children(func() { a.windowsWindowButtons(c) }) + } }) } diff --git a/ui/view.go b/ui/view.go index a0a0a321..47f833cb 100644 --- a/ui/view.go +++ b/ui/view.go @@ -121,28 +121,29 @@ func (a *App) view(c *ui.Context) { a.cloneDialog(c) a.noteImportDialog(c) a.noteExportDialog(c) + a.shortcutDialog(c) a.confirmDialog(c) // last: what a dialog asks shows over it a.saveState() } func (a *App) shortcuts(c *ui.Context) { - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyH) && a.page == "" { + if a.isShortcutTriggered(ShortcutHibernate, c) && a.page == "" { if s, ok := a.sessionByID(a.selected); ok && canHibernate(s) { a.hibernate(a.hibernateTargets(s)) } } - if c.Shortcut(ui.Cmd, ui.KeyN) { + if a.isShortcutTriggered(ShortcutNewSession, c) { if p, ok := a.currentProject(); ok { a.openNewSession(p) } } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyEnter) { + if a.isShortcutTriggered(ShortcutMaximize, c) { a.toggleMaximized(a.selected) } - if c.Shortcut(ui.Cmd, ui.KeyO) { + if a.isShortcutTriggered(ShortcutOpenFolder, c) { a.openFolder() } - if c.Shortcut(ui.Cmd, ui.KeyK) { + if a.isShortcutTriggered(ShortcutPalette, c) { switch { case a.page == pageNotes && !a.palette.open && a.notes.OpenJump(): // On a page of notes, it jumps to a section of it. @@ -152,32 +153,32 @@ func (a *App) shortcuts(c *ui.Context) { a.openPalette() } } - if c.Shortcut(ui.Cmd, ui.KeyP) { + if a.isShortcutTriggered(ShortcutQuickOpen, c) { if a.quick.open { a.quick.open = false } else { a.openQuickOpen() } } - if c.Shortcut(ui.Cmd, ui.KeyB) { + if a.isShortcutTriggered(ShortcutSidebar, c) { a.state.RailWide = !a.state.RailWide } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyBracketRight) { + if a.isShortcutTriggered(ShortcutNextSession, c) { a.stepSession(1) } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyBracketLeft) { + if a.isShortcutTriggered(ShortcutPrevSession, c) { a.stepSession(-1) } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyL) { + if a.isShortcutTriggered(ShortcutSplitView, c) { a.setSplit(!a.splitOn()) } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyD) { + if a.isShortcutTriggered(ShortcutSidePanel, c) { a.togglePanel() } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyP) { + if a.isShortcutTriggered(ShortcutBrowserTool, c) { a.toggleTool(panelBrowser) } - if c.Shortcut(ui.Cmd|ui.Shift, ui.KeyS) { + if a.isShortcutTriggered(ShortcutSessionsPanel, c) { a.toggleSessionsPanel() } } diff --git a/ui/widget/controls.go b/ui/widget/controls.go index 2cf9b10e..df710172 100644 --- a/ui/widget/controls.go +++ b/ui/widget/controls.go @@ -22,7 +22,7 @@ const ( // Button is a capsule with its label, an optional icon and shortcut hint. func Button(c *ui.Context, kind ButtonKind, svg *ui.SVG, label, hint string) ui.Element { - b := ui.ButtonBase(c).Gap(theme.S1).PaddingX(theme.S3).Height(theme.ControlHeight).Radius(theme.RadiusFull). + b := ui.ButtonBase(c).Gap(theme.S1).PaddingX(theme.S3).Height(theme.ControlHeight).Radius(theme.RadiusControl). Cursor(ui.CursorPointer).Label(label).Shrink(0) hover := b.Hovered() && !b.IsDisabled() weight := 400 @@ -46,6 +46,9 @@ func Button(c *ui.Context, kind ButtonKind, svg *ui.SVG, label, hint string) ui. } } b.Background(QuickTint(b, "bg", bg)).TextColor(theme.Text) + if theme.Rule > 0 { + b.InsetShadow(0, 0, 0, theme.Rule, theme.Border) + } case Danger: bg := theme.Fade(theme.Error, 0.16) if hover { @@ -105,21 +108,25 @@ func IconButton(c *ui.Context, svg *ui.SVG, label string, on bool) ui.Element { func Well(e ui.Element, focused bool) ui.Element { Edge(e.Background(theme.Sunken)) if focused { - e.Border(2, theme.Now) + if theme.CurrentStyle().ID == "windows-11" { + e.Border(1, theme.Border).InsetShadow(0, -2, 0, 0, theme.Now) + } else { + e.Border(2, theme.Now) + } } return e } // Input is a text input: a well as a capsule. func Input(c *ui.Context, value *string, placeholder string) ui.Element { - in := ui.TextInput(c, value).Height(theme.ControlHeight).PaddingX(theme.S3).Radius(theme.RadiusFull). + in := ui.TextInput(c, value).Height(theme.ControlHeight).PaddingX(theme.S3).Radius(theme.RadiusControl). FontSize(theme.TextUI).Placeholder(placeholder).Label(placeholder) return Well(in, in.Focused()) } // SearchField is a filter input with a search icon in front. func SearchField(c *ui.Context, value *string, placeholder string, svg *ui.SVG) { - box := ui.Row(c).FillWidth().Height(theme.ControlHeight).PaddingX(theme.S3).Gap(theme.S2).Radius(theme.RadiusFull) + box := ui.Row(c).FillWidth().Height(theme.ControlHeight).PaddingX(theme.S3).Gap(theme.S2).Radius(theme.RadiusControl) Well(box, box.FocusWithin()).Children(func() { ui.Icon(c, svg).FontSize(14).TextColor(theme.TextSoft) ui.TextInputBase(c, value).Grow(1).FontSize(theme.TextUI).Placeholder(placeholder).Label(placeholder) @@ -128,7 +135,7 @@ func SearchField(c *ui.Context, value *string, placeholder string, svg *ui.SVG) // Trigger styles the face of a drop-down, open while its pop-up shows. func Trigger(t ui.Element, open bool) ui.Element { - t.FillWidth().Height(theme.ControlHeight).PaddingX(theme.S3).Gap(theme.S2).Radius(theme.RadiusFull).Cursor(ui.CursorPointer) + t.FillWidth().Height(theme.ControlHeight).PaddingX(theme.S3).Gap(theme.S2).Radius(theme.RadiusControl).Cursor(ui.CursorPointer) Well(t, open) if t.Hovered() { t.Background(theme.Fade(theme.TextStrong, 0.1)) diff --git a/ui/windowbuttons.go b/ui/windowbuttons.go index 815cd723..119c2d40 100644 --- a/ui/windowbuttons.go +++ b/ui/windowbuttons.go @@ -5,8 +5,35 @@ import ( "github.com/enowx/enowx/ui/icon" "github.com/enowx/enowx/ui/theme" + "github.com/enowx/enowx/ui/widget" ) +// Window control style options. +const ( + WindowControlStyleMac = "mac" + WindowControlStyleWindows = "windows" +) + +// isWindowsControlStyle reports whether Windows caption buttons are chosen. +func (a *App) isWindowsControlStyle() bool { + return a.state.WindowControlStyle == WindowControlStyleWindows +} + +// setWindowControlStyle sets the window controls appearance ("mac" or "windows"). +func (a *App) setWindowControlStyle(style string) { + if style != WindowControlStyleWindows { + style = WindowControlStyleMac + } + if a.state.WindowControlStyle == style { + return + } + a.state.WindowControlStyle = style + a.saveState() + if a.win != nil { + a.win.Invalidate() + } +} + // The window's close, minimize and maximize buttons, drawn by the app so // they look the same on macOS, Windows and Linux: three dots on the left, // their signs showing while the pointer is over them, grey while the @@ -25,7 +52,7 @@ type windowControl interface { ToggleFullScreen() } -// windowButtons draws them in the title bar. +// windowButtons draws them in the title bar (Mac style traffic lights). func (a *App) windowButtons(c *ui.Context) { win := a.winCtl if win == nil && a.win != nil { @@ -74,3 +101,84 @@ func (a *App) windowButtons(c *ui.Context) { } }) } + +// windowsWindowButtons draws Windows-style caption buttons (Minimize, Maximize, Close) +// at the right side of the title bar. +func (a *App) windowsWindowButtons(c *ui.Context) { + win := a.winCtl + if win == nil && a.win != nil { + win = a.win + } + if win == nil { + return + } + + group := ui.Row(c.Key("windows-buttons")).Shrink(0).Height(theme.BarHeight) + group.Children(func() { + // 1. Minimize + minBtn := ui.ButtonBase(c.Key("win-minimize")). + Height(theme.BarHeight).Width(44).Shrink(0).Center(). + Label("Minimize").Tooltip("Minimize").Cursor(ui.CursorPointer) + minBg := theme.Fade(theme.Selected, 0) + if minBtn.Hovered() { + minBg = theme.Selected + } + fg := theme.TextSoft + if minBtn.Hovered() { + fg = theme.TextStrong + } else if a.blurred { + fg = theme.Idle + } + minBtn.Background(widget.QuickTint(minBtn, "bg", minBg)).Children(func() { + ui.Icon(c, icon.Minus).FontSize(11).TextColor(fg) + }) + if minBtn.Clicked() { + win.Minimize() + } + + // 2. Maximize / Restore + maxBtn := ui.ButtonBase(c.Key("win-maximize")). + Height(theme.BarHeight).Width(44).Shrink(0).Center(). + Label("Maximize").Tooltip("Maximize").Cursor(ui.CursorPointer) + maxBg := theme.Fade(theme.Selected, 0) + if maxBtn.Hovered() { + maxBg = theme.Selected + } + maxFg := theme.TextSoft + if maxBtn.Hovered() { + maxFg = theme.TextStrong + } else if a.blurred { + maxFg = theme.Idle + } + maxBtn.Background(widget.QuickTint(maxBtn, "bg", maxBg)).Children(func() { + ui.Icon(c, icon.Square).FontSize(10).TextColor(maxFg) + }) + if maxBtn.Clicked() { + if zoomGoesFullScreen && maxBtn.ClickModifiers()&ui.Alt == 0 { + win.ToggleFullScreen() + } else { + win.ToggleMaximize() + } + } + + // 3. Close + closeBtn := ui.ButtonBase(c.Key("win-close")). + Height(theme.BarHeight).Width(46).Shrink(0).Center(). + Label("Close").Tooltip("Close").Cursor(ui.CursorPointer) + closeBg := theme.Fade(theme.Selected, 0) + closeFg := theme.TextSoft + if closeBtn.Hovered() { + closeBg = ui.Hex("#E81123") // Windows close red + closeFg = ui.Hex("#FFFFFF") + } else if a.blurred { + closeFg = theme.Idle + } + closeBtn.Background(widget.QuickTint(closeBtn, "bg", closeBg)).Children(func() { + ui.Icon(c, icon.Close).FontSize(11).TextColor(closeFg) + }) + if closeBtn.Clicked() { + win.Close() + } + }) +} + diff --git a/ui/windowbuttons_test.go b/ui/windowbuttons_test.go index 2d37b5c9..0965f77b 100644 --- a/ui/windowbuttons_test.go +++ b/ui/windowbuttons_test.go @@ -1,6 +1,7 @@ package ui import ( + "path/filepath" "testing" "github.com/egoist/mygo/ui" @@ -40,3 +41,76 @@ func TestWindowButtons(t *testing.T) { t.Fatalf("did %q", got) } } + +func TestWindowsStyleButtons(t *testing.T) { + a := New(Options{}) + a.state.WindowControlStyle = WindowControlStyleWindows + w := &fakeWindow{} + a.winCtl = w + tt := ui.NewTester(func(c *ui.Context) { + ui.Column(c).Width(900).Height(60).Background(theme.Ground).Children(func() { a.titleBar(c) }) + }, 900, 60) + tt.Frame() + + // Mac dots Zoom should not be present + if _, ok := tt.Find("Zoom"); ok { + t.Fatal("mac style Zoom button should not exist in Windows style") + } + + // Click Windows buttons: Maximize, Minimize, Close + for _, b := range []string{"Maximize", "Minimize", "Close"} { + if err := tt.Click(b); err != nil { + t.Fatal(err) + } + tt.Frame() + } + zoom := "maximize" + if zoomGoesFullScreen { + zoom = "full screen" + } + if got := w.did; len(got) != 3 || got[0] != zoom || got[1] != "minimize" || got[2] != "close" { + t.Fatalf("did %q", got) + } +} + +func TestWindowControlStyleAppearanceSetting(t *testing.T) { + file := filepath.Join(t.TempDir(), "state.json") + app := New(Options{StateFile: file}) + app.host = LocalHost() + + tt := ui.NewTester(func(c *ui.Context) { + ui.Column(c).Width(1000).Height(800).Background(theme.Ground).Children(func() { app.appearancePage(c) }) + }, 1000, 800) + tt.Frame() + + // Initially mac style + if app.isWindowsControlStyle() { + t.Fatal("expected default to be mac style") + } + + // Click "Windows style" + if err := tt.Click("Windows style"); err != nil { + t.Fatal(err) + } + tt.Frame() + + if !app.isWindowsControlStyle() { + t.Fatal("expected Windows style to be active") + } + + app.saveState() + reopened := New(Options{StateFile: file}) + if reopened.state.WindowControlStyle != WindowControlStyleWindows { + t.Fatalf("expected reopened to have windows control style, got %q", reopened.state.WindowControlStyle) + } + + // Switch back to "Mac style" + if err := tt.Click("Mac style"); err != nil { + t.Fatal(err) + } + tt.Frame() + if app.isWindowsControlStyle() { + t.Fatal("expected Mac style to be active") + } +} + From ac44a035ba2fca786a60f8108d3c7caaceef6f69 Mon Sep 17 00:00:00 2001 From: Kyuzan0 Date: Tue, 10 Nov 2026 17:48:17 +0700 Subject: [PATCH 3/3] Fix appearance page layout overflow and new session conversation resume Position window control style card below appearance mode and accent picker to prevent segmented control label overflow in 1200x720 resolution. Prevent new sessions from erroneously inheriting older transcript files from other sessions in the same directory. --- daemon/persist.go | 11 +---------- ui/appearance.go | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/daemon/persist.go b/daemon/persist.go index 2145e998..1db659fb 100644 --- a/daemon/persist.go +++ b/daemon/persist.go @@ -533,16 +533,7 @@ func (s *Server) sessionConversation(sess *session.Session) string { if convo != "" { return convo } - if meta.Conversation != "" { - return meta.Conversation - } - if home, err := os.UserHomeDir(); err == nil { - found := transcript.Find(meta.Agent, home, sess.Info().Dir) - if len(found) > 0 { - return transcript.ConversationID(meta.Agent, found[0].Path) - } - } - return "" + return meta.Conversation } // agentEnv is what meta's agent runs with when started again: its diff --git a/ui/appearance.go b/ui/appearance.go index a3b49ef8..a2a3f1f0 100644 --- a/ui/appearance.go +++ b/ui/appearance.go @@ -72,9 +72,9 @@ func (a *App) appearancePage(c *ui.Context) { a.backgroundCard(c) ui.Grid(c).FillWidth().Grow(1).Columns(2).Gap(theme.S3).AlignItems(ui.Stretch).Children(func() { widget.Card(c).Children(func() { a.appearanceMode(c) }) - widget.Card(c).Children(func() { a.windowControlStyleCard(c) }) + widget.Card(c).Children(func() { a.accentPicker(c) }) }) - widget.Card(c).Children(func() { a.accentPicker(c) }) + widget.Card(c).Children(func() { a.windowControlStyleCard(c) }) }) widget.Card(c).Grow(5).Basis(0).Children(func() { a.themePreview(c) }) })