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
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 | |
|---|---|
Expand Down Expand Up @@ -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 —
Expand Down
17 changes: 17 additions & 0 deletions internal/coord/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
@@ -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())

Expand Down
2 changes: 2 additions & 0 deletions internal/ui/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 2 additions & 1 deletion internal/ui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
formNewSession formKind = iota
formAddProject
formEditProject
formEditSession
)

const (
Expand All @@ -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.
//
Expand Down
150 changes: 150 additions & 0 deletions internal/ui/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}
6 changes: 3 additions & 3 deletions internal/ui/formproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions internal/ui/formsession.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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/<name> checked out under the state dir. Parallel sessions never collide."},
Expand All @@ -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
}
Loading
Loading