From 4b2cb4242f59520d823c6750b25c84c57f4670ea Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 21:41:24 -0400 Subject: [PATCH] feat(statusfile): render the ambient drift line when idle Co-Authored-By: Claude Sonnet 4.6 --- internal/statusfile/statusfile.go | 45 +++++++++++++++----------- internal/statusfile/statusfile_test.go | 29 ++++++++++++++--- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/internal/statusfile/statusfile.go b/internal/statusfile/statusfile.go index 16bd12c..f30179b 100644 --- a/internal/statusfile/statusfile.go +++ b/internal/statusfile/statusfile.go @@ -34,12 +34,15 @@ func DefaultPath() (string, error) { } // Render produces the single status line for the harness envelope, dispatching -// on the active mode. An idle harness ("") renders "idle"; an unknown kind -// renders a generic fallback so a future mode degrades gracefully rather than -// rendering nothing. -func Render(env mode.Envelope, now time.Time) string { +// on the active mode. An idle harness ("") renders the ambient coaching line as +// "⚠ " when one is present, else "idle"; an unknown kind renders a generic +// fallback so a future mode degrades gracefully. +func Render(env mode.Envelope, ambientLine string, now time.Time) string { switch env.ActiveMode { case "": + if line := strings.TrimSpace(ambientLine); line != "" { + return "⚠ " + line + } return "idle" case "focus": st, ok := env.Mode.(focus.State) @@ -121,22 +124,24 @@ func remaining(st focus.State, now time.Time) string { return fmt.Sprintf("%dm", mins) } -// Writer periodically renders the harness envelope and writes it to a file, -// rewriting only when the line changes. +// Writer periodically renders the harness envelope (plus the ambient line) and +// writes it to a file, rewriting only when the line changes. type Writer struct { - path string - state func() mode.Envelope - now func() time.Time - wake chan struct{} + path string + state func() mode.Envelope + ambient func() string + now func() time.Time + wake chan struct{} last string wrote bool } -// NewWriter builds a Writer for path, reading the harness envelope via the given -// accessor (typically harness.State). -func NewWriter(path string, state func() mode.Envelope) *Writer { - return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)} +// NewWriter builds a Writer for path, reading the harness envelope via state and +// the ambient coaching line via ambient (either may be supplied; a nil ambient +// is treated as "" by write). +func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer { + return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)} } // Wake asks the writer to re-render now rather than at the next tick. It is the @@ -174,14 +179,18 @@ func (w *Writer) Run(ctx context.Context) { } func (w *Writer) write() { - line := Render(w.state(), w.now()) - if w.wrote && line == w.last { + line := "" + if w.ambient != nil { + line = w.ambient() + } + rendered := Render(w.state(), line, w.now()) + if w.wrote && rendered == w.last { return } - if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil { + if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil { log.Printf("statusfile: write %s: %v", w.path, err) return } - w.last = line + w.last = rendered w.wrote = true } diff --git a/internal/statusfile/statusfile_test.go b/internal/statusfile/statusfile_test.go index fd6698b..8c6dd9f 100644 --- a/internal/statusfile/statusfile_test.go +++ b/internal/statusfile/statusfile_test.go @@ -66,7 +66,7 @@ func TestRender(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := Render(tc.env, now); got != tc.want { + if got := Render(tc.env, "", now); got != tc.want { t.Errorf("Render() = %q, want %q", got, tc.want) } }) @@ -77,7 +77,7 @@ func TestWriterWritesAndDedups(t *testing.T) { path := filepath.Join(t.TempDir(), "status") now := time.Unix(1_000_000, 0) env := focusEnv(focusState(domain.RuntimePlanning)) - w := NewWriter(path, func() mode.Envelope { return env }) + w := NewWriter(path, func() mode.Envelope { return env }, nil) w.now = func() time.Time { return now } w.write() @@ -107,7 +107,7 @@ func TestWriterRemovesFileOnCancel(t *testing.T) { path := filepath.Join(t.TempDir(), "status") w := NewWriter(path, func() mode.Envelope { return focusEnv(focusState(domain.RuntimePlanning)) - }) + }, nil) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { w.Run(ctx); close(done) }() @@ -131,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) { var mu sync.Mutex st := activeState(in10m, "ontask", "") - w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) }) + w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) }, nil) w.now = func() time.Time { return now } ctx, cancel := context.WithCancel(context.Background()) @@ -148,6 +148,27 @@ func TestWriterWakeRendersPromptly(t *testing.T) { waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") }) } +func TestRenderIdleShowsAmbientLine(t *testing.T) { + got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0)) + if got != "⚠ deep in YouTube, no goal" { + t.Fatalf("idle+ambient render = %q", got) + } +} + +func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) { + if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" { + t.Fatalf("idle+no-ambient render = %q, want idle", got) + } +} + +func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) { + // A non-idle envelope renders its own mode, never the ambient line. + got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0)) + if got == "ambient ignored" || got == "⚠ ambient ignored" { + t.Fatalf("active mode must not render the ambient line, got %q", got) + } +} + func fileEquals(path, want string) bool { b, err := os.ReadFile(path) return err == nil && string(b) == want