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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ bodek --notify # desktop notifications (OSC 9
bodek --theme ember-light # start with a theme (/theme switches live)
bodek --verbosity quiet # calmer start: info notes hidden
bodek --plain # linear mode: transcript to scrollback (a11y, pipes)
bodek --reduce-motion # calmer transcript: clock lane at 2s, no accent pulses
bodek -- --prompt-caching # pass extra flags through to `odek serve`
bodek version # print the bodek version
bodek upgrade # download and install the latest release
Expand Down
29 changes: 16 additions & 13 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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)
reduceMot bool // --reduce-motion: calmer transcript (slower clock lane, no accent pulses)
extraArgs []string

persist settings.Settings // the loaded file, re-saved when /theme switches
Expand Down Expand Up @@ -82,6 +83,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.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() {
_, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- <odek serve flags>]\n\n")
Expand Down Expand Up @@ -215,19 +217,20 @@ func run() error {
setupSignalHandler(srv, cl)

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,
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: workspace.Open(),
Fresh: cfg.fresh || !cfg.resume,
OnThemeChange: func(name string) error {
cfg.persist.Theme = name
return settings.Save(cfg.persist)
Expand Down
3 changes: 3 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type Settings struct {
Verbosity string `json:"verbosity,omitempty"`
Thinking string `json:"thinking,omitempty"`
Resume *bool `json:"resume,omitempty"`
// ReduceMotion dials the transcript's live-motion cadence down
// (clock lane >= 2s, no accent pulse on the new-output row).
ReduceMotion *bool `json:"reduceMotion,omitempty"`
}

// Path returns the settings file location: $BODEK_CONFIG if set, else
Expand Down
22 changes: 20 additions & 2 deletions internal/tui/approval_expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func approvalTTL(ev client.Event) time.Duration {
// the closest client-side approximation (single-digit ms skew locally).
func (m *Model) stampApprovalDeadline(ev client.Event) {
m.apprDeadlines = append(m.apprDeadlines, time.Now().Add(approvalTTL(ev)))
m.apprBellFired = false // a fresh request re-arms the urgent-window BEL
}

// apprSecondsLeft is the queue head's remaining lifetime in whole seconds
Expand Down Expand Up @@ -100,14 +101,31 @@ func (m *Model) handleApprovalExpiry(now time.Time) tea.Cmd {
m.approvals = kept
m.apprDeadlines = keptDL

// (A3) the countdown entering its urgent window (< 10s left) rings the
// bell exactly once per request — a tick inside the window must not
// re-fire, and expiry pruning below stays silent.
var cmds []tea.Cmd
if !m.apprBellFired && len(m.approvals) > 0 {
if secs := m.apprSecondsLeft(); secs > 0 && secs <= approvalUrgentSecs {
m.apprBellFired = true
if a := m.attentionFor(attentionApproval); !a.empty() {
a.title, a.notify = "", "" // the card is already on screen — bell only
cmds = append(cmds, m.attentionCmd(a))
}
}
}

if dropped == 0 {
return nil
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}
// Operator-facing: the card they were staring at just vanished. Use
// transientNoteCmd so quiet does not swallow it and so the sweep
// arms from Update (addTransientNote would leave a sticky idle note).
const note = "approval expired · odek will find an alternative"
cmds := []tea.Cmd{m.transientNoteCmd(note)}
cmds = append(cmds, m.transientNoteCmd(note))
if len(m.approvals) > 0 {
m.setRunStatus("approval required")
} else if m.busy {
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/attention.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
attentionApproval // an approval is waiting (approval_request)
attentionJobDone // a background job exited cleanly (jobs watcher)
attentionJobFailed // a background job failed / timed out / was killed
attentionFailed // a turn ended in error (error event)
)

// attention is the plan of terminal-attention effects for one state change.
Expand Down Expand Up @@ -64,6 +65,8 @@ func (m *Model) attentionFor(kind attentionKind) attention {
prefix, note = "✓ bg job done", "bodek: background job finished"
case attentionJobFailed:
prefix, note = "✗ bg job failed", "bodek: background job failed"
case attentionFailed:
prefix, note = "✗ turn failed", "bodek: turn failed"
default:
return attention{}
}
Expand Down
6 changes: 2 additions & 4 deletions internal/tui/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,10 @@ func (m *Model) shelfView() string {
}
chips = append(chips, th.headerKey.Render("📎 "+truncate(strings.Join(names, " · "), max(m.width/3, 12))))
}
if n := len(m.queue); n > 0 && !m.qfocus && m.curApproval() == nil && m.panel != panelQueue {
if n := len(m.queue); n > 0 && !m.qfocus && m.panel != panelQueue {
chips = append(chips, th.scroll.Render(fmt.Sprintf("▸ %d queued", n)))
}
if m.busy && !m.vp.AtBottom() {
chips = append(chips, th.scroll.Render("↓ new output"))
}
// (F3) new-output lives on one steady footer row — no shelf duplicate.
if m.skillSuggest != nil {
name := m.skillSuggest.SkillName
if name == "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func TestSessionHomeAfterClear(t *testing.T) {
if strings.Contains(out, "fix the flaky test") == false {
t.Errorf("session home missing last prompt:\n%s", out)
}
if !strings.Contains(out, "touched") {
if !strings.Contains(out, "") {
t.Errorf("session home missing receipt:\n%s", out)
}
if !strings.Contains(out, "session") {
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/cosmetics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ func TestApprovalHeadDropsQueuedChip(t *testing.T) {
if strings.Contains(body, "queued") {
t.Errorf("approval card head still carries the queued count:\n%s", body)
}
// F1: the shelf chip is the single owner of the PROMPT-queue count — the
// footer must not repeat it (the approval head's own queue hint stays).
foot := plain(m.footer())
if !strings.Contains(foot, "1 queued") {
t.Errorf("footer lost the queued count:\n%s", foot)
if strings.Contains(foot, "▸") && strings.Contains(foot, "queued") {
t.Errorf("footer still carries the prompt-queue count:\n%s", foot)
}
}
25 changes: 22 additions & 3 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (m *Model) ingestWireEvent(ev client.Event) (tea.Model, tea.Cmd) {
// listen inside another Batch left execBatchMsg waiting on a nested listen
// and the header's ready badge never came back after Hi.
func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) {
m.lastEvent = time.Now() // (R5) the head's last-event age resets on each batch
var model tea.Model = m
for _, ev := range events {
var cmd tea.Cmd
Expand Down Expand Up @@ -382,6 +383,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// classified card renders below a partial reply, or as the
// turn's only content. No side-note degradation.
setTurnMarker(&m.msgs[i], m.errorCard(ev.Message))
m.msgs[i].failed = true // ✗ marks the head for this session; a resumed transcript re-derives nothing
// (A3) one BEL per failed turn: the guard latches until the next
// turn opens, so trailing retries cannot re-ring it.
if !m.failBellFired {
m.failBellFired = true
attn = m.attentionCmd(m.attentionFor(attentionFailed))
}
}
} else if !cancelled {
m.addNote("error: " + ev.Message)
Expand Down Expand Up @@ -651,9 +659,10 @@ 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.cancelAck = false // a wake run's errors are real errors again
m.skillSuggest = nil // the suggestion's window closed with the last turn
m.wakeArmed = false // consumed: the marker lives on the card now
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
m.wakeArmed = false // consumed: the marker lives on the card now
if wake {
m.status = "waking for bg job"
} else {
Expand Down Expand Up @@ -1167,26 +1176,36 @@ func (m *Model) transientNoteCmd(s string) tea.Cmd {
func (m *Model) pushNote(s string, exp time.Time) {
// Provider errors routinely embed full 4xx bodies — cap the size so one
// verbose notice cannot flood the transcript tail (count caps below).
// Tier rides the deadline: alert dwell ⇒ alert tier (errors,
// disconnects) outranking newer transients in the one-line pick. The
// threshold sits between noticeTTL and alertTTL so scheduling jitter
// cannot misfile either tier.
alert := time.Until(exp) > noticeTTL
m.notices = append(m.notices, truncate(sanitize(s), 400))
m.noticeExp = append(m.noticeExp, exp)
m.noticeAlert = append(m.noticeAlert, alert)
if len(m.notices) > 6 {
m.notices = m.notices[len(m.notices)-6:]
m.noticeExp = m.noticeExp[len(m.noticeExp)-6:]
m.noticeAlert = m.noticeAlert[len(m.noticeAlert)-6:]
}
}

// pruneNotices drops transient notices whose expiry has passed.
func (m *Model) pruneNotices(now time.Time) {
kept := m.notices[:0]
keptExp := m.noticeExp[:0]
keptAlert := m.noticeAlert[:0]
for i, n := range m.notices {
if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) {
kept = append(kept, n)
keptExp = append(keptExp, exp)
keptAlert = append(keptAlert, m.noticeAlert[i])
}
}
m.notices = kept
m.noticeExp = keptExp
m.noticeAlert = keptAlert
}

// noticeSweep schedules the next expiry sweep at the earliest pending
Expand Down
7 changes: 4 additions & 3 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,9 +474,10 @@ func (m *Model) sendPrompt(text string) tea.Cmd {
m.ta.Reset()
m.closeAC()
m.busy = true
m.cancelAck = false // a fresh run's errors are real errors again
m.skillSuggest = nil // the suggestion's window closed with the turn
m.wakeArmed = false // a local send is never a wake turn
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
m.wakeArmed = false // a local send is never a wake turn
m.status = "thinking"
m.runStart = time.Now()
if m.sessionStart.IsZero() {
Expand Down
8 changes: 2 additions & 6 deletions internal/tui/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -737,19 +737,15 @@ func agentStatusGlyph(phase, status string) string {
if phase == "queued" {
return "◔" // ◔ not ◌: the lamp glyphs belong to the connection state
}
return ""
return ""
}
switch status {
case "success":
return "✓"
case "partial", "budget_exhausted":
return "◐"
case "error":
case "error", "cancelled", "timeout":
return "✗"
case "cancelled":
return "⊘"
case "timeout":
return "⏱"
}
return "•"
}
Expand Down
Loading