diff --git a/README.md b/README.md index 6e92746..ee1778a 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ the arrows keep working inside `claude`. | `↵` | open / attach | | `n` | new session | | `a` | add project | -| `e` | rename project | +| `e` | rename the focused project or session | | `x` | end session — close and keep the worktree, or delete both | | `c` | connect this session to one on another project | | `t` | theme picker | @@ -122,7 +122,8 @@ the arrows keep working inside `claude`. `x` and `c` act on the selected session, so they need the sessions column to have focus — `tab` moves it there, and the focused column carries the accent -border. `↵` opens from either column. +border. `e` reads the same focus and renames whichever of the two the cursor +is on. `↵` opens from either column. | Command — press `ctrl+g` first | | |---|---| @@ -173,13 +174,16 @@ The project field steps with `←`/`→` and opens the full list on `↵`, so it stays usable whether you have three projects or ninety. A project is listed under the **Name** you give it when you register it, which -defaults to the directory it sits in. `e` on the dashboard renames one; the -path is not editable there, because changing it makes a different project -rather than the same one under another name. The last field picks -the agent — see **Choosing the agent** below. +defaults to the directory it sits in. `e` renames one while the projects list +has focus; the path is not editable there, because changing it makes a +different project rather than the same one under another name. The last field +picks the agent — see **Choosing the agent** below. Session names are `scheming-hawk-jhgk`: two words you can say out loud plus a -suffix that makes the branch unique. +suffix that makes the branch unique. The title beside one is yours, and `e` +changes it when the work turns into something else. The generated name and the +branch stay as they are — the worktree is on disk under that name, and a +session whose agent is running tells its siblings the new title immediately. `x` asks what to do with the worktree. **Close** stops the agent, forgets the session, and leaves the worktree on disk because it may hold uncommitted work — diff --git a/internal/coord/registry.go b/internal/coord/registry.go index a099a49..23cfaa9 100644 --- a/internal/coord/registry.go +++ b/internal/coord/registry.go @@ -10,6 +10,23 @@ func (c *Coordinator) Register(s Session) { c.sessions[s.ID] = s } +// Retitle changes what a live session is called, which is what its siblings +// read when they list who else is working. +// +// A session nobody registered is ignored rather than reported. The title lives +// in the store, and a session whose agent is not running takes the new one from +// there when Register next announces it. +func (c *Coordinator) Retitle(id, title string) { + c.mu.Lock() + defer c.mu.Unlock() + s, ok := c.sessions[id] + if !ok { + return + } + s.Title = title + c.sessions[id] = s +} + // Registered lists the session ids the coordinator currently knows about, so // the caller can reconcile them against the processes that are actually alive. func (c *Coordinator) Registered() []string { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index cccacf9..445b933 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,12 +1,29 @@ package store import ( + "fmt" "os" "path/filepath" "testing" "time" ) +// TestMain points the state directory somewhere disposable for every test in +// the package, because this is the package that decides where Save writes. A +// test that forgets t.Setenv writes the user's own state.json instead, which +// is how a passing suite deleted a developer's registered projects. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "deck-store-state") + if err != nil { + fmt.Fprintln(os.Stderr, "test state dir:", err) + os.Exit(1) + } + os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + func TestSaveLoadRoundTrip(t *testing.T) { t.Setenv("XDG_STATE_HOME", t.TempDir()) diff --git a/internal/ui/actions.go b/internal/ui/actions.go index edff53a..199ae13 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -71,6 +71,8 @@ func (m Model) commitForm() (tea.Model, tea.Cmd) { f.fields[editFieldName].value(), f.fields[editFieldDescription].value(), ) + case formEditSession: + return m.renameSession(f.subject, f.fields[editSessionFieldTitle].value()) case formNewSession: return m.newSession( f.fields[sessionFieldProject].value(), diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index bcf0f2f..7283d44 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -69,5 +69,5 @@ func (m Model) dashboardFooter() string { if m.focus == colContent { return s.Footer.Render(" ↑/↓ session · ←/→ section · tab projects · ↵ open · n new · e rename · x close · ? help · q quit") } - return s.Footer.Render(" ↑/↓ project · tab sessions · ↵ open · n new session · a add project · ? help · q quit") + return s.Footer.Render(" ↑/↓ project · tab sessions · ↵ open · n new · a add project · e rename · ? help · q quit") } diff --git a/internal/ui/form.go b/internal/ui/form.go index 750074d..7374770 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -16,6 +16,7 @@ const ( formNewSession formKind = iota formAddProject formEditProject + formEditSession ) const ( @@ -24,7 +25,7 @@ const ( ) // form is the modal used for the flows that need input: opening a session, -// registering a project, and renaming one. It is deliberately small — two to +// registering a project, and renaming either. It is deliberately small — one to // four fields, no nesting, no validation framework. Anything larger belongs in // a library. // diff --git a/internal/ui/form_test.go b/internal/ui/form_test.go index 3431dc2..c4f9dd4 100644 --- a/internal/ui/form_test.go +++ b/internal/ui/form_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/tripledownab/deck/internal/coord" "github.com/tripledownab/deck/internal/store" "os" "path/filepath" @@ -340,3 +341,152 @@ func TestEditProjectFormPrefillsWhatIsThere(t *testing.T) { t.Errorf("placeholder = %q, want the directory name", got) } } + +// TestRenameSessionKeepsTheSameSession is the project rule applied to the +// other list: the form writes to the row it was opened on, not to whatever the +// cursor moved to while it was up. +func TestRenameSessionKeepsTheSameSession(t *testing.T) { + st := &store.State{} + p := st.AddProject(store.Project{Name: "api-gateway", Path: "/code/api-gateway"}) + first := st.AddSession(store.Session{ + ProjectID: p.ID, Name: "swift-otter-aaaa", Title: "wire up the parser", + }) + st.AddSession(store.Session{ + ProjectID: p.ID, Name: "brave-heron-bbbb", Title: "port the tests", + }) + + m := New(st, "bash", nil) + m.form = editSessionForm(first) + m.listIx = 1 // the cursor moves after the form opens + + next, _ := m.renameSession(m.form.subject, "rewrite the lexer") + got := next.(Model).state + + if got.Sessions[0].Title != "rewrite the lexer" { + t.Errorf("first session = %q, want the new title", got.Sessions[0].Title) + } + if got.Sessions[1].Title != "port the tests" { + t.Errorf("the selected session was renamed instead: %q", got.Sessions[1].Title) + } + // The generated name is what the worktree and the branch are built from, + // so a rename that touched it would strand both. + if got.Sessions[0].Name != "swift-otter-aaaa" { + t.Errorf("name = %q, want it untouched", got.Sessions[0].Name) + } +} + +// TestRenameSessionShowsSiblingsTheNewTitle covers the copy the coordinator +// keeps. It takes a session's title when the agent starts, so without a second +// write every sibling is answered with the old one for the rest of the run — +// and the title is the only thing in that answer a reader can act on. +func TestRenameSessionShowsSiblingsTheNewTitle(t *testing.T) { + c, err := coord.Start(t.TempDir()) + if err != nil { + t.Fatalf("coordinator: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + st := &store.State{} + p := st.AddProject(store.Project{Name: "api-gateway", Path: "/code/api-gateway"}) + renamed := st.AddSession(store.Session{ + ProjectID: p.ID, Name: "swift-otter-aaaa", Title: "wire up the parser", + }) + watcher := st.AddSession(store.Session{ + ProjectID: p.ID, Name: "brave-heron-bbbb", Title: "port the tests", + }) + for _, s := range []*store.Session{renamed, watcher} { + c.Register(coord.Session{ID: s.ID, ProjectID: p.ID, Name: s.Name, Title: s.Title}) + } + + m := New(st, "bash", nil).WithCoordinator(c) + if _, cmd := m.renameSession(renamed.ID, "rewrite the lexer"); cmd != nil { + t.Fatalf("rename returned a command: %v", cmd) + } + + rows := c.Siblings(watcher.ID) + if len(rows) != 1 { + t.Fatalf("siblings = %d rows, want 1", len(rows)) + } + if got := rows[0]["title"]; got != "rewrite the lexer" { + t.Errorf("sibling reads title %q, want the new one", got) + } +} + +// TestRenameFollowsTheFocusedColumn pins which of the two lists e acts on. +// The key reads the focus the way x and c do, because a key that reaches into +// the unfocused column makes the accent border a lie. +func TestRenameFollowsTheFocusedColumn(t *testing.T) { + newModel := func() Model { + st := &store.State{} + p := st.AddProject(store.Project{Name: "api-gateway", Path: "/code/api-gateway"}) + st.AddSession(store.Session{ProjectID: p.ID, Name: "swift-otter-aaaa", Title: "the parser"}) + st.AddProject(store.Project{Name: "empty", Path: "/code/empty"}) + return New(st, "bash", nil) + } + + t.Run("the sessions list renames the session", func(t *testing.T) { + m := newModel() + m.focus = colContent + + // Through Update, so this covers the key reaching the handler and not + // only the handler being right. + next, _ := m.Update(typed("e")) + f := next.(Model).form + if f == nil || f.kind != formEditSession { + t.Fatalf("form = %+v, want the session rename form", f) + } + if got := f.fields[editSessionFieldTitle].value(); got != "the parser" { + t.Errorf("title field = %q, want the session's own", got) + } + }) + + t.Run("the projects list renames the project", func(t *testing.T) { + m := newModel() + m.focus = colProjects + + next, _ := m.Update(typed("e")) + f := next.(Model).form + if f == nil || f.kind != formEditProject { + t.Fatalf("form = %+v, want the project rename form", f) + } + }) + + t.Run("an empty sessions list falls back to the project", func(t *testing.T) { + m := newModel() + m.focus = colContent + m.projectIx = 1 // the project with no sessions + + next, _ := m.Update(typed("e")) + f := next.(Model).form + if f == nil || f.kind != formEditProject { + t.Fatalf("form = %+v, want the project rename form", f) + } + }) +} + +// TestEditSessionFormPrefillsTheTitle pins the one field and the subject. The +// heading carries the generated name because the title is the field being +// replaced, so it cannot also be what says which session this is. +func TestEditSessionFormPrefillsTheTitle(t *testing.T) { + sess := &store.Session{ID: "s1", Name: "swift-otter-aaaa", Title: "wire up the parser"} + f := editSessionForm(sess) + + if f.subject != "s1" { + t.Errorf("subject = %q, want the session id", f.subject) + } + if len(f.fields) != 1 { + t.Fatalf("fields = %d, want only the title", len(f.fields)) + } + if got := f.fields[editSessionFieldTitle].value(); got != "wire up the parser" { + t.Errorf("title field = %q", got) + } + if !strings.Contains(f.title, "swift-otter-aaaa") { + t.Errorf("heading = %q, want the generated name in it", f.title) + } + // Required, so ^s on a cleared field refuses instead of leaving a card + // labelled with the generated name it was given to replace. + f.fields[editSessionFieldTitle].input.SetValue("") + if f.submitted() { + t.Error("an empty title was accepted") + } +} diff --git a/internal/ui/formproject.go b/internal/ui/formproject.go index 4239993..8939c21 100644 --- a/internal/ui/formproject.go +++ b/internal/ui/formproject.go @@ -63,9 +63,9 @@ func newProjectForm(repoPath string) *form { return f } -// Field indices in the rename form. Its own set rather than the add form's: -// there is no path field here, so the positions differ, and sharing constants -// between two shapes is how the wrong string reaches the store. +// Field indices in the project rename form. Its own set rather than the add +// form's: there is no path field here, so the positions differ, and sharing +// constants between two shapes is how the wrong string reaches the store. const ( editFieldName = iota editFieldDescription diff --git a/internal/ui/formsession.go b/internal/ui/formsession.go index 0e06dce..ad9f71f 100644 --- a/internal/ui/formsession.go +++ b/internal/ui/formsession.go @@ -1,9 +1,11 @@ package ui -// The new-session form: its field order, and the choices each field offers. +// The session forms: opening one, and retitling one that is already running. import ( "github.com/charmbracelet/bubbles/textinput" + + "github.com/tripledownab/deck/internal/store" ) // Field indices in the new-session form. Named because commitForm reads them @@ -35,11 +37,6 @@ const ( // without rebuilding the form; picking an impossible combination is caught on // commit with a message that says which. func newSessionForm(projects []choice, selected int, canWorktree bool, agent string) *form { - title := textinput.New() - title.Placeholder = "what should this session do?" - title.CharLimit = 120 - title.Prompt = "" - f := &form{ kind: formNewSession, title: "New session", @@ -48,8 +45,7 @@ func newSessionForm(projects []choice, selected int, canWorktree bool, agent str {kind: fieldChoice, label: "Project", selected: selected, choices: projects, pickable: true, help: "←/→ to step, ↵ to choose from the full list"}, - {kind: fieldText, label: "Title", input: title, - help: "Shown on the sidebar card. Not sent to the agent."}, + titleField(""), {kind: fieldChoice, label: "Working copy", selected: defaultWorkingCopy(canWorktree), choices: []choice{ {label: "Isolated git worktree", value: "worktree", help: "New branch session/ checked out under the state dir. Parallel sessions never collide."}, @@ -64,3 +60,45 @@ func newSessionForm(projects []choice, selected int, canWorktree bool, agent str f.focus(sessionFieldTitle) return f } + +// titleField builds the Title input both session forms carry. +// +// One builder rather than a copy in each: the two forms write the same value +// to the same place, and a placeholder or a limit raised in one of them would +// make the rename form describe a different field from the one that created +// the session. +func titleField(value string) field { + title := textinput.New() + title.Placeholder = "what should this session do?" + title.CharLimit = 120 + title.Prompt = "" + title.SetValue(value) + return field{kind: fieldText, label: "Title", input: title, + help: "Shown on the sidebar card. Not sent to the agent."} +} + +// The rename form's only field. Named for the same reason the others are: +// commitForm reads it positionally. +const editSessionFieldTitle = 0 + +// editSessionForm renames a session that already exists. +// +// The title is the only field, because it is the only name a session has that +// nothing else is built on. Name and Branch are in the worktree path and in +// git, so editing them here would rename neither and leave both pointing at a +// session that no longer claims them. +// +// The generated name is in the heading rather than in a field: it says which +// row this form was opened on, which the title alone cannot once it is being +// replaced. +func editSessionForm(sess *store.Session) *form { + f := &form{ + kind: formEditSession, + title: "Rename " + sess.Name, + hint: "↵ or ^s save · esc cancel", + subject: sess.ID, + fields: []field{titleField(sess.Title)}, + } + f.focus(editSessionFieldTitle) + return f +} diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go index fa1d0ae..dec6834 100644 --- a/internal/ui/helpers_test.go +++ b/internal/ui/helpers_test.go @@ -1,17 +1,42 @@ package ui -// Shared fixtures for the tests that need a real agent. Everything here drives +// Shared fixtures for the tests that need a real agent, and the guard that +// keeps the whole package off the real state directory. Everything here drives // bash under a PTY rather than faking a Runner: the UI reads Status(), Err() // and Render() off a live process, and a stub would prove only that the stub // behaves as written. import ( + "fmt" + "os" "testing" "time" "github.com/tripledownab/deck/internal/agent" ) +// TestMain points the state directory somewhere disposable for every test in +// the package. +// +// This is not tidiness. Any test that renames, adds or closes something calls +// store.Save, which writes $XDG_STATE_HOME/deck/state.json — and with the +// variable unset that path is the real one under the home directory. Running +// `go test ./internal/ui` replaced a developer's registered projects with a +// two-project fixture, and the suite passed while doing it. Per-test t.Setenv +// protects the test that remembers to call it; this protects the one that does +// not. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "deck-ui-state") + if err != nil { + fmt.Fprintln(os.Stderr, "test state dir:", err) + os.Exit(1) + } + os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + // startAgent runs bash under a PTY and returns the runner, stopped on cleanup. // // An empty script gives a plain interactive bash that stays up. Anything else diff --git a/internal/ui/keyroutes.go b/internal/ui/keyroutes.go index f3db165..74d06ff 100644 --- a/internal/ui/keyroutes.go +++ b/internal/ui/keyroutes.go @@ -29,7 +29,7 @@ func (m Model) dashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.AddProject): return m.openBrowser() case key.Matches(msg, m.keys.Rename): - return m.openEditProjectForm() + return m.openRenameForm() case key.Matches(msg, m.keys.Connect): // Resolved first, not inline: focusedSession writes its refusal notice // into m, and Go does not order a method's receiver against a call in diff --git a/internal/ui/keys.go b/internal/ui/keys.go index a78d553..5cd51c5 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -63,7 +63,7 @@ func defaultKeys() keyMap { SwitchCol: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch column (dashboard)")), NewSession: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new session")), AddProject: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add project")), - Rename: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "rename project")), + Rename: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "rename the focused project or session")), Delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "end session")), Connect: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "connect to a session elsewhere")), Theme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), diff --git a/internal/ui/projects.go b/internal/ui/projects.go index 8becdf2..6ef4658 100644 --- a/internal/ui/projects.go +++ b/internal/ui/projects.go @@ -23,43 +23,6 @@ func (m Model) openBrowser() (tea.Model, tea.Cmd) { return m, nil } -// openEditProjectForm renames the project under the cursor. -func (m Model) openEditProjectForm() (tea.Model, tea.Cmd) { - p := m.currentProject() - if p == nil { - m.notice = "no project to rename" - return m, nil - } - m.form = editProjectForm(p) - return m, nil -} - -// renameProject applies the rename form. -// -// The project is found by id, not by cursor position: the form may have been -// open while the selection moved, and writing to whatever is selected now -// would rename the wrong row. -func (m Model) renameProject(id, name, description string) (tea.Model, tea.Cmd) { - p := m.state.Project(id) - if p == nil { - m.formProblem(fmt.Errorf("that project is no longer registered")) - return m, nil - } - // Same fallback as registering: an empty name is the directory it sits in, - // so a cleared field cannot leave a blank row in the sidebar. - if name == "" { - name = filepath.Base(p.Path) - } - p.Name, p.Description = name, description - if err := m.state.Save(); err != nil { - m.formProblem(err) - return m, nil - } - m.form = nil - m.notice = "renamed to " + p.Name - return m, nil -} - // resolveProject turns a user-supplied path into the path to store. // // A path inside a git repository collapses to that repository's root, so one diff --git a/internal/ui/rename.go b/internal/ui/rename.go new file mode 100644 index 0000000..ca210b5 --- /dev/null +++ b/internal/ui/rename.go @@ -0,0 +1,99 @@ +package ui + +// Renaming what the dashboard lists. Registering a project is projects.go and +// creating a session is sessions.go; this is only the second name either gets. + +import ( + "fmt" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" +) + +// openRenameForm is e on the dashboard. It renames whichever row the cursor is +// on: the session when the sessions list has focus, the project otherwise. +// +// The focused column decides, the way it decides for x and c — a key that +// reaches into the unfocused column makes the accent border a lie. It does not +// go through focusedSession, because that refuses with a notice when the +// projects list has focus, and here that list has something to rename. +// +// A focused but empty sessions list falls back to the project, which is then +// the only name on screen to change and is what e meant before a session had +// one of its own. +func (m Model) openRenameForm() (tea.Model, tea.Cmd) { + if m.focus == colContent { + if sess := m.dashboardSession(); sess != nil { + m.form = editSessionForm(sess) + return m, nil + } + } + return m.openEditProjectForm() +} + +// openEditProjectForm renames the project under the cursor. +func (m Model) openEditProjectForm() (tea.Model, tea.Cmd) { + p := m.currentProject() + if p == nil { + m.notice = "no project to rename" + return m, nil + } + m.form = editProjectForm(p) + return m, nil +} + +// renameProject applies the rename form. +// +// The project is found by id, not by cursor position: the form may have been +// open while the selection moved, and writing to whatever is selected now +// would rename the wrong row. +func (m Model) renameProject(id, name, description string) (tea.Model, tea.Cmd) { + p := m.state.Project(id) + if p == nil { + m.formProblem(fmt.Errorf("that project is no longer registered")) + return m, nil + } + // Same fallback as registering: an empty name is the directory it sits in, + // so a cleared field cannot leave a blank row in the sidebar. + if name == "" { + name = filepath.Base(p.Path) + } + p.Name, p.Description = name, description + if err := m.state.Save(); err != nil { + m.formProblem(err) + return m, nil + } + m.form = nil + m.notice = "renamed to " + p.Name + return m, nil +} + +// renameSession applies the session rename form. +// +// By id rather than by cursor position, for the reason renameProject is. The +// sidebar needs no rebuild: its rows hold the store's own session, so the new +// title is on screen the next frame. +// +// The coordinator is told separately because it keeps its own copy of a +// running session, taken when the agent started. Without this an agent asking +// who its siblings are is answered with the old title for the rest of the run, +// and the title is the only part of that answer that says what a sibling is +// doing. +func (m Model) renameSession(id, title string) (tea.Model, tea.Cmd) { + sess := m.state.Session(id) + if sess == nil { + m.formProblem(fmt.Errorf("that session is no longer open")) + return m, nil + } + sess.Title = title + if err := m.state.Save(); err != nil { + m.formProblem(err) + return m, nil + } + if m.coord != nil { + m.coord.Retitle(id, title) + } + m.form = nil + m.notice = "renamed to " + title + return m, nil +}