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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ own front-end settings are separate; see [Configuration](#configuration).
| `F1` | Show the help card |
| `wheel` (with `--mouse`) | Scroll the transcript · click tool rows, turn heads, and the cockpit |
| `⏎` (disconnected, empty input) | Retry the connection |
| `^C` | Quit |
| `^C` | Quit (confirm: `y` or a second `^C`) |

**Every printable character always types.** No bare letter, digit, or
punctuation key is ever bound in the composer — actions live on chords and
Expand Down Expand Up @@ -342,6 +342,7 @@ full command and press `⏎`.
|---------|--------|
| `/help` | Show available commands and key bindings |
| `/clear` | Clear the conversation (two-step confirm; idle only) |
| `/new` | Start a fresh session — new ID, empty context; the old one stays resumable via `/sessions` (idle only) |
| `/copy` | Copy the last reply to the clipboard (OSC 52) |
| `/retry` | Re-send the last prompt (queues it if a turn is running) |
| `/theme [name]` | Switch the color theme at runtime and persist it (`ember-dark` · `ember-light` · `high-contrast` · `classic`) |
Expand Down
6 changes: 2 additions & 4 deletions internal/tui/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.vp.GotoBottom()
return m, nil
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
}
return m, nil
}
Expand Down Expand Up @@ -115,8 +114,7 @@ func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.vp.GotoBottom()
return m, nil
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
default:
// Single printable runes only — modifiers (ctrl+X, alt+X) must not
// splice escape bytes into the confirmation buffer.
Expand Down
3 changes: 1 addition & 2 deletions internal/tui/cockpit.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ func (m *Model) handleCockpitMsg(msg cockpitMsg) tea.Cmd {
func (m *Model) handlePopoverKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
case "esc", "h", "q":
m.popover = false
m.refresh()
Expand Down
15 changes: 15 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ func slashCommands() []command {
}
return m.armConfirm(confirmClear, "the conversation")
}},
{"new", "start a fresh session (the old one stays resumable)", func(m *Model, _ string) tea.Cmd {
// Idle-only like /clear, plus the connection-scoped state a
// forced redial would destroy: pending approvals die with the
// socket, and queued prompts belong to THIS conversation.
if m.busy {
return m.transientNoteCmd("can't start a new session while a turn runs — esc cancels it first")
}
if len(m.approvals) > 0 {
return m.transientNoteCmd("answer the pending approval first — it dies with the connection")
}
if len(m.queue) > 0 {
return m.transientNoteCmd("drain the prompt queue first (ctrl+q)")
}
return m.startFreshSession()
}},
{"copy", "copy the last reply to the clipboard (OSC 52)", func(m *Model, _ string) tea.Cmd {
return m.copyLastReply()
}},
Expand Down
14 changes: 14 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,17 @@ func TestE2EAllCommands(t *testing.T) {
t.Fatal("/quit did not set quitting")
}
},
"/new": func(t *testing.T, m *Model) {
if m.sessionID != "" || m.authToken != "" {
t.Fatalf("session identity not dropped: sid=%q", m.sessionID)
}
if len(m.msgs) != 0 {
t.Fatalf("transcript not wiped: %d msgs", len(m.msgs))
}
if !m.freshStart {
t.Fatal("freshStart not armed for the reconnect note")
}
},
"/plan": func(t *testing.T, m *Model) {
if m.panel != panelPlan {
t.Fatalf("/plan opened panel %d", m.panel)
Expand Down Expand Up @@ -330,6 +341,9 @@ func TestE2EAllCommands(t *testing.T) {
case "/cancel":
m.busy = true
m.sessionID, m.authToken = "s1", "a1"
case "/new":
m.sessionID, m.authToken = "s1", "a1"
m.msgs = append(m.msgs, message{role: roleUser, content: "x"})
case "/attach":
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
Expand Down
9 changes: 7 additions & 2 deletions internal/tui/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,15 @@ func TestCommandPopupKeepsTyping(t *testing.T) {
if got := m.ta.Value(); got != "/" {
t.Errorf("input after backspace = %q, want %q", got, "/")
}
// ctrl+c still quits while the popup has capture.
// ctrl+c arms the quit gate while the popup has capture; a second ^C
// confirms.
m.Update(key("ctrl+c"))
if m.confirm != confirmQuit {
t.Error("ctrl+c should arm the quit gate while the popup is open")
}
m.Update(key("ctrl+c"))
if !m.quitting {
t.Error("ctrl+c should quit while the popup is open")
t.Error("second ctrl+c should quit while the popup is open")
}
}

Expand Down
6 changes: 5 additions & 1 deletion internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,11 @@ func TestTinyHelpers(t *testing.T) {
func TestQuitKeys(t *testing.T) {
m := wired(t)
m.Update(key("ctrl+c"))
if m.confirm != confirmQuit || m.quitting {
t.Fatalf("ctrl+c should arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting)
}
m.Update(key("ctrl+c"))
if !m.quitting {
t.Error("ctrl+c should set quitting")
t.Error("second ctrl+c should set quitting")
}
}
6 changes: 5 additions & 1 deletion internal/tui/dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ func TestPanelKeyBoundsAndQuit(t *testing.T) {
t.Errorf("panelSel = %d", m.panelSel)
}
m.Update(key("ctrl+c"))
if m.confirm != confirmQuit || m.quitting {
t.Fatalf("ctrl+c in panel should arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting)
}
m.Update(key("ctrl+c"))
if !m.quitting {
t.Error("ctrl+c in panel should quit")
t.Error("second ctrl+c in panel should quit")
}
}

Expand Down
6 changes: 5 additions & 1 deletion internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.relayout() // the busy status line is gone with the socket
if cmd := m.scheduleReconnect(0); cmd != nil {
m.status = "reconnecting…"
m.addTransientNote("connection lost — reconnecting…")
if m.freshStart {
m.addTransientNote("starting a fresh session…")
} else {
m.addTransientNote("connection lost — reconnecting…")
}
m.refresh()
// The interim note fades via the sweep; the reconnect outcome
// (success or the ⏎-retry hint) replaces it within seconds.
Expand Down
3 changes: 1 addition & 2 deletions internal/tui/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ func (m *Model) closeFind() {
func (m *Model) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
case "esc", "alt+f":
m.closeFind()
return m, nil
Expand Down
8 changes: 6 additions & 2 deletions internal/tui/find_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,14 @@ func TestFindKeyRouting(t *testing.T) {
seedConversation(m)
m.Update(key("alt+f"))

// ctrl+c quits from the find bar like from anywhere else.
// ctrl+c arms the quit gate from the find bar like from anywhere else.
m.Update(key("ctrl+c"))
if m.confirm != confirmQuit || m.quitting {
t.Fatalf("ctrl+c in the find bar did not arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting)
}
m.Update(key("ctrl+c"))
if !m.quitting {
t.Error("ctrl+c in the find bar did not quit")
t.Error("second ctrl+c in the find bar did not quit")
}

// Backspace pops the query and rescans; past the last rune it is a
Expand Down
3 changes: 1 addition & 2 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ func (m *Model) handleACKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.closeAC()
return m, nil
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
}
// Any other key is plain input: forward it to the textarea, then
// re-evaluate the popup against the new value — typing narrows the
Expand Down
27 changes: 25 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ type Model struct {
planAvail planAvailability // endpoint health tri-state
planTrig bool // a plan tool_call awaits tail-batch pickup
planResetPending bool // session changed; reset+refetch at tail
freshStart bool // /new drop: reconnect lands on a fresh session
planDebSeq int // debounce window sequence
planReqSeq int // fetch request sequence
planPollSeq int // armed poll tick sequence
Expand Down Expand Up @@ -726,8 +727,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {

switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
case "esc":
// The run's kill switch sits behind the same two-step gate as every
// other destructive action — esc is too easy to hit by accident.
Expand Down Expand Up @@ -908,6 +908,29 @@ func (m *Model) clearConversation() {
m.refresh()
}

// startFreshSession tears down the current conversation AND its server-side
// session: /clear only wipes the local view, while the session (history,
// context) keeps accumulating on the server. Dropping sessionID/authToken
// before the forced redial leaves adoptSession nothing to re-adopt, so the
// fresh connection stays sessionless — and odek mints a brand-new session
// (new ID, empty history and memory buffer) on the connection's first prompt.
// The old session stays on disk, resumable via /sessions.
func (m *Model) startFreshSession() tea.Cmd {
m.clearConversation()
m.sessionID = ""
m.authToken = ""
m.pendModel = m.model // the new session re-asserts the active model
m.resetPlanState()
m.freshStart = true
cl := m.cl
return func() tea.Msg {
if cl != nil {
_ = cl.Close() // the drop runs the standard disconnect→reconnect flow
}
return nil
}
}

// curApproval returns the head of the approval queue, or nil when empty.
func (m *Model) curApproval() *client.Event {
if len(m.approvals) == 0 {
Expand Down
146 changes: 146 additions & 0 deletions internal/tui/new_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package tui

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"

"github.com/BackendStack21/bodek/internal/client"
)

// /new starts a genuinely fresh session: the transcript is wiped AND the
// connection drops its session identity, so the forced redial lands on a
// sessionless connection — odek binds sessions per connection, and the next
// prompt on a sessionless one mints a brand-new session server-side. The old
// session stays on disk, resumable via /sessions.

// runNew invokes the /new command through the registry.
func runNew(m *Model) tea.Cmd {
for _, c := range slashCommands() {
if c.name == "new" {
return c.run(m, "")
}
}
return nil
}

func TestNewCommandRegistered(t *testing.T) {
for _, c := range slashCommands() {
if c.name == "new" {
if c.desc == "" {
t.Fatal("/new registered with an empty description")
}
return
}
}
t.Fatal("/new is not in the command registry")
}

func seedSessionState(m *Model) {
m.sessionID = "s1"
m.authToken = "a1"
m.model = "glm-5.3-flash"
seedConversation(m)
m.planInit = true
m.planVer = 2
m.planAvail = planAvailable
}

func TestNewGuards(t *testing.T) {
t.Run("busy", func(t *testing.T) {
m := wired(t)
seedSessionState(m)
busyTurn(m)

if cmd := runNew(m); cmd == nil {
t.Fatal("busy /new returned no command (want a note cmd)")
}
if m.sessionID != "s1" || len(m.msgs) == 0 {
t.Fatalf("busy /new must be a no-op: sid=%q msgs=%d", m.sessionID, len(m.msgs))
}
})
t.Run("pending approval", func(t *testing.T) {
m := wired(t)
seedSessionState(m)
m.handleEvent(client.Event{Type: "approval_request", ID: "apr-1"})

if cmd := runNew(m); cmd == nil {
t.Fatal("/new with a pending approval returned no command (want a note cmd)")
}
if m.sessionID != "s1" || len(m.msgs) == 0 {
t.Fatalf("/new must not run with a pending approval: sid=%q msgs=%d", m.sessionID, len(m.msgs))
}
})
t.Run("queued prompts", func(t *testing.T) {
m := wired(t)
seedSessionState(m)
m.queue = []string{"queued for THIS conversation"}

if cmd := runNew(m); cmd == nil {
t.Fatal("/new with a queue returned no command (want a note cmd)")
}
if m.sessionID != "s1" || len(m.queue) != 1 {
t.Fatalf("/new must not run with queued prompts: sid=%q queue=%d", m.sessionID, len(m.queue))
}
})
}

func TestNewResetsAndDropsIdentity(t *testing.T) {
m := wired(t)
seedSessionState(m)

cmd := runNew(m)
if cmd == nil {
t.Fatal("/new returned no command (want the socket close)")
}

if m.sessionID != "" || m.authToken != "" {
t.Fatalf("session identity not dropped: sid=%q tok=%q", m.sessionID, m.authToken)
}
if len(m.msgs) != 0 || m.turnStats != nil || m.toolTotal != 0 {
t.Fatalf("transcript not wiped: msgs=%d stats=%d tools=%d", len(m.msgs), len(m.turnStats), m.toolTotal)
}
if m.planInit || m.planAvail != planUnknown || m.planVer != 0 {
t.Fatalf("plan state not reset: init=%v avail=%v ver=%d", m.planInit, m.planAvail, m.planVer)
}
if m.pendModel != "glm-5.3-flash" {
t.Errorf("model not re-asserted on the next prompt: pendModel=%q", m.pendModel)
}
if !m.freshStart {
t.Error("freshStart flag not armed — reconnect will show the resume note")
}
// The reconnect must have nothing to re-adopt: adoptSession no-ops on an
// empty session id, so the fresh connection stays sessionless.
if cmd := m.adoptSession(); cmd != nil {
t.Error("adoptSession is not a no-op after /new — the old session would be re-adopted")
}
}

func TestNewReconnectLandsFresh(t *testing.T) {
m := wired(t)
seedSessionState(m)
runNew(m)
m.disconn = true // the socket close lands as EventDisconnected

m.handleReconnect(reconnectMsg{cl: m.cl})

if m.disconn || m.status != "ready" {
t.Fatalf("reconnect did not land: disconn=%v status=%q", m.disconn, m.status)
}
if m.freshStart {
t.Error("freshStart flag not consumed by the reconnect")
}
if m.sessionID != "" {
t.Fatalf("reconnect re-adopted a session: %q", m.sessionID)
}
found := false
for _, n := range m.notices {
if strings.Contains(n, "fresh session") {
found = true
}
}
if !found {
t.Errorf("no fresh-session note after reconnect: %v", m.notices)
}
}
3 changes: 1 addition & 2 deletions internal/tui/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,7 @@ func fuzzyScore(query, s string) (int, bool) {
func (m *Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
return m, m.armConfirm(confirmQuit, "bodek")
case "esc", "ctrl+k":
m.pal.open = false
m.relayout()
Expand Down
Loading