Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,5 @@ 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. |
| D290 | 2026-11-11 | The terminal shows no "not running" line over the grid (#745, owner: "its better that this doesnt exist"; docs/spec/Terminal.md, States). It replaces the not running line of D80. While the program is not running or the connection is lost, the grid stays and is dimmed to 50% (`staleOpacity`); the dim shows the state. The lines for "terminal unavailable: …" and an incompatible core stay. |
1 change: 1 addition & 0 deletions docs/spec/KeyHints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
1 change: 1 addition & 0 deletions docs/spec/ProjectRow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
79 changes: 62 additions & 17 deletions internal/app/shortcut/shortcut.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
40 changes: 40 additions & 0 deletions internal/app/shortcut/shortcut_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
5 changes: 5 additions & 0 deletions internal/app/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 25 additions & 2 deletions internal/app/shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion internal/app/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions internal/app/sidebar/sidebar_projectkeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
}
}
9 changes: 9 additions & 0 deletions internal/app/sidebar/spaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
Loading