diff --git a/README.md b/README.md index 02a3e0d96..2a426714c 100644 --- a/README.md +++ b/README.md @@ -192,11 +192,42 @@ Useful controls: | `Enter` | send the prompt | | `/` | open slash-command suggestions | | `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) | -| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) | +| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work; not remappable) | +| `Esc` / `Ctrl+G` | dismiss a popup / cancel (Ctrl+G is emacs abort; not remappable) | | `Shift+Tab` | cycle permission mode | +| `Ctrl+Y` | expand / collapse the plan panel | | `Ctrl+B` | show/hide the sidebar | | `Ctrl+C` | cancel or exit | +#### Leader keybinds (`keybindings.json`) + +The leader prefix and slash-command chords live in `keybindings.json`, created +automatically next to `config.json` when missing: + +| Layer | Path | +|---|---| +| User | `~/.config/zero/keybindings.json` | +| Project | `/.zero/keybindings.json` | + +Schema (slash → letter, or `""` to leave unassigned / unbind): + +```json +{ + "leaderKey": "ctrl+x", + "leader": { + "/model": "m", + "/provider": "p", + "/theme": "", + "/clear": "" + } +} +``` + +- Seeded with **every** builtin slash command; defaults match the table above. +- Project overlays user; omitted keys keep the previous layer. +- `leaderKey` must include a modifier (`ctrl` / `alt` / `cmd`). +- Global toggles (`togglePlan`, `toggleSidebar`, …) stay under `config.json` → `keybindings`. + Common slash commands: | Command | Purpose | diff --git a/internal/cli/app.go b/internal/cli/app.go index 07769724a..08e034a5a 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -786,6 +786,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a if userConfigPath != "" { sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt") } + // Leader keybindings.json: register slash catalog for auto-seed, ensure files + // exist next to config when missing, then resolve defaults ← user ← project. + config.SetKeybindingsPrimarySlashes(tui.PrimarySlashNames()) + keybindingsFile := loadResolvedKeybindings(userConfigPath, projectConfigPath, workspaceRoot) // Build the hooks dispatcher out of the AgentOptions literal so its trust skip // report can be combined with the plugin activation's, and emit at most one // notice when project hooks/plugins were dropped for an untrusted workspace. @@ -864,6 +868,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a PermissionMode: permissionMode, Notify: resolved.Notify, KeyBindings: resolved.KeyBindings, + KeybindingsFile: keybindingsFile, STT: resolved.STT, BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager), ShutdownDictationServer: sttServerManager.Shutdown, @@ -882,6 +887,37 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a }) } +// loadResolvedKeybindings ensures keybindings.json next to user/project config +// when missing, loads both layers, and merges onto code defaults. +func loadResolvedKeybindings(userConfigPath, projectConfigPath, workspaceRoot string) config.KeybindingsFile { + defaults := config.DefaultKeybindingsFile(tui.PrimarySlashNames()) + if userConfigPath != "" { + _ = config.EnsureKeybindingsBesideConfig(userConfigPath) + } + projectKB := "" + if projectConfigPath != "" { + projectKB = config.KeybindingsPathBeside(projectConfigPath) + _ = config.EnsureKeybindingsBesideConfig(projectConfigPath) + } else if workspaceRoot != "" { + // Project config may not exist yet; still ensure under .zero if that dir + // already has a config, otherwise only seed user path above. + candidate := filepath.Join(workspaceRoot, ".zero", "config.json") + if _, err := os.Stat(candidate); err == nil { + _ = config.EnsureKeybindingsBesideConfig(candidate) + projectKB = config.KeybindingsPathBeside(candidate) + } + } + userFile, err := config.LoadKeybindingsFile(config.KeybindingsPathBeside(userConfigPath)) + if err != nil { + userFile = config.KeybindingsFile{} + } + projectFile, err := config.LoadKeybindingsFile(projectKB) + if err != nil { + projectFile = config.KeybindingsFile{} + } + return config.ResolveKeybindings(defaults, userFile, projectFile) +} + func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult { if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" { return nil diff --git a/internal/config/keybindings_file.go b/internal/config/keybindings_file.go new file mode 100644 index 000000000..abe83e9ed --- /dev/null +++ b/internal/config/keybindings_file.go @@ -0,0 +1,311 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// KeybindingsFile is the on-disk schema for keybindings.json (leader prefix + +// slash-command chords). Global toggles stay in config.json. +type KeybindingsFile struct { + LeaderKey string `json:"leaderKey,omitempty"` + Leader map[string]string `json:"leader,omitempty"` +} + +// DefaultLeaderKey is the built-in leader prefix. +const DefaultLeaderKey = "ctrl+x" + +// DefaultLeaderAssignments maps primary slash commands to their built-in +// second-key letters (slash → letter). Unlisted builtins seed as "". +func DefaultLeaderAssignments() map[string]string { + return map[string]string{ + "/model": "m", + "/provider": "p", + "/plan": "P", + "/stt-model": "M", + "/voice": "v", + "/clear": "c", + "/context": "C", + "/stop": "s", + "/image": "i", + "/resume": "r", + "/rewind": "u", + "/tools": "t", + "/retry": "R", + } +} + +// primarySlashesForSeed is set by SetKeybindingsPrimarySlashes so config writers +// can seed a full catalog without importing the TUI package. +var primarySlashesForSeed []string + +// SetKeybindingsPrimarySlashes registers the full primary slash catalog used when +// auto-seeding keybindings.json next to config.json. Call once at process start +// (CLI) with tui.PrimarySlashNames(). +func SetKeybindingsPrimarySlashes(names []string) { + primarySlashesForSeed = append([]string(nil), names...) +} + +// KeybindingsPrimarySlashes returns the registered seed catalog (may be empty +// before SetKeybindingsPrimarySlashes). +func KeybindingsPrimarySlashes() []string { + return append([]string(nil), primarySlashesForSeed...) +} + +// DefaultKeybindingsFile builds the seed document: leaderKey + every primary +// slash (assigned letters from DefaultLeaderAssignments, else ""). +// If primarySlashes is empty, falls back to KeybindingsPrimarySlashes, then to +// only the assigned defaults. +func DefaultKeybindingsFile(primarySlashes []string) KeybindingsFile { + names := primarySlashes + if len(names) == 0 { + names = primarySlashesForSeed + } + if len(names) == 0 { + for slash := range DefaultLeaderAssignments() { + names = append(names, slash) + } + sort.Strings(names) + } + assignments := DefaultLeaderAssignments() + leader := make(map[string]string, len(names)) + for _, slash := range names { + slash = strings.TrimSpace(slash) + if slash == "" { + continue + } + if letter, ok := assignments[slash]; ok { + leader[slash] = letter + } else { + leader[slash] = "" + } + } + // Ensure every default assignment is present even if catalog omitted it. + for slash, letter := range assignments { + if _, ok := leader[slash]; !ok { + leader[slash] = letter + } + } + return KeybindingsFile{ + LeaderKey: DefaultLeaderKey, + Leader: leader, + } +} + +// KeybindingsPathBeside returns the keybindings.json path next to a config.json path. +func KeybindingsPathBeside(configPath string) string { + if strings.TrimSpace(configPath) == "" { + return "" + } + return filepath.Join(filepath.Dir(configPath), "keybindings.json") +} + +// DefaultUserKeybindingsPath is the user-level keybindings.json (sibling of config.json). +func DefaultUserKeybindingsPath() (string, error) { + configPath, err := DefaultUserConfigPath() + if err != nil { + return "", err + } + return KeybindingsPathBeside(configPath), nil +} + +// ProjectKeybindingsPath is /.zero/keybindings.json. +func ProjectKeybindingsPath(workspaceRoot string) string { + return filepath.Join(workspaceRoot, ".zero", "keybindings.json") +} + +// EnsureKeybindingsFile writes seed JSON if path is missing. Never overwrites. +// Creation uses O_CREATE|O_EXCL so concurrent processes racing to seed the same +// path cannot replace a file another process (or the user) already wrote. +func EnsureKeybindingsFile(path string, seed KeybindingsFile) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("empty keybindings path") + } + // Fast path: already present. + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect keybindings %s: %w", path, err) + } + if seed.Leader == nil { + seed = DefaultKeybindingsFile(nil) + } + if strings.TrimSpace(seed.LeaderKey) == "" { + seed.LeaderKey = DefaultLeaderKey + } + return createKeybindingsFileIfAbsent(path, seed) +} + +// EnsureKeybindingsBesideConfig seeds keybindings.json next to configPath when absent. +func EnsureKeybindingsBesideConfig(configPath string) error { + path := KeybindingsPathBeside(configPath) + if path == "" { + return nil + } + return EnsureKeybindingsFile(path, DefaultKeybindingsFile(nil)) +} + +// LoadKeybindingsFile reads a keybindings.json. Missing file returns empty +// KeybindingsFile and nil error (caller merges with defaults). +func LoadKeybindingsFile(path string) (KeybindingsFile, error) { + if strings.TrimSpace(path) == "" { + return KeybindingsFile{}, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return KeybindingsFile{}, nil + } + return KeybindingsFile{}, fmt.Errorf("read keybindings %s: %w", path, err) + } + var file KeybindingsFile + if err := json.Unmarshal(data, &file); err != nil { + return KeybindingsFile{}, fmt.Errorf("parse keybindings %s: %w", path, err) + } + if file.Leader == nil { + file.Leader = map[string]string{} + } + return file, nil +} + +// ResolveKeybindings overlays user then project on defaults. +// Omitted leaderKey keeps previous; present leader keys overlay (including ""). +func ResolveKeybindings(defaults, user, project KeybindingsFile) KeybindingsFile { + out := cloneKeybindingsFile(defaults) + if out.Leader == nil { + out.Leader = map[string]string{} + } + if strings.TrimSpace(out.LeaderKey) == "" { + out.LeaderKey = DefaultLeaderKey + } + overlayKeybindings(&out, user) + overlayKeybindings(&out, project) + return out +} + +func overlayKeybindings(dst *KeybindingsFile, src KeybindingsFile) { + if key := strings.TrimSpace(src.LeaderKey); key != "" { + dst.LeaderKey = key + } + if dst.Leader == nil { + dst.Leader = map[string]string{} + } + for slash, letter := range src.Leader { + dst.Leader[slash] = letter + } +} + +func cloneKeybindingsFile(in KeybindingsFile) KeybindingsFile { + out := KeybindingsFile{LeaderKey: in.LeaderKey} + if in.Leader != nil { + out.Leader = make(map[string]string, len(in.Leader)) + for k, v := range in.Leader { + out.Leader[k] = v + } + } + return out +} + +// encodeKeybindingsJSON returns indented JSON bytes for file (with defaults filled). +func encodeKeybindingsJSON(file KeybindingsFile) ([]byte, error) { + out := file + if strings.TrimSpace(out.LeaderKey) == "" { + out.LeaderKey = DefaultLeaderKey + } + if out.Leader == nil { + out.Leader = map[string]string{} + } + // Stable key order for readable diffs (maps encode in insertion order). + sortedLeader := make(map[string]string, len(out.Leader)) + keys := make([]string, 0, len(out.Leader)) + for k := range out.Leader { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sortedLeader[k] = out.Leader[k] + } + out.Leader = sortedLeader + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return nil, fmt.Errorf("encode keybindings JSON: %w", err) + } + return append(data, '\n'), nil +} + +// createKeybindingsFileIfAbsent writes seed only when path does not exist. +// Uses O_CREATE|O_EXCL so a concurrent creator that lost the race treats the +// existing file as success and does not overwrite it (unlike rename). +func createKeybindingsFileIfAbsent(path string, file KeybindingsFile) error { + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create keybindings directory %s: %w", dir, err) + } + } + data, err := encodeKeybindingsJSON(file) + if err != nil { + return err + } + // Exclusive create: fails with ErrExist if another process won the race or + // the user already has a file. Never truncate/replace an existing path. + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { + return nil + } + return fmt.Errorf("create keybindings %s: %w", path, err) + } + _, writeErr := f.Write(data) + closeErr := f.Close() + if writeErr != nil { + _ = os.Remove(path) // leave no partial seed behind + return fmt.Errorf("write keybindings %s: %w", path, writeErr) + } + if closeErr != nil { + _ = os.Remove(path) + return fmt.Errorf("write keybindings %s: %w", path, closeErr) + } + return nil +} + +// writeKeybindingsFile overwrites path (tests / explicit rewrites only). +// Prefer createKeybindingsFileIfAbsent for user-facing seed paths. +func writeKeybindingsFile(path string, file KeybindingsFile) error { + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create keybindings directory %s: %w", dir, err) + } + } + data, err := encodeKeybindingsJSON(file) + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".zero-keybindings-*.tmp") + if err != nil { + return fmt.Errorf("write keybindings %s: %w", path, err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("secure keybindings permissions %s: %w", path, err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write keybindings %s: %w", path, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write keybindings %s: %w", path, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("write keybindings %s: %w", path, err) + } + return nil +} diff --git a/internal/config/keybindings_file_test.go b/internal/config/keybindings_file_test.go new file mode 100644 index 000000000..fee9b39fe --- /dev/null +++ b/internal/config/keybindings_file_test.go @@ -0,0 +1,231 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestDefaultKeybindingsFileSeedsCatalog(t *testing.T) { + seed := DefaultKeybindingsFile([]string{"/model", "/help", "/exit"}) + if seed.LeaderKey != DefaultLeaderKey { + t.Fatalf("LeaderKey = %q, want %q", seed.LeaderKey, DefaultLeaderKey) + } + if seed.Leader["/model"] != "m" { + t.Fatalf("/model = %q, want m", seed.Leader["/model"]) + } + if seed.Leader["/help"] != "" { + t.Fatalf("/help = %q, want empty", seed.Leader["/help"]) + } + if seed.Leader["/exit"] != "" { + t.Fatalf("/exit = %q, want empty", seed.Leader["/exit"]) + } + // Default assignments always present even if catalog omitted them. + if seed.Leader["/provider"] != "p" { + t.Fatalf("/provider missing from seed: %#v", seed.Leader) + } +} + +func TestEnsureKeybindingsFileCreatesOnce(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "keybindings.json") + seed := DefaultKeybindingsFile([]string{"/model", "/help"}) + if err := EnsureKeybindingsFile(path, seed); err != nil { + t.Fatalf("Ensure: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Mutate file, ensure again must not overwrite. + if err := os.WriteFile(path, []byte(`{"leaderKey":"alt+x","leader":{}}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := EnsureKeybindingsFile(path, seed); err != nil { + t.Fatalf("Ensure again: %v", err) + } + raw2, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(raw2) == string(raw) { + t.Fatal("expected user file to remain after second ensure") + } + if !strings.Contains(string(raw2), `"leaderKey":"alt+x"`) && !strings.Contains(string(raw2), `"leaderKey": "alt+x"`) { + t.Fatalf("user content clobbered: %s", raw2) + } +} + +func TestEnsureKeybindingsBesideConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + SetKeybindingsPrimarySlashes([]string{"/model", "/help"}) + t.Cleanup(func() { SetKeybindingsPrimarySlashes(nil) }) + if err := EnsureKeybindingsBesideConfig(configPath); err != nil { + t.Fatal(err) + } + kbPath := KeybindingsPathBeside(configPath) + file, err := LoadKeybindingsFile(kbPath) + if err != nil { + t.Fatal(err) + } + if file.Leader["/model"] != "m" || file.Leader["/help"] != "" { + t.Fatalf("unexpected seed: %#v", file.Leader) + } +} + +func TestWriteConfigFileSeedsKeybindings(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + SetKeybindingsPrimarySlashes([]string{"/model", "/theme"}) + t.Cleanup(func() { SetKeybindingsPrimarySlashes(nil) }) + if err := writeConfigFile(configPath, FileConfig{}); err != nil { + t.Fatal(err) + } + kbPath := KeybindingsPathBeside(configPath) + if _, err := os.Stat(kbPath); err != nil { + t.Fatalf("expected sibling keybindings.json: %v", err) + } +} + +func TestResolveKeybindingsOverlay(t *testing.T) { + defaults := DefaultKeybindingsFile([]string{"/model", "/clear", "/theme"}) + user := KeybindingsFile{ + LeaderKey: "alt+space", + Leader: map[string]string{ + "/theme": "m", + "/clear": "", + }, + } + project := KeybindingsFile{ + Leader: map[string]string{ + "/theme": "t", + }, + } + got := ResolveKeybindings(defaults, user, project) + if got.LeaderKey != "alt+space" { + t.Fatalf("LeaderKey = %q", got.LeaderKey) + } + if got.Leader["/theme"] != "t" { + t.Fatalf("/theme = %q, want project t", got.Leader["/theme"]) + } + if got.Leader["/clear"] != "" { + t.Fatalf("/clear should be unbound, got %q", got.Leader["/clear"]) + } + if got.Leader["/model"] != "m" { + t.Fatalf("/model default lost: %q", got.Leader["/model"]) + } +} + +func TestLoadKeybindingsMissingIsEmpty(t *testing.T) { + file, err := LoadKeybindingsFile(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Fatal(err) + } + if file.LeaderKey != "" || len(file.Leader) != 0 { + t.Fatalf("unexpected file: %#v", file) + } +} + +func TestLoadKeybindingsCorrupt(t *testing.T) { + path := filepath.Join(t.TempDir(), "keybindings.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadKeybindingsFile(path) + if err == nil { + t.Fatal("want parse error") + } +} + +func TestKeybindingsJSONRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "keybindings.json") + seed := DefaultKeybindingsFile([]string{"/model", "/help"}) + if err := writeKeybindingsFile(path, seed); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var decoded KeybindingsFile + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.LeaderKey != seed.LeaderKey || decoded.Leader["/model"] != "m" { + t.Fatalf("round-trip mismatch: %#v", decoded) + } +} + +// Concurrent Ensure on a missing path must leave exactly one valid file and +// never report an error when another process wins the create race. +func TestEnsureKeybindingsFileConcurrentCreate(t *testing.T) { + path := filepath.Join(t.TempDir(), "keybindings.json") + seed := DefaultKeybindingsFile([]string{"/model", "/help", "/theme"}) + + const n = 32 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + errs <- EnsureKeybindingsFile(path, seed) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent Ensure: %v", err) + } + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var decoded KeybindingsFile + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("concurrent create left invalid JSON: %v\n%s", err, raw) + } + if decoded.LeaderKey != DefaultLeaderKey || decoded.Leader["/model"] != "m" { + t.Fatalf("unexpected seed after concurrent create: %#v", decoded) + } + + // User edits must survive concurrent re-ensure (O_EXCL no-op, not rename). + custom := []byte(`{"leaderKey":"alt+x","leader":{"/model":"z"}}` + "\n") + if err := os.WriteFile(path, custom, 0o600); err != nil { + t.Fatal(err) + } + errs2 := make(chan error, n) + var wg2 sync.WaitGroup + for i := 0; i < n; i++ { + wg2.Add(1) + go func() { + defer wg2.Done() + // Different seed would overwrite under a racy rename; must not here. + errs2 <- EnsureKeybindingsFile(path, DefaultKeybindingsFile([]string{"/model"})) + }() + } + wg2.Wait() + close(errs2) + for err := range errs2 { + if err != nil { + t.Fatalf("concurrent Ensure on existing: %v", err) + } + } + raw2, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(raw2) != string(custom) { + t.Fatalf("concurrent Ensure overwrote user file:\n got %s\nwant %s", raw2, custom) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 718d1702f..56457362a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -142,7 +142,8 @@ type KeyBindingsConfig struct { ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"` // CycleReasoning cycles through reasoning effort levels (default: ctrl+t). CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"` - // TogglePlan toggles the plan panel expansion (default: ctrl+p). + // TogglePlan toggles the plan panel expansion (default: ctrl+y). + // Ctrl+P/N are reserved for modal nav; Ctrl+G is reserved as Esc. TogglePlan KeyBindingDef `json:"togglePlan,omitempty"` // ToggleSidebar toggles the right context sidebar (default: ctrl+b). ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"` diff --git a/internal/config/writer.go b/internal/config/writer.go index 34af3027a..dc554438a 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -775,5 +775,7 @@ func writeConfigFile(path string, cfg FileConfig) error { if err := os.Rename(tmpPath, path); err != nil { return fmt.Errorf("write config %s: %w", path, err) } + // Best-effort: seed sibling keybindings.json when missing (never overwrite). + _ = EnsureKeybindingsBesideConfig(path) return nil } diff --git a/internal/tui/binding_test.go b/internal/tui/binding_test.go index 2a8df4476..d2ad6f686 100644 --- a/internal/tui/binding_test.go +++ b/internal/tui/binding_test.go @@ -217,7 +217,7 @@ func TestConfigToBindingPipeline(t *testing.T) { ToggleDetailed: "option+o", ToggleMouse: "ctrl+e", CycleReasoning: "ctrl+t", - TogglePlan: "ctrl+p", + TogglePlan: "ctrl+y", ToggleSidebar: "option+b", } @@ -231,7 +231,7 @@ func TestConfigToBindingPipeline(t *testing.T) { {"toggleDetailed", bindings.toggleDetailed, "Alt+O"}, {"toggleMouse", bindings.toggleMouse, "Ctrl+E"}, {"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"}, - {"togglePlan", bindings.togglePlan, "Ctrl+P"}, + {"togglePlan", bindings.togglePlan, "Ctrl+Y"}, {"toggleSidebar", bindings.toggleSidebar, "Alt+B"}, } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index db1fad80f..4296b3f17 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -426,6 +426,16 @@ func listCommandNames() []string { return names } +// PrimarySlashNames returns every primary builtin slash (no aliases). Used to +// seed keybindings.json with the full command inventory. +func PrimarySlashNames() []string { + names := make([]string, 0, len(commandDefinitions)) + for _, command := range commandDefinitions { + names = append(names, command.name) + } + return names +} + func formatCommandHelpLines() []string { return formatGroupedCommandHelpLines() } diff --git a/internal/tui/input_compat.go b/internal/tui/input_compat.go index 547d9c119..1632376d7 100644 --- a/internal/tui/input_compat.go +++ b/internal/tui/input_compat.go @@ -46,6 +46,12 @@ func keyBackspace(msg tea.KeyMsg) bool { return keyIs(msg, tea.KeyBackspace) || keyCtrl(msg, 'h') } +// keyEsc reports Escape or Ctrl+G (emacs abort). Ctrl+G is reserved and not +// assignable as a user shortcut. +func keyEsc(msg tea.KeyMsg) bool { + return keyIs(msg, tea.KeyEsc) || keyCtrl(msg, 'g') +} + func mouseEvent(msg tea.MouseMsg) tea.Mouse { return msg.Mouse() } diff --git a/internal/tui/input_compat_test.go b/internal/tui/input_compat_test.go index 4e6fb9822..313fd4406 100644 --- a/internal/tui/input_compat_test.go +++ b/internal/tui/input_compat_test.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "testing" tea "charm.land/bubbletea/v2" ) @@ -83,3 +84,15 @@ func renderContent(rendered any) string { return fmt.Sprint(v) } } + +func TestKeyEscMatchesEscAndCtrlG(t *testing.T) { + if !keyEsc(testKey(tea.KeyEsc)) { + t.Fatal("keyEsc must match Esc") + } + if !keyEsc(testKeyCtrl('g')) { + t.Fatal("keyEsc must match Ctrl+G") + } + if keyEsc(testKeyCtrl('c')) { + t.Fatal("keyEsc must not match Ctrl+C") + } +} diff --git a/internal/tui/keybinding_help.go b/internal/tui/keybinding_help.go index c8075b1c8..2bfe1c0ba 100644 --- a/internal/tui/keybinding_help.go +++ b/internal/tui/keybinding_help.go @@ -1,5 +1,5 @@ // keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a -// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat, +// rich set of chord bindings (Ctrl+T effort, Ctrl+Y plan, drill-in subchat, // Shift+Tab permission mode, …) that are otherwise invisible — only learnable // by reading the source. A single-key `?` overlay (opened on an empty composer) // lists them grouped, so the keymap is discoverable the way the reference TUIs @@ -37,10 +37,10 @@ func (m model) buildKeybindingGroups() []keybindingGroup { bindings: []keybinding{ {"Enter", "send the message"}, {"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"}, - {"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"}, + {"Esc / Ctrl+G (\u00d72)", "cancel the run / dismiss a popup / clear the input"}, {"Ctrl+C", "cancel the run, then quit"}, - {"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"}, - {"Ctrl+X ?", "list every Ctrl+X slash shortcut"}, + {m.leaderKeyLabel() + " then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"}, + {m.leaderKeyLabel() + " ?", "list every " + m.leaderKeyLabel() + " slash shortcut"}, {"?", "show this help (on an empty input)"}, }, }, @@ -49,7 +49,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup { bindings: []keybinding{ {labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"}, {"Shift+Tab", "cycle permission mode (auto \u2194 ask)"}, - {labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"}, + {labelOr(m.keyBindings.togglePlan, "Ctrl+Y"), "expand / collapse the plan panel (when no menu is open)"}, }, }, { diff --git a/internal/tui/keybinding_help_test.go b/internal/tui/keybinding_help_test.go index e6b056f25..e530141fc 100644 --- a/internal/tui/keybinding_help_test.go +++ b/internal/tui/keybinding_help_test.go @@ -41,6 +41,7 @@ func TestHelpOverlayClosesOnQuestionMarkAndEsc(t *testing.T) { }{ {"question-mark", testKeyText("?")}, {"esc", testKey(tea.KeyEsc)}, + {"ctrl+g", testKeyCtrl('g')}, {"q", testKeyText("q")}, {"enter", testKey(tea.KeyEnter)}, } { @@ -77,7 +78,7 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) { for _, want := range []string{ "Keyboard Shortcuts", "Ctrl+T", "cycle reasoning effort", - "Shift+Tab", "Ctrl+P", "Ctrl+O", + "Shift+Tab", "Ctrl+Y", "Ctrl+O", "drill into its sub-session", "Ctrl+X then letter", "/model", keybindingHelpFooter, @@ -227,7 +228,7 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) { m.keyBindings.toggleMouse = parseBinding("ctrl+m") // Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never // reaches configurable global bindings. - m.keyBindings.toggleSidebar = parseBinding("ctrl+y") + m.keyBindings.toggleSidebar = parseBinding("ctrl+l") if !m.sidebarToggleAllowed() { t.Fatal("sidebar toggle should be allowed") } @@ -248,10 +249,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) { } initialSidebar := next.sidebarHidden - updated, _ = next.Update(testKeyCtrl('y')) + updated, _ = next.Update(testKeyCtrl('l')) next = updated.(model) if next.sidebarHidden == initialSidebar { - t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer") + t.Fatal("remapped Ctrl+L toggleSidebar binding should still fire with a non-empty composer") } if next.composerValue() != "hello" { t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue()) diff --git a/internal/tui/keybindings.go b/internal/tui/keybindings.go index f9e841bdf..91cfe75f6 100644 --- a/internal/tui/keybindings.go +++ b/internal/tui/keybindings.go @@ -339,6 +339,7 @@ var reservedBindings = []struct { }{ {parseBinding("ctrl+c"), "cancel / exit"}, {parseBinding("esc"), "cancel / close"}, + {parseBinding("ctrl+g"), "escape / cancel (emacs abort)"}, {parseBinding("enter"), "submit"}, {parseBinding("shift+tab"), "cycle permission mode"}, {parseBinding("tab"), "navigation / completion"}, @@ -347,6 +348,8 @@ var reservedBindings = []struct { {parseBinding("down"), "history/navigation"}, {parseBinding("pgup"), "transcript scroll"}, {parseBinding("pgdown"), "transcript scroll"}, + {parseBinding("ctrl+p"), "previous item in menus"}, + {parseBinding("ctrl+n"), "next item in menus"}, {parseBinding("ctrl+f"), "favorite model (in the /model picker)"}, {parseBinding("?"), "help overlay"}, } @@ -366,7 +369,7 @@ func sanitizeKeyBindings(b keyBindings) (keyBindings, []string) { {"toggleDetailed", &b.toggleDetailed, parseBinding("ctrl+o")}, {"toggleMouse", &b.toggleMouse, parseBinding("ctrl+e")}, {"cycleReasoning", &b.cycleReasoning, parseBinding("ctrl+t")}, - {"togglePlan", &b.togglePlan, parseBinding("ctrl+p")}, + {"togglePlan", &b.togglePlan, parseBinding("ctrl+y")}, {"toggleSidebar", &b.toggleSidebar, parseBinding("ctrl+b")}, } diff --git a/internal/tui/leader.go b/internal/tui/leader.go index b3420f3ae..6aaef3f0c 100644 --- a/internal/tui/leader.go +++ b/internal/tui/leader.go @@ -1,38 +1,22 @@ package tui import ( + "fmt" + "sort" + "strings" "time" "unicode" tea "charm.land/bubbletea/v2" ) -// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord -// cancels itself. Short enough to not feel sticky; long enough to find the -// second key without racing. +// leaderTimeout is how long the leader prefix waits for a follow-up key before +// the chord cancels itself. Short enough to not feel sticky; long enough to +// find the second key without racing. const leaderTimeout = 2 * time.Second -// leaderCommandByKey maps the second key of a Ctrl+X chord to a builtin slash -// command. Case-sensitive so m/M and p/P stay distinct. Only bare commands (no -// args) — same as typing the slash and pressing Enter with an empty argument. -var leaderCommandByKey = map[rune]string{ - 'm': "/model", - 'p': "/provider", - 'P': "/plan", - 'M': "/stt-model", - 'v': "/voice", - 'c': "/clear", - 'C': "/context", - 's': "/stop", - 'i': "/image", - 'r': "/resume", - 'u': "/rewind", - 't': "/tools", - 'R': "/retry", -} - // leaderExpiredMsg clears leader-pending when the follow-up window elapses. -// seq must match m.leaderSeq or the tick is stale (a later Ctrl+X re-armed). +// seq must match m.leaderSeq or the tick is stale (a later leader re-armed). type leaderExpiredMsg struct { seq int } @@ -62,15 +46,15 @@ func (m model) clearLeader() model { // (?), or cancels the chord. Ctrl+C is handled before this is called so // exit/cancel still works. func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Second Ctrl+X cancels (does not re-arm). - if keyCtrl(msg, 'x') { + // Second press of the same leader key cancels (does not re-arm). + if m.matchesLeaderKey(msg) { return m.clearLeader(), nil } - if keyIs(msg, tea.KeyEsc) { + if keyEsc(msg) { return m.clearLeader(), nil } - // Ctrl+X ? opens the full leader-chord map (discoverability for every wired - // slash shortcut). Not a letter binding, so handle before leaderSecondKey. + // Leader + ? opens the full leader-chord map. Not a letter binding, so + // handle before leaderSecondKey. if keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) { m = m.clearLeader() m.leaderHelpOverlay = true @@ -80,7 +64,7 @@ func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { return m.clearLeader(), nil } - slash, mapped := leaderCommandByKey[key] + slash, mapped := m.leaderCommands[key] if !mapped { return m.clearLeader(), nil } @@ -88,36 +72,149 @@ func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.executeSlash(slash) } -// leaderHelpBindings is the stable, display-ordered table of every wired -// Ctrl+X chord. Keep in sync with leaderCommandByKey. -func leaderHelpBindings() []keybinding { - return []keybinding{ - {"Ctrl+X m", "open /model"}, - {"Ctrl+X p", "open /provider"}, - {"Ctrl+X P", "run /plan"}, - {"Ctrl+X M", "open /stt-model"}, - {"Ctrl+X v", "toggle /voice"}, - {"Ctrl+X c", "run /clear"}, - {"Ctrl+X C", "run /context"}, - {"Ctrl+X s", "run /stop"}, - {"Ctrl+X i", "run /image"}, - {"Ctrl+X r", "open /resume"}, - {"Ctrl+X u", "run /rewind"}, - {"Ctrl+X t", "run /tools"}, - {"Ctrl+X R", "run /retry"}, - {"Ctrl+X ?", "show this list"}, - } -} - -const leaderHelpFooter = "? or Esc to close \u00b7 letter after Ctrl+X runs the command" - -// renderLeaderHelpLines builds the body of the Ctrl+X ? overlay — same shape as -// renderKeybindingHelpLines (group title + key rows + footer) so the two modals -// share layout and column alignment. -func renderLeaderHelpLines(innerWidth int) []string { +// matchesLeaderKey reports whether msg is the resolved leader prefix. +// Ctrl-letter leaders also match the C0 control-byte form some terminals emit +// without ModCtrl (e.g. Ctrl+X as Code 0x18), matching the built-in keyCtrl +// fallback path for default bindings. +func (m model) matchesLeaderKey(msg tea.KeyMsg) bool { + if m.leaderKey.isZero() { + return ctrlLetterMatches(msg, 'x') + } + if m.leaderKey.Matcher()(msg) { + return true + } + // Custom ctrl+letter only (no alt/shift/cmd): accept control-byte form. + if m.leaderKey.ctrl && !m.leaderKey.alt && !m.leaderKey.shift && !m.leaderKey.cmd && + m.leaderKey.code >= 'a' && m.leaderKey.code <= 'z' { + return ctrlLetterByte(msg, m.leaderKey.code) + } + return false +} + +// ctrlLetterMatches is true for Ctrl+letter via ModCtrl or raw C0 control byte. +func ctrlLetterMatches(msg tea.KeyMsg, letter rune) bool { + if letter < 'a' || letter > 'z' { + return false + } + if keyCtrl(msg, letter) { + return true + } + return ctrlLetterByte(msg, letter) +} + +// ctrlLetterByte reports the terminal control-byte encoding of Ctrl+letter +// (Ctrl+A = 0x01 … Ctrl+Z = 0x1A) with no modifier flags set. +func ctrlLetterByte(msg tea.KeyMsg, letter rune) bool { + if letter < 'a' || letter > 'z' { + return false + } + return keyCode(msg) == (letter-'a'+1) && msg.Key().Mod == 0 +} + +// leaderPendingHint is the faint status line while a leader chord is armed. +// Examples come from the resolved leaderCommands map so remaps/unbindings show +// correctly; ? list and Esc cancel stay fixed. +func (m model) leaderPendingHint() string { + label := m.leaderKeyLabel() + examples := leaderPendingExamples(m.leaderCommands, 2) + if len(examples) == 0 { + return fmt.Sprintf("%s — await shortcut (? list · Esc cancel)", label) + } + return fmt.Sprintf("%s — await shortcut (%s · ? list · Esc cancel)", label, strings.Join(examples, " · ")) +} + +// leaderPendingExamples returns up to n "letter name" snippets from commands, +// sorted the same way as the leader help table (stable, lowercase-first). +func leaderPendingExamples(commands map[rune]string, n int) []string { + if n <= 0 || len(commands) == 0 { + return nil + } + letters := make([]rune, 0, len(commands)) + for r := range commands { + letters = append(letters, r) + } + sort.Slice(letters, func(i, j int) bool { + a, b := letters[i], letters[j] + al, bl := unicode.ToLower(a), unicode.ToLower(b) + if al != bl { + return al < bl + } + return a < b + }) + if len(letters) > n { + letters = letters[:n] + } + out := make([]string, 0, len(letters)) + for _, r := range letters { + slash := commands[r] + name := strings.TrimPrefix(slash, "/") + if name == "" { + name = slash + } + out = append(out, fmt.Sprintf("%c %s", r, name)) + } + return out +} + +// leaderHelpBindings builds the display-ordered table of every assigned leader +// chord from the resolved map. +func leaderHelpBindings(label string, commands map[rune]string) []keybinding { + if label == "" { + label = "Ctrl+X" + } + letters := make([]rune, 0, len(commands)) + for r := range commands { + letters = append(letters, r) + } + sort.Slice(letters, func(i, j int) bool { + a, b := letters[i], letters[j] + // Lowercase before uppercase for the same base; then by rune. + al, bl := unicode.ToLower(a), unicode.ToLower(b) + if al != bl { + return al < bl + } + return a < b + }) + out := make([]keybinding, 0, len(letters)+1) + for _, r := range letters { + slash := commands[r] + out = append(out, keybinding{ + keys: fmt.Sprintf("%s %c", label, r), + desc: leaderHelpDesc(slash), + }) + } + out = append(out, keybinding{ + keys: fmt.Sprintf("%s ?", label), + desc: "show this list", + }) + return out +} + +func leaderHelpDesc(slash string) string { + switch slash { + case "/model", "/provider", "/stt-model", "/resume", "/image": + return "open " + slash + case "/voice": + return "toggle " + slash + default: + return "run " + slash + } +} + +func leaderHelpFooter(label string) string { + if label == "" { + label = "Ctrl+X" + } + return "? or Esc to close \u00b7 letter after " + label + " runs the command" +} + +// renderLeaderHelpLines builds the body of the leader ? overlay — same shape as +// renderKeybindingHelpLines so the two modals share layout and column alignment. +func (m model) renderLeaderHelpLines(innerWidth int) []string { + label := m.leaderKeyLabel() groups := []keybindingGroup{{ title: "Slash commands", - bindings: leaderHelpBindings(), + bindings: leaderHelpBindings(label, m.leaderCommands), }} keyColumn := keybindingKeyColumnWidth(groups) lines := make([]string, 0, 32) @@ -131,17 +228,17 @@ func renderLeaderHelpLines(innerWidth int) []string { } } lines = append(lines, "") - lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter)) + lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter(label))) return lines } -// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the -// general ? keyboard-shortcut overlay: same width helper, border style -// (zeroTheme.line), panel fill, and centered block. +// renderLeaderHelpOverlay frames the leader chord map like the general ? +// keyboard-shortcut overlay. func (m model) renderLeaderHelpOverlay(width int) string { overlayWidth := keybindingHelpOverlayWidth(width) - lines := renderLeaderHelpLines(overlayWidth - 4) - block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel) + lines := m.renderLeaderHelpLines(overlayWidth - 4) + title := m.leaderKeyLabel() + " Shortcuts" + block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.line, zeroTheme.panel) return centerRenderedBlock(block, width) } diff --git a/internal/tui/leader_config.go b/internal/tui/leader_config.go new file mode 100644 index 000000000..ff28622e8 --- /dev/null +++ b/internal/tui/leader_config.go @@ -0,0 +1,303 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/Gitlawb/zero/internal/config" +) + +// resolvedLeader is the runtime leader prefix + inverted letter→slash map. +type resolvedLeader struct { + key parsedBinding + commands map[rune]string + notices []string +} + +// defaultLeaderKeyBinding is the built-in Ctrl+X leader. +func defaultLeaderKeyBinding() parsedBinding { + return parseBinding(config.DefaultLeaderKey) +} + +// resolveLeaderConfig validates and inverts a merged KeybindingsFile into +// dispatch-ready fields. Soft-fails to defaults with notices. +// toggles may be sanitized (conflicting bindings cleared to zero) so the chosen +// leader key never shadows a global toggle; the returned keyBindings is what +// the model should use for dispatch. +func resolveLeaderConfig(file config.KeybindingsFile, toggles keyBindings) (resolvedLeader, keyBindings) { + out := resolvedLeader{ + key: defaultLeaderKeyBinding(), + commands: defaultLeaderCommands(), + } + + // --- leaderKey --- + var preferred parsedBinding + var rejectReason string + if raw := strings.TrimSpace(file.LeaderKey); raw != "" { + parsed := parseBinding(raw) + switch { + case parsed.isZero(): + rejectReason = fmt.Sprintf("keybindings.leaderKey (%q) is invalid", raw) + case !leaderKeyHasModifier(parsed): + rejectReason = fmt.Sprintf("keybindings.leaderKey (%q) must include a modifier (ctrl/alt/cmd)", raw) + case leaderKeyReserved(parsed): + rejectReason = fmt.Sprintf("keybindings.leaderKey (%s) conflicts with a reserved shortcut", parsed.Label()) + case leaderKeyConflictsToggle(parsed, toggles): + rejectReason = fmt.Sprintf("keybindings.leaderKey (%s) conflicts with a global toggle binding", parsed.Label()) + default: + preferred = parsed + } + } + if !preferred.isZero() { + out.key = preferred + } + + // Ensure the chosen key is usable even when the default itself collides with + // a remapped toggle (or when the preferred key was rejected and fallback + // would still be unsafe). + var ensureNotices []string + out.key, toggles, ensureNotices = ensureUsableLeaderKey(out.key, toggles) + if rejectReason != "" { + out.notices = append(out.notices, fmt.Sprintf( + "%s; using %s instead.", rejectReason, out.key.Label())) + } + out.notices = append(out.notices, ensureNotices...) + + // --- leader map (slash → letter) --- + if len(file.Leader) == 0 { + return out, toggles + } + + // Start from defaults, overlay file entries. + slashToLetter := defaultLeaderSlashToLetter() + for slash, letter := range file.Leader { + slash = strings.TrimSpace(slash) + if slash == "" { + continue + } + if !strings.HasPrefix(slash, "/") || strings.ContainsAny(slash, " \t") { + out.notices = append(out.notices, fmt.Sprintf( + "keybindings.leader %q ignored: must be a bare slash command.", slash)) + continue + } + letter = strings.TrimSpace(letter) + if letter == "" { + delete(slashToLetter, slash) + continue + } + if strings.EqualFold(slash, "/edit") { + out.notices = append(out.notices, "keybindings.leader /edit is not allowed (replaces the composer draft); ignored.") + continue + } + if _, ok := resolveCommand(slash); !ok { + out.notices = append(out.notices, fmt.Sprintf( + "keybindings.leader %q ignored: unknown slash command.", slash)) + continue + } + if utf8.RuneCountInString(letter) != 1 { + out.notices = append(out.notices, fmt.Sprintf( + "keybindings.leader %q ignored: letter must be one character or \"\".", slash)) + continue + } + r := []rune(letter)[0] + if r == '?' { + out.notices = append(out.notices, fmt.Sprintf( + "keybindings.leader %q ignored: '?' is reserved for the chord-map overlay.", slash)) + continue + } + slashToLetter[slash] = r + } + + // Invert with stable slash order so duplicate letters drop deterministically. + slashes := make([]string, 0, len(slashToLetter)) + for slash := range slashToLetter { + slashes = append(slashes, slash) + } + sort.Strings(slashes) + + commands := make(map[rune]string, len(slashes)) + claimed := map[rune]string{} + for _, slash := range slashes { + r := slashToLetter[slash] + if prev, ok := claimed[r]; ok { + out.notices = append(out.notices, fmt.Sprintf( + "keybindings.leader %q and %q both use letter %q; keeping %q.", + prev, slash, string(r), prev)) + continue + } + claimed[r] = slash + commands[r] = slash + } + out.commands = commands + return out, toggles +} + +// leaderKeyFallbacks are tried (after the built-in default) when the preferred +// leader key and the default are both unusable even after clearing colliding +// toggles. All include a modifier and avoid reservedBindings. +var leaderKeyFallbacks = []string{ + config.DefaultLeaderKey, // ctrl+x + "alt+x", + "ctrl+\\", + "alt+space", +} + +// ensureUsableLeaderKey returns a leader binding that is not reserved and does +// not collide with any effective global toggle. When the only way to free the +// built-in default is to drop a remapped toggle that claimed it, that toggle is +// cleared (zero → its own default) with a notice. +func ensureUsableLeaderKey(preferred parsedBinding, toggles keyBindings) (parsedBinding, keyBindings, []string) { + var notices []string + if leaderKeyUsable(preferred, toggles) { + return preferred, toggles, nil + } + + def := defaultLeaderKeyBinding() + if leaderKeyUsable(def, toggles) { + return def, toggles, nil + } + + // Default is claimed by a toggle (or somehow reserved). Clear toggles that + // use the default chord so Ctrl+X can remain the stable product default. + if cleared, names := clearTogglesMatching(toggles, def); len(names) > 0 { + toggles = cleared + notices = append(notices, fmt.Sprintf( + "keybindings leader default (%s) conflicted with %s; those toggles were reset to their defaults so the leader key stays available.", + def.Label(), strings.Join(names, ", "))) + if leaderKeyUsable(def, toggles) { + return def, toggles, notices + } + } + + // Last resort: walk a short list of safe alternatives. + for _, raw := range leaderKeyFallbacks { + cand := parseBinding(raw) + if leaderKeyUsable(cand, toggles) { + notices = append(notices, fmt.Sprintf( + "keybindings.leaderKey fell back to %s (no non-conflicting default was available).", + cand.Label())) + return cand, toggles, notices + } + } + + // Should be unreachable: still return default for dispatch stability. + notices = append(notices, fmt.Sprintf( + "keybindings.leaderKey could not find a free chord; using %s (may conflict).", + def.Label())) + return def, toggles, notices +} + +func leaderKeyUsable(p parsedBinding, toggles keyBindings) bool { + if p.isZero() || !leaderKeyHasModifier(p) { + return false + } + if leaderKeyReserved(p) || leaderKeyConflictsToggle(p, toggles) { + return false + } + return true +} + +func leaderKeyHasModifier(p parsedBinding) bool { + return p.ctrl || p.alt || p.cmd +} + +// clearTogglesMatching sets any effective toggle equal to chord back to zero +// (built-in default). Returns the updated bindings and the JSON field names cleared. +func clearTogglesMatching(toggles keyBindings, chord parsedBinding) (keyBindings, []string) { + if chord.isZero() { + return toggles, nil + } + type entry struct { + name string + binding *parsedBinding + def parsedBinding + } + entries := []entry{ + {"toggleDetailed", &toggles.toggleDetailed, parseBinding("ctrl+o")}, + {"toggleMouse", &toggles.toggleMouse, parseBinding("ctrl+e")}, + {"cycleReasoning", &toggles.cycleReasoning, parseBinding("ctrl+t")}, + {"togglePlan", &toggles.togglePlan, parseBinding("ctrl+y")}, + {"toggleSidebar", &toggles.toggleSidebar, parseBinding("ctrl+b")}, + } + var names []string + for _, e := range entries { + if effectiveBinding(*e.binding, e.def) == chord { + // Only clear when the effective chord is this one. If configured is + // zero and default matches, clearing is a no-op for storage but the + // default still matches — that case is a product conflict with a + // built-in toggle default (e.g. leader wanting ctrl+o). Skip zeroing + // defaults; caller should pick another leader key. + if e.binding.isZero() { + continue + } + *e.binding = parsedBinding{} + names = append(names, "keybindings."+e.name) + } + } + return toggles, names +} + +func defaultLeaderCommands() map[rune]string { + out := make(map[rune]string, len(config.DefaultLeaderAssignments())) + for slash, letter := range config.DefaultLeaderAssignments() { + if letter == "" { + continue + } + out[[]rune(letter)[0]] = slash + } + return out +} + +func defaultLeaderSlashToLetter() map[string]rune { + out := make(map[string]rune, len(config.DefaultLeaderAssignments())) + for slash, letter := range config.DefaultLeaderAssignments() { + if letter == "" { + continue + } + out[slash] = []rune(letter)[0] + } + return out +} + +func leaderKeyReserved(p parsedBinding) bool { + for _, reserved := range reservedBindings { + if p == reserved.binding { + return true + } + } + return false +} + +func leaderKeyConflictsToggle(p parsedBinding, toggles keyBindings) bool { + // Effective chord for each toggle: configured value or built-in default. + effective := []parsedBinding{ + effectiveBinding(toggles.toggleDetailed, parseBinding("ctrl+o")), + effectiveBinding(toggles.toggleMouse, parseBinding("ctrl+e")), + effectiveBinding(toggles.cycleReasoning, parseBinding("ctrl+t")), + effectiveBinding(toggles.togglePlan, parseBinding("ctrl+y")), + effectiveBinding(toggles.toggleSidebar, parseBinding("ctrl+b")), + } + for _, e := range effective { + if !e.isZero() && e == p { + return true + } + } + return false +} + +func effectiveBinding(configured, defaultB parsedBinding) parsedBinding { + if !configured.isZero() { + return configured + } + return defaultB +} + +// leaderKeyLabel returns the display label for the resolved leader (fallback Ctrl+X). +func (m model) leaderKeyLabel() string { + if m.leaderKey.isZero() { + return defaultLeaderKeyBinding().Label() + } + return m.leaderKey.Label() +} diff --git a/internal/tui/leader_config_test.go b/internal/tui/leader_config_test.go new file mode 100644 index 000000000..2687a91a3 --- /dev/null +++ b/internal/tui/leader_config_test.go @@ -0,0 +1,216 @@ +package tui + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +func TestResolveLeaderConfigDefaults(t *testing.T) { + file := config.DefaultKeybindingsFile(PrimarySlashNames()) + got, _ := resolveLeaderConfig(file, keyBindings{}) + if got.key.Label() != "Ctrl+X" { + t.Fatalf("leader key = %q", got.key.Label()) + } + if got.commands['m'] != "/model" || got.commands['p'] != "/provider" { + t.Fatalf("default map incomplete: %#v", got.commands) + } + if len(got.notices) != 0 { + t.Fatalf("unexpected notices: %v", got.notices) + } +} + +func TestResolveLeaderConfigUnassignAndRemap(t *testing.T) { + file := config.KeybindingsFile{ + Leader: map[string]string{ + "/model": "", // free the letter before reassigning + "/theme": "m", + "/clear": "", + }, + } + // Merge onto defaults the way ResolveKeybindings would for a partial overlay. + merged := config.ResolveKeybindings(config.DefaultKeybindingsFile(PrimarySlashNames()), file, config.KeybindingsFile{}) + got, _ := resolveLeaderConfig(merged, keyBindings{}) + if got.commands['m'] != "/theme" { + t.Fatalf("m = %q, want /theme", got.commands['m']) + } + if _, ok := got.commands['c']; ok { + t.Fatal("/clear should be unbound") + } +} + +func TestResolveLeaderConfigRejectsReservedLeaderKey(t *testing.T) { + for _, key := range []string{"ctrl+p", "ctrl+n", "ctrl+g", "esc", "x"} { + got, _ := resolveLeaderConfig(config.KeybindingsFile{LeaderKey: key}, keyBindings{}) + if got.key.Label() != "Ctrl+X" { + t.Fatalf("leaderKey %q should fall back to Ctrl+X, got %s", key, got.key.Label()) + } + if len(got.notices) == 0 { + t.Fatalf("leaderKey %q should produce a notice", key) + } + if !leaderKeyUsable(got.key, keyBindings{}) { + t.Fatalf("fallback for %q must be usable, got %s", key, got.key.Label()) + } + } +} + +func TestResolveLeaderConfigFallbackNotConflictingToggle(t *testing.T) { + // Preferred leader conflicts with togglePlan; fallback Ctrl+X must still be free. + toggles := keyBindings{togglePlan: parseBinding("ctrl+y")} + got, outToggles := resolveLeaderConfig(config.KeybindingsFile{LeaderKey: "ctrl+y"}, toggles) + if got.key.Label() != "Ctrl+X" { + t.Fatalf("leader = %q, want Ctrl+X", got.key.Label()) + } + if !leaderKeyUsable(got.key, outToggles) { + t.Fatalf("fallback leader must not conflict with toggles: %s", got.key.Label()) + } + if len(got.notices) == 0 || !strings.Contains(got.notices[0], "global toggle") { + t.Fatalf("want toggle-conflict notice, got %v", got.notices) + } +} + +func TestResolveLeaderConfigSanitizesToggleClaimingDefault(t *testing.T) { + // A remapped toggle owns Ctrl+X; invalid leaderKey would naively fall back to + // Ctrl+X and shadow the toggle — clear the toggle instead. + toggles := keyBindings{toggleSidebar: parseBinding("ctrl+x")} + got, outToggles := resolveLeaderConfig(config.KeybindingsFile{LeaderKey: "ctrl+p"}, toggles) + if got.key.Label() != "Ctrl+X" { + t.Fatalf("leader = %q, want Ctrl+X after freeing default", got.key.Label()) + } + if !outToggles.toggleSidebar.isZero() { + t.Fatalf("toggleSidebar should be cleared, got %s", outToggles.toggleSidebar.Label()) + } + if !leaderKeyUsable(got.key, outToggles) { + t.Fatal("leader must be usable after toggle sanitize") + } + found := false + for _, n := range got.notices { + if strings.Contains(n, "toggleSidebar") || strings.Contains(n, "conflicted with") { + found = true + } + } + if !found { + t.Fatalf("want notice about clearing toggle, got %v", got.notices) + } +} + +func TestResolveLeaderConfigEmptyLeaderKeyStillAvoidsToggleCollision(t *testing.T) { + // No leaderKey in file → default Ctrl+X, but toggle already claims it. + toggles := keyBindings{toggleDetailed: parseBinding("ctrl+x")} + got, outToggles := resolveLeaderConfig(config.KeybindingsFile{}, toggles) + if got.key.Label() != "Ctrl+X" { + t.Fatalf("leader = %q", got.key.Label()) + } + if !outToggles.toggleDetailed.isZero() { + t.Fatalf("toggleDetailed claiming Ctrl+X should be cleared, got %s", outToggles.toggleDetailed.Label()) + } + if !leaderKeyUsable(got.key, outToggles) { + t.Fatal("default leader must be usable") + } +} + +func TestResolveLeaderConfigRejectsEditAndUnknown(t *testing.T) { + file := config.KeybindingsFile{ + Leader: map[string]string{ + "/edit": "e", + "/notreal": "z", + }, + } + merged := config.ResolveKeybindings(config.DefaultKeybindingsFile(PrimarySlashNames()), file, config.KeybindingsFile{}) + got, _ := resolveLeaderConfig(merged, keyBindings{}) + if _, ok := got.commands['e']; ok { + t.Fatal("/edit must not be bound") + } + if _, ok := got.commands['z']; ok { + t.Fatal("unknown slash must not be bound") + } + if len(got.notices) < 2 { + t.Fatalf("want notices for /edit and unknown, got %v", got.notices) + } +} + +func TestResolveLeaderConfigDuplicateLetters(t *testing.T) { + file := config.KeybindingsFile{ + LeaderKey: "ctrl+x", + Leader: map[string]string{ + "/model": "m", + "/theme": "m", + "/help": "", + "/provider": "p", + }, + } + got, _ := resolveLeaderConfig(file, keyBindings{}) + // sort.Strings: /model before /theme, so /model wins. + if got.commands['m'] != "/model" { + t.Fatalf("duplicate should keep /model, got %q (notices=%v)", got.commands['m'], got.notices) + } + foundDupNotice := false + for _, n := range got.notices { + if strings.Contains(n, "both use letter") { + foundDupNotice = true + } + } + if !foundDupNotice { + t.Fatalf("want duplicate-letter notice, got %v", got.notices) + } +} + +func TestCustomLeaderKeyArmsAndCancels(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + KeybindingsFile: config.KeybindingsFile{ + LeaderKey: "alt+x", + Leader: config.DefaultLeaderAssignments(), + }, + }) + if m.leaderKeyLabel() != "Alt+X" { + t.Fatalf("label = %q", m.leaderKeyLabel()) + } + updated, _ := m.Update(testKeyAlt('x')) + next := updated.(model) + if !next.leaderPending { + t.Fatal("Alt+X should arm leader") + } + updated, _ = next.Update(testKeyAlt('x')) + next = updated.(model) + if next.leaderPending { + t.Fatal("second Alt+X should cancel") + } + // Default Ctrl+X must not arm when remapped. + m = newModel(context.Background(), Options{ + ModelName: "gpt-4o", + KeybindingsFile: config.KeybindingsFile{ + LeaderKey: "alt+x", + Leader: config.DefaultLeaderAssignments(), + }, + }) + updated, _ = m.Update(testKeyCtrl('x')) + next = updated.(model) + if next.leaderPending { + t.Fatal("Ctrl+X must not arm when leader is Alt+X") + } +} + +func TestPrimarySlashNamesCoversBuiltins(t *testing.T) { + names := PrimarySlashNames() + if len(names) < 30 { + t.Fatalf("expected full catalog, got %d", len(names)) + } + seen := map[string]bool{} + for _, n := range names { + if !strings.HasPrefix(n, "/") { + t.Fatalf("bad name %q", n) + } + if seen[n] { + t.Fatalf("duplicate %q", n) + } + seen[n] = true + } + for slash := range config.DefaultLeaderAssignments() { + if !seen[slash] { + t.Fatalf("leader default %s missing from PrimarySlashNames", slash) + } + } +} diff --git a/internal/tui/leader_test.go b/internal/tui/leader_test.go index 3fe9e96cd..4ad25e486 100644 --- a/internal/tui/leader_test.go +++ b/internal/tui/leader_test.go @@ -6,6 +6,8 @@ import ( "testing" tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/config" ) func TestLeaderArmsOnCtrlX(t *testing.T) { @@ -23,6 +25,44 @@ func TestLeaderArmsOnCtrlX(t *testing.T) { } } +func TestLeaderArmsOnCtrlXControlByte(t *testing.T) { + // Some terminals emit Ctrl+X as C0 byte 0x18 without ModCtrl. + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + msg := tea.KeyPressMsg(tea.Key{Code: 0x18}) // Ctrl+X control byte + updated, _ := m.Update(msg) + next := updated.(model) + if !next.leaderPending { + t.Fatal("Ctrl+X control-byte form should arm leader-pending") + } +} + +func TestCustomLeaderMatchesControlByte(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + KeybindingsFile: config.KeybindingsFile{ + LeaderKey: "ctrl+k", + Leader: config.DefaultLeaderAssignments(), + }, + }) + // Matcher path (Code+ModCtrl). + if !m.matchesLeaderKey(testKeyCtrl('k')) { + t.Fatal("custom ctrl+k should match ModCtrl form") + } + // Control-byte path: Ctrl+K = 0x0B. + if !m.matchesLeaderKey(tea.KeyPressMsg(tea.Key{Code: 0x0B})) { + t.Fatal("custom ctrl+k should match control-byte form") + } + // Unrelated control byte must not match. + if m.matchesLeaderKey(tea.KeyPressMsg(tea.Key{Code: 0x18})) { + t.Fatal("ctrl+k leader must not match Ctrl+X control byte") + } + // Alt leaders must not use control-byte matching. + m.leaderKey = parseBinding("alt+x") + if m.matchesLeaderKey(tea.KeyPressMsg(tea.Key{Code: 0x18})) { + t.Fatal("alt+x must not match Ctrl+X control byte") + } +} + func TestLeaderModelOpensPicker(t *testing.T) { m := newModel(context.Background(), Options{ ModelName: "gpt-4o", @@ -278,8 +318,9 @@ func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) { } func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) listed := map[string]bool{} - for _, b := range leaderHelpBindings() { + for _, b := range leaderHelpBindings(m.leaderKeyLabel(), m.leaderCommands) { // "Ctrl+X m" → last field is the letter (skip the "?" meta row for map check). parts := strings.Fields(b.keys) if len(parts) != 2 || parts[0] != "Ctrl+X" { @@ -294,13 +335,13 @@ func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) { t.Fatalf("unexpected key label %q", b.keys) } listed[string(runes[0])] = true - if _, ok := leaderCommandByKey[runes[0]]; !ok { - t.Fatalf("help lists %q but leaderCommandByKey has no entry", b.keys) + if _, ok := m.leaderCommands[runes[0]]; !ok { + t.Fatalf("help lists %q but leaderCommands has no entry", b.keys) } } - for key := range leaderCommandByKey { + for key := range m.leaderCommands { if !listed[string(key)] { - t.Fatalf("leaderCommandByKey has %q but help table omits it", string(key)) + t.Fatalf("leaderCommands has %q but help table omits it", string(key)) } } } @@ -336,3 +377,33 @@ func TestLeaderSecondKeyFromTextCapital(t *testing.T) { t.Fatalf("leaderSecondKey(m) = %q,%v, want m,true", key, ok) } } + +func TestLeaderPendingHintUsesResolvedCommands(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + // Defaults include /clear on c and /context on C — first two in sort order + // are lowercase letters before related uppercase; c clear then C context, etc. + // Prefer an explicit map for a stable assertion. + m.leaderCommands = map[rune]string{ + 't': "/theme", + 'n': "/new", + } + hint := m.leaderPendingHint() + if !strings.Contains(hint, "n new") || !strings.Contains(hint, "t theme") { + t.Fatalf("hint should list remapped commands, got %q", hint) + } + if !strings.Contains(hint, "? list") || !strings.Contains(hint, "Esc cancel") { + t.Fatalf("hint must keep list/cancel guidance, got %q", hint) + } + if strings.Contains(hint, "m model") || strings.Contains(hint, "p provider") { + t.Fatalf("hint must not hardcode default examples when remapped, got %q", hint) + } + + m.leaderCommands = nil + hint = m.leaderPendingHint() + if !strings.Contains(hint, "? list") || !strings.Contains(hint, "Esc cancel") { + t.Fatalf("empty map should still show list/cancel, got %q", hint) + } + if strings.Contains(hint, " · m ") || strings.Contains(hint, "model") { + t.Fatalf("empty map should not invent examples, got %q", hint) + } +} diff --git a/internal/tui/modal_selection_test.go b/internal/tui/modal_selection_test.go index eafc02bd2..e2d62ace3 100644 --- a/internal/tui/modal_selection_test.go +++ b/internal/tui/modal_selection_test.go @@ -91,7 +91,7 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) { } } -func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) { +func TestCtrlYTogglesPlanWhenNoModal(t *testing.T) { m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) m.plan = planPanelState{ steps: []planStep{{content: "step one", status: "pending"}}, @@ -100,10 +100,10 @@ func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) { t.Fatal("setup: plan should be non-empty") } initial := m.plan.expanded - updated, _ := m.Update(testKeyCtrl('p')) + updated, _ := m.Update(testKeyCtrl('y')) next := updated.(model) if next.plan.expanded == initial { - t.Fatal("Ctrl+P with no modal should toggle plan.expanded") + t.Fatal("Ctrl+Y with no modal should toggle plan.expanded") } } @@ -133,23 +133,25 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) { } } -func TestCtrlNNoOpWithoutModal(t *testing.T) { - m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) - m.plan = planPanelState{ - steps: []planStep{{content: "step one", status: "pending"}}, - expanded: false, - } - before := m.plan.expanded - updated, _ := m.Update(testKeyCtrl('n')) - next := updated.(model) - if next.picker != nil || next.suggestionsActive() { - t.Fatal("Ctrl+N idle must not open a modal") - } - if next.plan.expanded != before { - t.Fatal("Ctrl+N idle must not toggle the plan panel") - } - if next.composerValue() != "" { - t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue()) +func TestCtrlPNNoOpWithoutModal(t *testing.T) { + for _, key := range []rune{'n', 'p'} { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.plan = planPanelState{ + steps: []planStep{{content: "step one", status: "pending"}}, + expanded: false, + } + before := m.plan.expanded + updated, _ := m.Update(testKeyCtrl(key)) + next := updated.(model) + if next.picker != nil || next.suggestionsActive() { + t.Fatalf("Ctrl+%c idle must not open a modal", key-'a'+'A') + } + if next.plan.expanded != before { + t.Fatalf("Ctrl+%c idle must not toggle the plan panel", key-'a'+'A') + } + if next.composerValue() != "" { + t.Fatalf("Ctrl+%c idle must not type into composer, got %q", key-'a'+'A', next.composerValue()) + } } } diff --git a/internal/tui/model.go b/internal/tui/model.go index b47be5957..7ae9c1dd5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -152,12 +152,16 @@ type model struct { transcript []transcriptRow transcriptDetailed bool helpOverlay bool // the `?` keyboard-shortcut overlay is open - // leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord. + // leaderHelpOverlay is the leader+? modal listing every leader slash chord. leaderHelpOverlay bool - // leaderPending is true after Ctrl+X until a second key, Esc, or timeout - // resolves the chord (see leader.go). leaderSeq invalidates a stale tick. - leaderPending bool - leaderSeq int + // leaderPending is true after the leader key until a second key, Esc, or + // timeout resolves the chord (see leader.go). leaderSeq invalidates a stale tick. + leaderPending bool + leaderSeq int + // leaderKey / leaderCommands are the resolved leader prefix and letter→slash + // map from keybindings.json (defaults: Ctrl+X + built-in chords). + leaderKey parsedBinding + leaderCommands map[rune]string transcriptBodyHeights *transcriptBodyHeightCache input textinput.Model composer composerState @@ -772,6 +776,12 @@ func newModel(ctx context.Context, options Options) model { notifier.SetFocused(true) resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings)) + leaderFile := options.KeybindingsFile + if leaderFile.Leader == nil && strings.TrimSpace(leaderFile.LeaderKey) == "" { + // No file loaded: use code defaults (tests / callers that omit Options). + leaderFile = config.DefaultKeybindingsFile(PrimarySlashNames()) + } + resolvedLeader, resolvedKeyBindings := resolveLeaderConfig(leaderFile, resolvedKeyBindings) m := model{ ctx: ctx, @@ -814,6 +824,8 @@ func newModel(ctx context.Context, options Options) model { reasoningEffort: options.ReasoningEffort, responseStyle: defaultedResponseStyle(options.ResponseStyle), keyBindings: resolvedKeyBindings, + leaderKey: resolvedLeader.key, + leaderCommands: resolvedLeader.commands, themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme), hasDarkBg: true, userAgent: options.UserAgent, @@ -855,6 +867,9 @@ func newModel(ctx context.Context, options Options) model { for _, warning := range keyBindingWarnings { m = m.appendSystemNotice(warning) } + for _, notice := range resolvedLeader.notices { + m = m.appendSystemNotice(notice) + } return m } @@ -1227,6 +1242,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyPressMsg: + // Ctrl+G is a reserved Escape synonym (emacs abort). Normalize early so + // every Esc path (setup, leader, pickers, cancel confirm, …) accepts it. + if keyCtrl(msg, 'g') { + msg = tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}) + } // Paste-detection timing trackers. MUST run before any early return // so burst counting stays accurate regardless of which branch fires. now := m.now() @@ -1292,13 +1312,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // Leader owns every keystroke until resolved; never type into the composer. return m.handleLeaderKey(msg) } - if keyCtrl(msg, 'x') && m.canArmLeader() { + if m.matchesLeaderKey(msg) && m.canArmLeader() { return m.armLeader() } - // Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the - // switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P - // falls through to that binding; idle Ctrl+N is a reserved no-op so it - // never reaches remapped configurable bindings (e.g. toggleSidebar). + // Emacs Ctrl+P / Ctrl+N move selection in open menus. Both chords are + // reserved for list navigation and are never assignable as shortcuts. + // Idle (no modal): reserved no-op so they never reach remapped bindings. if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) { delta := 1 if keyCtrl(msg, 'p') { @@ -1307,9 +1326,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if next, cmd, ok := m.moveModalSelection(delta); ok { return next, cmd } - if keyCtrl(msg, 'n') { - return m, nil - } + return m, nil } switch { case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }): @@ -1571,9 +1588,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.noBlockingModal() { return m.cycleReasoningEffort() } - case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }): - // Ctrl+P toggles the plan panel expansion (collapse/expand step list). - // Modal selection is handled before this switch so menus win over toggle. + case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'y') }): + // Ctrl+Y toggles the plan panel expansion (collapse/expand step list). + // Ctrl+P/N are reserved for modal nav; Ctrl+G is reserved as Esc. if m.noBlockingModal() && !m.plan.isEmpty() { m.plan.expanded = !m.plan.expanded return m, nil @@ -2784,7 +2801,7 @@ func (m model) composerIdleHint() string { // Leader-pending is always shown (even mid-type) so the user knows the next // key is a chord, not composer input. if m.leaderPending { - return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)") + return zeroTheme.faint.Render(m.leaderPendingHint()) } // Managed (alt-screen) mode only: inline mode prints to native scrollback where // this footer row isn't a stable surface. Hidden while typing, during a run, in @@ -2804,9 +2821,9 @@ func (m model) composerIdleHint() string { case tierNarrow: hint = "? shortcuts" case tierMedium: - hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey) + hint = fmt.Sprintf("? shortcuts · %s cmds · %s sidebar", m.leaderKeyLabel(), sidebarKey) default: - hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey) + hint = fmt.Sprintf("? shortcuts · %s cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", m.leaderKeyLabel(), sidebarKey, detailKey, mouseKey) } return zeroTheme.faint.Render(hint) } diff --git a/internal/tui/options.go b/internal/tui/options.go index 409110704..22008d7a8 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -79,6 +79,10 @@ type Options struct { // KeyBindingsConfig means "use built-in defaults" for each action. KeyBindings config.KeyBindingsConfig + // KeybindingsFile is the resolved keybindings.json (leader prefix + slash + // chords). Zero value uses built-in Ctrl+X + default letter map. + KeybindingsFile config.KeybindingsFile + // STT configures speech-to-text dictation (§ docs/dictation.md). STT config.STTConfig // BuildDictationTranscriber constructs the transcriber for the current STT diff --git a/internal/tui/plan_panel.go b/internal/tui/plan_panel.go index 088a38a58..cfe403d99 100644 --- a/internal/tui/plan_panel.go +++ b/internal/tui/plan_panel.go @@ -284,7 +284,7 @@ func (m model) renderPlanPanel(width int) string { // composer. It returns the full panel when it fits within maxHeight, otherwise // a one-line summary (so a long plan can't crowd out the transcript or the // input). maxHeight <= 0 means "no budget" and always uses the summary line. -// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the +// Returns "" when the plan is not visible. Ctrl+Y (expand) still forces the // full list via m.plan.expanded, but the height budget always wins to keep the // composer on screen. func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {