Files
2026-06-05 21:41:24 -04:00

197 lines
5.8 KiB
Go

// Package statusfile mirrors the harness's runtime status into a single-line
// file (~/.keel_status) so an external consumer — a window-manager status
// bar — can display it with a plain file read.
package statusfile
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
// statusFileName is the file written under the user's home directory.
const statusFileName = ".keel_status"
// interval is how often the writer re-renders. Minute granularity is enough for
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
const interval = time.Minute
// DefaultPath resolves ~/.keel_status.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, statusFileName), nil
}
// Render produces the single status line for the harness envelope, dispatching
// on the active mode. An idle harness ("") renders the ambient coaching line as
// "⚠ <line>" 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)
if !ok {
// Defensive: the envelope claims focus but carries an unexpected
// payload. Render a safe fallback instead of panicking on the assert.
return "focus"
}
return renderFocus(st, now)
case "offscreen":
return renderOffscreen(env.Mode)
default:
return env.ActiveMode
}
}
// renderOffscreen produces the off-screen status line from the mode's untyped
// View payload (a map[string]any read from the live in-process envelope). It
// shows the proposed next action when one is present, a warning glyph on error,
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
// placeholder rather than panicking.
func renderOffscreen(m any) string {
const thinking = "off-screen: thinking…"
view, ok := m.(map[string]any)
if !ok {
return thinking
}
status, _ := view["status"].(string)
switch status {
case "proposed":
na, _ := view["next_action"].(string)
if na = strings.TrimSpace(na); na != "" {
return "off-screen: " + na
}
return thinking
case "error":
return "off-screen: ⚠"
default:
return thinking
}
}
// renderFocus produces the focus status line. It is empty for Locked/idle focus
// states so a bar module can hide itself. Drift status strings ("drifting")
// match the JSON contract the web UI already consumes.
func renderFocus(st focus.State, now time.Time) string {
switch st.RuntimeState {
case domain.RuntimeActive:
timer := remaining(st, now)
switch {
case st.Drift != nil && st.Drift.Status == "drifting":
return "⚠ DRIFT " + timer
case st.Drift != nil && st.Drift.Nudge != "":
return "● " + timer + " ·?"
default:
return "● " + timer
}
case domain.RuntimePlanning:
return "◔ planning"
case domain.RuntimeReview:
return "✓ review"
default:
return ""
}
}
// remaining formats the minutes left until the commitment deadline, rounded up
// so a partial minute still reads as a minute and the count hits 0m only at the
// end. It returns 0m when no deadline is known.
func remaining(st focus.State, now time.Time) string {
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
return "0m"
}
left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now)
if left < 0 {
left = 0
}
mins := int((left + time.Minute - 1) / time.Minute) // ceil
return fmt.Sprintf("%dm", mins)
}
// 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
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 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
// hook the harness's change notifications fire through, so drift transitions
// reach the status bar promptly instead of lagging the web UI by up to a tick.
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
// a single re-render, and the actual write still happens on the Run goroutine,
// so concurrent callers never race on the file.
func (w *Writer) Wake() {
select {
case w.wake <- struct{}{}:
default: // a re-render is already pending
}
}
// Run writes the status file immediately, then re-renders on each wake or tick
// when the rendered line has changed. The tick still advances the minute
// countdown when nothing else changes. It removes the file on ctx cancellation
// so a stale status does not linger after shutdown.
func (w *Writer) Run(ctx context.Context) {
t := time.NewTicker(interval)
defer t.Stop()
w.write()
for {
select {
case <-ctx.Done():
_ = os.Remove(w.path)
return
case <-w.wake:
w.write()
case <-t.C:
w.write()
}
}
}
func (w *Writer) write() {
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(rendered), 0o644); err != nil {
log.Printf("statusfile: write %s: %v", w.path, err)
return
}
w.last = rendered
w.wrote = true
}