Skip to content

Commit 809fd56

Browse files
authored
fix(tui): surface bg job completions as alert notes with attention (#64)
A finished background job surfaced as a 3-second transient note on a 10-second poll cadence — practically invisible, as demonstrated live when a GoReleaser watch completed unnoticed. Exit transitions now post alert-tier notes (10s dwell) that name the command head, and fire the attention layer (bell / OSC 9 desktop notification, user-gated as for turns) so the operator is actually told. Clean exits announce '✓ bg job done'; failed, timed-out, and killed jobs announce '✗ bg job failed'.
1 parent 0a22bed commit 809fd56

5 files changed

Lines changed: 153 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,10 @@ shared grammar:
404404
the delegating transcript step, `⏎` the full registry record — trust,
405405
budget, cost, and artifact lines included.
406406
- **Jobs** — the session's background commands (odek ≥ v1.38), live-polled
407-
every 3s; a background watcher surfaces starts and exits as transcript
408-
notes even with the tab closed. `⏎` opens the job's output viewer (`f`
407+
every 3s; a background watcher surfaces starts as transcript notes and
408+
exits as **alert-tier notes naming the command** — with a bell / desktop
409+
notification (same gates as turns) so a finished job is never missed,
410+
even with the tab closed. `⏎` opens the job's output viewer (`f`
409411
pages further output), `s` stops a running job (two-step, same gate as
410412
`/stop`). Jobs are session-scoped: other sessions' jobs never appear.
411413
- **Events** — the `odek.event/v1` ring: `f` filter to this session, `x`

‎internal/tui/attention.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
type attentionKind int
1212

1313
const (
14-
attentionDone attentionKind = iota // a turn finished (done event)
15-
attentionApproval // an approval is waiting (approval_request)
14+
attentionDone attentionKind = iota // a turn finished (done event)
15+
attentionApproval // an approval is waiting (approval_request)
16+
attentionJobDone // a background job exited cleanly (jobs watcher)
17+
attentionJobFailed // a background job failed / timed out / was killed
1618
)
1719

1820
// attention is the plan of terminal-attention effects for one state change.
@@ -58,6 +60,10 @@ func (m *Model) attentionFor(kind attentionKind) attention {
5860
prefix, note = "⚠ approval needed", "bodek: approval needed"
5961
case attentionDone:
6062
prefix, note = "✓ done", "bodek: turn complete"
63+
case attentionJobDone:
64+
prefix, note = "✓ bg job done", "bodek: background job finished"
65+
case attentionJobFailed:
66+
prefix, note = "✗ bg job failed", "bodek: background job failed"
6167
default:
6268
return attention{}
6369
}

‎internal/tui/jobs_notify_test.go‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
10+
"github.com/BackendStack21/bodek/internal/client"
11+
)
12+
13+
// ── bg job completion visibility (J1 + J2) ──────────────────────────────────
14+
//
15+
// A finished background job used to surface as a 3-second transient note —
16+
// on a 10-second poll cadence, with no command context and no attention
17+
// trigger. Practically invisible. The contract now: terminal transitions
18+
// post alert-tier notes that name the command, and fire the attention
19+
// layer (bell / OSC 9) so the operator is actually told.
20+
21+
// exitedJob is a finished job snapshot with a recognizable command head.
22+
func exitedJob(status string, code int) client.Job {
23+
c := code
24+
return client.Job{ID: "bg_0000abcd", Command: "gh pr checks 63 --watch",
25+
Status: status, RuntimeS: 104, ExitCode: &c}
26+
}
27+
28+
// seedRunning primes the diff map so the next applyJobs sees a transition.
29+
func seedRunning(t *testing.T) *Model {
30+
t.Helper()
31+
m := newTestModel()
32+
m.applyJobs([]client.Job{{ID: "bg_0000abcd", Command: "gh pr checks 63 --watch",
33+
Status: "running", RuntimeS: 3}}, nil)
34+
return m
35+
}
36+
37+
// TestJobExitAlertTier pins J1: a terminal transition posts an alert-tier
38+
// note (dwell in (noticeTTL, alertTTL]), not a 3s transient.
39+
func TestJobExitAlertTier(t *testing.T) {
40+
m := seedRunning(t)
41+
m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil)
42+
43+
note, exp := lastNoteMatching(m, "bg_0000abcd")
44+
if note == "" {
45+
t.Fatal("exit transition posted no note")
46+
}
47+
if dwell := time.Until(exp); dwell <= noticeTTL || dwell > alertTTL {
48+
t.Errorf("exit note dwell = %v, want alert tier (%v, %v]", dwell, noticeTTL, alertTTL)
49+
}
50+
}
51+
52+
// TestJobExitNoteNamesCommand pins the command context: the exit note
53+
// carries the sanitized command head so the operator knows WHICH job.
54+
func TestJobExitNoteNamesCommand(t *testing.T) {
55+
m := seedRunning(t)
56+
m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil)
57+
58+
note, _ := lastNoteMatching(m, "bg_0000abcd")
59+
for _, want := range []string{"gh pr checks 63 --watch", "exited 0", "1m44s"} {
60+
if !strings.Contains(note, want) {
61+
t.Errorf("exit note missing %q: %q", want, note)
62+
}
63+
}
64+
}
65+
66+
// TestJobExitAttentionCmd pins J2: a transition fires the attention layer;
67+
// a steady-state re-apply fires nothing.
68+
func TestJobExitAttentionCmd(t *testing.T) {
69+
m := seedRunning(t)
70+
var attn tea.Cmd
71+
attn = m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil)
72+
if attn == nil {
73+
t.Fatal("terminal transition fired no attention")
74+
}
75+
76+
if attn = m.applyJobs([]client.Job{exitedJob("exited", 0)}, nil); attn != nil {
77+
t.Error("steady-state re-apply must not re-fire attention")
78+
}
79+
}
80+
81+
// TestJobAttentionKinds covers the new kinds' rendering plan: ✓ for clean
82+
// exits, ✗ for failures; notification and bell stay user-gated.
83+
func TestJobAttentionKinds(t *testing.T) {
84+
if jobAttentionKind(exitedJob("exited", 0)) != attentionJobDone {
85+
t.Error("clean exit should map to attentionJobDone")
86+
}
87+
for _, s := range []string{"failed", "timeout", "killed"} {
88+
if jobAttentionKind(exitedJob(s, 1)) != attentionJobFailed {
89+
t.Errorf("%s should map to attentionJobFailed", s)
90+
}
91+
}
92+
93+
m := newTestModel()
94+
m.notify = true
95+
if a := m.attentionFor(attentionJobDone); !strings.Contains(a.sequence(), "bg job done") {
96+
t.Errorf("done attention missing title/notify: %q", a.sequence())
97+
}
98+
if a := m.attentionFor(attentionJobFailed); !strings.Contains(a.sequence(), "bg job failed") {
99+
t.Errorf("failed attention missing title/notify: %q", a.sequence())
100+
}
101+
m.notify = false
102+
if a := m.attentionFor(attentionJobDone); strings.Contains(a.sequence(), "\x1b]9;") {
103+
t.Errorf("notify disabled but OSC 9 emitted: %q", a.sequence())
104+
}
105+
}

‎internal/tui/jobs_tab.go‎

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import (
1818
// agent-side completion notice never leaves the LLM payload. bodek therefore
1919
// watches GET /api/jobs itself: a 10s cadence whenever a session is live —
2020
// the canonical case is a job finishing while the operator reads output —
21-
// stepping up to 3s while the tab is visible. Diffs surface as transient
22-
// notes; the tab is the full surface (rows, output detail, stop).
21+
// stepping up to 3s while the tab is visible. Terminal transitions surface
22+
// as alert-tier notes naming the command and fire the attention layer; the
23+
// tab is the full surface (rows, output detail, stop).
2324

2425
const (
2526
jobsPollEvery = 3 * time.Second // tab visible: live view
@@ -91,10 +92,12 @@ func (m *Model) fetchJobs() tea.Cmd {
9192
}
9293
}
9394

94-
// applyJobs stores a snapshot; watcher diffs become transient notes. The
95-
// first successful snapshot baselines silently — jobs that predate the
96-
// attach belong to the tab, not the transcript.
97-
func (m *Model) applyJobs(jobs []client.Job, err error) {
95+
// applyJobs stores a snapshot; watcher diffs become alert-tier notes — a
96+
// finished job is actionable, not housekeeping — and terminal transitions
97+
// fire the attention layer (bell / OSC 9). The first successful snapshot
98+
// baselines silently — jobs that predate the attach belong to the tab, not
99+
// the transcript. Returns the attention cmd (nil without a transition).
100+
func (m *Model) applyJobs(jobs []client.Job, err error) tea.Cmd {
98101
if err != nil {
99102
if errors.Is(err, client.ErrJobsUnavailable) {
100103
m.jobsOff = true
@@ -103,17 +106,18 @@ func (m *Model) applyJobs(jobs []client.Job, err error) {
103106
m.panelMsg = "background commands need odek ≥ v1.38.0"
104107
m.refresh()
105108
}
106-
return
109+
return nil
107110
}
108111
if m.panel == panelJobs {
109112
// Transient network blips surface here only when the operator
110113
// is looking; the silent watcher keeps ticking.
111114
m.panelMsg = "error: " + err.Error()
112115
m.refresh()
113116
}
114-
return
117+
return nil
115118
}
116119
m.jobs = jobs
120+
var attn tea.Cmd
117121
if m.jobsPrev == nil {
118122
m.jobsPrev = make(map[string]string, len(jobs))
119123
for _, j := range jobs {
@@ -133,7 +137,10 @@ func (m *Model) applyJobs(jobs []client.Job, err error) {
133137
case !seen:
134138
m.addTransientNote("bg · " + jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " · " + sanitize(j.Command))
135139
case old == "running" && j.Status != "running":
136-
m.addTransientNote(jobExitNote(j))
140+
// Alert tier + attention: a finished job must survive a
141+
// glance-away, and the operator opted into knowing.
142+
m.addNote(jobExitNote(j))
143+
attn = m.attentionCmd(m.attentionFor(jobAttentionKind(j)))
137144
}
138145
m.jobsPrev[j.ID] = j.Status
139146
}
@@ -147,6 +154,7 @@ func (m *Model) applyJobs(jobs []client.Job, err error) {
147154
if m.panelSel >= m.panelLen() {
148155
m.panelSel = max(m.panelLen()-1, 0)
149156
}
157+
return attn
150158
}
151159

152160
// rearmJobs schedules the next fetch: the fast chain while the tab is
@@ -339,16 +347,29 @@ func jobStatusGlyph(status string) string {
339347
return "·"
340348
}
341349

342-
// jobExitNote is the watcher's terminal-transition note: glyph, id, terminal
343-
// status, exit code when the server reported one, humanized runtime.
350+
// jobExitNote is the watcher's terminal-transition note: glyph, id, command
351+
// head, terminal status, exit code when the server reported one, humanized
352+
// runtime.
344353
func jobExitNote(j client.Job) string {
345-
s := jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " " + j.Status
354+
s := jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " · " + truncate(sanitize(j.Command), 48)
346355
if j.ExitCode != nil {
347-
s += fmt.Sprintf(" %d", *j.ExitCode)
356+
s += " — " + j.Status + fmt.Sprintf(" %d", *j.ExitCode)
357+
} else {
358+
s += " — " + j.Status
348359
}
349360
return s + " · " + fmtRuntime(j.RuntimeS)
350361
}
351362

363+
// jobAttentionKind maps a terminal job status to its attention kind.
364+
func jobAttentionKind(j client.Job) attentionKind {
365+
switch j.Status {
366+
case "failed", "timeout", "killed":
367+
return attentionJobFailed
368+
default:
369+
return attentionJobDone
370+
}
371+
}
372+
352373
// fmtRuntime humanizes seconds the way the transcript does durations.
353374
func fmtRuntime(s float64) string {
354375
if s < 0 {

‎internal/tui/model.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,9 +577,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
577577
return m, m.handleJobsTick(msg)
578578

579579
case jobsFetchedMsg:
580-
m.applyJobs(msg.jobs, msg.err)
580+
attn := m.applyJobs(msg.jobs, msg.err)
581581
m.refresh()
582-
return m, tea.Batch(m.rearmJobs(), m.noticeSweep())
582+
return m, tea.Batch(m.rearmJobs(), m.noticeSweep(), attn)
583583

584584
case jobOutputMsg:
585585
return m, m.handleJobOutput(msg)

0 commit comments

Comments
 (0)