From 543e7439f5f452d1ab6e908f630a50d36a8e1aa3 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 16:29:30 -0400 Subject: [PATCH 1/4] feat: add ClampSelected to CanvasModel --- internal/tui/canvas.go | 12 +++++ internal/tui/canvas_test.go | 98 +++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index 7ad7698..9805d3e 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -57,6 +57,18 @@ func (canvas *CanvasModel) SetSelected(index int) { } } +func (canvas *CanvasModel) ClampSelected() { + threads := canvas.store.All() + maxIndex := len(threads) - 1 + if maxIndex < 0 { + canvas.selected = 0 + return + } + if canvas.selected > maxIndex { + canvas.selected = maxIndex + } +} + func (canvas *CanvasModel) MoveRight() { threads := canvas.store.All() if canvas.selected < len(threads)-1 { diff --git a/internal/tui/canvas_test.go b/internal/tui/canvas_test.go index d3128b1..9853604 100644 --- a/internal/tui/canvas_test.go +++ b/internal/tui/canvas_test.go @@ -7,87 +7,131 @@ import ( "github.com/robinojw/dj/internal/state" ) -func TestCanvasNavigation(t *testing.T) { +const ( + testThreadID1 = "t-1" + testThreadID2 = "t-2" + testThreadID3 = "t-3" + testThreadTitle1 = "First" + testThreadTitle2 = "Second" + testThreadTitle3 = "Third" + testCanvasWidth = 120 + testCanvasHeight = 30 +) + +func TestCanvasNavigation(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - store.Add("t-3", "Third") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) + store.Add(testThreadID3, testThreadTitle3) canvas := NewCanvasModel(store) if canvas.SelectedIndex() != 0 { - t.Errorf("expected initial index 0, got %d", canvas.SelectedIndex()) + test.Errorf("expected initial index 0, got %d", canvas.SelectedIndex()) } canvas.MoveRight() if canvas.SelectedIndex() != 1 { - t.Errorf("expected index 1 after right, got %d", canvas.SelectedIndex()) + test.Errorf("expected index 1 after right, got %d", canvas.SelectedIndex()) } canvas.MoveLeft() if canvas.SelectedIndex() != 0 { - t.Errorf("expected index 0 after left, got %d", canvas.SelectedIndex()) + test.Errorf("expected index 0 after left, got %d", canvas.SelectedIndex()) } } -func TestCanvasNavigationBounds(t *testing.T) { +func TestCanvasNavigationBounds(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) canvas := NewCanvasModel(store) canvas.MoveLeft() if canvas.SelectedIndex() != 0 { - t.Errorf("expected clamped at 0, got %d", canvas.SelectedIndex()) + test.Errorf("expected clamped at 0, got %d", canvas.SelectedIndex()) } canvas.MoveRight() canvas.MoveRight() if canvas.SelectedIndex() != 1 { - t.Errorf("expected clamped at 1, got %d", canvas.SelectedIndex()) + test.Errorf("expected clamped at 1, got %d", canvas.SelectedIndex()) } } -func TestCanvasSelectedThreadID(t *testing.T) { +func TestCanvasSelectedThreadID(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) canvas := NewCanvasModel(store) canvas.MoveRight() id := canvas.SelectedThreadID() - if id != "t-2" { - t.Errorf("expected t-2, got %s", id) + if id != testThreadID2 { + test.Errorf("expected %s, got %s", testThreadID2, id) } } -func TestCanvasEmptyStore(t *testing.T) { +func TestCanvasEmptyStore(test *testing.T) { store := state.NewThreadStore() canvas := NewCanvasModel(store) if canvas.SelectedThreadID() != "" { - t.Errorf("expected empty ID for empty canvas") + test.Errorf("expected empty ID for empty canvas") + } + + canvas.MoveRight() + if canvas.SelectedIndex() != 0 { + test.Errorf("expected 0 for empty canvas") } +} + +func TestCanvasClampSelectedAfterDeletion(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) + canvas := NewCanvasModel(store) canvas.MoveRight() + + if canvas.SelectedIndex() != 1 { + test.Fatalf("expected index 1, got %d", canvas.SelectedIndex()) + } + + store.Delete(testThreadID2) + canvas.ClampSelected() + + if canvas.SelectedIndex() != 0 { + test.Errorf("expected clamped to 0, got %d", canvas.SelectedIndex()) + } +} + +func TestCanvasClampSelectedEmptyStore(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, "Only") + + canvas := NewCanvasModel(store) + store.Delete(testThreadID1) + canvas.ClampSelected() + if canvas.SelectedIndex() != 0 { - t.Errorf("expected 0 for empty canvas") + test.Errorf("expected 0 for empty store, got %d", canvas.SelectedIndex()) } } -func TestCanvasViewWithDimensions(t *testing.T) { +func TestCanvasViewWithDimensions(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - store.Add("t-3", "Third") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) + store.Add(testThreadID3, testThreadTitle3) canvas := NewCanvasModel(store) - canvas.SetDimensions(120, 30) + canvas.SetDimensions(testCanvasWidth, testCanvasHeight) output := canvas.View() - if !strings.Contains(output, "First") { - t.Errorf("expected First in output:\n%s", output) + if !strings.Contains(output, testThreadTitle1) { + test.Errorf("expected %s in output:\n%s", testThreadTitle1, output) } } From 1b4fb954facfaca0172982d1cd9537da96c77e30 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 16:36:56 -0400 Subject: [PATCH 2/4] feat: add k keybinding to kill selected session --- internal/tui/app.go | 2 + internal/tui/app_kill_test.go | 127 ++++++ internal/tui/app_prefix_test.go | 200 +++++++++ internal/tui/app_protocol_test.go | 137 ++++++ internal/tui/app_pty.go | 37 ++ internal/tui/app_session_test.go | 290 +++++++++++++ internal/tui/app_test.go | 678 +++--------------------------- 7 files changed, 849 insertions(+), 622 deletions(-) create mode 100644 internal/tui/app_kill_test.go create mode 100644 internal/tui/app_prefix_test.go create mode 100644 internal/tui/app_protocol_test.go create mode 100644 internal/tui/app_session_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 71b1983..42f8b2c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -219,6 +219,8 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { app.helpVisible = !app.helpVisible case " ", "s": return app.togglePin() + case "k": + return app.killSession() } return app, nil } diff --git a/internal/tui/app_kill_test.go b/internal/tui/app_kill_test.go new file mode 100644 index 0000000..2658912 --- /dev/null +++ b/internal/tui/app_kill_test.go @@ -0,0 +1,127 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func TestAppKillSessionRemovesThread(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + store.Add(testThreadID2, testTitleThread2) + app := NewAppModel(store) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ := app.Update(kKey) + appModel := updated.(AppModel) + + threads := store.All() + if len(threads) != 1 { + test.Fatalf("expected 1 thread after kill, got %d", len(threads)) + } + if threads[0].ID != testThreadID2 { + test.Errorf("expected %s remaining, got %s", testThreadID2, threads[0].ID) + } + _ = appModel +} + +func TestAppKillSessionStopsPTY(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ = app.Update(kKey) + appModel := updated.(AppModel) + + if len(appModel.ptySessions) != 0 { + test.Errorf("expected 0 PTY sessions after kill, got %d", len(appModel.ptySessions)) + } +} + +func TestAppKillSessionUnpins(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ = app.Update(kKey) + appModel := updated.(AppModel) + + if len(appModel.sessionPanel.PinnedSessions()) != 0 { + test.Errorf("expected 0 pinned after kill, got %d", len(appModel.sessionPanel.PinnedSessions())) + } +} + +func TestAppKillSessionClampsSelection(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + store.Add(testThreadID2, testTitleThread2) + app := NewAppModel(store) + + rightKey := tea.KeyMsg{Type: tea.KeyRight} + updated, _ := app.Update(rightKey) + app = updated.(AppModel) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ = app.Update(kKey) + appModel := updated.(AppModel) + + if appModel.canvas.SelectedIndex() != 0 { + test.Errorf("expected selection clamped to 0, got %d", appModel.canvas.SelectedIndex()) + } +} + +func TestAppKillSessionWithNoThreadsDoesNothing(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ := app.Update(kKey) + appModel := updated.(AppModel) + + if len(store.All()) != 0 { + test.Errorf("expected 0 threads, got %d", len(store.All())) + } + _ = appModel +} + +func TestAppKillSessionReturnsFocusToCanvas(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ = app.Update(kKey) + appModel := updated.(AppModel) + + if appModel.FocusPane() != FocusPaneCanvas { + test.Errorf("expected canvas focus after killing last pinned, got %d", appModel.FocusPane()) + } +} diff --git a/internal/tui/app_prefix_test.go b/internal/tui/app_prefix_test.go new file mode 100644 index 0000000..ad851f4 --- /dev/null +++ b/internal/tui/app_prefix_test.go @@ -0,0 +1,200 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func TestAppCtrlBMOpensMenu(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ := app.Update(ctrlB) + app = updated.(AppModel) + + mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} + updated, _ = app.Update(mKey) + app = updated.(AppModel) + + if !app.MenuVisible() { + test.Error("expected menu to be visible") + } +} + +func TestAppMenuEscCloses(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ := app.Update(ctrlB) + app = updated.(AppModel) + + mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} + updated, _ = app.Update(mKey) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + if app.MenuVisible() { + test.Error("expected menu hidden after Esc") + } +} + +func TestAppCtrlBEscCancelsPrefix(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ := app.Update(ctrlB) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + if app.MenuVisible() { + test.Error("expected menu not visible after prefix cancel") + } +} + +func TestAppMenuNavigation(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ := app.Update(ctrlB) + app = updated.(AppModel) + + mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} + updated, _ = app.Update(mKey) + app = updated.(AppModel) + + downKey := tea.KeyMsg{Type: tea.KeyDown} + updated, _ = app.Update(downKey) + app = updated.(AppModel) + + if app.menu.SelectedIndex() != 1 { + test.Errorf("expected menu index 1, got %d", app.menu.SelectedIndex()) + } +} + +func TestAppCtrlBXUnpinsSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + + tabKey := tea.KeyMsg{Type: tea.KeyTab} + updated, _ = app.Update(tabKey) + app = updated.(AppModel) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ = app.Update(ctrlB) + app = updated.(AppModel) + + xKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} + updated, _ = app.Update(xKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + if len(app.sessionPanel.PinnedSessions()) != 0 { + test.Errorf("expected 0 pinned after unpin, got %d", len(app.sessionPanel.PinnedSessions())) + } + if app.FocusPane() != FocusPaneCanvas { + test.Errorf("expected focus back to canvas, got %d", app.FocusPane()) + } +} + +func TestAppCtrlBZTogglesZoom(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ = app.Update(ctrlB) + app = updated.(AppModel) + + zKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}} + updated, _ = app.Update(zKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + if !app.sessionPanel.Zoomed() { + test.Error("expected zoomed after Ctrl+B z") + } +} + +func TestAppCtrlBRightCyclesPaneRight(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + store.Add(testThreadID2, testTitleThread2) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + space := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(space) + app = updated.(AppModel) + + app.canvas.MoveRight() + updated, _ = app.Update(space) + app = updated.(AppModel) + + tab := tea.KeyMsg{Type: tea.KeyTab} + updated, _ = app.Update(tab) + app = updated.(AppModel) + + if app.sessionPanel.ActivePaneIdx() != 0 { + test.Fatalf("expected active pane 0, got %d", app.sessionPanel.ActivePaneIdx()) + } + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ = app.Update(ctrlB) + app = updated.(AppModel) + + rightKey := tea.KeyMsg{Type: tea.KeyRight} + updated, _ = app.Update(rightKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + if app.sessionPanel.ActivePaneIdx() != 1 { + test.Errorf("expected active pane 1, got %d", app.sessionPanel.ActivePaneIdx()) + } +} + +func TestAppViewShowsDividerWhenPinned(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + app.width = testAppWidth + app.height = testAppHeight + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + view := app.View() + if !strings.Contains(view, "─") { + test.Error("expected divider line in view when sessions pinned") + } +} diff --git a/internal/tui/app_protocol_test.go b/internal/tui/app_protocol_test.go new file mode 100644 index 0000000..ba752ad --- /dev/null +++ b/internal/tui/app_protocol_test.go @@ -0,0 +1,137 @@ +package tui + +import ( + "testing" + + "github.com/robinojw/dj/internal/state" +) + +const ( + errExpected1Thread = "expected 1 thread, got %d" + errExpectedThreadID = "expected thread %s, got %s" +) + +func TestAppHandlesSessionConfigured(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + msg := SessionConfiguredMsg{ + SessionID: testSessionID1, + Model: testModelName, + } + updated, _ := app.Update(msg) + appModel := updated.(AppModel) + + if appModel.sessionID != testSessionID1 { + test.Errorf("expected sessionID %s, got %s", testSessionID1, appModel.sessionID) + } + + threads := store.All() + if len(threads) != 1 { + test.Fatalf(errExpected1Thread, len(threads)) + } + if threads[0].ID != testSessionID1 { + test.Errorf(errExpectedThreadID, testSessionID1, threads[0].ID) + } +} + +func TestAppHandlesTaskStarted(test *testing.T) { + store := state.NewThreadStore() + store.Add(testSessionID1, testTitleTest) + + app := NewAppModel(store) + app.sessionID = testSessionID1 + + updated, _ := app.Update(TaskStartedMsg{}) + appModel := updated.(AppModel) + + thread, _ := store.Get(testSessionID1) + if thread.Status != state.StatusActive { + test.Errorf("expected active, got %s", thread.Status) + } + if appModel.currentMessageID == "" { + test.Error("expected currentMessageID to be set") + } + if len(thread.Messages) != 1 { + test.Fatalf("expected 1 message, got %d", len(thread.Messages)) + } +} + +func TestAppHandlesAgentDelta(test *testing.T) { + store := state.NewThreadStore() + store.Add(testSessionID1, testTitleTest) + + app := NewAppModel(store) + app.sessionID = testSessionID1 + app.currentMessageID = testMessageID1 + + thread, _ := store.Get(testSessionID1) + thread.AppendMessage(state.ChatMessage{ID: testMessageID1, Role: testRoleAssistant}) + + updated, _ := app.Update(AgentDeltaMsg{Delta: testDeltaHello}) + _ = updated.(AppModel) + + if thread.Messages[0].Content != testDeltaHello { + test.Errorf("expected %s, got %s", testDeltaHello, thread.Messages[0].Content) + } +} + +func TestAppHandlesTaskComplete(test *testing.T) { + store := state.NewThreadStore() + store.Add(testSessionID1, testTitleTest) + + app := NewAppModel(store) + app.sessionID = testSessionID1 + app.currentMessageID = testMessageID1 + + updated, _ := app.Update(TaskCompleteMsg{LastMessage: testLastMessageDone}) + appModel := updated.(AppModel) + + thread, _ := store.Get(testSessionID1) + if thread.Status != state.StatusCompleted { + test.Errorf("expected completed, got %s", thread.Status) + } + if appModel.currentMessageID != "" { + test.Error("expected currentMessageID to be cleared") + } +} + +func TestAppHandlesAgentDeltaWithoutSession(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + updated, _ := app.Update(AgentDeltaMsg{Delta: testDeltaTest}) + _ = updated.(AppModel) +} + +func TestAppAutoApprovesExecRequest(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + msg := ExecApprovalRequestMsg{EventID: testEventID1, Command: testCommandLS} + updated, _ := app.Update(msg) + _ = updated.(AppModel) +} + +func TestAppHandlesThreadCreatedMsg(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app.width = testAppWidth + app.height = testAppHeight + + msg := ThreadCreatedMsg{ThreadID: testNewThreadID, Title: testTitleNewThread} + updated, _ := app.Update(msg) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 1 { + test.Fatalf(errExpected1Thread, len(threads)) + } + if threads[0].ID != testNewThreadID { + test.Errorf(errExpectedThreadID, testNewThreadID, threads[0].ID) + } + if appModel.FocusPane() != FocusPaneSession { + test.Errorf("expected session focus, got %d", appModel.FocusPane()) + } +} diff --git a/internal/tui/app_pty.go b/internal/tui/app_pty.go index ab04c85..52ff8e7 100644 --- a/internal/tui/app_pty.go +++ b/internal/tui/app_pty.go @@ -79,6 +79,43 @@ func (app AppModel) openSession() (tea.Model, tea.Cmd) { return app, app.rebalancePTYSizes() } +func (app AppModel) selectedThreadID() string { + if app.canvasMode == CanvasModeTree { + return app.tree.SelectedID() + } + return app.canvas.SelectedThreadID() +} + +func (app AppModel) killSession() (tea.Model, tea.Cmd) { + threadID := app.selectedThreadID() + if threadID == "" { + return app, nil + } + + app.stopAndRemovePTY(threadID) + app.sessionPanel.Unpin(threadID) + app.store.Delete(threadID) + app.canvas.ClampSelected() + app.tree.Refresh() + app.statusBar.SetThreadCount(len(app.store.All())) + + hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 + if !hasPinned { + app.focusPane = FocusPaneCanvas + } + + return app, app.rebalancePTYSizes() +} + +func (app *AppModel) stopAndRemovePTY(threadID string) { + ptySession, exists := app.ptySessions[threadID] + if !exists { + return + } + ptySession.Stop() + delete(app.ptySessions, threadID) +} + func (app AppModel) togglePin() (tea.Model, tea.Cmd) { threadID := app.canvas.SelectedThreadID() if threadID == "" { diff --git a/internal/tui/app_session_test.go b/internal/tui/app_session_test.go new file mode 100644 index 0000000..2379a29 --- /dev/null +++ b/internal/tui/app_session_test.go @@ -0,0 +1,290 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const ( + errExpected1Pinned = "expected 1 pinned session, got %d" + errExpectedSessionFocus = "expected session focus, got %d" + errExpectedStrFmt = "expected %s, got %s" +) + +func TestAppEnterOpensSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTestTask) + + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() + + if appModel.FocusPane() != FocusPaneSession { + test.Errorf(errExpectedSessionFocus, appModel.FocusPane()) + } + + if len(appModel.sessionPanel.PinnedSessions()) != 1 { + test.Fatalf(errExpected1Pinned, len(appModel.sessionPanel.PinnedSessions())) + } + + _, hasPTY := appModel.ptySessions[testThreadID1] + if !hasPTY { + test.Errorf("expected PTY session for thread %s", testThreadID1) + } +} + +func TestAppEscClosesSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTestTask) + + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = appModel.Update(escKey) + appModel = updated.(AppModel) + + if appModel.FocusPane() != FocusPaneCanvas { + test.Errorf("expected canvas focus after Esc, got %d", appModel.FocusPane()) + } + + _, hasPTY := appModel.ptySessions[testThreadID1] + if !hasPTY { + test.Error("expected PTY session to stay alive after Esc") + } + + if len(appModel.sessionPanel.PinnedSessions()) != 1 { + test.Error("expected session to remain pinned after Esc") + } +} + +func TestAppEnterWithNoThreadsDoesNothing(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + appModel := updated.(AppModel) + + if appModel.FocusPane() != FocusPaneCanvas { + test.Errorf("expected canvas focus when no threads, got %d", appModel.FocusPane()) + } +} + +func TestAppForwardKeyToPTY(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + if app.FocusPane() != FocusPaneSession { + test.Fatal("expected session focus after Enter") + } + + letterKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} + updated, _ = app.Update(letterKey) + app = updated.(AppModel) + + if app.FocusPane() != FocusPaneSession { + test.Errorf("expected session focus maintained, got %d", app.FocusPane()) + } +} + +func TestAppReconnectsExistingPTY(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + if app.FocusPane() != FocusPaneCanvas { + test.Errorf("expected canvas focus, got %d", app.FocusPane()) + } + + updated, _ = app.Update(enterKey) + app = updated.(AppModel) + + if app.FocusPane() != FocusPaneSession { + test.Errorf("expected session focus on reconnect, got %d", app.FocusPane()) + } + + if len(app.ptySessions) != 1 { + test.Errorf("expected 1 PTY session (reused), got %d", len(app.ptySessions)) + } +} + +func TestAppHandlesPTYOutput(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleTest) + + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + exitMsg := PTYOutputMsg{ThreadID: testThreadID1, Exited: true} + updated, _ = app.Update(exitMsg) + _ = updated.(AppModel) +} + +func TestAppSpacePinsSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() + + if len(appModel.sessionPanel.PinnedSessions()) != 1 { + test.Fatalf("expected 1 pinned, got %d", len(appModel.sessionPanel.PinnedSessions())) + } + if appModel.sessionPanel.PinnedSessions()[0] != testThreadID1 { + test.Errorf(errExpectedStrFmt, testThreadID1, appModel.sessionPanel.PinnedSessions()[0]) + } +} + +func TestAppSpaceUnpinsSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + appModel := updated.(AppModel) + + updated2, _ := appModel.Update(spaceKey) + appModel2 := updated2.(AppModel) + defer appModel2.StopAllPTYSessions() + + if len(appModel2.sessionPanel.PinnedSessions()) != 0 { + test.Errorf("expected 0 pinned after unpin, got %d", len(appModel2.sessionPanel.PinnedSessions())) + } +} + +func TestAppTabSwitchesToSessionPanel(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + + tabKey := tea.KeyMsg{Type: tea.KeyTab} + updated, _ = app.Update(tabKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + if app.FocusPane() != FocusPaneSession { + test.Errorf("expected FocusPaneSession, got %d", app.FocusPane()) + } +} + +func TestAppTabDoesNothingWithNoPinnedSessions(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + tabKey := tea.KeyMsg{Type: tea.KeyTab} + updated, _ := app.Update(tabKey) + app = updated.(AppModel) + + if app.FocusPane() != FocusPaneCanvas { + test.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) + } +} + +func TestAppNewThreadCreatesAndOpensSession(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app.width = testAppWidth + app.height = testAppHeight + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + + if cmd == nil { + test.Fatal("expected command from n key") + } + + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 1 { + test.Fatalf(errExpected1Thread, len(threads)) + } + + if app.FocusPane() != FocusPaneSession { + test.Errorf(errExpectedSessionFocus, app.FocusPane()) + } + + if len(app.sessionPanel.PinnedSessions()) != 1 { + test.Errorf(errExpected1Pinned, len(app.sessionPanel.PinnedSessions())) + } +} + +func TestAppNewThreadIncrementsTitle(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app.width = testAppWidth + app.height = testAppHeight + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + escKey := tea.KeyMsg{Type: tea.KeyEsc} + + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + updated, cmd = app.Update(nKey) + app = updated.(AppModel) + msg = cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != testExpectedTwo { + test.Fatalf("expected %d threads, got %d", testExpectedTwo, len(threads)) + } + if threads[0].Title != testTitleSession1 { + test.Errorf(errExpectedStrFmt, testTitleSession1, threads[0].Title) + } + if threads[1].Title != testTitleSession2 { + test.Errorf(errExpectedStrFmt, testTitleSession2, threads[1].Title) + } +} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 31b1098..bba81f0 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -8,10 +8,36 @@ import ( "github.com/robinojw/dj/internal/state" ) -func TestAppHandlesArrowKeys(t *testing.T) { +const ( + testSessionID1 = "s-1" + testNewThreadID = "t-new" + testTitleTest = "Test" + testTitleTestTask = "Test Task" + testTitleThread1 = "Thread 1" + testTitleThread2 = "Thread 2" + testTitleNewThread = "New Thread" + testTitleSession1 = "Session 1" + testTitleSession2 = "Session 2" + testCommandCat = "cat" + testCommandEcho = "echo" + testArgHello = "hello" + testMessageID1 = "msg-1" + testModelName = "o4-mini" + testEventID1 = "req-1" + testCommandLS = "ls" + testDeltaHello = "Hello" + testDeltaTest = "test" + testLastMessageDone = "Done" + testRoleAssistant = "assistant" + testAppWidth = 120 + testAppHeight = 40 + testExpectedTwo = 2 +) + +func TestAppHandlesArrowKeys(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) app := NewAppModel(store) @@ -20,103 +46,18 @@ func TestAppHandlesArrowKeys(t *testing.T) { appModel := updated.(AppModel) if appModel.canvas.SelectedIndex() != 1 { - t.Errorf("expected index 1 after right, got %d", appModel.canvas.SelectedIndex()) - } -} - -func TestAppHandlesSessionConfigured(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - msg := SessionConfiguredMsg{ - SessionID: "s-1", - Model: "o4-mini", - } - updated, _ := app.Update(msg) - appModel := updated.(AppModel) - - if appModel.sessionID != "s-1" { - t.Errorf("expected sessionID s-1, got %s", appModel.sessionID) - } - - threads := store.All() - if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) - } - if threads[0].ID != "s-1" { - t.Errorf("expected thread s-1, got %s", threads[0].ID) - } -} - -func TestAppHandlesTaskStarted(t *testing.T) { - store := state.NewThreadStore() - store.Add("s-1", "Test") - - app := NewAppModel(store) - app.sessionID = "s-1" - - updated, _ := app.Update(TaskStartedMsg{}) - appModel := updated.(AppModel) - - thread, _ := store.Get("s-1") - if thread.Status != state.StatusActive { - t.Errorf("expected active, got %s", thread.Status) - } - if appModel.currentMessageID == "" { - t.Error("expected currentMessageID to be set") - } - if len(thread.Messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(thread.Messages)) - } -} - -func TestAppHandlesAgentDelta(t *testing.T) { - store := state.NewThreadStore() - store.Add("s-1", "Test") - - app := NewAppModel(store) - app.sessionID = "s-1" - app.currentMessageID = "msg-1" - - thread, _ := store.Get("s-1") - thread.AppendMessage(state.ChatMessage{ID: "msg-1", Role: "assistant"}) - - updated, _ := app.Update(AgentDeltaMsg{Delta: "Hello"}) - _ = updated.(AppModel) - - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) - } -} - -func TestAppHandlesTaskComplete(t *testing.T) { - store := state.NewThreadStore() - store.Add("s-1", "Test") - - app := NewAppModel(store) - app.sessionID = "s-1" - app.currentMessageID = "msg-1" - - updated, _ := app.Update(TaskCompleteMsg{LastMessage: "Done"}) - appModel := updated.(AppModel) - - thread, _ := store.Get("s-1") - if thread.Status != state.StatusCompleted { - t.Errorf("expected completed, got %s", thread.Status) - } - if appModel.currentMessageID != "" { - t.Error("expected currentMessageID to be cleared") + test.Errorf("expected index 1 after right, got %d", appModel.canvas.SelectedIndex()) } } -func TestAppToggleCanvasMode(t *testing.T) { +func TestAppToggleCanvasMode(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "Test") + store.Add(testThreadID1, testTitleTest) app := NewAppModel(store) if app.CanvasMode() != CanvasModeGrid { - t.Errorf("expected grid mode, got %d", app.CanvasMode()) + test.Errorf("expected grid mode, got %d", app.CanvasMode()) } tKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} @@ -124,14 +65,14 @@ func TestAppToggleCanvasMode(t *testing.T) { appModel := updated.(AppModel) if appModel.CanvasMode() != CanvasModeTree { - t.Errorf("expected tree mode, got %d", appModel.CanvasMode()) + test.Errorf("expected tree mode, got %d", appModel.CanvasMode()) } } -func TestAppTreeNavigationWhenFocused(t *testing.T) { +func TestAppTreeNavigationWhenFocused(test *testing.T) { store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) app := NewAppModel(store) @@ -143,12 +84,12 @@ func TestAppTreeNavigationWhenFocused(t *testing.T) { updated, _ = app.Update(downKey) app = updated.(AppModel) - if app.tree.SelectedID() != "t-2" { - t.Errorf("expected tree at t-2, got %s", app.tree.SelectedID()) + if app.tree.SelectedID() != testThreadID2 { + test.Errorf("expected tree at %s, got %s", testThreadID2, app.tree.SelectedID()) } } -func TestAppHandlesQuit(t *testing.T) { +func TestAppHandlesQuit(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) @@ -156,162 +97,11 @@ func TestAppHandlesQuit(t *testing.T) { _, cmd := app.Update(quitKey) if cmd == nil { - t.Fatal("expected quit command") - } -} - -func TestAppEnterOpensSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test Task") - - app := NewAppModel(store, WithInteractiveCommand("cat")) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - if appModel.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus, got %d", appModel.FocusPane()) - } - - if len(appModel.sessionPanel.PinnedSessions()) != 1 { - t.Fatalf("expected 1 pinned session, got %d", len(appModel.sessionPanel.PinnedSessions())) - } - - _, hasPTY := appModel.ptySessions["t-1"] - if !hasPTY { - t.Error("expected PTY session to be stored for thread t-1") + test.Fatal("expected quit command") } } -func TestAppEscClosesSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test Task") - - app := NewAppModel(store, WithInteractiveCommand("cat")) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = appModel.Update(escKey) - appModel = updated.(AppModel) - - if appModel.FocusPane() != FocusPaneCanvas { - t.Errorf("expected canvas focus after Esc, got %d", appModel.FocusPane()) - } - - _, hasPTY := appModel.ptySessions["t-1"] - if !hasPTY { - t.Error("expected PTY session to stay alive after Esc") - } - - if len(appModel.sessionPanel.PinnedSessions()) != 1 { - t.Error("expected session to remain pinned after Esc") - } -} - -func TestAppEnterWithNoThreadsDoesNothing(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - - if appModel.FocusPane() != FocusPaneCanvas { - t.Errorf("expected canvas focus when no threads, got %d", appModel.FocusPane()) - } -} - -func TestAppCtrlBMOpensMenu(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - updated, _ = app.Update(mKey) - app = updated.(AppModel) - - if !app.MenuVisible() { - t.Error("expected menu to be visible") - } -} - -func TestAppMenuEscCloses(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - updated, _ = app.Update(mKey) - app = updated.(AppModel) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - if app.MenuVisible() { - t.Error("expected menu hidden after Esc") - } -} - -func TestAppCtrlBEscCancelsPrefix(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - if app.MenuVisible() { - t.Error("expected menu not visible after prefix cancel") - } -} - -func TestAppMenuNavigation(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - updated, _ = app.Update(mKey) - app = updated.(AppModel) - - downKey := tea.KeyMsg{Type: tea.KeyDown} - updated, _ = app.Update(downKey) - app = updated.(AppModel) - - if app.menu.SelectedIndex() != 1 { - t.Errorf("expected menu index 1, got %d", app.menu.SelectedIndex()) - } -} - -func TestAppHelpToggle(t *testing.T) { +func TestAppHelpToggle(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) @@ -320,18 +110,18 @@ func TestAppHelpToggle(t *testing.T) { appModel := updated.(AppModel) if !appModel.HelpVisible() { - t.Error("expected help to be visible") + test.Error("expected help to be visible") } updated, _ = appModel.Update(helpKey) appModel = updated.(AppModel) if appModel.HelpVisible() { - t.Error("expected help to be hidden") + test.Error("expected help to be hidden") } } -func TestAppHelpEscCloses(t *testing.T) { +func TestAppHelpEscCloses(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) @@ -344,11 +134,11 @@ func TestAppHelpEscCloses(t *testing.T) { appModel = updated.(AppModel) if appModel.HelpVisible() { - t.Error("expected help hidden after Esc") + test.Error("expected help hidden after Esc") } } -func TestAppNewThread(t *testing.T) { +func TestAppNewThread(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) @@ -356,394 +146,38 @@ func TestAppNewThread(t *testing.T) { _, cmd := app.Update(nKey) if cmd == nil { - t.Error("expected command for thread creation") - } -} - -func TestAppHandlesThreadCreatedMsg(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand("cat")) - app.width = 120 - app.height = 40 - - msg := ThreadCreatedMsg{ThreadID: "t-new", Title: "New Thread"} - updated, _ := app.Update(msg) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) - } - if threads[0].ID != "t-new" { - t.Errorf("expected thread t-new, got %s", threads[0].ID) - } - if appModel.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus, got %d", appModel.FocusPane()) + test.Error("expected command for thread creation") } } -func TestAppHandlesAgentDeltaWithoutSession(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - updated, _ := app.Update(AgentDeltaMsg{Delta: "test"}) - _ = updated.(AppModel) -} - -func TestAppAutoApprovesExecRequest(t *testing.T) { +func TestAppFocusPaneDefaultsToCanvas(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) - msg := ExecApprovalRequestMsg{EventID: "req-1", Command: "ls"} - updated, _ := app.Update(msg) - _ = updated.(AppModel) -} - -func TestAppForwardKeyToPTY(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store, WithInteractiveCommand("cat")) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if app.FocusPane() != FocusPaneSession { - t.Fatal("expected session focus after Enter") - } - - letterKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} - updated, _ = app.Update(letterKey) - app = updated.(AppModel) - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus maintained, got %d", app.FocusPane()) - } -} - -func TestAppReconnectsExistingPTY(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store, WithInteractiveCommand("cat")) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = app.Update(escKey) - app = updated.(AppModel) - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected canvas focus, got %d", app.FocusPane()) - } - - updated, _ = app.Update(enterKey) - app = updated.(AppModel) - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus on reconnect, got %d", app.FocusPane()) - } - - if len(app.ptySessions) != 1 { - t.Errorf("expected 1 PTY session (reused), got %d", len(app.ptySessions)) + test.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) } } -func TestAppHandlesPTYOutput(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store, WithInteractiveCommand("cat")) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - exitMsg := PTYOutputMsg{ThreadID: "t-1", Exited: true} - updated, _ = app.Update(exitMsg) - _ = updated.(AppModel) -} - -func TestAppSpacePinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - if len(appModel.sessionPanel.PinnedSessions()) != 1 { - t.Fatalf("expected 1 pinned, got %d", len(appModel.sessionPanel.PinnedSessions())) - } - if appModel.sessionPanel.PinnedSessions()[0] != "t-1" { - t.Errorf("expected t-1, got %s", appModel.sessionPanel.PinnedSessions()[0]) - } -} - -func TestAppSpaceUnpinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - appModel := updated.(AppModel) - - updated2, _ := appModel.Update(spaceKey) - appModel2 := updated2.(AppModel) - defer appModel2.StopAllPTYSessions() - - if len(appModel2.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned after unpin, got %d", len(appModel2.sessionPanel.PinnedSessions())) - } -} - -func TestAppTabSwitchesToSessionPanel(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tabKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected FocusPaneSession, got %d", app.FocusPane()) - } -} - -func TestAppTabDoesNothingWithNoPinnedSessions(t *testing.T) { +func TestAppHasPinnedSessions(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ := app.Update(tabKey) - app = updated.(AppModel) - - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) - } -} - -func TestAppCtrlBXUnpinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tabKey) - app = updated.(AppModel) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - xKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} - updated, _ = app.Update(xKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - if len(app.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned after unpin, got %d", len(app.sessionPanel.PinnedSessions())) - } - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected focus back to canvas, got %d", app.FocusPane()) - } -} - -func TestAppCtrlBZTogglesZoom(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - zKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}} - updated, _ = app.Update(zKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if !app.sessionPanel.Zoomed() { - t.Error("expected zoomed after Ctrl+B z") + test.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) } } -func TestAppCtrlBRightCyclesPaneRight(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - store.Add("t-2", "Thread 2") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - space := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(space) - app = updated.(AppModel) - - app.canvas.MoveRight() - updated, _ = app.Update(space) - app = updated.(AppModel) - - tab := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tab) - app = updated.(AppModel) - - if app.sessionPanel.ActivePaneIdx() != 0 { - t.Fatalf("expected active pane 0, got %d", app.sessionPanel.ActivePaneIdx()) - } - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - rightKey := tea.KeyMsg{Type: tea.KeyRight} - updated, _ = app.Update(rightKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if app.sessionPanel.ActivePaneIdx() != 1 { - t.Errorf("expected active pane 1, got %d", app.sessionPanel.ActivePaneIdx()) - } -} - -func TestAppViewShowsDividerWhenPinned(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - app.width = 120 - app.height = 40 - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - view := app.View() - if !strings.Contains(view, "─") { - t.Error("expected divider line in view when sessions pinned") - } -} - -func TestHelpShowsPinKeybinding(t *testing.T) { +func TestHelpShowsPinKeybinding(test *testing.T) { help := NewHelpModel() view := help.View() if !strings.Contains(view, "Space") { - t.Error("expected Space keybinding in help") + test.Error("expected Space keybinding in help") } if !strings.Contains(view, "Ctrl+B x") { - t.Error("expected Ctrl+B x keybinding in help") + test.Error("expected Ctrl+B x keybinding in help") } if !strings.Contains(view, "Ctrl+B z") { - t.Error("expected Ctrl+B z keybinding in help") - } -} - -func TestAppFocusPaneDefaultsToCanvas(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) - } -} - -func TestAppHasPinnedSessions(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - if len(app.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) - } -} - -func TestAppNewThreadCreatesAndOpensSession(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand("cat")) - app.width = 120 - app.height = 40 - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - - if cmd == nil { - t.Fatal("expected command from n key") - } - - msg := cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) - } - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus after new thread, got %d", app.FocusPane()) - } - - if len(app.sessionPanel.PinnedSessions()) != 1 { - t.Errorf("expected 1 pinned session, got %d", len(app.sessionPanel.PinnedSessions())) - } -} - -func TestAppNewThreadIncrementsTitle(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand("cat")) - app.width = 120 - app.height = 40 - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - escKey := tea.KeyMsg{Type: tea.KeyEsc} - - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - msg := cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - updated, cmd = app.Update(nKey) - app = updated.(AppModel) - msg = cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 2 { - t.Fatalf("expected 2 threads, got %d", len(threads)) - } - if threads[0].Title != "Session 1" { - t.Errorf("expected 'Session 1', got %s", threads[0].Title) - } - if threads[1].Title != "Session 2" { - t.Errorf("expected 'Session 2', got %s", threads[1].Title) + test.Error("expected Ctrl+B z keybinding in help") } } From a8c01a24332968cfdebe5efbfd0f2389fccfbcd2 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 16:37:51 -0400 Subject: [PATCH 3/4] feat: add kill session keybinding to help screen --- internal/tui/app_test.go | 8 ++++++++ internal/tui/help.go | 1 + 2 files changed, 9 insertions(+) diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index bba81f0..e28bcdc 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -181,3 +181,11 @@ func TestHelpShowsPinKeybinding(test *testing.T) { test.Error("expected Ctrl+B z keybinding in help") } } + +func TestHelpShowsKillKeybinding(test *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Kill") { + test.Error("expected Kill keybinding in help") + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index a57c0b3..3b89440 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -44,6 +44,7 @@ var keybindings = []keybinding{ {"Esc", "Back / close overlay"}, {"t", "Toggle tree view"}, {"n", "New thread"}, + {"k", "Kill selected session"}, {"Ctrl+B", "Prefix key (tmux-style)"}, {"Ctrl+B m", "Open context menu"}, {"Ctrl+B ←/→", "Cycle session panes"}, From c3c040cf3adb058c3d8215324072dd7041a49e0f Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 16:45:35 -0400 Subject: [PATCH 4/4] test: add tree mode kill session test --- internal/tui/app_kill_test.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/tui/app_kill_test.go b/internal/tui/app_kill_test.go index 2658912..e88b2a7 100644 --- a/internal/tui/app_kill_test.go +++ b/internal/tui/app_kill_test.go @@ -7,6 +7,8 @@ import ( "github.com/robinojw/dj/internal/state" ) +const errExpectedRemaining = "expected %s remaining, got %s" + func TestAppKillSessionRemovesThread(test *testing.T) { store := state.NewThreadStore() store.Add(testThreadID1, testTitleThread1) @@ -22,7 +24,7 @@ func TestAppKillSessionRemovesThread(test *testing.T) { test.Fatalf("expected 1 thread after kill, got %d", len(threads)) } if threads[0].ID != testThreadID2 { - test.Errorf("expected %s remaining, got %s", testThreadID2, threads[0].ID) + test.Errorf(errExpectedRemaining, testThreadID2, threads[0].ID) } _ = appModel } @@ -125,3 +127,30 @@ func TestAppKillSessionReturnsFocusToCanvas(test *testing.T) { test.Errorf("expected canvas focus after killing last pinned, got %d", appModel.FocusPane()) } } + +func TestAppKillSessionInTreeMode(test *testing.T) { + store := state.NewThreadStore() + store.Add(testThreadID1, testThreadTitle1) + store.Add(testThreadID2, testThreadTitle2) + app := NewAppModel(store) + + tKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} + updated, _ := app.Update(tKey) + app = updated.(AppModel) + + downKey := tea.KeyMsg{Type: tea.KeyDown} + updated, _ = app.Update(downKey) + app = updated.(AppModel) + + kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + updated, _ = app.Update(kKey) + _ = updated.(AppModel) + + threads := store.All() + if len(threads) != 1 { + test.Fatalf("expected 1 thread after kill in tree mode, got %d", len(threads)) + } + if threads[0].ID != testThreadID1 { + test.Errorf(errExpectedRemaining, testThreadID1, threads[0].ID) + } +}