From c07342313c6c9912c476fae00253c225884480a1 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 17:22:46 -0300 Subject: [PATCH] =?UTF-8?q?Shortcuts:=20=E2=8C=98=E2=87=A71=20to=20?= =?UTF-8?q?=E2=8C=98=E2=87=A79=20select=20a=20project=20and=20its=20orches?= =?UTF-8?q?trator=20(#744,=20D289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DECISIONS.md | 1 + docs/spec/KeyHints.md | 1 + docs/spec/ProjectRow.md | 1 + docs/spec/README.md | 2 + internal/app/shortcut/shortcut.go | 79 +++++++++++++++---- internal/app/shortcut/shortcut_test.go | 40 ++++++++++ internal/app/shortcuts.go | 5 ++ internal/app/shortcuts_test.go | 27 ++++++- internal/app/sidebar/sidebar.go | 7 +- .../app/sidebar/sidebar_projectkeys_test.go | 53 +++++++++++++ internal/app/sidebar/spaces.go | 9 +++ 11 files changed, 205 insertions(+), 20 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1e23747a..69dbf9e3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -263,3 +263,4 @@ replaces it. | D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI (replaced by D288); the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). | | D287 | 2026-11-11 | A fenced `contr-seq` block in Claude's answer draws as a sequence diagram (#696, PR 2; ChatView.md, Diagrams), in package `internal/app/diagram` like the flow (D284): actors as columns with a dashed lifeline, one line per message (`A -> B: text`, `A --> B: text` for a dashed reply, `A -> A: text` to itself), tokens only and the chat text size. A block that does not parse or is too large (more than 6 actors or 16 messages, an actor over 28 or a message over 40 characters, wider than the column) shows as its code block. The `chat-writing` skill tells Claude when and how to write a `contr-flow` and a `contr-seq` block, and that other diagrams stay as text. | Issue #696, step 2. | | D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). | +| D289 | 2026-11-11 | ⌘⇧1 to ⌘⇧8 select the first to the eighth project in the sidebar's order and ⌘⇧9 the last (#744), as ⌘1 to ⌘9 do for tabs (D190). The selection lands as ⌘⌥← / ⌘⌥→ land (D248): the project's orchestrator, else its main checkout; a number with no project does nothing. MyGo matches the physical key with exact modifiers, so ⌘⇧1 arrives as Key1 with ⌘⇧, not as "!", and does not fire ⌘1. A project row has one key chip: the rows before and after the open project keep `⌥←` / `⌥→`, and the other rows show `⇧1` to `⇧8`, and `⇧9` on the last past the eighth. | diff --git a/docs/spec/KeyHints.md b/docs/spec/KeyHints.md index 008dc2da..f18c32fe 100644 --- a/docs/spec/KeyHints.md +++ b/docs/spec/KeyHints.md @@ -27,6 +27,7 @@ leave it. No chip is clipped. The icon column (D128) follows the same rule. | Icon column: Open projects, New orchestrator | `O`, `N` | the icon | | Agent rows above and below the selected row | `⌥↑`, `⌥↓` | the status glyph (also of a worker row and in the icon column) | | Project rows before and after the open project | `⌥←`, `⌥→`; with two projects the other row shows `⌥→` | the folder icon | +| Other project rows | `⇧1` to `⇧8` for the first eight projects; `⇧9` on the last project past them (D289) | the folder icon | | Questions notch | `J` | the dot | | Chat \| Terminal switch | `E` | the switch (it has no icon) | diff --git a/docs/spec/ProjectRow.md b/docs/spec/ProjectRow.md index 546868ca..4f365b3f 100644 --- a/docs/spec/ProjectRow.md +++ b/docs/spec/ProjectRow.md @@ -58,6 +58,7 @@ Design system page: component ProjectRow. Shared rules: [README.md](README.md). - ↑ ↓ move; ⏎ focuses the terminal (D145). A key never folds a project. ← fold and → open are not built yet. - `⌘⌥←` and `⌘⌥→` select the project before or after the open one from anywhere in the window, around the ends, as a click on its row does (D248). Hidden projects are skipped; with one project they do nothing. While ⌘ is held, the row before shows `⌥←` and the row after `⌥→` on its folder icon; with two projects the other row shows `⌥→` (KeyHints.md). +- `⌘⇧1` to `⌘⇧8` select the first to the eighth project in the sidebar's order, and `⌘⇧9` the last, as a click on its row does: its orchestrator, else its main checkout (D289). A number with no project does nothing. While ⌘ is held, a row that is not before or after the open one shows its key on the folder icon: `⇧1` to `⇧8`, and `⇧9` on the last row past the eighth. ## Edge cases diff --git a/docs/spec/README.md b/docs/spec/README.md index 8bbd65b8..48e99490 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -178,6 +178,8 @@ The owner's from-zero canvas (https://claude.ai/artifact/9bmbRdJc8qDMSRXm6obEb3) | `⌘⇧[` `⌘⇧]` | select the previous or next tab, around the ends | window | | `⌘⌥↑` `⌘⌥↓` | select the agent row above or below in the sidebar | window | | `⌘⌥←` `⌘⌥→` | select the project before or after the open one in the sidebar, around the ends (D248) | window | +| `⌘⇧1`–`⌘⇧8` | select that project of the sidebar, in its order, as a click on its row (D289) | window | +| `⌘⇧9` | select the last project of the sidebar (D289) | window | | `1`–`9` | pick that choice | questions card, with the focus | | `⏎` | send the typed answer, else the picked choice; go to the next question | questions card | | `⇥` | next question chip | questions card | diff --git a/internal/app/shortcut/shortcut.go b/internal/app/shortcut/shortcut.go index b5750b42..f6100b81 100644 --- a/internal/app/shortcut/shortcut.go +++ b/internal/app/shortcut/shortcut.go @@ -62,6 +62,17 @@ const ( Tab7 ID = "tab-7" Tab8 ID = "tab-8" LastTab ID = "last-tab" + // Project1 to Project8 select that project of the sidebar; LastProject selects the last + // project (D289). + Project1 ID = "project-1" + Project2 ID = "project-2" + Project3 ID = "project-3" + Project4 ID = "project-4" + Project5 ID = "project-5" + Project6 ID = "project-6" + Project7 ID = "project-7" + Project8 ID = "project-8" + LastProject ID = "last-project" ) // Shortcut is one key of the list. @@ -109,6 +120,15 @@ var list = []Shortcut{ {Tab7, ui.Cmd, ui.Key7, "7"}, {Tab8, ui.Cmd, ui.Key8, "8"}, {LastTab, ui.Cmd, ui.Key9, "9"}, + {Project1, ui.Cmd | ui.Shift, ui.Key1, "1"}, + {Project2, ui.Cmd | ui.Shift, ui.Key2, "2"}, + {Project3, ui.Cmd | ui.Shift, ui.Key3, "3"}, + {Project4, ui.Cmd | ui.Shift, ui.Key4, "4"}, + {Project5, ui.Cmd | ui.Shift, ui.Key5, "5"}, + {Project6, ui.Cmd | ui.Shift, ui.Key6, "6"}, + {Project7, ui.Cmd | ui.Shift, ui.Key7, "7"}, + {Project8, ui.Cmd | ui.Shift, ui.Key8, "8"}, + {LastProject, ui.Cmd | ui.Shift, ui.Key9, "9"}, } // All returns a copy of the list. @@ -202,45 +222,70 @@ func (s Shortcut) Accelerator() string { return strings.Join(append(parts, key), "+") } -// tabIDs are the ids of the numbered tabs, in order. -var tabIDs = [NumberedTabs]ID{Tab1, Tab2, Tab3, Tab4, Tab5, Tab6, Tab7, Tab8} - // TabKeys are the tab shortcuts in the order the checks run: ⌘1 to ⌘8, then ⌘9. -func TabKeys() []Shortcut { +func TabKeys() []Shortcut { return tabSet.keys() } + +// TabIndex is the index of the tab that s selects among n tabs, in the visible order of the +// title bar (D190), and false when s selects none: a numbered tab past the last, or no tabs. +func TabIndex(s ID, n int) (int, bool) { return tabSet.index(s, n) } + +// TabShortcut is the shortcut that selects tab i of n: ⌘1 to ⌘8 for the first eight, ⌘9 for the +// last tab past them. false: the tab has no key. +func TabShortcut(i, n int) (Shortcut, bool) { return tabSet.shortcut(i, n) } + +// ProjectKeys are the project shortcuts in the order the checks run: ⌘⇧1 to ⌘⇧8, then ⌘⇧9 (D289). +func ProjectKeys() []Shortcut { return projectSet.keys() } + +// ProjectIndex is the index of the project that s selects among n projects, in the order of the +// sidebar, and false when s selects none: a numbered project past the last, or no projects. +func ProjectIndex(s ID, n int) (int, bool) { return projectSet.index(s, n) } + +// ProjectShortcut is the shortcut that selects project i of n: ⌘⇧1 to ⌘⇧8 for the first eight, +// ⌘⇧9 for the last project past them. false: the project has no key. +func ProjectShortcut(i, n int) (Shortcut, bool) { return projectSet.shortcut(i, n) } + +// numbered is a set of numbered shortcuts: ⌘1 to ⌘8 and ⌘9 for the last, or the same with ⇧. +type numbered struct { + ids [NumberedTabs]ID + last ID +} + +var ( + tabSet = numbered{[NumberedTabs]ID{Tab1, Tab2, Tab3, Tab4, Tab5, Tab6, Tab7, Tab8}, LastTab} + projectSet = numbered{[NumberedTabs]ID{Project1, Project2, Project3, Project4, Project5, Project6, Project7, Project8}, LastProject} +) + +func (s numbered) keys() []Shortcut { out := make([]Shortcut, 0, NumberedTabs+1) - for _, id := range tabIDs { + for _, id := range s.ids { out = append(out, Of(id)) } - return append(out, Of(LastTab)) + return append(out, Of(s.last)) } -// TabIndex is the index of the tab that s selects among n tabs, in the visible order of the -// title bar (D190), and false when s selects none: a numbered tab past the last, or no tabs. -func TabIndex(s ID, n int) (int, bool) { +func (s numbered) index(id ID, n int) (int, bool) { if n <= 0 { return 0, false } - if s == LastTab { + if id == s.last { return n - 1, true } - for i, id := range tabIDs { - if id == s { + for i, x := range s.ids { + if x == id { return i, i < n } } return 0, false } -// TabShortcut is the shortcut that selects tab i of n: ⌘1 to ⌘8 for the first eight, ⌘9 for the -// last tab past them. false: the tab has no key. -func TabShortcut(i, n int) (Shortcut, bool) { +func (s numbered) shortcut(i, n int) (Shortcut, bool) { switch { case i < 0 || i >= n: return Shortcut{}, false case i < NumberedTabs: - return Of(tabIDs[i]), true + return Of(s.ids[i]), true case i == n-1: - return Of(LastTab), true + return Of(s.last), true } return Shortcut{}, false } diff --git a/internal/app/shortcut/shortcut_test.go b/internal/app/shortcut/shortcut_test.go index aaec2d21..c7d8bed1 100644 --- a/internal/app/shortcut/shortcut_test.go +++ b/internal/app/shortcut/shortcut_test.go @@ -165,3 +165,43 @@ func TestAKeyWithCmdShowsNothingUntilCmdIsReleased(t *testing.T) { t.Error("a new ⌘ press shows no chips") } } + +// The project keys and the project chips agree, as the tab keys do (D289). +func TestProjectKeysAndProjectIndexAgree(t *testing.T) { + for n := 0; n <= 12; n++ { + for i := range n { + s, ok := ProjectShortcut(i, n) + if !ok { + if i < NumberedTabs || i == n-1 { + t.Errorf("project %d of %d has no key", i, n) + } + continue + } + if got, ok := ProjectIndex(s.ID, n); !ok || got != i { + t.Errorf("project %d of %d: key %q selects %d %v", i, n, s.ID, got, ok) + } + } + } + keys := ProjectKeys() + if len(keys) != 9 || keys[0].ID != Project1 || keys[8].ID != LastProject { + t.Fatalf("ProjectKeys %v", keys) + } + if keys[0].Glyphs() != "⌘⇧1" || keys[0].Hint() != "⇧1" || keys[8].Hint() != "⇧9" { + t.Errorf("project key caps %q %q %q", keys[0].Glyphs(), keys[0].Hint(), keys[8].Hint()) + } + if _, ok := ProjectIndex(Project5, 3); ok { + t.Error("⌘⇧5 selects a project of 3") + } + if i, ok := ProjectIndex(LastProject, 3); !ok || i != 2 { + t.Errorf("⌘⇧9 of 3 projects: %d %v", i, ok) + } + if _, ok := ProjectIndex(LastProject, 0); ok { + t.Error("⌘⇧9 with no projects") + } + if _, ok := ProjectIndex(Tab1, 3); ok { + t.Error("⌘1 selects a project") + } + if _, ok := ProjectShortcut(9, 12); ok { + t.Error("project 9 of 12 has a key") + } +} diff --git a/internal/app/shortcuts.go b/internal/app/shortcuts.go index 4105b934..737515d6 100644 --- a/internal/app/shortcuts.go +++ b/internal/app/shortcuts.go @@ -42,6 +42,11 @@ func (f *Frame) windowKeys(c *ui.Context, win float32) { if shortcut.Of(shortcut.NextProject).Pressed(c) { f.SpacesList.StepProject(1) } + for _, s := range shortcut.ProjectKeys() { + if s.Pressed(c) { + f.SpacesList.PickProject(s.ID) + } + } } if shortcut.Of(shortcut.PrevTab).Pressed(c) { f.stepTab(-1) diff --git a/internal/app/shortcuts_test.go b/internal/app/shortcuts_test.go index 3e39b5b2..c68de0d7 100644 --- a/internal/app/shortcuts_test.go +++ b/internal/app/shortcuts_test.go @@ -157,13 +157,16 @@ func TestCmdAltArrowsStepTheAgentRowsOfTheSidebar(t *testing.T) { } } -func TestCmdAltLeftRightStepTheProjectsOfTheSidebar(t *testing.T) { +// projectsFrame is a frame with a sidebar of one project for each name, each with an orchestrator +// "o"+name, and "oa" selected. +func projectsFrame(t *testing.T, names ...string) (*Frame, *ui.Tester) { + t.Helper() snap := tabSnap() f, tt := agentFrame(t, &snap) base := apptest.CanonicalTmp(t) var orchs []agents.Worker var panes []agents.Worker - for i, name := range []string{"a", "b"} { + for i, name := range names { dir := filepath.Join(base, name) apptest.MainRepo(t, dir, "ref: refs/heads/main\n") o := apptest.OrchW("o"+name, "lead-"+name, "p") @@ -178,6 +181,11 @@ func TestCmdAltLeftRightStepTheProjectsOfTheSidebar(t *testing.T) { f.SpacesList.Sync() f.Selection.Select(state.Selected{Kind: state.KindOrchestrator, ID: "oa"}) tt.Frame() + return f, tt +} + +func TestCmdAltLeftRightStepTheProjectsOfTheSidebar(t *testing.T) { + f, tt := projectsFrame(t, "a", "b") for _, c := range []struct { key shortcut.ID want string @@ -191,6 +199,21 @@ func TestCmdAltLeftRightStepTheProjectsOfTheSidebar(t *testing.T) { } } +func TestCmdShiftNumbersSelectTheProjectsOfTheSidebar(t *testing.T) { + f, tt := projectsFrame(t, "a", "b", "c") + for _, c := range []struct { + key shortcut.ID + want string + }{ + {shortcut.Project2, "ob"}, {shortcut.LastProject, "oc"}, {shortcut.Project5, "oc"}, {shortcut.Project1, "oa"}, + } { + press(tt, c.key) + if got := selected(f); got != c.want { + t.Fatalf("after %s: %q, want %q", c.key, got, c.want) + } + } +} + // chips counts the key chips of the last frame. func chips(tt *ui.Tester) int { n := 0 diff --git a/internal/app/sidebar/sidebar.go b/internal/app/sidebar/sidebar.go index 68c3f1b9..44f07387 100644 --- a/internal/app/sidebar/sidebar.go +++ b/internal/app/sidebar/sidebar.go @@ -387,12 +387,17 @@ func (l *SpacesList) projectRow(c *ui.Context, g spaces.RepoGroup, index int) { o.Menu = func(m *ui.Menu) { l.projectMenu(m, g) } o.Lead = func() ui.Element { return rowIcon(c, "folder", iconInk) } // The rows before and after the open project show the keys that select them (⌘⌥←, ⌘⌥→). With - // two projects the other one is both: it shows ⌥→. + // two projects the other one is both: it shows ⌥→. A row has one chip, so the other rows with + // a number key show it: ⇧1 to ⇧8, and ⇧9 on the last past the eighth (D289). switch index { case l.projectStep(1): o.Hint = shortcut.Of(shortcut.NextProject).HintIf(l.Hints) case l.projectStep(-1): o.Hint = shortcut.Of(shortcut.PrevProject).HintIf(l.Hints) + default: + if s, ok := shortcut.ProjectShortcut(index, len(l.groups)); ok { + o.Hint = s.HintIf(l.Hints) + } } if index == 0 { kit.Row(c, o) diff --git a/internal/app/sidebar/sidebar_projectkeys_test.go b/internal/app/sidebar/sidebar_projectkeys_test.go index 7437c57d..60a7b882 100644 --- a/internal/app/sidebar/sidebar_projectkeys_test.go +++ b/internal/app/sidebar/sidebar_projectkeys_test.go @@ -9,6 +9,7 @@ import ( "github.com/contursofran/contr/internal/agents" "github.com/contursofran/contr/internal/app/apptest" "github.com/contursofran/contr/internal/app/kit" + "github.com/contursofran/contr/internal/app/shortcut" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/projects" ) @@ -171,3 +172,55 @@ func TestTheAgentRowChipsStayInTheirRows(t *testing.T) { apptest.ChipOn(t, h.tt, kit.KeyHintLabel("⌥↑"), "lead, idle, opus", theme.SidebarWidth, theme.WindowHeight) apptest.ChipOn(t, h.tt, kit.KeyHintLabel("⌥↓"), "b-242, working, sonnet", theme.SidebarWidth, theme.WindowHeight) } + +func TestCmdShiftNumbersPickTheProjects(t *testing.T) { + h := repos(t, "alpha", "beta", "gamma") + h.sel.Clear() + h.list.PickProject(shortcut.Project2) + if got := openProject(h); got != "beta" { + t.Fatalf("⌘⇧2: %q, want beta", got) + } + h.list.PickProject(shortcut.LastProject) + if got := openProject(h); got != "gamma" { + t.Fatalf("⌘⇧9: %q, want gamma", got) + } + before, _ := h.sel.Current() + h.list.PickProject(shortcut.Project5) + if after, _ := h.sel.Current(); after != before { + t.Errorf("⌘⇧5 of 3 projects: %+v, then %+v", before, after) + } +} + +func TestTheProjectPickIsAClickOnTheRow(t *testing.T) { + h := repos(t, "alpha", "beta") + g := h.list.Groups()[1] + h.sel.Clear() + h.list.PickProject(shortcut.Project2) + picked, _ := h.sel.Current() + h.sel.Clear() + h.list.Activate(g) + clicked, _ := h.sel.Current() + if picked != clicked { + t.Errorf("⌘⇧2 selected %+v, a click %+v", picked, clicked) + } +} + +func TestTheOtherProjectRowsShowTheirNumberChips(t *testing.T) { + h := repos(t, "alpha", "beta", "gamma", "delta") + groups := h.list.Groups() + h.list.Switch(groups[0].CommonDir) // beta shows ⌥→, delta ⌥← + h.list.Hints = true + h.tt.Frame() + apptest.ChipOn(t, h.tt, kit.KeyHintLabel("⇧1"), ProjectLabel(groups[0]), theme.SidebarWidth, theme.WindowHeight) + apptest.ChipOn(t, h.tt, kit.KeyHintLabel("⇧3"), ProjectLabel(groups[2]), theme.SidebarWidth, theme.WindowHeight) + for _, key := range []string{"⇧2", "⇧4", "⇧9"} { + if !apptest.Absent(h.tt, kit.KeyHintLabel(key)) { + t.Errorf("chip %q shows", key) + } + } + h.list.Hints = false + h.tt.Frame() + if !apptest.Absent(h.tt, kit.KeyHintLabel("⇧1")) { + t.Error("⇧1 shows with ⌘ up") + } +} diff --git a/internal/app/sidebar/spaces.go b/internal/app/sidebar/spaces.go index 2143eded..6c71ed91 100644 --- a/internal/app/sidebar/spaces.go +++ b/internal/app/sidebar/spaces.go @@ -23,6 +23,7 @@ import ( "github.com/contursofran/contr/internal/agents" "github.com/contursofran/contr/internal/app/agentstate" + "github.com/contursofran/contr/internal/app/shortcut" "github.com/contursofran/contr/internal/app/spaces" "github.com/contursofran/contr/internal/app/state" "github.com/contursofran/contr/internal/app/tabmodel" @@ -702,6 +703,14 @@ func (l *SpacesList) StepProject(d int) { } } +// PickProject selects the project that the shortcut id stands for (⌘⇧1 to ⌘⇧8, ⌘⇧9 the last; +// D289), as StepProject does. A number past the last project does nothing. UI thread. +func (l *SpacesList) PickProject(id shortcut.ID) { + if i, ok := shortcut.ProjectIndex(id, len(l.groups)); ok && l.selection != nil { + l.selectProject(l.groups[i]) + } +} + // Orchestrated tells whether an orchestrator holds a terminal in project g. func (l *SpacesList) Orchestrated(g spaces.RepoGroup) bool { for _, o := range l.Snapshot().Orchestrators {