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")
+ }
+}
+