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: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # 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
Expand Down Expand Up @@ -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 <id>` 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
Expand Down Expand Up @@ -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 <id>` 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) |
Expand Down
49 changes: 33 additions & 16 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand All @@ -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 <id> # 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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
44 changes: 44 additions & 0 deletions cmd/bodek/resume_hint.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
112 changes: 112 additions & 0 deletions cmd/bodek/resume_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"bytes"
"io"
"strings"
"testing"

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

// --session <id> 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 <id>. 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)
}
}
32 changes: 32 additions & 0 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`. 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.
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>", 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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading