feat(statusfile): render the ambient drift line when idle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,15 @@ func DefaultPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render produces the single status line for the harness envelope, dispatching
|
// Render produces the single status line for the harness envelope, dispatching
|
||||||
// on the active mode. An idle harness ("") renders "idle"; an unknown kind
|
// on the active mode. An idle harness ("") renders the ambient coaching line as
|
||||||
// renders a generic fallback so a future mode degrades gracefully rather than
|
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
|
||||||
// rendering nothing.
|
// fallback so a future mode degrades gracefully.
|
||||||
func Render(env mode.Envelope, now time.Time) string {
|
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
|
||||||
switch env.ActiveMode {
|
switch env.ActiveMode {
|
||||||
case "":
|
case "":
|
||||||
|
if line := strings.TrimSpace(ambientLine); line != "" {
|
||||||
|
return "⚠ " + line
|
||||||
|
}
|
||||||
return "idle"
|
return "idle"
|
||||||
case "focus":
|
case "focus":
|
||||||
st, ok := env.Mode.(focus.State)
|
st, ok := env.Mode.(focus.State)
|
||||||
@@ -121,22 +124,24 @@ func remaining(st focus.State, now time.Time) string {
|
|||||||
return fmt.Sprintf("%dm", mins)
|
return fmt.Sprintf("%dm", mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer periodically renders the harness envelope and writes it to a file,
|
// Writer periodically renders the harness envelope (plus the ambient line) and
|
||||||
// rewriting only when the line changes.
|
// writes it to a file, rewriting only when the line changes.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
path string
|
path string
|
||||||
state func() mode.Envelope
|
state func() mode.Envelope
|
||||||
now func() time.Time
|
ambient func() string
|
||||||
wake chan struct{}
|
now func() time.Time
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
last string
|
last string
|
||||||
wrote bool
|
wrote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter builds a Writer for path, reading the harness envelope via the given
|
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||||
// accessor (typically harness.State).
|
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||||
func NewWriter(path string, state func() mode.Envelope) *Writer {
|
// is treated as "" by write).
|
||||||
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
|
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
|
// 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() {
|
func (w *Writer) write() {
|
||||||
line := Render(w.state(), w.now())
|
line := ""
|
||||||
if w.wrote && line == w.last {
|
if w.ambient != nil {
|
||||||
|
line = w.ambient()
|
||||||
|
}
|
||||||
|
rendered := Render(w.state(), line, w.now())
|
||||||
|
if w.wrote && rendered == w.last {
|
||||||
return
|
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)
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.last = line
|
w.last = rendered
|
||||||
w.wrote = true
|
w.wrote = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestRender(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -77,7 +77,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
now := time.Unix(1_000_000, 0)
|
now := time.Unix(1_000_000, 0)
|
||||||
env := focusEnv(focusState(domain.RuntimePlanning))
|
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.now = func() time.Time { return now }
|
||||||
|
|
||||||
w.write()
|
w.write()
|
||||||
@@ -107,7 +107,7 @@ func TestWriterRemovesFileOnCancel(t *testing.T) {
|
|||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
w := NewWriter(path, func() mode.Envelope {
|
w := NewWriter(path, func() mode.Envelope {
|
||||||
return focusEnv(focusState(domain.RuntimePlanning))
|
return focusEnv(focusState(domain.RuntimePlanning))
|
||||||
})
|
}, nil)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() { w.Run(ctx); close(done) }()
|
go func() { w.Run(ctx); close(done) }()
|
||||||
@@ -131,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
|
|||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
st := activeState(in10m, "ontask", "")
|
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 }
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -148,6 +148,27 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
|
|||||||
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
|
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 {
|
func fileEquals(path, want string) bool {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
return err == nil && string(b) == want
|
return err == nil && string(b) == want
|
||||||
|
|||||||
Reference in New Issue
Block a user