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
6 changes: 5 additions & 1 deletion internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,11 @@ func validSessionID(id string) bool {
// copySessionID puts the active session id on the clipboard — the payload
// for a later `bodek --session <id>`. The ✓ Copied flash is the only ack.
func (m *Model) copySessionID() tea.Cmd {
if !validSessionID(m.sessionID) {
// sessionLive, not just a non-empty id: the connect-time session event
// stamps an id before any prompt — that placeholder is never the
// operator's session, so the command stays dormant until a prompt (or
// a resume) makes the session real.
if !m.sessionLive || !validSessionID(m.sessionID) {
return m.transientNoteCmd("no session yet — the id exists once a session is created; send a prompt first")
}
return m.copyText(m.sessionID)
Expand Down
17 changes: 17 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ type command struct {
run func(m *Model, args string) tea.Cmd
}

// commandOffered gates discoverability (slash popup + /help). A command
// with no gate is always offered; /copy-session-id stays hidden until a
// session actually exists — the connect-time session frame stamps an id
// before any prompt, and that placeholder must not be copied.
func commandOffered(m *Model, c command) bool {
if c.name == "copy-session-id" {
return m.sessionLive
}
return true
}

// slashCommands is the registry. Keeping it a function keeps the closures
// simple and avoids package-init ordering concerns.
func slashCommands() []command {
Expand Down Expand Up @@ -205,6 +216,9 @@ func (m *Model) runSelectedCommand() tea.Cmd {
func (m *Model) openCmdAC(query string) {
var items []client.Resource
for _, c := range slashCommands() {
if !commandOffered(m, c) {
continue
}
if strings.HasPrefix(c.name, query) {
items = append(items, client.Resource{
ID: "/" + c.name, Type: "command", Label: "/" + c.name, Detail: c.desc,
Expand Down Expand Up @@ -301,6 +315,9 @@ func (m *Model) buildHelpCard() string {
b.WriteString("\n" + th.statsLabel.Render("commands"))
const cmdW = 10 // longest name is "/thinking"
for _, c := range slashCommands() {
if !commandOffered(m, c) {
continue
}
b.WriteString("\n" + th.tipKey.Render(padRight("/"+c.name, cmdW)) + " " + th.tipText.Render(c.desc))
}
b.WriteString(rule)
Expand Down
1 change: 1 addition & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func TestE2EAllCommands(t *testing.T) {
},
"/copy-session-id": func(t *testing.T, m *Model) {
m.sessionID = "sess-e2e"
m.sessionLive = true // a live session is the copy gate
if cmd := m.copySessionID(); cmd == nil {
t.Fatal("/copy-session-id returned nil cmd with a session on record")
}
Expand Down
71 changes: 71 additions & 0 deletions internal/tui/copy_session_id_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,83 @@ func TestCopySessionIDRegistered(t *testing.T) {
func TestCopySessionIDCopiesActiveSession(t *testing.T) {
m := newTestModel()
m.sessionID = "sess-abc123"
m.sessionLive = true
runCopySessionID(m)
if !m.copyFlashing() {
t.Error("copying a session id must arm the ✓ Copied flash")
}
}

// A session id stamped by the connect-time session event is not a live
// session yet: no prompt was sent, so the id may point at a placeholder
// the operator never created. /copy-session-id must stay unavailable.
func TestCopySessionIDUnavailableBeforeFirstPrompt(t *testing.T) {
m := newTestModel()
m.sessionID = "sess-connect-stamp" // wire session frame, no prompt sent
runCopySessionID(m)
if m.copyFlashing() {
t.Error("pre-session copy must not arm the ✓ Copied flash")
}
if n := len(m.notices); n == 0 {
t.Fatal("pre-session copy must record a visible note")
} else if got := m.notices[n-1]; !strings.Contains(got, "no session") || !strings.Contains(got, "prompt") {
t.Errorf("note = %q, want a warning naming the session and a prompt hint", got)
}
}

// The first prompt creates the session: the command becomes usable.
func TestCopySessionIDAvailableAfterFirstPrompt(t *testing.T) {
m := newTestModel()
m.sessionID = "sess-abc123"
m.sessionLive = true // armed by sendPrompt
runCopySessionID(m)
if !m.copyFlashing() {
t.Error("after a prompt the session id must copy")
}
}

// /new tears the session down: the command goes dormant again until the
// next prompt creates the replacement session.
func TestCopySessionIDDormantAfterNewSession(t *testing.T) {
m := newTestModel()
m.sessionID = "sess-old"
m.sessionLive = true
m.startFreshSession()
if m.sessionLive {
t.Error("startFreshSession must clear sessionLive")
}
}

// Hidden surfaces: the slash popup and /help must not offer the command
// before the session exists — unavailable means not discoverable.
func TestCopySessionIDHiddenPreSession(t *testing.T) {
m := newTestModel()
m.sessionID = "sess-connect-stamp"
m.openCmdAC("")
for _, it := range m.ac.items {
if it.ID == "/copy-session-id" {
t.Error("AC popup must not offer /copy-session-id before the session exists")
}
}
if strings.Contains(m.buildHelpCard(), "/copy-session-id") {
t.Error("/help must not list /copy-session-id before the session exists")
}
m.sessionLive = true
m.openCmdAC("")
found := false
for _, it := range m.ac.items {
if it.ID == "/copy-session-id" {
found = true
}
}
if !found {
t.Error("AC popup must offer /copy-session-id once the session is live")
}
if !strings.Contains(m.buildHelpCard(), "/copy-session-id") {
t.Error("/help must list /copy-session-id once the session is live")
}
}

func TestCopySessionIDWithoutSession(t *testing.T) {
m := newTestModel()
if cmd := m.copySessionID(); cmd == nil {
Expand Down
1 change: 1 addition & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ func (m *Model) beginWireTurn(wake bool) {
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake})
m.curIdx = len(m.msgs) - 1
m.busy = true
m.sessionLive = true // a wire turn runs inside a real session
m.cancelAck = false // a wake run's errors are real errors again
m.failBellFired = false // a fresh turn re-arms the failure BEL
m.skillSuggest = nil // the suggestion's window closed with the last turn
Expand Down
1 change: 1 addition & 0 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ func (m *Model) sendPrompt(text string) tea.Cmd {
m.ta.Reset()
m.closeAC()
m.busy = true
m.sessionLive = true // the first prompt creates the session
m.cancelAck = false // a fresh run's errors are real errors again
m.failBellFired = false // a fresh local turn re-arms the failure BEL
m.skillSuggest = nil // the suggestion's window closed with the turn
Expand Down
16 changes: 9 additions & 7 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,14 @@ type Model struct {
histIdx int // index into history while navigating
histDraft string // input stashed while navigating history

model string
sandbox bool
sessionID string
authToken string // session-scoped token (for cancel / resume)
pendModel string // model to apply on the next prompt
thinking string // canonical: "" inherit, or disabled|low|medium|high
expandAll bool // Ctrl+E: render every step's full output/logs
model string
sandbox bool
sessionID string
sessionLive bool // a prompt created/adopted this session — copy-session-id's gate
authToken string // session-scoped token (for cancel / resume)
pendModel string // model to apply on the next prompt
thinking string // canonical: "" inherit, or disabled|low|medium|high
expandAll bool // Ctrl+E: render every step's full output/logs

odekVersion string // engine version, shown in the cockpit stats sheet ("" hides it)
bodekVersion string // bodek's own version, for the startup update check
Expand Down Expand Up @@ -1243,6 +1244,7 @@ func (m *Model) clearConversation() tea.Cmd {
func (m *Model) startFreshSession() tea.Cmd {
homeFetch := m.clearConversation()
m.sessionID = ""
m.sessionLive = false // /new: no session until the next prompt creates one
m.authToken = ""
m.pendModel = m.model // the new session re-asserts the active model
m.resetPlanState()
Expand Down
1 change: 1 addition & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) tea.Cmd {
// on the connection (session_switch restores the server-side memory
// buffer; the reply's session event re-syncs state).
m.sessionID = msg.sess.ID
m.sessionLive = true // an adopted session is live: its id is copyable
m.authToken = msg.token
m.tokens.Set(msg.sess.ID, msg.token)
if msg.sess.Model != "" {
Expand Down
Loading