Skip to content

Commit 172d853

Browse files
committed
fix(tui): gate /copy-session-id behind a live session
The wire session event stamps an id on connect, before any prompt, so /copy-session-id copied that placeholder. Gate the command (and its visibility in the slash popup and /help) on sessionLive — armed by the first prompt, a resume, or a wire turn, cleared by /new.
1 parent fabc86c commit 172d853

8 files changed

Lines changed: 106 additions & 8 deletions

File tree

‎internal/tui/clipboard.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,11 @@ func validSessionID(id string) bool {
241241
// copySessionID puts the active session id on the clipboard — the payload
242242
// for a later `bodek --session <id>`. The ✓ Copied flash is the only ack.
243243
func (m *Model) copySessionID() tea.Cmd {
244-
if !validSessionID(m.sessionID) {
244+
// sessionLive, not just a non-empty id: the connect-time session event
245+
// stamps an id before any prompt — that placeholder is never the
246+
// operator's session, so the command stays dormant until a prompt (or
247+
// a resume) makes the session real.
248+
if !m.sessionLive || !validSessionID(m.sessionID) {
245249
return m.transientNoteCmd("no session yet — the id exists once a session is created; send a prompt first")
246250
}
247251
return m.copyText(m.sessionID)

‎internal/tui/commands.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ type command struct {
1919
run func(m *Model, args string) tea.Cmd
2020
}
2121

22+
// commandOffered gates discoverability (slash popup + /help). A command
23+
// with no gate is always offered; /copy-session-id stays hidden until a
24+
// session actually exists — the connect-time session frame stamps an id
25+
// before any prompt, and that placeholder must not be copied.
26+
func commandOffered(m *Model, c command) bool {
27+
if c.name == "copy-session-id" {
28+
return m.sessionLive
29+
}
30+
return true
31+
}
32+
2233
// slashCommands is the registry. Keeping it a function keeps the closures
2334
// simple and avoids package-init ordering concerns.
2435
func slashCommands() []command {
@@ -205,6 +216,9 @@ func (m *Model) runSelectedCommand() tea.Cmd {
205216
func (m *Model) openCmdAC(query string) {
206217
var items []client.Resource
207218
for _, c := range slashCommands() {
219+
if !commandOffered(m, c) {
220+
continue
221+
}
208222
if strings.HasPrefix(c.name, query) {
209223
items = append(items, client.Resource{
210224
ID: "/" + c.name, Type: "command", Label: "/" + c.name, Detail: c.desc,
@@ -301,6 +315,9 @@ func (m *Model) buildHelpCard() string {
301315
b.WriteString("\n" + th.statsLabel.Render("commands"))
302316
const cmdW = 10 // longest name is "/thinking"
303317
for _, c := range slashCommands() {
318+
if !commandOffered(m, c) {
319+
continue
320+
}
304321
b.WriteString("\n" + th.tipKey.Render(padRight("/"+c.name, cmdW)) + " " + th.tipText.Render(c.desc))
305322
}
306323
b.WriteString(rule)

‎internal/tui/commands_e2e_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func TestE2EAllCommands(t *testing.T) {
138138
},
139139
"/copy-session-id": func(t *testing.T, m *Model) {
140140
m.sessionID = "sess-e2e"
141+
m.sessionLive = true // a live session is the copy gate
141142
if cmd := m.copySessionID(); cmd == nil {
142143
t.Fatal("/copy-session-id returned nil cmd with a session on record")
143144
}

‎internal/tui/copy_session_id_test.go‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,83 @@ func TestCopySessionIDRegistered(t *testing.T) {
3434
func TestCopySessionIDCopiesActiveSession(t *testing.T) {
3535
m := newTestModel()
3636
m.sessionID = "sess-abc123"
37+
m.sessionLive = true
3738
runCopySessionID(m)
3839
if !m.copyFlashing() {
3940
t.Error("copying a session id must arm the ✓ Copied flash")
4041
}
4142
}
4243

44+
// A session id stamped by the connect-time session event is not a live
45+
// session yet: no prompt was sent, so the id may point at a placeholder
46+
// the operator never created. /copy-session-id must stay unavailable.
47+
func TestCopySessionIDUnavailableBeforeFirstPrompt(t *testing.T) {
48+
m := newTestModel()
49+
m.sessionID = "sess-connect-stamp" // wire session frame, no prompt sent
50+
runCopySessionID(m)
51+
if m.copyFlashing() {
52+
t.Error("pre-session copy must not arm the ✓ Copied flash")
53+
}
54+
if n := len(m.notices); n == 0 {
55+
t.Fatal("pre-session copy must record a visible note")
56+
} else if got := m.notices[n-1]; !strings.Contains(got, "no session") || !strings.Contains(got, "prompt") {
57+
t.Errorf("note = %q, want a warning naming the session and a prompt hint", got)
58+
}
59+
}
60+
61+
// The first prompt creates the session: the command becomes usable.
62+
func TestCopySessionIDAvailableAfterFirstPrompt(t *testing.T) {
63+
m := newTestModel()
64+
m.sessionID = "sess-abc123"
65+
m.sessionLive = true // armed by sendPrompt
66+
runCopySessionID(m)
67+
if !m.copyFlashing() {
68+
t.Error("after a prompt the session id must copy")
69+
}
70+
}
71+
72+
// /new tears the session down: the command goes dormant again until the
73+
// next prompt creates the replacement session.
74+
func TestCopySessionIDDormantAfterNewSession(t *testing.T) {
75+
m := newTestModel()
76+
m.sessionID = "sess-old"
77+
m.sessionLive = true
78+
m.startFreshSession()
79+
if m.sessionLive {
80+
t.Error("startFreshSession must clear sessionLive")
81+
}
82+
}
83+
84+
// Hidden surfaces: the slash popup and /help must not offer the command
85+
// before the session exists — unavailable means not discoverable.
86+
func TestCopySessionIDHiddenPreSession(t *testing.T) {
87+
m := newTestModel()
88+
m.sessionID = "sess-connect-stamp"
89+
m.openCmdAC("")
90+
for _, it := range m.ac.items {
91+
if it.ID == "/copy-session-id" {
92+
t.Error("AC popup must not offer /copy-session-id before the session exists")
93+
}
94+
}
95+
if strings.Contains(m.buildHelpCard(), "/copy-session-id") {
96+
t.Error("/help must not list /copy-session-id before the session exists")
97+
}
98+
m.sessionLive = true
99+
m.openCmdAC("")
100+
found := false
101+
for _, it := range m.ac.items {
102+
if it.ID == "/copy-session-id" {
103+
found = true
104+
}
105+
}
106+
if !found {
107+
t.Error("AC popup must offer /copy-session-id once the session is live")
108+
}
109+
if !strings.Contains(m.buildHelpCard(), "/copy-session-id") {
110+
t.Error("/help must list /copy-session-id once the session is live")
111+
}
112+
}
113+
43114
func TestCopySessionIDWithoutSession(t *testing.T) {
44115
m := newTestModel()
45116
if cmd := m.copySessionID(); cmd == nil {

‎internal/tui/events.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ func (m *Model) beginWireTurn(wake bool) {
674674
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake})
675675
m.curIdx = len(m.msgs) - 1
676676
m.busy = true
677+
m.sessionLive = true // a wire turn runs inside a real session
677678
m.cancelAck = false // a wake run's errors are real errors again
678679
m.failBellFired = false // a fresh turn re-arms the failure BEL
679680
m.skillSuggest = nil // the suggestion's window closed with the last turn

‎internal/tui/input.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ func (m *Model) sendPrompt(text string) tea.Cmd {
515515
m.ta.Reset()
516516
m.closeAC()
517517
m.busy = true
518+
m.sessionLive = true // the first prompt creates the session
518519
m.cancelAck = false // a fresh run's errors are real errors again
519520
m.failBellFired = false // a fresh local turn re-arms the failure BEL
520521
m.skillSuggest = nil // the suggestion's window closed with the turn

‎internal/tui/model.go‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,13 +262,14 @@ type Model struct {
262262
histIdx int // index into history while navigating
263263
histDraft string // input stashed while navigating history
264264

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

273274
odekVersion string // engine version, shown in the cockpit stats sheet ("" hides it)
274275
bodekVersion string // bodek's own version, for the startup update check
@@ -1243,6 +1244,7 @@ func (m *Model) clearConversation() tea.Cmd {
12431244
func (m *Model) startFreshSession() tea.Cmd {
12441245
homeFetch := m.clearConversation()
12451246
m.sessionID = ""
1247+
m.sessionLive = false // /new: no session until the next prompt creates one
12461248
m.authToken = ""
12471249
m.pendModel = m.model // the new session re-asserts the active model
12481250
m.resetPlanState()

‎internal/tui/panels.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) tea.Cmd {
10071007
// on the connection (session_switch restores the server-side memory
10081008
// buffer; the reply's session event re-syncs state).
10091009
m.sessionID = msg.sess.ID
1010+
m.sessionLive = true // an adopted session is live: its id is copyable
10101011
m.authToken = msg.token
10111012
m.tokens.Set(msg.sess.ID, msg.token)
10121013
if msg.sess.Model != "" {

0 commit comments

Comments
 (0)