From 28ad0e03682189cd45ba0a0a98f5e6e49ee38ed4 Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:24:40 +0200 Subject: [PATCH 1/2] Move the project rename into its own file projects.go is registration: the explorer, the path resolution, and the record that comes out of them. Renaming is the second name a row gets, and it is about to have a session counterpart, so it moves out whole before that lands rather than after. --- internal/ui/projects.go | 37 ------------------------------- internal/ui/rename.go | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 internal/ui/rename.go 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..2d63622 --- /dev/null +++ b/internal/ui/rename.go @@ -0,0 +1,48 @@ +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" +) + +// 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 +} From 576d9bb0a947e700ec6ec393e070bd29c118b9bd Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:20:46 +0200 Subject: [PATCH 2/2] Rename a session with e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session's title was set once on the new-session form and never again, so a session that turned into different work kept the wrong label on its card and told every sibling the wrong thing about itself. e now reads the focused column the way x and c do: the sessions list renames the session, the projects list renames the project. The form carries one required field, because the title is the only name a session has that nothing is built on — Name and Branch are in the worktree path and in git, and editing them here would rename neither. The coordinator keeps its own copy of a live session, taken when the agent started, so it is told separately. Title is the only field that can change after Register, so Retitle closes that gap rather than patching one case of it. TestMain arrives with it. The rename tests call store.Save, and with XDG_STATE_HOME unset that writes the real ~/.local/state/deck/state.json — a passing suite replaced a developer's registered projects with a fixture. Per-test t.Setenv protects the test that remembers; this protects the one that does not. --- README.md | 18 +++-- internal/coord/registry.go | 17 ++++ internal/store/store_test.go | 17 ++++ internal/ui/actions.go | 2 + internal/ui/dashboard.go | 2 +- internal/ui/form.go | 3 +- internal/ui/form_test.go | 150 +++++++++++++++++++++++++++++++++++ internal/ui/formproject.go | 6 +- internal/ui/formsession.go | 54 +++++++++++-- internal/ui/helpers_test.go | 27 ++++++- internal/ui/keyroutes.go | 2 +- internal/ui/keys.go | 2 +- internal/ui/rename.go | 51 ++++++++++++ 13 files changed, 328 insertions(+), 23 deletions(-) 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/rename.go b/internal/ui/rename.go index 2d63622..ca210b5 100644 --- a/internal/ui/rename.go +++ b/internal/ui/rename.go @@ -10,6 +10,27 @@ import ( 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() @@ -46,3 +67,33 @@ func (m Model) renameProject(id, name, description string) (tea.Model, tea.Cmd) 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 +}