From 6f4952cb44a233d52ddf41f72d6ffb87de790bc6 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 30 Sep 2026 09:03:30 +0200 Subject: [PATCH] feat(tui,cli): copy-session-id command and exact-session resume - /copy-session-id copies the active session id to the clipboard - bodek --session resumes one exact session (implies resume; --new still wins) - after exit, print a ready-to-paste resume line naming the session just closed; the live session id beats the workspace store, ids are charset-validated before reaching a paste-into-shell surface --- README.md | 6 ++ cmd/bodek/main.go | 49 ++++++++---- cmd/bodek/resume_hint.go | 44 +++++++++++ cmd/bodek/resume_hint_test.go | 112 +++++++++++++++++++++++++++ internal/tui/clipboard.go | 32 ++++++++ internal/tui/commands.go | 3 + internal/tui/commands_e2e_test.go | 9 +++ internal/tui/copy_session_id_test.go | 106 +++++++++++++++++++++++++ internal/tui/local.go | 8 +- internal/tui/model.go | 6 ++ 10 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 cmd/bodek/resume_hint.go create mode 100644 cmd/bodek/resume_hint_test.go create mode 100644 internal/tui/copy_session_id_test.go diff --git a/README.md b/README.md index 972d3eb..a48f06c 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ bodek looks for `odek` on your `PATH`. To point at a specific binary use ```bash bodek # launch odek serve and start fresh in this directory bodek --resume # continue this directory's last session (opt-in) +bodek --session # resume one exact session (also printed on exit) bodek --sandbox # run tool calls inside odek's Docker sandbox bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token @@ -251,6 +252,10 @@ own front-end settings are separate; see [Configuration](#configuration). transcript (`session_switch`); a failed resume is a note, never a leftover approval form. `/new` or `--new` always starts a fresh session; the old one stays resumable via `/sessions`. +- **Resume a specific session** — `/copy-session-id` copies the active + session id; `bodek --session ` resumes it directly (implies + `--resume`; `--new` still wins). When bodek exits, it prints the exact + line for the session that was just closed, so one paste brings it back. - **Session home** — first-run shows the working directory, the last session title when one exists, and `type a task · ^K`. After `^L`, the cleared transcript keeps the last prompt and coding receipt so the @@ -486,6 +491,7 @@ full command and press `⏎`. | `/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 | +| `/copy-session-id` | Copy this session's id — paste it into `bodek --session ` to resume later | | `/export` | Save the session transcript next to you — `/export [md|json]` (markdown by default, never overwrites) | | `/retry` | Re-send the last prompt (queues it if a turn is running) | | `/queue` | Manage the prompt queue — priority, delete, send now (the full manager over the `^Q` strip) | diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 5323846..0c08079 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -45,6 +45,7 @@ type config struct { thinking string // startup reasoning depth (empty = inherit / seed from serve) fresh bool // --new: skip last-session resume resume bool // --resume: opt back in to last-session resume (default false) + sessionID string // --session: resume this exact session id reduceMot bool // --reduce-motion: calmer transcript (slower clock lane, no accent pulses) extraArgs []string @@ -84,6 +85,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { fs.StringVar(&cfg.verbosity, "verbosity", verbDefault, "noise dial: quiet (info notes hidden, compact steps), normal, detailed (steps expand) — /verbosity switches at runtime and persists") fs.StringVar(&cfg.thinking, "thinking", st.Thinking, "reasoning depth: disabled, low, medium, high — /thinking and ^T switch at runtime and persist") fs.BoolVar(&cfg.resume, "resume", st.Bool(st.Resume, false), "resume this directory's last session on start (--resume=false disables; off by default)") + fs.StringVar(&cfg.sessionID, "session", "", "resume a specific session by id (implies --resume; /copy-session-id copies it)") fs.BoolVar(&cfg.reduceMot, "reduce-motion", st.Bool(st.ReduceMotion, false), "calm transcript for motion-sensitive readers: clock lane ticks at 2s, no accent pulses") fs.BoolVar(&cfg.fresh, "new", false, "start a fresh session (always skips last-session resume)") fs.Usage = func() { @@ -97,6 +99,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { _, _ = fmt.Fprintf(fs.Output(), "\nExamples:\n") _, _ = fmt.Fprintf(fs.Output(), " bodek # spawn odek serve and start fresh in this directory\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --resume # continue this directory's last session (off by default)\n") + _, _ = fmt.Fprintf(fs.Output(), " bodek --session # resume one exact session (/copy-session-id copies the id)\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --new # start a fresh session\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --sandbox # spawn odek serve with Docker sandbox\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n") @@ -253,21 +256,23 @@ func run() error { cwd = "." } + ws := workspace.Open() model := tui.New(cl, tui.Options{ - Sandbox: cfg.sandbox, - CWD: cwd, - LogPath: logPath, - OdekVersion: srv.Version, - Version: currentVersion(), - Bell: cfg.bel, - Notify: cfg.notify, - Plain: cfg.plain, - ReduceMotion: cfg.reduceMot, - Theme: cfg.theme, - Verbosity: cfg.verbosity, - Thinking: cfg.thinking, - Workspace: workspace.Open(), - Fresh: cfg.fresh || !cfg.resume, + Sandbox: cfg.sandbox, + CWD: cwd, + LogPath: logPath, + OdekVersion: srv.Version, + Version: currentVersion(), + Bell: cfg.bel, + Notify: cfg.notify, + Plain: cfg.plain, + ReduceMotion: cfg.reduceMot, + Theme: cfg.theme, + Verbosity: cfg.verbosity, + Thinking: cfg.thinking, + Workspace: ws, + Fresh: fresh(cfg), + ResumeSession: cfg.sessionID, OnThemeChange: func(name string) error { cfg.persist.Theme = name return settings.Save(cfg.persist) @@ -298,8 +303,20 @@ func run() error { // deferred srv.Stop() and cl.Close() still execute with terminal state // intact. setupSignalHandler(p) - if _, err := p.Run(); err != nil { - return fmt.Errorf("TUI exited: %w", err) + final, runErr := p.Run() + // The alt screen is down: suggest resuming the session that was just + // closed, with its exact id (Ctrl+C included — Run returns the same + // way). The live model id wins over the store; a crashed TUI still + // left a resumable session, so the hint fires on the error path too. + if interactive { + liveID := "" + if m, ok := final.(*tui.Model); ok { + liveID = m.SessionID() + } + printResumeHint(ws, cwd, liveID, os.Stderr) + } + if runErr != nil { + return fmt.Errorf("TUI exited: %w", runErr) } return nil } diff --git a/cmd/bodek/resume_hint.go b/cmd/bodek/resume_hint.go new file mode 100644 index 0000000..5b2fa1e --- /dev/null +++ b/cmd/bodek/resume_hint.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "io" + "regexp" + + "github.com/BackendStack21/bodek/internal/workspace" +) + +// fresh reports whether this launch must skip resume: --new always wins, +// and without --resume or --session the default is a fresh start. +func fresh(cfg config) bool { + return cfg.fresh || (!cfg.resume && cfg.sessionID == "") +} + +// sessionIDRe guards ids that reach the printed hint (a paste-into-shell +// target): a corrupted store entry must never smuggle a newline or escape +// into what the operator is told to run. +var sessionIDRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// resumeHintLine builds the after-exit resume hint naming the exact session +// that was just closed — the same id /copy-session-id puts on the +// clipboard. The live session id wins; the cwd store is the fallback for +// runs that never reached a session frame. An empty string means there is +// nothing to suggest. +func resumeHintLine(ws *workspace.Store, cwd, liveID string) string { + id := liveID + if id == "" && ws != nil && cwd != "" { + id = ws.Load(cwd).SessionID + } + if !sessionIDRe.MatchString(id) { + return "" + } + return fmt.Sprintf("⎸ resume the session you just closed: bodek --session %s", id) +} + +// printResumeHint writes the hint after the TUI has left the alt screen +// (stderr is ours then, next to the shutdown messages). +func printResumeHint(ws *workspace.Store, cwd, liveID string, w io.Writer) { + if line := resumeHintLine(ws, cwd, liveID); line != "" { + _, _ = fmt.Fprintln(w, line) + } +} diff --git a/cmd/bodek/resume_hint_test.go b/cmd/bodek/resume_hint_test.go new file mode 100644 index 0000000..7d86231 --- /dev/null +++ b/cmd/bodek/resume_hint_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/workspace" +) + +// --session resumes one exact session and implies --resume. + +func TestSessionFlagParses(t *testing.T) { + cfg, err := parseConfig([]string{"--session", "sess-abc123"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if cfg.sessionID != "sess-abc123" { + t.Errorf("cfg.sessionID = %q, want sess-abc123", cfg.sessionID) + } + if resumes(cfg) { + t.Error("--session must not flip the bare --resume path (it drives Options.ResumeSession)") + } +} + +func TestSessionFlagDefaultsEmpty(t *testing.T) { + cfg, err := parseConfig(nil, io.Discard) + if err != nil { + t.Fatal(err) + } + if cfg.sessionID != "" { + t.Errorf("cfg.sessionID = %q, want empty by default", cfg.sessionID) + } +} + +func TestSessionFlagImpliesResume(t *testing.T) { + cfg, err := parseConfig([]string{"--session", "sess-abc123"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if fresh(cfg) { + t.Error("--session must opt into resume-on-start") + } + cfgNew, err := parseConfig([]string{"--session", "sess-abc123", "--new"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if !fresh(cfgNew) { + t.Error("--new must force a fresh start even with --session") + } +} + +// isolateWS points BODEK_WORKSPACE at a temp dir so tests never touch the +// real ~/.bodek/workspaces.json. +func isolateWS(t *testing.T) { + t.Helper() + t.Setenv("BODEK_WORKSPACE", t.TempDir()+"/workspaces.json") +} + +// The Ctrl+C closure hint names the session just closed so the printed +// line is directly runnable: bodek --session . The live id wins over +// the store. + +func TestResumeHintWithSavedSession(t *testing.T) { + isolateWS(t) + ws := workspace.Open() + cwd := "/tmp/bodek-hint-test" + _ = ws.Save(cwd, workspace.State{SessionID: "sess-9"}) + if got := resumeHintLine(ws, cwd, ""); !strings.Contains(got, "bodek --session sess-9") { + t.Errorf("hint %q must name the exact session", got) + } + // The live session id beats a stale store value. + if got := resumeHintLine(ws, cwd, "sess-live"); !strings.Contains(got, "bodek --session sess-live") { + t.Errorf("live id must win, got %q", got) + } +} + +func TestResumeHintWithoutSession(t *testing.T) { + isolateWS(t) + if got := resumeHintLine(workspace.Open(), "/tmp/bodek-hint-empty", ""); got != "" { + t.Errorf("no saved session must yield no hint, got %q", got) + } + if got := resumeHintLine(nil, "/tmp/bodek-hint-nil", ""); got != "" { + t.Errorf("nil store must yield no hint, got %q", got) + } +} + +// A hostile id (from a corrupted store) must never reach the printed, +// paste-into-shell hint line. +func TestResumeHintRejectsUnsafeID(t *testing.T) { + cases := []string{"", "two\nlines", "ansi\x1b[31m", "sp ace", "../../etc"} + for _, id := range cases { + if got := resumeHintLine(nil, "", id); got != "" { + t.Errorf("unsafe id %q produced hint %q", id, got) + } + } +} + +func TestPrintResumeHint(t *testing.T) { + isolateWS(t) + var buf bytes.Buffer + printResumeHint(nil, "/tmp/bodek-hint-nil", "", &buf) + if buf.Len() != 0 { + t.Errorf("nil store must print nothing, got %q", buf.String()) + } + buf.Reset() + printResumeHint(workspace.Open(), "/tmp/bodek-hint-print", "sess-9", &buf) + if got := buf.String(); !strings.Contains(got, "bodek --session sess-9") { + t.Errorf("hint output %q must carry the full command", got) + } +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index 7dd8fce..a5e6c03 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -215,6 +215,38 @@ func (m *Model) copyLastReply() tea.Cmd { return m.copyText(m.lastReply()) } +// SessionID returns the active session id ("" before the first session +// frame). This is what the CLI reads at teardown so the after-exit resume +// hint names the session actually being closed, not a stale store value. +func (m *Model) SessionID() string { return m.sessionID } + +// validSessionID guards ids that reach a shell-paste surface (the exit +// hint, the clipboard): a corrupted store entry must never smuggle a +// newline or escape into what the operator is told to run. +func validSessionID(id string) bool { + if id == "" { + return false + } + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-': + default: + return false + } + } + return true +} + +// 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) { + return m.transientNoteCmd("no active session yet") + } + return m.copyText(m.sessionID) +} + // copyFocusedTurn puts the focused surface on the clipboard: an open // reasoning block, an expanded step, or the turn reply. After alt+m it // yanks the sanitized range between the mark and the current focus. diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ec7da16..c5a00a4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -53,6 +53,9 @@ func slashCommands() []command { {"copy", "copy the last reply to the clipboard", func(m *Model, _ string) tea.Cmd { return m.copyLastReply() }}, + {"copy-session-id", "copy this session's id — resume with --session ", func(m *Model, _ string) tea.Cmd { + return m.copySessionID() + }}, {"export", "save the session transcript — /export [md|json]", runExport}, {"theme", "switch the color theme — /theme [name]", runTheme}, {"verbosity", "noise dial — /verbosity [quiet|normal|detailed]", func(m *Model, args string) tea.Cmd { diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 497a399..43cd327 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -136,6 +136,15 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/copy returned nil cmd with a reply on record") } }, + "/copy-session-id": func(t *testing.T, m *Model) { + m.sessionID = "sess-e2e" + if cmd := m.copySessionID(); cmd == nil { + t.Fatal("/copy-session-id returned nil cmd with a session on record") + } + if !m.copyFlashing() { + t.Error("copying the session id must arm the ✓ Copied flash") + } + }, "/export": func(t *testing.T, m *Model) { // The e2e model has no live server: with no session on record // the command degrades to an honest note, never a panic. diff --git a/internal/tui/copy_session_id_test.go b/internal/tui/copy_session_id_test.go new file mode 100644 index 0000000..e6afa1b --- /dev/null +++ b/internal/tui/copy_session_id_test.go @@ -0,0 +1,106 @@ +package tui + +import ( + "testing" + + "github.com/BackendStack21/bodek/internal/workspace" +) + +// /copy-session-id puts the active session id on the clipboard so the +// operator can paste it into `bodek --session ` later. + +func runCopySessionID(m *Model) { + for _, c := range slashCommands() { + if c.name == "copy-session-id" { + c.run(m, "") + return + } + } +} + +func TestCopySessionIDRegistered(t *testing.T) { + for _, c := range slashCommands() { + if c.name == "copy-session-id" { + if c.desc == "" { + t.Fatal("/copy-session-id registered with an empty description") + } + return + } + } + t.Fatal("/copy-session-id is not in the command registry") +} + +func TestCopySessionIDCopiesActiveSession(t *testing.T) { + m := newTestModel() + m.sessionID = "sess-abc123" + runCopySessionID(m) + if !m.copyFlashing() { + t.Error("copying a session id must arm the ✓ Copied flash") + } +} + +func TestCopySessionIDWithoutSession(t *testing.T) { + m := newTestModel() + if cmd := m.copySessionID(); cmd == nil { + t.Fatal("no-session copy must produce an explanatory note") + } + if m.copyFlashing() { + t.Error("nothing was copied — the flash must stay off") + } +} + +// A hostile id must never reach the clipboard — the payload is a +// paste-into-shell target. +func TestCopySessionIDRejectsUnsafeID(t *testing.T) { + m := newTestModel() + m.sessionID = "evil\nrm -rf" + if cmd := m.copySessionID(); cmd == nil { + t.Fatal("unsafe id must produce a note, not a panic") + } + if m.copyFlashing() { + t.Error("nothing was copied — the flash must stay off") + } +} + +// SessionID() is what the CLI reads at teardown for the exit hint. +func TestSessionIDAccessor(t *testing.T) { + m := newTestModel() + if m.SessionID() != "" { + t.Errorf("fresh model SessionID = %q, want empty", m.SessionID()) + } + m.sessionID = "sess-9" + if m.SessionID() != "sess-9" { + t.Errorf("SessionID = %q, want sess-9", m.SessionID()) + } +} + +// Options.ResumeSession resumes one exact session (the --session flag's +// landing spot): it beats the workspace-derived last session; --new still +// wins. +func TestResumeSessionOptionSeedsPendingResume(t *testing.T) { + t.Setenv("BODEK_WORKSPACE", t.TempDir()+"/workspaces.json") + ws := workspace.Open() + m := newTestModel() + m.ws = ws + m.opts.CWD = "/tmp/bodek-test-restore" + m.opts.ResumeSession = "sess-explicit" + _ = ws.Save(m.opts.CWD, workspace.State{SessionID: "sess-last"}) + m.restoreWorkspace() + if m.pendingResume != "sess-explicit" { + t.Errorf("pendingResume = %q, want the explicit --session id", m.pendingResume) + } +} + +func TestResumeSessionFreshStillWins(t *testing.T) { + t.Setenv("BODEK_WORKSPACE", t.TempDir()+"/workspaces.json") + ws := workspace.Open() + m := newTestModel() + m.ws = ws + m.opts.CWD = "/tmp/bodek-test-fresh" + m.opts.ResumeSession = "sess-explicit" + m.opts.Fresh = true + m.restoreWorkspace() + if m.pendingResume != "" { + t.Errorf("--new must skip resume; pendingResume = %q", m.pendingResume) + } +} diff --git a/internal/tui/local.go b/internal/tui/local.go index 9aa61ed..a91e34c 100644 --- a/internal/tui/local.go +++ b/internal/tui/local.go @@ -43,8 +43,12 @@ func (m *Model) restoreWorkspace() { _ = m.attachFile(p) } m.resumeTitle = st.SessionTitle - if !m.opts.Fresh && st.SessionID != "" { - m.pendingResume = st.SessionID + if !m.opts.Fresh { + if m.opts.ResumeSession != "" { + m.pendingResume = m.opts.ResumeSession // --session beats the saved id + } else if st.SessionID != "" { + m.pendingResume = st.SessionID + } } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 3193478..cb26ed9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -178,6 +178,12 @@ type Options struct { // Fresh skips last-session resume (--new / after /new). Fresh bool + // ResumeSession names one exact session id to resume on start + // (--session ). It beats the cwd's last-session id from the + // workspace store; Fresh (--new) still wins. Empty keeps the + // workspace-driven default. + ResumeSession string + // Plain selects the linear rendering mode: no alt-screen, append-only // scrollback transcript, severity prefixes instead of color (--plain). Plain bool