diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index a7ab2b2..c070447 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -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 `. 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) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c5a00a4..a68847f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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 { @@ -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, @@ -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) diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 43cd327..2c0d908 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -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") } diff --git a/internal/tui/copy_session_id_test.go b/internal/tui/copy_session_id_test.go index 29fa7bf..6f3be9f 100644 --- a/internal/tui/copy_session_id_test.go +++ b/internal/tui/copy_session_id_test.go @@ -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 { diff --git a/internal/tui/events.go b/internal/tui/events.go index 1a0ee35..f8ccb50 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -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 diff --git a/internal/tui/input.go b/internal/tui/input.go index 696a88f..e6d7ee0 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -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 diff --git a/internal/tui/model.go b/internal/tui/model.go index cb26ed9..ad5e970 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 @@ -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() diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 27a91bd..4a47554 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -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 != "" {