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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,16 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
| `/` | Open the command palette (see below) |
| `@` | Attach a file (see below) |
| `alt+↑` / `alt+↓` | Jump to the previous / next turn |
| `alt+f` | Search the transcript (`⏎` next match · `N` previous) |
| `^F` | Fold/unfold the most recent turn card (click any turn head with `--mouse`) |
| `tab` | Open/close the latest reasoning block (live turns auto-expand) |
| `^R` | Browse & resume saved sessions |
| `^O` | Switch the model |
| `^T` | Toggle extended thinking for the next turn |
| `^J` | Insert a newline in the input |
| `^L` | Clear the conversation |
| `^L` | Clear the conversation (two-step confirm: `y` clears, any other key cancels) |
| `^E` | Toggle tool details — every step expands to its full output/logs |
| `^Y` | Copy the last reply to the clipboard (OSC 52 — needs a supporting terminal) |
| `Esc` | Cancel the running turn (queued prompts return to the input) |
| `↑` / `↓` / `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript (arrows at the input's edge lines) |
| `^P` / `^N` | Recall previous prompts (prompt history) |
Expand Down Expand Up @@ -167,7 +169,8 @@ command and press `⏎`.
| Command | Action |
|---------|--------|
| `/help` | Show available commands and key bindings |
| `/clear` | Clear the conversation |
| `/clear` | Clear the conversation (two-step confirm; idle only) |
| `/copy` | Copy the last reply to the clipboard (OSC 52) |
| `/stats` | Session metrics card (cost, cache, context gauge) |
| `/server` | Cockpit — server, link, budget & session in one card (or click the header) |
| `/sessions` | Browse, search, pin, rename, export & resume sessions |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.8
github.com/muesli/termenv v0.16.0
golang.org/x/net v0.58.0
)
Expand All @@ -17,7 +18,6 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
Expand Down
123 changes: 123 additions & 0 deletions internal/tui/clear_confirm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package tui

import (
"strings"
"testing"
)

// ^L and /clear are destructive at conversation scope — the whole transcript
// plus every session counter. They must arm the same two-step confirm the
// panel row deletes use (y fires, any other key disarms) instead of wiping
// on a single keypress, and they must stay idle-only like ^L always was.

func seedConversation(m *Model) {
m.msgs = append(m.msgs,
message{role: roleUser, content: "hello"},
message{role: roleAsst, content: "world"},
)
m.toolTotal = 3
m.sessCtxTok = 100
}

func TestClearArmsConfirm(t *testing.T) {
m := newTestModel()
seedConversation(m)

m.Update(key("ctrl+l"))

if m.confirm != confirmClear {
t.Fatalf("ctrl+l did not arm confirmClear: %v", m.confirm)
}
if len(m.msgs) != 2 {
t.Fatalf("armed ^L already wiped the transcript: %d msgs", len(m.msgs))
}
if got := plain(m.View()); !strings.Contains(got, "clear the conversation?") {
t.Errorf("footer does not show the clear confirm gate:\n%s", got)
}

m.Update(key("y"))
if m.confirm != confirmNone {
t.Error("y did not disarm the confirm")
}
if len(m.msgs) != 0 || m.toolTotal != 0 || m.sessCtxTok != 0 {
t.Errorf("y did not clear: msgs=%d tools=%d ctx=%d",
len(m.msgs), m.toolTotal, m.sessCtxTok)
}
}

func TestClearConfirmDisarmsOnOtherKey(t *testing.T) {
m := newTestModel()
seedConversation(m)

m.Update(key("ctrl+l"))
if m.confirm != confirmClear {
t.Fatalf("ctrl+l did not arm confirmClear: %v", m.confirm)
}

m.Update(key("esc"))
if m.confirm != confirmNone {
t.Error("esc did not disarm the confirm")
}
if len(m.msgs) != 2 {
t.Errorf("disarm lost the transcript: %d msgs", len(m.msgs))
}

// After disarming, printable keys type again — they never clear.
m.Update(key("x"))
if len(m.msgs) != 2 {
t.Errorf("rune keypress cleared the transcript: %d msgs", len(m.msgs))
}
}

func TestClearIgnoredWhileBusy(t *testing.T) {
m := newTestModel()
seedConversation(m)
m.busy = true

m.Update(key("ctrl+l"))

if m.confirm != confirmNone {
t.Error("^L armed a confirm mid-turn")
}
if len(m.msgs) != 2 {
t.Errorf("^L cleared mid-turn: %d msgs", len(m.msgs))
}
}

func TestSlashClearArmsConfirm(t *testing.T) {
m := newTestModel()
seedConversation(m)

m.Update(key("/"))
m.ta.SetValue("/clear")
m.Update(key("enter"))

if m.confirm != confirmClear {
t.Fatalf("/clear did not arm confirmClear: %v", m.confirm)
}
if len(m.msgs) != 2 {
t.Errorf("armed /clear already wiped the transcript: %d msgs", len(m.msgs))
}

m.Update(key("y"))
if len(m.msgs) != 0 {
t.Errorf("y did not clear after /clear arm: %d msgs", len(m.msgs))
}
}

func TestSlashClearRefusedWhileBusy(t *testing.T) {
m := newTestModel()
seedConversation(m)
m.busy = true

m.Update(key("/"))
m.ta.SetValue("/clear")
m.Update(key("enter"))

if m.confirm != confirmNone {
t.Error("/clear armed a confirm mid-turn")
}
if len(m.msgs) != 2 {
t.Errorf("/clear cleared mid-turn: %d msgs", len(m.msgs))
}
}
63 changes: 63 additions & 0 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package tui

import (
"fmt"
"io"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)

// osc52Cap is the payload size where terminal emulators start dropping
// OSC 52 writes (tmux buffers ~100KB, Windows Terminal ~150KB). Larger
// copies are refused with a note rather than silently truncated.
const osc52Cap = 100_000

// clipboardWrite is a tea.ExecCommand that writes a raw escape sequence to
// the terminal. tea.Println can't carry it: bodek runs on the alt-screen,
// where the renderer drops printed lines entirely. Exec briefly pauses the
// renderer and hands over the real terminal writer, so the sequence lands
// verbatim and frames can't interleave.
type clipboardWrite struct {
seq string
w io.Writer
}

func (c *clipboardWrite) SetStdin(io.Reader) {}
func (c *clipboardWrite) SetStderr(io.Writer) {}
func (c *clipboardWrite) SetStdout(w io.Writer) { c.w = w }

func (c *clipboardWrite) Run() error {
if c.w == nil {
return nil // no terminal writer (tests, headless contexts)
}
_, err := io.WriteString(c.w, c.seq)
return err
}

// lastReply returns the text of the most recent finalized assistant reply,
// or "" while none has landed (streaming turns and empty messages skipped).
func (m *Model) lastReply() string {
for i := len(m.msgs) - 1; i >= 0; i-- {
if msg := m.msgs[i]; msg.role == roleAsst && !msg.streaming && msg.content != "" {
return msg.content
}
}
return ""
}

// copyLastReply puts the latest assistant reply on the system clipboard via
// OSC 52: the sequence is consumed by the terminal emulator itself, so no
// external tool runs and the payload never leaves the machine. Terminals
// without OSC 52 support silently ignore the sequence — the note says so.
func (m *Model) copyLastReply() tea.Cmd {
text := m.lastReply()
if text == "" {
return m.transientNoteCmd("nothing to copy — no assistant reply yet")
}
if len(text) > osc52Cap {
return m.transientNoteCmd(fmt.Sprintf("reply too large for OSC 52 (%d bytes) — select it manually", len(text)))
}
note := m.transientNoteCmd(fmt.Sprintf("copied %d chars via OSC 52 — needs a supporting terminal", len(text)))
return tea.Batch(tea.Exec(&clipboardWrite{seq: ansi.SetSystemClipboard(text)}, nil), note)
}
100 changes: 100 additions & 0 deletions internal/tui/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package tui

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

"github.com/charmbracelet/x/ansi"
)

// ^Y / /copy put the latest assistant reply on the system clipboard via
// OSC 52 — written straight to the terminal with tea.Exec, because bodek
// runs on the alt-screen where tea.Println output is dropped entirely.

func TestOsc52Sequence(t *testing.T) {
got := ansi.SetSystemClipboard("hi")
want := "\x1b]52;c;aGk=\x07"
if got != want {
t.Errorf("osc52 sequence = %q, want %q", got, want)
}
}

func TestClipboardWriteRun(t *testing.T) {
var buf bytes.Buffer
c := &clipboardWrite{seq: "SEQ"}
c.SetStdin(nil)
c.SetStdout(&buf)
c.SetStderr(io.Discard)
if err := c.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
if buf.String() != "SEQ" {
t.Errorf("Run wrote %q, want %q", buf.String(), "SEQ")
}
}

func TestLastReplyPicksLatestFinalized(t *testing.T) {
m := newTestModel()
m.msgs = append(m.msgs,
message{role: roleUser, content: "q"},
message{role: roleAsst, streaming: true},
)
if got := m.lastReply(); got != "" {
t.Errorf("streaming turn not skipped: %q", got)
}
m.msgs[len(m.msgs)-1] = message{role: roleAsst, content: "first"}
m.msgs = append(m.msgs, message{role: roleAsst, content: "second"})
if got := m.lastReply(); got != "second" {
t.Errorf("lastReply = %q, want %q", got, "second")
}
}

func TestCopyLastReplyGuards(t *testing.T) {
m := newTestModel()

// Nothing to copy: the guard note must still fire (non-nil cmd).
if cmd := m.copyLastReply(); cmd == nil {
t.Error("empty transcript returned nil cmd; want the nothing-to-copy notice")
}

// Oversized reply: refuse instead of silently truncating.
m.msgs = append(m.msgs, message{role: roleAsst, content: strings.Repeat("x", osc52Cap+1)})
if cmd := m.copyLastReply(); cmd == nil {
t.Error("oversized reply returned nil cmd; want the refusal notice")
}
}

// errWriter always fails, to exercise Run's error propagation.
type errWriter struct{}

func (errWriter) Write(p []byte) (int, error) { return 0, errors.New("boom") }

func TestClipboardWriteRunner(t *testing.T) {
c := &clipboardWrite{seq: "\x1b]52;c;aGk=\x07"}

// Headless: no writer wired — the setters are no-ops and Run is silent.
c.SetStdin(nil)
c.SetStderr(io.Discard)
if err := c.Run(); err != nil {
t.Errorf("Run with no writer = %v, want nil", err)
}

// Wired: the sequence lands verbatim on the terminal writer.
var buf bytes.Buffer
c.SetStdout(&buf)
if err := c.Run(); err != nil {
t.Fatalf("Run = %v, want nil", err)
}
if buf.String() != c.seq {
t.Errorf("Run wrote %q, want %q", buf.String(), c.seq)
}

// A failing writer must surface its error, not swallow it.
c.SetStdout(errWriter{})
if err := c.Run(); err == nil {
t.Error("Run swallowed the writer error")
}
}
11 changes: 9 additions & 2 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ func slashCommands() []command {
return nil
}},
{"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd {
m.clearConversation()
return nil
// Same gate as ^L, and idle-only: a mid-turn wipe would drop the
// view out from under the streaming turn.
if m.busy {
return m.transientNoteCmd("can't clear while a turn runs — esc cancels it first")
}
return m.armConfirm(confirmClear, "the conversation")
}},
{"copy", "copy the last reply to the clipboard (OSC 52)", func(m *Model, _ string) tea.Cmd {
return m.copyLastReply()
}},
{"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd {
m.showStats()
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,23 @@ func TestE2EAllCommands(t *testing.T) {
}
},
"/clear": func(t *testing.T, m *Model) {
// The command arms the two-step confirm; y fires the wipe.
if m.confirm != confirmClear {
t.Fatalf("/clear did not arm confirmClear: %v", m.confirm)
}
m.Update(key("y"))
if len(m.msgs) != 0 {
t.Fatalf("/clear left %d messages", len(m.msgs))
}
},
"/copy": func(t *testing.T, m *Model) {
// With a finalized reply on record the copy path dispatches
// (guard branches are unit-tested in clipboard_test.go).
m.msgs = append(m.msgs, message{role: roleAsst, content: "the answer"})
if cmd := m.copyLastReply(); cmd == nil {
t.Fatal("/copy returned nil cmd with a reply on record")
}
},
"/stats": func(t *testing.T, m *Model) {
card := lastMsg(m)
if card == nil || !card.raw || !strings.Contains(plain(card.content), "⬡ session") {
Expand Down
Loading