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..1db659fb 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,11 @@ 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 + } + return meta.Conversation } // 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/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..a2a3f1f0 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. @@ -51,6 +74,7 @@ func (a *App) appearancePage(c *ui.Context) { 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).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/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") + } +} 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") + } +} +