From e204d8158de5f552bdc80f0b860945f7cc1571fa Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:01:02 +0100 Subject: [PATCH 1/6] feat(work): watch the work list on a live kanban board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `spinloop work board`: four state columns re-read from the work list API on a cadence, with detail-and-log-tail, in-place add form, abort and confirm-before-remove — all through the API alone, so refusals read the way the API states them. A client of the work list API only, and it refuses a pipe in favour of `work list`. Closes #246 --- CHANGELOG.md | 4 + cmd/spinloop/work.go | 1 + cmd/spinloop/work_board.go | 81 ++ cmd/spinloop/work_board_model.go | 830 +++++++++++ cmd/spinloop/work_board_render.go | 603 ++++++++ cmd/spinloop/work_board_test.go | 1265 +++++++++++++++++ docs/commands/index.md | 2 +- docs/commands/work.md | 53 +- docs/guides/work-items.md | 9 +- go.mod | 2 + go.sum | 4 + .../changes/add-work-board-tui/.openspec.yaml | 2 + openspec/changes/add-work-board-tui/design.md | 163 +++ .../changes/add-work-board-tui/proposal.md | 70 + .../specs/work-board/spec.md | 260 ++++ .../specs/work-commands/spec.md | 37 + openspec/changes/add-work-board-tui/tasks.md | 61 + 17 files changed, 3441 insertions(+), 6 deletions(-) create mode 100644 cmd/spinloop/work_board.go create mode 100644 cmd/spinloop/work_board_model.go create mode 100644 cmd/spinloop/work_board_render.go create mode 100644 cmd/spinloop/work_board_test.go create mode 100644 openspec/changes/add-work-board-tui/.openspec.yaml create mode 100644 openspec/changes/add-work-board-tui/design.md create mode 100644 openspec/changes/add-work-board-tui/proposal.md create mode 100644 openspec/changes/add-work-board-tui/specs/work-board/spec.md create mode 100644 openspec/changes/add-work-board-tui/specs/work-commands/spec.md create mode 100644 openspec/changes/add-work-board-tui/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbd2915..ef9bbc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- feat: watch the work list on a live kanban board with `spinloop work board` (#246) + ## [1.42.0] - 2026-09-20 ### Added - feat: make metrics and logs top-level verbs diff --git a/cmd/spinloop/work.go b/cmd/spinloop/work.go index 7eaddff1..bec3f064 100644 --- a/cmd/spinloop/work.go +++ b/cmd/spinloop/work.go @@ -55,6 +55,7 @@ fails before it calls the API, naming the flag.`, workAbortCmd(), workRemoveCmd(), workLogsCmd(), + workBoardCmd(), ) return c } diff --git a/cmd/spinloop/work_board.go b/cmd/spinloop/work_board.go new file mode 100644 index 00000000..94e4198c --- /dev/null +++ b/cmd/spinloop/work_board.go @@ -0,0 +1,81 @@ +// `work board`: the kanban view of a running orchestrator's work list. +// This is the command layer — flags, the terminal check, and the program; +// the model and the renderers live in work_board_model.go and +// work_board_render.go, so the screen logic is tested without a command +// and the command without a screen. + +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// cmdWorkBoard is the seam the suite calls, the family's own. +func cmdWorkBoard(args []string) error { return execCmd(workBoardCmd(), args) } + +func workBoardCmd() *cobra.Command { + var base, apiToken, apiTokenFile string + c := &cobra.Command{ + Use: "board", + Short: "watch the work list on a kanban board", + Long: `watches the orchestrator's work list as a live kanban board — a +column per state (Backlog, Running, Done, Failed), a card per item — +re-read from the work list API on a cadence, so cards move as the run +works. + +The arrow keys move the selection, enter opens the item's detail (its full +instructions, its tags and record, and its kept output tailed live), esc +closes it. n opens a form that adds an item through the API — the same add +` + "`work add`" + ` sends — a stops a running item, x removes one that is not, +after asking. r reads again at once; q or Ctrl+C leaves. Keys are offered +only where they would do something. + +Like every work command the board names the API with --url and presents +its token; the run's view of the items is the source of truth, and a +refusal reads the way the API states it. The board needs a terminal; to +report the same work into a pipe, use spinloop work list instead.`, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + b, token, err := workTarget("work board", base, apiToken, apiTokenFile) + if err != nil { + return err + } + return runWorkBoard(b, token) + }, + } + fs := c.Flags() + workAPIFlags(fs, &base, &apiToken, &apiTokenFile) + c.ValidArgsFunction = noPositionals + return c +} + +// runWorkBoard opens the view. The terminal check comes after the target +// resolves — a missing --url names the flag wherever the board is asked +// for — and before anything is drawn: a piped invocation never +// half-enters the view. +func runWorkBoard(base, token string) error { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return fmt.Errorf("the work board needs an interactive terminal — " + + "report the work into a pipe with spinloop work list instead") + } + return runWorkBoardProgram(newWorkBoardModel(base, token)) +} + +// runWorkBoardProgram runs the view on the alternate screen, the model +// held by pointer so the first round's answers survive — Bubble Tea +// restores the terminal on the way out, whatever key got here. +func runWorkBoardProgram(m *workBoardModel) error { + prog := tea.NewProgram(m, tea.WithAltScreen()) + m.send = prog.Send + if _, err := prog.Run(); err != nil { + return fmt.Errorf("work board: %w", err) + } + return nil +} diff --git a/cmd/spinloop/work_board_model.go b/cmd/spinloop/work_board_model.go new file mode 100644 index 00000000..2caba94f --- /dev/null +++ b/cmd/spinloop/work_board_model.go @@ -0,0 +1,830 @@ +// The `work board` model: the columns, the selection, the detail, the add +// form, and how keys and messages move them. As with the fleet dashboard's +// model, every rule here is plain Go over plain data — the clock, the +// intervals and the API's address are inputs — so the suite drives the +// whole screen without a terminal. The board is a client of the work list +// API alone: like the one-shot work commands, it never touches the items +// file, the state, or the logs. + +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// The board's cadences and its clock, variables so a test never waits on +// one or is at the mercy of the other. The refresh cadence is the fleet +// dashboard's own pace: one GET per tick, and the run it watches changes +// on the scale of agent turns, not milliseconds. The tail cadence matches +// `work logs -f`, which the detail pane tails the same way. +var ( + workBoardRefreshInterval = 5 * time.Second + workBoardTailInterval = 1 * time.Second + workBoardSpinInterval = 100 * time.Millisecond +) + +// workBoardStaleThreshold is how many refresh intervals a reading may age +// before the title bar says so rather than draw it as the present state of +// the run — the dashboard's rule, and the same three rather than one so a +// single late round does not flicker the board grey. +const workBoardStaleThreshold = 3 + +// workBoardStaleAfter is when the board's reading counts as aged. A +// function of the cadence, so a test that shortens the cadence shortens +// the threshold with it. +func workBoardStaleAfter() time.Duration { + return workBoardStaleThreshold * workBoardRefreshInterval +} + +// workBoardNow is the board's clock, a variable so elapsed times a test +// renders are the test's to fix. +var workBoardNow = time.Now + +// workBoardVerb is one of the board's actions on an item, as its status +// lines name it. +type workBoardVerb string + +const ( + workAbort workBoardVerb = "abort" + workRemove workBoardVerb = "remove" + workAdd workBoardVerb = "add" +) + +// progress is what the status line shows while the action is in flight: +// the tool's one spinner, the verb, and how long the call has been out — +// an abort holds the call for the run's stop grace, so the wait has to +// show it is moving. +func (a workBoardAction) progress(now time.Time) string { + line := spinnerFrame(now) + " " + string(a.verb) + "ing " + a.id + if a.since.IsZero() { + return line + } + elapsed := now.Sub(a.since) + if elapsed < 0 { + elapsed = 0 + } + return line + " " + formatDuration(int(elapsed.Seconds())) +} + +// workBoardAction is the one action in flight: the board sends one call at +// a time, so one slot carries it — the verb, the item it concerns, and +// when it began. The zero value is an idle board. +type workBoardAction struct { + verb workBoardVerb + id string + since time.Time +} + +// workBoardForm is the add form: five fields, the cursor on one of them, +// and — where the API refused the last send — the refusal itself, kept +// visibly for the corrected send to replace. +type workBoardForm struct { + fields []textinput.Model // id, instructions, dir, tags, priority — workFormLabel's order + cursor int // the field taking keystrokes + err string // the API's refusal of the last send; cleared by the next +} + +// The form's fields, in the order the form draws them and the labels it +// draws beside them. Required marks the three an item cannot do without — +// a mark, not a validator: the API stays the only judge of an item's +// shape, and says so itself. +var ( + workFormLabels = []string{"id*", "instructions*", "dir*", "tags", "priority"} + workFormPromptW = 13 + workFormPriority = 4 // the index of the one field the form guards itself +) + +// newWorkBoardForm builds the form with its inputs sized and blink off: +// the board is a standing surface, and a caret blinking on its own is +// both animation the spec does not ask for and movement a byte-stable +// render test cannot allow. +func newWorkBoardForm(width int) workBoardForm { + f := workBoardForm{fields: make([]textinput.Model, len(workFormLabels))} + for i := range f.fields { + ti := textinput.New() + ti.Cursor.Blink = false + ti.Width = width + ti.Prompt = "" + f.fields[i] = ti + } + f.focus(0) + return f +} + +// focus moves the caret to one field: into it, and out of whichever was. +func (f *workBoardForm) focus(i int) { + if i < 0 || i >= len(f.fields) { + return + } + f.fields[f.cursor].Blur() + f.cursor = i + f.fields[i].Focus() +} + +// dirty reports whether anything has been typed — which decides whether an +// escape closes the form or first asks to discard. +func (f *workBoardForm) dirty() bool { + for _, ti := range f.fields { + if ti.Value() != "" { + return true + } + } + return false +} + +// values returns the form as the item the API will be asked to add. Tags +// are the field split on spaces — the same values `work add --tag` takes +// one flag at a time; whether they are well-formed is the API's to say. +func (f *workBoardForm) values() (workAddBody, error) { + priority := 0 + if v := strings.TrimSpace(f.fields[workFormPriority].Value()); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return workAddBody{}, fmt.Errorf("priority %q is not a number", v) + } + priority = n + } + var tags []string + if v := strings.Join(strings.Fields(f.fields[3].Value()), " "); v != "" { + tags = strings.Fields(v) + } + return workAddBody{ + ID: f.fields[0].Value(), + Instructions: f.fields[1].Value(), + Dir: f.fields[2].Value(), + Tags: tags, + Priority: priority, + }, nil +} + +// clear drops the form's text and any refusal, ready for the next item. +func (f *workBoardForm) clear() { + for i := range f.fields { + f.fields[i].SetValue("") + f.fields[i].SetCursor(0) + } + f.cursor = 0 + f.err = "" + f.focus(0) +} + +// workBoardModel is the program's state. The items are the last reading; +// the cursor names a column and a row within it; the detail, the removal +// question and the add form are the three modes a screen stands in, and +// keys route form → question → detail → board. +type workBoardModel struct { + base string + token string + + items []orchestrator.ItemView // the last reading, in the API's order + readingAt time.Time // when it was taken + readErr string // why the last round failed; "" when it did not + + cursor [2]int // column, then row within that column + scrolls [4]int // the first visible card row per column + busy bool // a read round is in flight + action workBoardAction // the one action in flight, if any + + statusLine string + + detail bool + detailItem orchestrator.ItemView // the item in view, kept from the reading + detailGen int // bumped per open; stale log replies are discarded + detailBusy bool // a log round is in flight + detailSeen string // the whole log as last fetched, for the suffix + detailLog string // the tailed lines, trimmed to the pane + detailNote string // why the pane is empty + + formOpen bool + formAsk bool // the discard question stands in front of the form + form workBoardForm + + confirm bool // a removal stands in front of the board, waiting on its yes + + send func(msg tea.Msg) + width, height int +} + +// The board's columns, in the order the spec stands them. +var workBoardColumns = []struct { + title string + state string +}{ + {"Backlog", orchestrator.StateBacklog}, + {"Running", orchestrator.StateRunning}, + {"Done", orchestrator.StateDone}, + {"Failed", orchestrator.StateFailed}, +} + +// Msgs. + +type workBoardTickMsg time.Time +type workBoardSpinMsg time.Time +type workBoardTailTickMsg time.Time + +// workBoardReadMsg is one completed round of the list. at is when the +// answer arrived; Update draws it only if it is newer than what is on +// screen — the dashboard's race guard, for the same reason. +type workBoardReadMsg struct { + items []orchestrator.ItemView + at time.Time + err error +} + +// workBoardActionMsg is one completed action. +type workBoardActionMsg struct { + verb workBoardVerb + id string + err error +} + +// workBoardTailMsg is one completed poll of the item in view's log. gen +// ties it to the detail view that started it. +type workBoardTailMsg struct { + gen int + log string + gone bool // the item left the list: its log 404s, and the tail ends + err error +} + +func workBoardTickCmd() tea.Cmd { + return tea.Tick(workBoardRefreshInterval, func(time.Time) tea.Msg { return workBoardTickMsg{} }) +} + +func workBoardSpinCmd() tea.Cmd { + return tea.Tick(workBoardSpinInterval, func(time.Time) tea.Msg { return workBoardSpinMsg{} }) +} + +func workBoardTailTickCmd() tea.Cmd { + return tea.Tick(workBoardTailInterval, func(time.Time) tea.Msg { return workBoardTailTickMsg{} }) +} + +// newWorkBoardModel builds the board over the API it will call. The first +// round arrives on Init, as everywhere else; a board that has read nothing +// yet draws four empty columns, not an error. +func newWorkBoardModel(base, token string) *workBoardModel { + return &workBoardModel{base: base, token: token} +} + +func (m *workBoardModel) Init() tea.Cmd { + return tea.Batch(workBoardTickCmd(), m.startRound()) +} + +// columnIndexes buckets the reading into the four columns, each column +// holding its items in the API's order — the order `work list` prints, so +// the two surfaces cannot show the run in two orders. +func (m *workBoardModel) columnIndexes() [4][]int { + var cols [4][]int + for i, v := range m.items { + for c := range workBoardColumns { + if v.State == workBoardColumns[c].state { + cols[c] = append(cols[c], i) + break + } + } + } + return cols +} + +// selectedItem is the card under the cursor, or nil where the cursor +// stands on nothing — an empty column, or an empty board. +func (m *workBoardModel) selectedItem() *orchestrator.ItemView { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if c >= len(cols) || r < 0 || r >= len(cols[c]) { + return nil + } + v := m.items[cols[c][r]] + return &v +} + +func (m *workBoardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clamp() + case workBoardTickMsg: + // One tick, rescheduling itself, starting a round only when none is + // in flight — a slow API stretches a round rather than overlapping + // the next. + return m, tea.Batch(workBoardTickCmd(), m.startRound()) + case workBoardReadMsg: + m.busy = false + if msg.err != nil { + m.readErr = msg.err.Error() + return m, nil + } + if msg.at.Before(m.readingAt) { + return m, nil // an older answer than what is on screen + } + m.readErr = "" + m.items = msg.items + m.readingAt = msg.at + m.retainDetail() + m.clamp() + case workBoardSpinMsg: + if m.action.verb == "" { + return m, nil + } + return m, workBoardSpinCmd() + case workBoardActionMsg: + m.action = workBoardAction{} + if msg.verb == workAdd { + // The form carries its own answer: closed on acceptance, still + // open under the API's refusal so a field can be corrected. + if msg.err == nil { + m.formOpen = false + m.formAsk = false + m.form.clear() + m.statusLine = fmt.Sprintf("item %q added", msg.id) + } else { + m.form.err = msg.err.Error() + } + } else { + m.statusLine = workBoardActionLine(msg) + } + // What the action changed is what the operator is waiting to see: + // a round is due now rather than at the tick. + return m, m.startRound() + case workBoardTailTickMsg: + if !m.detail { + return m, nil + } + return m, tea.Batch(workBoardTailTickCmd(), m.startTailRound()) + case workBoardTailMsg: + m.detailBusy = false + if !m.detail || msg.gen != m.detailGen { + return m, nil // the view closed, or reopened on another item + } + m.applyTail(msg) + case tea.KeyMsg: + return m.updateKey(msg) + } + return m, nil +} + +// updateKey routes a key by mode: the form, then the removal question, +// then the detail, then the board. +func (m *workBoardModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.formOpen { + if m.formAsk { + return m, m.updateFormAsk(msg) + } + return m, m.updateFormKey(msg) + } + if m.confirm { + switch msg.String() { + case "y": + v := m.selectedItem() + m.confirm = false + if v == nil { + return m, nil + } + return m, m.beginAction(workRemove, v.ID) + case "n", "esc": + m.confirm = false + m.statusLine = "declined — nothing removed" + case "q", "ctrl+c": + m.confirm = false + return m, tea.Quit + } + return m, nil + } + if m.detail { + if msg.String() == "esc" { + m.detail = false + } + return m, nil + } + return m, m.updateBoardKey(msg) +} + +func (m *workBoardModel) updateBoardKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "q", "ctrl+c": + return tea.Quit + case "up": + m.moveRow(-1) + case "down": + m.moveRow(1) + case "left": + m.moveColumn(-1) + case "right": + m.moveColumn(1) + case "enter": + if v := m.selectedItem(); v != nil { + return m.openDetail(*v) + } + case "a": + // No client-side guard on state: a backlog abort is the API's + // refusal to make, in its own words. Nor one on the action slot: + // beginAction answers a second key with its own status line. + if v := m.selectedItem(); v != nil { + return m.beginAction(workAbort, v.ID) + } + case "x": + if m.selectedItem() != nil { + m.confirm = true + } + case "n": + m.formOpen = true + m.formAsk = false + m.form = newWorkBoardForm(m.formFieldWidth()) + case "r": + return m.startRound() + } + return nil +} + +// updateFormKey answers the keys the add form reads. Typing and the caret +// keys belong to the focused textinput; up/down step the field cursor; +// enter advances and, on the last field, sends. The priority field takes +// only digits and a leading minus — a non-number could not even be asked +// of the API, so the keystroke simply does not enter the field. +func (m *workBoardModel) updateFormKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc": + if m.form.dirty() { + m.formAsk = true // discard is a deliberate act, not one escape + } else { + m.closeForm("nothing added") + } + return nil + case "up": + m.form.focus(m.form.cursor - 1) + return nil + case "down", "tab": + m.form.focus(m.form.cursor + 1) + return nil + case "enter": + if m.form.cursor < len(m.form.fields)-1 { + m.form.focus(m.form.cursor + 1) + return nil + } + return m.sendForm() + } + if m.form.cursor == workFormPriority && msg.Type == tea.KeyRunes { + allowed := make([]rune, 0, len(msg.Runes)) + for _, r := range msg.Runes { + if (r >= '0' && r <= '9') || r == '-' { + allowed = append(allowed, r) + } + } + if len(allowed) == 0 { + return nil + } + msg.Runes = allowed + msg.Type = tea.KeyRunes + } + ti, cmd := m.form.fields[m.form.cursor].Update(msg) + m.form.fields[m.form.cursor] = ti + return cmd +} + +// sendForm is the form's enter on the last field: the assembled item goes +// to the API — the API alone judges its shape — and the form stays open +// under the refusal, its text intact, if the API says no. +func (m *workBoardModel) sendForm() tea.Cmd { + body, err := m.form.values() + if err != nil { + m.form.err = err.Error() // the one shape fault the form can state: its own priority field + return nil + } + m.form.err = "" + return m.beginAdd(body) +} + +func (m *workBoardModel) closeForm(status string) { + m.formOpen = false + m.formAsk = false + m.form.clear() + m.statusLine = status +} + +// updateFormAsk answers the discard question: it defaults to keeping the +// work, so only a deliberate yes closes and says nothing was sent. +func (m *workBoardModel) updateFormAsk(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "y": + m.closeForm("nothing added") + case "n", "esc": + m.formAsk = false + case "q", "ctrl+c": + m.formOpen = false + m.formAsk = false + return tea.Quit + } + return nil +} + +// startRound opens one read of the list, if none is in flight. The answer +// comes back as a msg stamped with the time it arrived, not the time the +// round started. +func (m *workBoardModel) startRound() tea.Cmd { + if m.busy { + return nil + } + m.busy = true + base, token := m.base, m.token + return func() tea.Msg { + data, err := workRequest(base, token, "GET", "/v1/items", nil) + if err != nil { + return workBoardReadMsg{at: workBoardNow(), err: err} + } + var out struct { + Data []orchestrator.ItemView `json:"data"` + } + if err := json.Unmarshal(data, &out); err != nil { + return workBoardReadMsg{at: workBoardNow(), err: fmt.Errorf("reading the work list: %w", err)} + } + return workBoardReadMsg{items: out.Data, at: workBoardNow()} + } +} + +// beginAction sets off one call against an item. One action at a time: +// a second key while a call is out is answered by the status line, not a +// second call. The call rides the same bound the one-shot commands put on +// every call. +func (m *workBoardModel) beginAction(verb workBoardVerb, id string) tea.Cmd { + if m.action.verb != "" { + m.statusLine = "still " + string(m.action.verb) + "ing " + m.action.id + return nil + } + base, token := m.base, m.token + m.action = workBoardAction{verb: verb, id: id, since: workBoardNow()} + // The repaint chain that animates the spinner runs through the + // program: this message restarts it, and its handler keeps it going + // while a call is out. Driven directly, as a test drives it, there is + // nothing to animate and no chain starts. + if m.send != nil { + m.send(workBoardSpinMsg(workBoardNow())) + } + switch verb { + case workAbort: + return func() tea.Msg { + _, err := workRequest(base, token, "POST", "/v1/items/"+url.PathEscape(id)+"/abort", nil) + return workBoardActionMsg{verb: verb, id: id, err: err} + } + case workRemove: + return func() tea.Msg { + _, err := workRequest(base, token, "DELETE", "/v1/items/"+url.PathEscape(id), nil) + return workBoardActionMsg{verb: verb, id: id, err: err} + } + } + return nil +} + +// beginAdd sends the form's item to the API's own add path. The form +// validates nothing about the item's shape — the API is the only judge, +// and the form is still open to receive its answer — but the priority the +// form assembled must still parse, since the form guards that field. +func (m *workBoardModel) beginAdd(body workAddBody) tea.Cmd { + if m.action.verb != "" { + m.statusLine = "still " + string(m.action.verb) + "ing " + m.action.id + return nil + } + base, token := m.base, m.token + m.action = workBoardAction{verb: workAdd, id: body.ID, since: workBoardNow()} + if m.send != nil { + m.send(workBoardSpinMsg(workBoardNow())) + } + return func() tea.Msg { + _, err := workRequest(base, token, "POST", "/v1/items", body) + return workBoardActionMsg{verb: workAdd, id: body.ID, err: err} + } +} + +// workBoardActionLine is the status line's account of a finished action: +// the API's own words for a refusal, unchanged — the refusal reads the +// way the API states it — and the plain fact for a success. +func workBoardActionLine(msg workBoardActionMsg) string { + if msg.err != nil { + return msg.err.Error() + } + switch msg.verb { + case workAbort: + return fmt.Sprintf("item %q stopped: it is back in the backlog", msg.id) + case workRemove: + return fmt.Sprintf("item %q removed", msg.id) + } + return fmt.Sprintf("item %q added", msg.id) +} + +// openDetail freezes the cursor onto the item in view, takes a copy of it +// (the reading behind may move on), and starts its tail. +func (m *workBoardModel) openDetail(v orchestrator.ItemView) tea.Cmd { + m.detail = true + m.detailItem = v + m.detailGen++ + m.detailBusy = false + m.detailSeen = "" + m.detailLog = "" + m.detailNote = "" + return tea.Batch(workBoardTailTickCmd(), m.startTailRound()) +} + +// startTailRound polls the item in view's kept output once. The API +// answers with the whole kept log, so the round fetches it and applyTail +// appends only what is new — the same trick `work logs -f` plays. +func (m *workBoardModel) startTailRound() tea.Cmd { + if m.detailBusy || m.detailItem.ID == "" { + return nil + } + if v := m.itemByID(m.detailItem.ID); v != nil && v.State != orchestrator.StateBacklog && v.State != orchestrator.StateRunning { + return nil // the item ended: whatever the last round brought is the whole log + } + m.detailBusy = true + gen := m.detailGen + base, token, id := m.base, m.token, m.detailItem.ID + return func() tea.Msg { + log, err := workLogFetch(base, token, id) + if err != nil { + return workBoardTailMsg{gen: gen, gone: workItemGone(err), err: err} + } + return workBoardTailMsg{gen: gen, log: log} + } +} + +// applyTail folds one poll into the pane: the suffix beyond what was last +// seen is appended, the pane keeps only what it can show, and a 404 — the +// item gone from the list, its kept output with it — ends the tail as +// cleanly as a follow ends. +func (m *workBoardModel) applyTail(msg workBoardTailMsg) { + if msg.gone { + m.detailNote = "the item is no longer in the list" + return + } + if msg.err != nil { + if m.detailLog == "" { + m.detailNote = msg.err.Error() + } + return + } + added := msg.log + if strings.HasPrefix(msg.log, m.detailSeen) { + added = msg.log[len(m.detailSeen):] + } + m.detailSeen = msg.log + if added != "" { + m.detailLog = lastLines(m.detailLog+added, m.detailCapacity()) + m.detailNote = "" + } else if m.detailLog == "" { + m.detailNote = "no kept output yet" + } +} + +// itemByID finds an item in the latest reading by id. +func (m *workBoardModel) itemByID(id string) *orchestrator.ItemView { + for i := range m.items { + if m.items[i].ID == id { + v := m.items[i] + return &v + } + } + return nil +} + +// retainDetail keeps the view current with the reading: the item's own +// fields refresh where it is still listed; where it has left the list the +// view keeps what it had, and its tail ends on its own terms. +func (m *workBoardModel) retainDetail() { + if !m.detail { + return + } + if v := m.itemByID(m.detailItem.ID); v != nil { + m.detailItem = *v + } +} + +// clamp re-fits the cursor and every column's window to the reading and +// the frame — after each read (cards may have moved or gone) and each +// resize (the window is smaller or roomier than it was). +func (m *workBoardModel) clamp() { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if c >= len(cols) || (len(cols[c]) == 0 && m.anyCards(cols)) { + // The cursor's column emptied while another has cards: step to the + // nearest one that has, the way the arrows skip empties. + if next := m.nearestColumn(c); next >= 0 { + c = next + } + } + if r >= len(cols[c]) { + r = len(cols[c]) - 1 + } + if r < 0 { + r = 0 + } + m.cursor = [2]int{c, r} + for i := range m.scrolls { + m.scrolls[i] = workBoardClampScroll(m.scrolls[i], len(cols[i]), m.visibleCards()) + } + m.keepVisible() +} + +func (m *workBoardModel) anyCards(cols [4][]int) bool { + for _, col := range cols { + if len(col) > 0 { + return true + } + } + return false +} + +// nearestColumn is the closest column holding cards, searched outwards +// from where the cursor stands; -1 when every column is empty. +func (m *workBoardModel) nearestColumn(from int) int { + for d := 1; d < len(workBoardColumns); d++ { + if from-d >= 0 && len(m.columnIndexes()[from-d]) > 0 { + return from - d + } + if from+d < len(workBoardColumns) && len(m.columnIndexes()[from+d]) > 0 { + return from + d + } + } + return -1 +} + +// moveRow steps within the cursor's column; the window follows. +func (m *workBoardModel) moveRow(delta int) { + cols := m.columnIndexes() + c := m.cursor[0] + r := m.cursor[1] + delta + if r < 0 { + r = 0 + } + if r >= len(cols[c]) { + r = len(cols[c]) - 1 + } + if r < 0 { + r = 0 + } + m.cursor = [2]int{c, r} + m.keepVisible() +} + +// moveColumn steps to the next column that holds cards, skipping the ones +// that do not; a column with nothing in it is nowhere to select. +func (m *workBoardModel) moveColumn(delta int) { + cols := m.columnIndexes() + c := m.cursor[0] + delta + for c >= 0 && c < len(workBoardColumns) && len(cols[c]) == 0 { + c += delta + } + if c < 0 || c >= len(workBoardColumns) { + return + } + m.cursor = [2]int{c, 0} + m.keepVisible() +} + +// keepVisible scrolls the cursor's column until its card is on screen. +func (m *workBoardModel) keepVisible() { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + avail := m.visibleCards() + if r < m.scrolls[c] || r >= m.scrolls[c]+avail { + m.scrolls[c] = r + } + m.scrolls[c] = workBoardClampScroll(m.scrolls[c], len(cols[c]), avail) +} + +// workBoardClampScroll bounds a column's window so it never scrolls past +// the cards it has. +func workBoardClampScroll(top, cards, avail int) int { + limit := cards - avail + if limit < 0 { + limit = 0 + } + if top > limit { + return limit + } + if top < 0 { + return 0 + } + return top +} + +func (m workBoardModel) effWidth() int { + if m.width < 1 { + return 80 + } + return m.width +} + +func (m workBoardModel) effHeight() int { + if m.height < 1 { + return 24 + } + return m.height +} diff --git a/cmd/spinloop/work_board_render.go b/cmd/spinloop/work_board_render.go new file mode 100644 index 00000000..46a7172a --- /dev/null +++ b/cmd/spinloop/work_board_render.go @@ -0,0 +1,603 @@ +// The `work board` renderers: the columns and their cards, the item's +// detail, and the add form. The contract is the fleet dashboard's: every +// line is clipped (never wrapped) and every block is pre-sized before +// lipgloss frames it, so the grid stays rectangular at any terminal size. +// The colours come from palette.go in the two groups that must not be +// swapped — the state words and marks wear the state colours `work list` +// uses, and the brand accent appears only on the tool's own chrome and on +// the thing the operator has selected. + +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// Card geometry: three content lines — id, instructions, meta — inside a +// rounded frame, plus one gap line between cards. Column headers, the +// title bar and the footer are the frame's fixed rows. +const ( + workBoardCardH = 5 + workBoardCardStep = workBoardCardH + 1 + workBoardMinColW = 16 + workBoardFixedH = 3 // title bar + column header row + footer +) + +// ansiFaint stands the board back behind the add form: the surface behind +// the modal keeps drawing, dimmed, because the board keeps living. +const ansiFaint = "\033[2m" + +// View draws the frame: the title bar, the columns, the footer — with the +// add form standing over them while it is open. +func (m workBoardModel) View() string { + if m.detail { + return m.detailView() + } + w := m.effWidth() + now := workBoardNow() + cols := m.columnIndexes() + widths := workBoardColWidths(w, m.narrow()) + + parts := []string{dashTitleBar("work board", m.titleDetail(now), w)} + colsH := 1 + m.visibleCards()*workBoardCardStep + narrow := m.narrow() + var blocks [][]string + var drawn []int + for c := range workBoardColumns { + if narrow && c != m.cursor[0] { + continue + } + blocks = append(blocks, m.columnBlock(c, cols[c], widths, colsH, now)) + drawn = append(drawn, c) + } + parts = append(parts, workBoardJoinRows(blocks, pickWidths(widths, drawn, w, narrow))) + parts = append(parts, m.footerLine(w, m.boardKeys())) + view := strings.Join(parts, "\n") + if m.formOpen { + view = m.formOverlay(view) + } + return view +} + +// titleDetail is the title bar's right half: where the board is reading, +// how much it holds, and — once the reading has aged past three +// cadences — how old it is, because a stale reading drawn identically to +// a fresh one is not the run's present state. +func (m workBoardModel) titleDetail(now time.Time) string { + detail := fmt.Sprintf("%s %d items", m.base, len(m.items)) + if age := m.readingAge(now); age != "" { + return detail + " reading " + age + } + return detail +} + +// readingAge is the "3m ago" the title bar carries once the reading is +// stale, or "" while it is current. +func (m workBoardModel) readingAge(now time.Time) string { + if m.readingAt.IsZero() { + return "" + } + age := now.Sub(m.readingAt) + if age < workBoardStaleAfter() { + return "" + } + return formatDuration(int(age.Seconds())) + " ago" +} + +// narrow is the one-column-per-screen mode: below the point where four +// columns are legible, the cursor's column stands alone at full width +// rather than four unreadable strips. +func (m workBoardModel) narrow() bool { + return (m.effWidth()-3)/4 < workBoardMinColW +} + +// workBoardColWidths splits the body across the columns, spending every +// column of width — the remainder from the division goes to the leading +// columns one at a time. +func workBoardColWidths(w int, narrow bool) []int { + if narrow { + return []int{w} + } + n := len(workBoardColumns) + base, extra := (w-(n-1))/n, (w-(n-1))%n + widths := make([]int, n) + for i := range widths { + widths[i] = base + if i < extra { + widths[i]++ + } + } + return widths +} + +// visibleCards is how many cards a column shows between the column +// headers and the footer. +func (m workBoardModel) visibleCards() int { + r := (m.effHeight() - workBoardFixedH) / workBoardCardStep + if r < 1 { + return 1 + } + return r +} + +// columnBlock is one column: its header, then the window of cards the +// cursor is looking at — always the selection's neighbourhood, which is +// how a fuller column than the screen drops cards nowhere silently. +func (m workBoardModel) columnBlock(c int, idx []int, widths []int, height int, now time.Time) []string { + w := widths[len(widths)-1] // the full width in narrow mode, else this column's own + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + marker := " " + if m.cursor[0] == c { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(brandAccent)).Render("▸ ") + } + count := dim.Render(fmt.Sprintf("%d", len(idx))) + header := dashClip(marker+workBoardColumns[c].title+" "+count, w) + + lines := []string{header} + avail := (height - 1) / workBoardCardStep + top := m.scrolls[c] + for slot := 0; slot < avail; slot++ { + i := top + slot + if i >= len(idx) { + break + } + // The card is a block: its lines join the column line by line, + // exactly as the dashboard's grid joins its tiles — appending the + // block as one entry would drop its newlines into a single row. + lines = append(lines, strings.Split(m.card(m.items[idx[i]], idx[i] == m.selectedIndex(), w, now), "\n")...) + lines = append(lines, "") + } + if len(idx) == 0 { + lines = append(lines, dim.Render(" —")) + } + for len(lines) < height { + lines = append(lines, "") + } + for i := range lines { + lines[i] = dashClip(lines[i], w) + } + return lines +} + +// selectedIndex is the flat item index under the cursor, or -1. +func (m workBoardModel) selectedIndex() int { + cols := m.columnIndexes() + c, r := m.cursor[0], m.cursor[1] + if r < 0 || r >= len(cols[c]) { + return -1 + } + return cols[c][r] +} + +// card is one item: its id (and priority, where it has one) on the bar, +// its instructions clipped to the card, and a meta line stating its +// state in the colour that reports it, beside what that state means — +// the node and elapsed time of a running item, the end of an ended one. +func (m workBoardModel) card(v orchestrator.ItemView, selected bool, w int, now time.Time) string { + inner := w - 2 + bar := dashClip(workBoardCardBar(v, inner), inner) + body := dashClip(workBoardWrapOne(v.Instructions, inner), inner) + meta := dashClip(workBoardCardMeta(v, now, inner), inner) + + style := lipgloss.NewStyle(). + Width(inner).Height(3). + Border(lipgloss.RoundedBorder()) + if selected { + style = style.BorderForeground(lipgloss.Color(brandAccent)) + } else { + style = style.BorderForeground(lipgloss.Color("240")) + } + return style.Render(bar + "\n" + body + "\n" + meta) +} + +// workBoardCardBar is a card's header line: the state mark, the id, and +// the priority where the item carries one. +func workBoardCardBar(v orchestrator.ItemView, inner int) string { + line := workBoardStateMark(v.State) + " " + v.ID + if v.Priority != 0 { + line += fmt.Sprintf(" p%d", v.Priority) + } + return line +} + +// workBoardStateMark is the card's coloured dot — the state read at a +// glance, in the same colours `work list` puts on the state word. +func workBoardStateMark(state string) string { + return workBoardStateColour(state) + "●" + ansiReset +} + +// workBoardStateColour is the raw-ANSI colour of a state, drawn from the +// very words `work list` colours with: amber is running, green done, red +// failed; backlog is everything not happening, so it is the faded grey. +// The brand accent is not in this switch and does not colour a card. +func workBoardStateColour(state string) string { + switch state { + case orchestrator.StateRunning: + return ansiYellow + case orchestrator.StateDone: + return ansiGreen + case orchestrator.StateFailed: + return ansiRed + default: + return ansiGrey + } +} + +// workBoardCardMeta is a card's third line: the state word as the work +// list colours it, and beside it what the state carries — the node and +// the time a running item has been up, the end of an ended item. +func workBoardCardMeta(v orchestrator.ItemView, now time.Time, inner int) string { + state := workListColouredState(v.State, true) + switch v.State { + case orchestrator.StateRunning: + extra := v.Node + if e := workBoardElapsed(v.StartedAt, now); e != "" { + if extra != "" { + extra += " " + } + extra += e + } + return state + " " + extra + case orchestrator.StateDone, orchestrator.StateFailed: + return state + " ended " + workBoardClock(v.EndedAt) + default: + return state + } +} + +// workBoardElapsed is how long a running item has been up, counted when +// it is drawn — so it counts up as the operator watches, and never +// carries the time the reading was taken as the time it matters. +func workBoardElapsed(rfc3339 string, now time.Time) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return "" + } + secs := int(now.Sub(t).Seconds()) + if secs < 0 { + secs = 0 + } + return formatDuration(secs) +} + +// workBoardClock is an instant as HH:MM, or a dash where the record has +// none or none the board can read. +func workBoardClock(rfc3339 string) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return "-" + } + return t.Local().Format("15:04") +} + +// workBoardWrapOne is one instructions line clipped to width; the card +// shows the beginning and the detail pane holds the rest. +func workBoardWrapOne(s string, w int) string { + return dashClip(strings.ReplaceAll(s, "\n", " "), w) +} + +// workBoardWrap breaks text into lines of at most w columns, on spaces, +// hard-cutting any single word longer than the line. It is the detail +// pane's wrap: full instructions, kept to the frame's width. +func workBoardWrap(s string, w int) []string { + if w < 1 { + w = 1 + } + var out []string + for _, para := range strings.Split(s, "\n") { + line := "" + for _, word := range strings.Fields(para) { + for lipgloss.Width(word) > w { + if line != "" { + out = append(out, line) + line = "" + } + out = append(out, dashClip(word, w)) + word = ansiCutRest(word, w) + } + if line == "" { + line = word + } else if lipgloss.Width(line)+1+lipgloss.Width(word) <= w { + line += " " + word + } else { + out = append(out, line) + line = word + } + } + out = append(out, line) + } + if len(out) == 0 { + out = []string{""} + } + return out +} + +// ansiCutRest drops the first w display columns of a plain string, the +// remainder of a word that overflowed. +func ansiCutRest(s string, w int) string { + // Plain text only (instructions arrive plain from the API), so + // counting runes is counting columns. + runes := []rune(s) + if w >= len(runes) { + return "" + } + return string(runes[w:]) +} + +// pickWidths is the width of each drawn column: the one full width when +// the board is narrow, otherwise the drawn columns' own slices of the +// four-way split. +func pickWidths(widths []int, drawn []int, w int, narrow bool) []int { + out := make([]int, len(drawn)) + for i, c := range drawn { + if narrow { + out[i] = w + } else { + out[i] = widths[c] + } + } + return out +} + +// workBoardJoinRows lays the column blocks side by side, one display line +// at a time — the grid's join, which keeps every row the frame's width +// however uneven the columns' contents are. +func workBoardJoinRows(blocks [][]string, widths []int) string { + lines := make([]string, 0, workBoardCardStep*16) + n := 0 + for _, b := range blocks { + if len(b) > n { + n = len(b) + } + } + for line := 0; line < n; line++ { + cells := make([]string, len(blocks)) + for c, b := range blocks { + cell := "" + if line < len(b) { + cell = b[line] + } + cells[c] = padTo(cell, widths[c]) + } + lines = append(lines, strings.TrimRight(strings.Join(cells, " "), " ")) + } + return strings.Join(lines, "\n") +} + +// padTo pads a clipped line to exactly w display columns. +func padTo(line string, w int) string { + if n := lipgloss.Width(line); n < w { + return line + strings.Repeat(" ", w-n) + } + return line +} + +// footerLine is the board's bottom line: the keys that would do something +// where the cursor stands, replaced by the removal question while one is +// pending and by an in-flight action's progress while a call is out; the +// status line rides at the end. +func (m workBoardModel) footerLine(w int, keys string) string { + line := dashKeyHints(keys) + if m.action.verb != "" { + line = m.action.progress(workBoardNow()) + } + if m.confirm { + v := m.selectedItem() + id := "" + if v != nil { + id = fmt.Sprintf(" %q", v.ID) + } + line = "remove item" + id + "?" + dashHintGap + + dashKeyHints("y yes"+dashHintGap+"n no") + } + if m.statusLine != "" { + line += " " + m.statusLine + } + if m.busy { + line += " reading…" + } + return dashClip(line, w) +} + +// boardKeys names the keys that would do something for what is selected: +// abort is named only on a running card, remove only on one that is not, +// detail only where there is an item to open. +func (m workBoardModel) boardKeys() string { + parts := []string{} + cols := m.columnIndexes() + if m.anyCards(cols) { + parts = append(parts, "↑↓←→ select") + } + v := m.selectedItem() + if v != nil { + parts = append(parts, "enter detail") + if v.State == orchestrator.StateRunning { + parts = append(parts, "a abort") + } else { + parts = append(parts, "x remove") + } + } + parts = append(parts, "n add", "r refresh", "q quit") + return strings.Join(parts, dashHintGap) +} + +// detailView is the full-screen item: the fields the reading carries, the +// instructions whole, and the kept log tailed into what remains. +func (m workBoardModel) detailView() string { + w, h := m.effWidth(), m.effHeight() + v := m.detailItem + fields := m.detailFields() + + state := workListColouredState(v.State, true) + header := dashTitleBar("work board · "+v.ID, state, w) + divider := strings.Repeat("─", w) + + logAvail := h - 5 - len(fields) // header, divider, divider, divider, footer + if logAvail < 1 { + logAvail = 1 + } + logLines := strings.Split(strings.TrimRight(m.detailLog, "\n"), "\n") + if m.detailLog == "" { + note := m.detailNote + if note == "" { + note = "waiting for the log…" + } + logLines = []string{note} + } + if len(logLines) > logAvail { + logLines = logLines[len(logLines)-logAvail:] + } + + parts := make([]string, 0, len(fields)+len(logLines)+4) + parts = append(parts, header, divider) + for _, line := range fields { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + for _, line := range logLines { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + parts = append(parts, m.footerLine(w, "esc back")) + return strings.Join(parts, "\n") +} + +// detailFields is the detail's whole item: every field the record +// carries, then the instructions unwrapped to the frame's width. The +// labels are the dim ink, the values the terminal's own. +func (m workBoardModel) detailFields() []string { + v := m.detailItem + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + label := func(s string) string { return dim.Render(s) } + var out []string + add := func(labelStr, value string) { + if value == "" { + return + } + out = append(out, label(labelStr+" ")+value) + } + add("dir", v.Dir) + add("tags", strings.Join(v.Tags, " ")) + add("node", v.Node) + add("started", workBoardClock(v.StartedAt)) + add("ended", workBoardClock(v.EndedAt)) + if v.State == orchestrator.StateFailed { + add("why", ansiRed+v.Why+ansiReset) + } + if m.readingAge(workBoardNow()) != "" { + add("reading", dim.Render(m.readingAge(workBoardNow()))) + } + out = append(out, label("instructions")) + out = append(out, workBoardWrap(v.Instructions, m.effWidth())...) + return out +} + +// detailCapacity is how many log lines the pane can show — the same +// figure the tail trims its buffer to, so the buffer never holds what +// the view could never draw. +func (m workBoardModel) detailCapacity() int { + h := m.effHeight() - 5 - len(m.detailFields()) + if h < 1 { + return 1 + } + return h +} + +// formFieldWidth is the width each textinput is given inside the form: +// the modal less its frame, the field marker, and the label column. +func (m workBoardModel) formFieldWidth() int { + w := m.formWidth() - 2 - 2 - workFormPromptW - 2 + if w < 10 { + return 10 + } + return w +} + +func (m workBoardModel) formWidth() int { + w := m.effWidth() - 4 + if w > 64 { + return 64 + } + if w < 30 { + return m.effWidth() + } + return w +} + +// formOverlay draws the add form over the board: the board's lines stand +// behind faint — still drawn, still live, stepped back — and the modal +// box sits centred over them. +func (m workBoardModel) formOverlay(view string) string { + w := m.effWidth() + lines := strings.Split(view, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ansiFaint + line + ansiReset + } + } + box := m.formBox() + boxLines := strings.Split(box, "\n") + pad := (m.effHeight() - len(boxLines)) / 2 + if pad < 0 { + pad = 0 + } + left := (w - lipgloss.Width(boxLines[0])) / 2 + if left < 0 { + left = 0 + } + for i, bline := range boxLines { + row := pad + i + if row >= len(lines) { + break + } + lines[row] = strings.Repeat(" ", left) + bline + } + return strings.Join(lines, "\n") +} + +// formBox is the modal: the five fields with their labels, the active one +// marked and its caret standing in it, the API's refusal of the last send +// where there is one, and the keys the form answers to. +func (m workBoardModel) formBox() string { + inner := m.formWidth() - 2 + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + bold := lipgloss.NewStyle().Bold(true) + + var b strings.Builder + b.WriteString(dashClip(" new work item"+strings.Repeat(" ", max(0, inner-15)), inner)) + b.WriteByte('\n') + for i, label := range workFormLabels { + marker := " " + labelStyle := dim + if m.form.cursor == i { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(brandAccent)).Render("❯ ") + labelStyle = lipgloss.NewStyle() + } + cell := marker + labelStyle.Render(fmt.Sprintf("%-*s", workFormPromptW, label)) + cell += m.form.fields[i].View() + b.WriteString(dashClip(cell, inner)) + b.WriteByte('\n') + } + if m.form.err != "" { + b.WriteString(dashClip(ansiRed+m.form.err+ansiReset, inner)) + b.WriteByte('\n') + } + if m.formAsk { + b.WriteString(dashClip(bold.Render("discard this item?")+" everything typed is kept until you say yes"+ + dashHintGap+dashKeyHints("y discard"+dashHintGap+"n keep"), inner)) + } else { + b.WriteString(dashClip(dashKeyHints("enter next/send"+dashHintGap+"up/down field"+dashHintGap+ + "esc cancel"+dashHintGap+"* required"), inner)) + } + style := lipgloss.NewStyle(). + Width(inner). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(brandAccent)) + return style.Render(b.String()) +} diff --git a/cmd/spinloop/work_board_test.go b/cmd/spinloop/work_board_test.go new file mode 100644 index 00000000..ad53caef --- /dev/null +++ b/cmd/spinloop/work_board_test.go @@ -0,0 +1,1265 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + teatest "github.com/charmbracelet/x/exp/teatest" + + "github.com/spinloop-ai/spinloop/internal/orchestrator" +) + +// The board's whole screen logic is driven here without a terminal: a fake +// work list API answers, keys and messages go straight into Update, and +// the view is read like a printout. The intervals and the clock are the +// package variables the model reads, so nothing in a test ever waits on a +// ticker or renders a moving time the test did not choose. + +// wbAPI is a fake work list API: the orchestrator's real shapes, the +// orchestrator's real refusals, and a record of every call the board +// made — which is how the tests prove the negative ones too. +type wbAPI struct { + mu sync.Mutex + items []orchestrator.ItemView + logs map[string]string + calls []string // "METHOD /path" + lastAdd workAddBody + failGET bool // answer GET /v1/items with a fault, as a stopped orchestrator would + srv *httptest.Server +} + +func newWBAPI(t *testing.T, items []orchestrator.ItemView, logs map[string]string) *wbAPI { + t.Helper() + a := &wbAPI{items: items, logs: logs} + if a.logs == nil { + a.logs = map[string]string{} + } + a.srv = httptest.NewServer(a) + t.Cleanup(a.srv.Close) + return a +} + +func (a *wbAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + a.calls = append(a.calls, r.Method+" "+r.URL.Path) + out := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + json.NewEncoder(w).Encode(v) + } + } + list := func(v string) string { + tail := strings.TrimPrefix(r.URL.Path, "/v1/items/") + tail = strings.TrimSuffix(tail, "/log") + return strings.TrimSuffix(tail, "/abort") + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/items": + if a.failGET { + out(http.StatusInternalServerError, + map[string]any{"error": map[string]string{"message": "the orchestrator is not answering"}}) + return + } + out(http.StatusOK, map[string]any{"data": a.items}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/items": + var body workAddBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + out(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "unreadable body"}}) + return + } + a.lastAdd = body + for _, v := range a.items { + if v.ID == body.ID { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is already in the list", body.ID), + }}) + return + } + } + a.items = append(a.items, orchestrator.ItemView{ + ID: body.ID, Instructions: body.Instructions, Dir: body.Dir, + Tags: body.Tags, Priority: body.Priority, State: orchestrator.StateBacklog, + }) + out(http.StatusOK, map[string]any{"ok": true}) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/abort"): + id := list(r.URL.Path) + for i := range a.items { + if a.items[i].ID == id { + if a.items[i].State != orchestrator.StateRunning { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is not running (%s)", id, a.items[i].State), + }}) + return + } + a.items[i].State = orchestrator.StateBacklog + a.items[i].Node = "" + out(http.StatusOK, map[string]any{"ok": true}) + return + } + } + out(http.StatusNotFound, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("the work list does not carry item %q", id), + }}) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/v1/items/"): + id := strings.TrimPrefix(r.URL.Path, "/v1/items/") + for i := range a.items { + if a.items[i].ID == id { + if a.items[i].State == orchestrator.StateRunning { + out(http.StatusConflict, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("item %q is running — abort it first", id), + }}) + return + } + a.items = append(a.items[:i], a.items[i+1:]...) + delete(a.logs, id) + out(http.StatusOK, map[string]any{"ok": true}) + return + } + } + out(http.StatusNotFound, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("the work list does not carry item %q", id), + }}) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log"): + id := list(r.URL.Path) + for _, v := range a.items { + if v.ID == id { + out(http.StatusOK, map[string]any{"log": a.logs[id]}) + return + } + } + out(http.StatusNotFound, map[string]any{"error": map[string]string{ + "message": fmt.Sprintf("the work list does not carry item %q", id), + }}) + default: + out(http.StatusNotFound, nil) + } +} + +func (a *wbAPI) setLog(id, log string) { + a.mu.Lock() + defer a.mu.Unlock() + a.logs[id] = log +} + +func (a *wbAPI) callCount(fragment string) int { + a.mu.Lock() + defer a.mu.Unlock() + n := 0 + for _, c := range a.calls { + if strings.Contains(c, fragment) { + n++ + } + } + return n +} + +func (a *wbAPI) lastBody() workAddBody { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastAdd +} + +// newWBTestModel builds a board over the fake API with the cadences the +// test drives — never a live ticker — and a fixed clock. +func newWBTestModel(t *testing.T, a *wbAPI) *workBoardModel { + t.Helper() + restore := []func(){ + setVar(&workBoardRefreshInterval, time.Hour), + setVar(&workBoardTailInterval, time.Hour), + setVar(&workBoardSpinInterval, time.Hour), + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000, 0) }), + } + t.Cleanup(func() { + for _, f := range restore { + f() + } + }) + m := newWorkBoardModel(a.srv.URL, "tok") + m.width, m.height = 100, 30 + return m +} + +func setVar[T any](p *T, v T) func() { + old := *p + *p = v + return func() { *p = old } +} + +// wbKey names a keystroke the way the footer names it. +func wbKey(name string) tea.KeyMsg { + switch name { + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEscape} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(name)} + } +} + +// wbKeys feeds keys through Update and returns the last command back. +func wbKeys(t *testing.T, m *workBoardModel, keys ...string) tea.Cmd { + t.Helper() + var cmd tea.Cmd + for _, k := range keys { + _, cmd = m.Update(wbKey(k)) + } + return cmd +} + +// wbRound runs one read of the list to its answer and folds it in. +func wbRound(t *testing.T, m *workBoardModel) { + t.Helper() + cmd := m.startRound() + if cmd == nil { + t.Fatal("no round could start (busy?)") + } + m.Update(cmd()) +} + +// wbAct presses the keys that set an action off, runs the call to its +// answer, and runs the follow-up read the answer triggers — the whole +// round trip the program would play. +func wbAct(t *testing.T, m *workBoardModel, keys ...string) { + t.Helper() + cmd := wbKeys(t, m, keys...) + if cmd == nil { + t.Fatal("the keys started no call") + } + _, next := m.Update(cmd()) + if next != nil { + m.Update(next()) + } +} + +var wbANSI = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func wbPlain(s string) string { return wbANSI.ReplaceAllString(s, "") } + +func wbItem(id, state string) orchestrator.ItemView { + v := orchestrator.ItemView{ID: id, Instructions: "do the " + id, Dir: "./" + id, State: state} + switch state { + case orchestrator.StateRunning: + v.Node = "node-1" + v.StartedAt = time.Unix(1700000000, 0).Add(-125 * time.Second).UTC().Format(time.RFC3339) + case orchestrator.StateDone, orchestrator.StateFailed: + v.EndedAt = time.Unix(1700000000, 0).Add(-40 * time.Minute).UTC().Format(time.RFC3339) + } + if state == orchestrator.StateFailed { + v.Why = "the agent gave up" + } + return v +} + +// --- the command and its gate --- + +func TestWorkBoard_CommandSeamRefusesWithoutATerminal(t *testing.T) { + err := cmdWorkBoard([]string{"--url", "http://127.0.0.1:1", "--api-token", "t"}) + if err == nil || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("seam error = %v, want the terminal refusal", err) + } +} + +func TestWorkBoard_TicksRescheduleWithoutDoublingRounds(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if m.busy { + t.Fatal("the board sat busy after a round") + } + // A tick reschedules itself and starts a round; a second tick while + // that round is out must not send another one. + _, cmd := m.Update(workBoardTickMsg{}) + if cmd == nil { + t.Fatal("a tick scheduled nothing") + } + if !m.busy { + t.Fatal("the tick started no round") + } + before := a.callCount("GET /v1/items") + _, cmd = m.Update(workBoardTickMsg{}) // rounds must not overlap + if cmd == nil { + t.Error("a waiting tick unscheduled itself") + } + m.Update(workBoardReadMsg{items: nil, at: workBoardNow()}) + if a.callCount("GET /v1/items") != before { + t.Error("the overlapping tick spent a call") + } + // Idle messages answer nothing: no chain while nothing is happening. + if _, cmd := m.Update(workBoardSpinMsg{}); cmd != nil { + t.Error("a spin message chained with no action in flight") + } + if _, cmd := m.Update(workBoardTailTickMsg{}); cmd != nil { + t.Error("a tail tick chained with no detail open") + } +} + +func TestWorkBoard_ActionProgressAndSpinChain(t *testing.T) { + fixed := time.Unix(1700000000, 0) + act := workBoardAction{verb: workAbort, id: "crank", since: fixed.Add(-3 * time.Second)} + line := act.progress(fixed) + if !strings.Contains(line, "aborting crank") || !strings.Contains(line, "3s") { + t.Errorf("progress line = %q", line) + } + + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + var sent []tea.Msg + m.send = func(msg tea.Msg) { sent = append(sent, msg) } + wbKeys(t, m, "right") + cmd := wbKeys(t, m, "a") // the call goes out and stays unanswered + if cmd == nil { + t.Fatal("the abort started nothing") + } + if len(sent) != 1 { + t.Fatalf("the action woke the repaint with %d messages", len(sent)) + } + if _, ok := sent[0].(workBoardSpinMsg); !ok { + t.Errorf("the action sent %T, want the spin message", sent[0]) + } + // While the call is out the chain keeps going… + _, cmd = m.Update(workBoardSpinMsg{}) + if cmd == nil { + t.Error("the spinner chain stopped mid-action") + } + // …and stops the moment the action answers. + m.Update(workBoardActionMsg{verb: workAbort, id: "crank"}) + if _, cmd := m.Update(workBoardSpinMsg{}); cmd != nil { + t.Error("the spinner chained past the answer") + } +} + +func TestWorkBoard_SecondActionWhileOneIsOutSendsNothing(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + cmd := wbKeys(t, m, "right", "a") + if cmd == nil { + t.Fatal("the abort started nothing") + } + // The first call is still out when the key arrives again. + if cmd2 := wbKeys(t, m, "a"); cmd2 != nil { + t.Error("a second abort was set off while the first was out") + } + if !strings.Contains(m.statusLine, "still aborting") { + t.Errorf("status = %q, want the still-busy line", m.statusLine) + } + // The form is turned the same away, and by the same words. + wbKeys(t, m, "n") + wbKeys(t, m, "later", "enter", "someday", "enter", "./d", "enter", "enter", "enter") + if cmd2 := wbKeys(t, m, "enter"); cmd2 != nil { + t.Error("an add was set off while the abort was out") + } + if !strings.Contains(m.statusLine, "still aborting") { + t.Errorf("status = %q, want the still-busy line", m.statusLine) + } + m.Update(cmd()) +} + +func TestWorkBoard_GeometryKeepsItsFloors(t *testing.T) { + m := workBoardModel{width: 10, height: 4} + if m.visibleCards() != 1 || m.detailCapacity() < 1 || m.formFieldWidth() < 10 { + t.Errorf("a crippled frame starved: cards=%d log=%d field=%d", + m.visibleCards(), m.detailCapacity(), m.formFieldWidth()) + } + if got := (workBoardAction{verb: workRemove, id: "x"}).progress(time.Unix(1700000000, 0)); got == "" || strings.Contains(got, " 0s") { + t.Errorf("an unstarted action reads %q", got) + } + // A form taller than the screen: it stands at the top and loses the + // rows the screen has no room for — it does not panic, and what + // fits is what shows. + m = workBoardModel{width: 64, height: 9} + m.formOpen = true + m.form = newWorkBoardForm(m.formFieldWidth()) + view := wbPlain(m.View()) + if !strings.Contains(view, "new work item") { + t.Errorf("the cramped form lost its title:\n%s", view) + } + if n := len(strings.Split(m.View(), "\n")); n > 9 { + t.Errorf("the cramped frame overflowed its screen: %d lines", n) + } + // The cut rest of a word shorter than the cut is nothing. + if ansiCutRest("short", 10) != "" { + t.Error("cutting past the end left something") + } +} + +func TestWorkBoard_NarrowTerminalDrawsTheCursorColumnAlone(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + }, nil) + m := newWBTestModel(t, a) + m.width = 30 // far too narrow for four columns + wbRound(t, m) + if !m.narrow() { + t.Fatal("30 columns were not called narrow") + } + view := wbPlain(m.View()) + if !strings.Contains(view, "solo") || strings.Contains(view, "crank") { + t.Errorf("narrow board drew more than the cursor's column:\n%s", view) + } + wbKeys(t, m, "right") + view = wbPlain(m.View()) + if !strings.Contains(view, "crank") || strings.Contains(view, "solo") { + t.Errorf("stepping did not carry the narrow board to the next column:\n%s", view) + } + // The form still fits: labels survive the floor width. + wbKeys(t, m, "n") + view = wbPlain(m.View()) + for _, want := range []string{"new work item", "instructions*", "priority"} { + if !strings.Contains(view, want) { + t.Errorf("the narrow form lost %q:\n%s", want, view) + } + } + // Narrower still: the field hits its floor and the form still draws. + wbKeys(t, m, "esc") + m.width = 20 + wbKeys(t, m, "n") + if !strings.Contains(wbPlain(m.View()), "new work item") { + t.Error("the floor-width form did not draw") + } +} + +func TestWorkBoard_PriorityThatCouldNotParseIsCaughtBeforeTheAPI(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "later", "enter", "someday", "enter", "./d", "enter", "enter", "1-3") + cmd := wbKeys(t, m, "enter") // the filter allows 1-3; the parser does not + if cmd != nil { + t.Error("an unparseable priority was sent to the API") + } + if !strings.Contains(m.form.err, "not a number") { + t.Errorf("form carries no fault: %q", m.form.err) + } + if !m.formOpen { + t.Error("the form closed over its own fault") + } + if a.callCount("POST /v1/items") != 0 { + t.Error("the API was asked for the broken item anyway") + } + // Corrected: it goes. + wbKeys(t, m, "backspace", "backspace", "2") + cmd = wbKeys(t, m, "enter") + if cmd == nil { + t.Fatal("the corrected send went nowhere") + } + _, next := m.Update(cmd()) + if next != nil { + m.Update(next()) + } + if a.callCount("POST /v1/items") != 1 { + t.Error("the corrected send did not reach the API") + } +} + +func TestWorkBoard_QuitFromTheDiscardQuestion(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "x", "esc") + if !m.formAsk { + t.Fatal("no discard question stood") + } + cmd := wbKeys(t, m, "q") + if cmd == nil || fmt.Sprintf("%T", cmd()) != "tea.QuitMsg" { + t.Error("q from the discard question did not end the program") + } +} + +func TestWorkBoard_AFaultyTailKeepsWhatWasShown(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") + // A fault over an empty pane is the note; over a filled one it + // changes nothing — the rows already shown stand. + m.Update(workBoardTailMsg{gen: m.detailGen, err: errBoardTest}) + if !strings.Contains(wbPlain(m.View()), "the API went quiet") { + t.Error("the empty pane carried no fault") + } + m.Update(workBoardTailMsg{gen: m.detailGen, log: "kept line\n"}) + m.Update(workBoardTailMsg{gen: m.detailGen, err: errBoardTest}) + view := wbPlain(m.View()) + if !strings.Contains(view, "kept line") { + t.Errorf("a fault erased the shown log:\n%s", view) + } + // Stale answers — an older gen — are discarded, not folded in. + m.Update(workBoardTailMsg{gen: m.detailGen + 1, log: "stale\n"}) + if strings.Contains(wbPlain(m.View()), "stale") { + t.Error("a stale tail answer landed") + } +} + +var errBoardTest = fmt.Errorf("the API went quiet") + +func TestWorkBoard_ClockReadingToleratesGarbage(t *testing.T) { + bad := wbItem("solo", orchestrator.StateBacklog) + bad.StartedAt = "not a time" + a := newWBAPI(t, []orchestrator.ItemView{bad}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if got := workBoardClock(bad.StartedAt); got != "-" { + t.Errorf("an unreadable instant rendered %q, want a dash", got) + } + if got := workBoardElapsed(bad.StartedAt, workBoardNow()); got != "" { + t.Errorf("an unreadable start rendered %q, want silence", got) + } + if workBoardClampScroll(-1, 5, 3) != 0 || workBoardClampScroll(9, 0, 3) != 0 { + t.Error("a window escaped its cards") + } + // A word too wide for the pane is cut, not wrapped or dropped. + lines := workBoardWrap("x"+strings.Repeat("y", 200), 10) + for _, l := range lines { + if len(l) > 10 { + t.Errorf("a wrapped line overflowed the width: %q", l) + } + } +} + +func TestWorkBoard_NoURLNamesTheFlag(t *testing.T) { + _, err := runWork(t, "board") + if err == nil || !strings.Contains(err.Error(), "--url") { + t.Fatalf("error = %v, want one naming --url", err) + } +} + +func TestWorkBoard_PipedRunRefusedNamingWorkList(t *testing.T) { + _, err := runWork(t, "board", "--url", "http://127.0.0.1:1", "--api-token", "t") + if err == nil { + t.Fatal("a piped board was not refused") + } + if !strings.Contains(err.Error(), "spinloop work list") { + t.Errorf("error = %q, want it to name spinloop work list", err) + } + if strings.Contains(err.Error(), "--url") { + t.Errorf("error = %q, want the terminal refusal, not the flag one", err) + } +} + +func TestWorkBoard_TwoTokenFlagsRefused(t *testing.T) { + _, err := runWork(t, "board", "--url", "http://127.0.0.1:1", + "--api-token", "a", "--api-token-file", "/dev/null") + if err == nil || !strings.Contains(err.Error(), "--api-token") || !strings.Contains(err.Error(), "--api-token-file") { + t.Fatalf("error = %v, want one naming both token flags", err) + } +} + +// --- columns and cards --- + +func TestWorkBoard_ColdRunDrawsFourEmptyColumns(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := wbPlain(m.View()) + for _, want := range []string{"Backlog 0", "Running 0", "Done 0", "Failed 0", "—"} { + if !strings.Contains(view, want) { + t.Errorf("cold view missing %q:\n%s", want, view) + } + } +} + +func TestWorkBoard_ColumnsHoldTheirStatesWithCounts(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + wbItem("past", orchestrator.StateDone), + wbItem("bust", orchestrator.StateFailed), + wbItem("later", orchestrator.StateBacklog), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := wbPlain(m.View()) + for _, want := range []string{"Backlog 2", "Running 1", "Done 1", "Failed 1"} { + if !strings.Contains(view, want) { + t.Errorf("view missing %q:\n%s", want, view) + } + } + for _, id := range []string{"solo", "crank", "past", "bust", "later"} { + if !strings.Contains(view, id) { + t.Errorf("view missing card %q:\n%s", id, view) + } + } +} + +func TestWorkBoard_RunningCardCarriesNodeElapsedAndStateColour(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + if !strings.Contains(view, "\x1b[33mrunning") { + t.Error("a running card does not carry the amber the work list gives running") + } + plain := wbPlain(view) + if !strings.Contains(plain, "node-1") { + t.Errorf("running card missing node:\n%s", plain) + } + if !strings.Contains(plain, "2m 5s") { + t.Errorf("running card missing elapsed:\n%s", plain) + } + // The count-up is a function of the clock when the card is drawn. + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000+120, 0) }) + later := wbPlain(m.View()) + if !strings.Contains(later, "4m 5s") { + t.Errorf("elapsed did not count up with the clock:\n%s", later) + } +} + +func TestWorkBoard_StateColoursMatchTheWorkList(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("past", orchestrator.StateDone), wbItem("bust", orchestrator.StateFailed), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + if !strings.Contains(view, "\x1b[92mdone") { + t.Error("a done card does not carry the work list's green") + } + if !strings.Contains(view, "\x1b[31mfailed") { + t.Error("a failed card does not carry the work list's red") + } +} + +func TestWorkBoard_LongInstructionsClipNotWrap(t *testing.T) { + long := wbItem("long", orchestrator.StateBacklog) + long.Instructions = strings.Repeat("word ", 80) + "ENDWORD" + a := newWBAPI(t, []orchestrator.ItemView{long}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + view := m.View() + plain := wbPlain(view) + if strings.Contains(plain, "ENDWORD") { + t.Error("the clipped tail of the instructions leaked into the card") + } + // The card keeps its shape: the frame's line count is the fixed one. + if n, want := len(strings.Split(view, "\n")), 3+m.visibleCards()*workBoardCardStep; n != want { + t.Errorf("board drew %d lines, want %d:\n%s", n, want, view) + } +} + +func TestWorkBoard_ColumnWindowFollowsTheSelection(t *testing.T) { + var items []orchestrator.ItemView + for i := 0; i < 10; i++ { + items = append(items, wbItem(fmt.Sprintf("item%02d", i), orchestrator.StateBacklog)) + } + a := newWBAPI(t, items, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + first := wbPlain(m.View()) + if !strings.Contains(first, "item00") || strings.Contains(first, "item09") { + t.Errorf("window did not open on the selection:\n%s", first) + } + wbKeys(t, m, "up") // the top of a column is its own wall + if m.cursor[1] != 0 { + t.Errorf("up at the top walked to row %d", m.cursor[1]) + } + wbKeys(t, m, "down", "down", "down", "down", "down", "down") + moved := wbPlain(m.View()) + if !strings.Contains(moved, "item06") { + t.Errorf("moving down did not bring the selection into view:\n%s", moved) + } + if !strings.Contains(moved, "item04") || strings.Contains(moved, "item00") { + t.Errorf("the window did not leave the passed cards behind:\n%s", moved) + } +} + +func TestWorkBoard_ArrowsSkipEmptyColumns(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("wait", orchestrator.StateBacklog), wbItem("gone", orchestrator.StateDone), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right") + if m.cursor[0] != 2 { + t.Errorf("cursor column = %d, want 2 (Running is empty; the cursor lands on Done)", m.cursor[0]) + } + wbKeys(t, m, "right") + if m.cursor[0] != 2 { + t.Errorf("cursor walked past the last card column to %d", m.cursor[0]) + } +} + +func TestWorkBoard_CardMovesBetweenReads(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + if v := strings.Count(wbPlain(m.View()), "Running 1"); v != 1 { + t.Fatal("crank was not running before the move") + } + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + view := wbPlain(m.View()) + if !strings.Contains(view, "Running 0") || !strings.Contains(view, "Done 1") { + t.Errorf("the card did not move with the run:\n%s", view) + } +} + +// --- refresh and staleness --- + +func TestWorkBoard_DroppedAPIAgesTheBoardAndRecovers(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + a.failGET = true + wbRound(t, m) + // The reading has now aged past three cadences: the board is still + // alive, still drawn, and honest about its age. + setVar(&workBoardNow, func() time.Time { return time.Unix(1700000000+4*3600, 0) }) + view := wbPlain(m.View()) + if !strings.Contains(view, "solo") { + t.Errorf("a failed round emptied the board:\n%s", view) + } + if !strings.Contains(view, "reading") || !strings.Contains(view, "ago") { + t.Errorf("the stale reading is not marked with its age:\n%s", view) + } + a.failGET = false + wbRound(t, m) + if strings.Contains(wbPlain(m.View()), " ago") { + t.Error("the age mark survived a good read") + } +} + +func TestWorkBoard_RReadsAtOnce(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + before := a.callCount("GET /v1/items") + cmd := wbKeys(t, m, "r") + if cmd == nil { + t.Fatal("r started no round") + } + m.Update(cmd()) + if a.callCount("GET /v1/items") != before+1 { + t.Error("r did not ask the API at once") + } +} + +func TestWorkBoard_QuitKeysEndTheProgram(t *testing.T) { + for _, k := range []string{"q", "ctrl+c"} { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + cmd := wbKeys(t, m, k) + if cmd == nil { + t.Fatalf("%q quit nothing", k) + } + if got := fmt.Sprintf("%T", cmd()); got != "tea.QuitMsg" { + t.Errorf("%q sent %s, want the quit message", k, got) + } + } +} + +// --- detail and tail --- + +func TestWorkBoard_DetailShowsTheWholeFailedItem(t *testing.T) { + failed := wbItem("bust", orchestrator.StateFailed) + failed.Instructions = strings.Repeat("detail ", 40) + "FULLSTOP" + failed.Tags = []string{"kind=fix", "area=api"} + a := newWBAPI(t, []orchestrator.ItemView{failed}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + mb := wbKeys(t, m, "right", "right", "right", "enter") // to the Failed column, then in + if mb == nil { + t.Fatal("enter opened nothing") + } + // The batch's tail chain is never run by hand — its tick is the test's + // hour-long stand-in for the live one — but the round it started is + // driven directly below by the tail tests. + if !m.detail { + t.Fatal("the detail did not open") + } + view := wbPlain(m.View()) + for _, want := range []string{"FULLSTOP", "./bust", "kind=fix", "area=api", "the agent gave up", "esc back"} { + if !strings.Contains(view, want) { + t.Errorf("detail missing %q:\n%s", want, view) + } + } +} + +func TestWorkBoard_DetailEscReturnsAndRefusesQuit(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("bust", orchestrator.StateFailed)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + cmd := wbKeys(t, m, "q") + if cmd != nil { + if got := fmt.Sprintf("%T", cmd()); got == "tea.quitMsg" { + t.Error("the board quit from inside the detail") + } + } + if !m.detail { + t.Error("q disturbed the detail") + } + wbKeys(t, m, "esc") + if m.detail { + t.Error("esc did not return to the board") + } + if m.cursor[0] != 3 || m.cursor[1] != 0 { + t.Errorf("the selection moved: %v", m.cursor) + } +} + +func TestWorkBoard_DetailTailsAndStops(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, + map[string]string{"crank": "first line\n"}) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") // openDetail starts the first round itself + + // A first fetch shows what is there; an empty pane is a note, not a + // fault. The answer below is the one openDetail's started round is + // answering with — injected as the program would deliver it. + m.Update(workBoardTailMsg{gen: m.detailGen, log: "first line\n"}) + if !strings.Contains(wbPlain(m.View()), "first line") { + t.Error("the tail did not show the kept output") + } + // The agent writes more: the next poll appends only what is new. + a.setLog("crank", "first line\nsecond line\n") + msg := m.startTailRound() + if msg == nil { + t.Fatal("the tail did not poll again") + } + m.Update(msg()) + view := wbPlain(m.View()) + if !strings.Contains(view, "second line") || strings.Count(view, "first line") != 1 { + t.Errorf("the tail did not append the suffix once:\n%s", view) + } + // The item ends: the tail stops, whatever the last poll brought standing. + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + if cmd := m.startTailRound(); cmd != nil { + t.Error("the tail kept polling an ended item") + } +} + +func TestWorkBoard_DetailOfAnItemWithNoLogIsEmptyNotAFault(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "enter") + m.Update(workBoardTailMsg{gen: m.detailGen, log: ""}) + view := wbPlain(m.View()) + if !strings.Contains(view, "no kept output yet") { + t.Errorf("empty pane without a note:\n%s", view) + } + // The item is removed mid-view: the next poll 404s, and the tail ends + // with a note — not with a fault, and not with the pane going blank. + a.mu.Lock() + a.items = nil + a.mu.Unlock() + wbRound(t, m) + msg := m.startTailRound() + if msg == nil { + t.Fatal("the tail did not poll once more to find the item gone") + } + m.Update(msg()) + view = wbPlain(m.View()) + if !strings.Contains(view, "no longer in the list") { + t.Errorf("gone item left no note:\n%s", view) + } +} + +// --- actions --- + +func TestWorkBoard_AbortMovesTheCardBackToBacklog(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "right", "a") + if a.callCount("POST /v1/items/crank/abort") != 1 { + t.Fatal("the abort did not reach the API") + } + if !strings.Contains(m.statusLine, "back in the backlog") { + t.Errorf("status = %q, want the stopped line", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "Running 0") || !strings.Contains(view, "Backlog 1") { + t.Errorf("the card did not move back:\n%s", view) + } +} + +func TestWorkBoard_BacklogAbortIsRefusedTheAPISWay(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "a") + if !strings.Contains(m.statusLine, `item "solo" is not running`) { + t.Errorf("status = %q, want the API's own refusal", m.statusLine) + } + if !strings.Contains(wbPlain(m.View()), "solo") { + t.Error("the refusal took the board down with it") + } +} + +func TestWorkBoard_RemovalAsksFirst(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "x") + footer := wbPlain(m.footerLine(m.effWidth(), m.boardKeys())) + if !strings.Contains(footer, `remove item "solo"?`) { + t.Errorf("the question did not stand: %q", footer) + } + if a.callCount("DELETE") != 0 { + t.Error("the removal was sent before the yes") + } + wbKeys(t, m, "n") + if a.callCount("DELETE") != 0 { + t.Error("a declined removal was sent anyway") + } + if !strings.Contains(m.statusLine, "nothing removed") { + t.Errorf("status = %q, want the declined line", m.statusLine) + } + if !strings.Contains(wbPlain(m.View()), "solo") { + t.Error("the declined card vanished") + } +} + +func TestWorkBoard_RemovalOnYesGoesThrough(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), wbItem("later", orchestrator.StateBacklog), + }, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "x", "y") + if a.callCount("DELETE /v1/items/solo") != 1 { + t.Fatal("the yes sent no DELETE") + } + if !strings.Contains(m.statusLine, `"solo" removed`) { + t.Errorf("status = %q", m.statusLine) + } + view := wbPlain(m.View()) + if strings.Contains(view, "● solo") || !strings.Contains(view, "later") || !strings.Contains(view, "Backlog 1") { + t.Errorf("the board outlived its card:\n%s", view) + } +} + +func TestWorkBoard_RunningRemovalRefusedNamingTheAbort(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbAct(t, m, "right", "x", "y") + if !strings.Contains(m.statusLine, "abort it first") { + t.Errorf("status = %q, want the refusal naming the abort", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "● crank") || !strings.Contains(view, "Running 1") { + t.Errorf("the refused removal took the card away:\n%s", view) + } +} + +// --- the add form --- + +func TestWorkBoard_FormOpensSizedAndStill(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + if !m.formOpen { + t.Fatal("n opened no form") + } + for i, f := range m.form.fields { + if f.Cursor.Blink { + t.Errorf("field %d blinks on its own", i) + } + } + view := wbPlain(m.View()) + for _, want := range []string{"new work item", "id*", "instructions*", "dir*", "tags", "priority", "❯"} { + if !strings.Contains(view, want) { + t.Errorf("form missing %q:\n%s", want, view) + } + } + // Byte-stable with the clock fixed: nothing moves on its own. + if m.View() != m.View() { + t.Error("the form's view is not byte-stable") + } +} + +func TestWorkBoard_PriorityFieldTakesOnlyNumbers(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + calls := len(a.calls) + wbKeys(t, m, "down", "down", "down", "down") // to priority + wbKeys(t, m, "12") + wbKeys(t, m, "abc") + wbKeys(t, m, "3") + if got := m.form.fields[workFormPriority].Value(); got != "123" { + t.Errorf("priority field = %q, want 123 — the letters must not stand", got) + } + if len(a.calls) != calls { + t.Error("keystrokes called the API") + } +} + +func TestWorkBoard_FormAddsEndToEnd(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("solo", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "karma", "enter", "make it so", "enter", "./repo", "enter", "enter") + cmd := wbKeys(t, m, "7", "enter") // enter on the last field sends + if cmd == nil { + t.Fatal("the last enter sent nothing") + } + _, next := m.Update(cmd()) + if next != nil { + m.Update(next()) // the read the accepted add kicks + } + if a.callCount("POST /v1/items") != 1 { + t.Fatal("the send reached no POST") + } + body := a.lastBody() + if body.ID != "karma" || body.Instructions != "make it so" || body.Dir != "./repo" || body.Priority != 7 { + t.Errorf("the API was sent %+v", body) + } + if m.formOpen { + t.Error("the accepted form did not close") + } + if !strings.Contains(m.statusLine, `"karma" added`) { + t.Errorf("status = %q", m.statusLine) + } + view := wbPlain(m.View()) + if !strings.Contains(view, "Backlog 2") || !strings.Contains(view, "karma") { + t.Errorf("the new card does not stand under Backlog:\n%s", view) + } +} + +func TestWorkBoard_FormRefusalIsCorrectedInPlace(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("dup", orchestrator.StateBacklog)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + wbKeys(t, m, "dup", "enter", "again", "enter", "./d", "enter", "enter") + cmd := wbKeys(t, m, "enter") // send with the id the list already carries + if cmd == nil { + t.Fatal("the first enter sent nothing") + } + _, next := m.Update(cmd()) + if next != nil { + m.Update(next()) // the refused send still spends its follow-up read + } + if !m.formOpen { + t.Fatal("a refusal closed the form") + } + if !strings.Contains(m.form.err, `item "dup" is already in the list`) { + t.Errorf("form carries no API refusal: %q", m.form.err) + } + // Correct the id in the form and send again: up to the first field, + // rub it out, type the new one, walk back down. + wbKeys(t, m, "up", "up", "up", "up") + wbKeys(t, m, "backspace", "backspace", "backspace", "fresh") + cmd = wbKeys(t, m, "down", "down", "down", "down", "enter") + if cmd == nil { + t.Fatal("the second enter sent nothing") + } + _, next = m.Update(cmd()) + if next != nil { + m.Update(next()) + } + if m.formOpen { + t.Error("the corrected send did not close the form") + } + if a.callCount("POST /v1/items") != 2 { + t.Errorf("POSTs = %d, want 2", a.callCount("POST /v1/items")) + } + if a.lastBody().ID != "fresh" { + t.Errorf("the second send was %+v", a.lastBody()) + } + if !strings.Contains(wbPlain(m.View()), "Backlog 2") { + t.Error("the corrected item never reached the board") + } +} + +func TestWorkBoard_FormEscapeGuard(t *testing.T) { + a := newWBAPI(t, nil, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + // Empty: esc closes and says so, sending nothing. + wbKeys(t, m, "n", "esc") + if m.formOpen { + t.Error("esc did not close an empty form") + } + if !strings.Contains(m.statusLine, "nothing added") { + t.Errorf("status = %q", m.statusLine) + } + // Typed: esc asks once; declining keeps everything; discarding sends nothing. + wbKeys(t, m, "n") + wbKeys(t, m, "half a thought", "esc") + if !m.formOpen || !m.formAsk { + t.Fatal("esc on typed text neither kept the form nor asked") + } + wbKeys(t, m, "n") + if !m.formOpen || m.formAsk || m.form.fields[0].Value() != "half a thought" { + t.Error("the kept form lost its place or its text") + } + wbKeys(t, m, "esc", "y") + if m.formOpen { + t.Error("a deliberate discard did not close the form") + } + if a.callCount("POST /v1/items") != 0 { + t.Error("the form sent anything") + } +} + +func TestWorkBoard_BoardLivesBehindTheForm(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "n") + a.mu.Lock() + a.items[0].State = orchestrator.StateDone + a.mu.Unlock() + wbRound(t, m) + view := wbPlain(m.View()) + if !strings.Contains(view, "new work item") { + t.Error("the round closed the form") + } + if !strings.Contains(view, "Done 1") { + t.Errorf("the board behind the form stopped moving:\n%s", view) + } + wbKeys(t, m, "esc") + if !strings.Contains(wbPlain(m.View()), "Done 1") { + t.Error("the card did not stand where the board was left") + } +} + +// --- completion and the program itself --- + +func TestWorkBoard_CompletionIsQuietAndTakeNoPositionals(t *testing.T) { + cands, directive := completeQuiet(t, "work", "board", "--") + if !hasAll(cands, "--url", "--api-token", "--api-token-file") { + t.Errorf("flags offered = %v", cands) + } + cands, directive = completeQuiet(t, "work", "board", "some-id") + if len(cands) != 0 { + t.Errorf("positionals offered: %v", cands) + } + if directive != ":4" { // NoFileComp: an id is never a path here + t.Errorf("directive = %q, want :4", directive) + } +} + +// wbFeed reads the live program's frames into one accumulated screen, +// colour stripped: a board that has nothing new to say stops redrawing, +// so a per-frame reader would miss words it needed — the whole screen +// read so far is the honest needle ground. +type wbFeed struct { + mu sync.Mutex + plain strings.Builder + done chan struct{} + closed bool +} + +func newWBFeed(t *testing.T, out io.Reader) *wbFeed { + t.Helper() + f := &wbFeed{done: make(chan struct{})} + go func() { + buf := make([]byte, 32*1024) + for { + select { + case <-f.done: + return + default: + } + n, err := out.Read(buf) + if n > 0 { + f.mu.Lock() + f.plain.WriteString(wbPlain(string(buf[:n]))) + f.mu.Unlock() + } + if err != nil && err != io.EOF { + return // the program is done; what was read is all there is + } + // teatest's output is a buffer: empty is not closed. The + // next frame is a poll away, so idle gently and keep reading. + time.Sleep(20 * time.Millisecond) + } + }() + t.Cleanup(func() { close(f.done) }) + return f +} + +func (f *wbFeed) until(t *testing.T, what string) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + f.mu.Lock() + got := strings.Contains(f.plain.String(), what) + f.mu.Unlock() + if got { + return + } + time.Sleep(20 * time.Millisecond) + } + f.mu.Lock() + screen := f.plain.String() + f.mu.Unlock() + if n := len(screen); n > 1200 { + screen = screen[n-1200:] + } + t.Fatalf("the board never showed %q — screen so far:\n%s", what, screen) +} + +func TestWorkBoard_ProgramSmoke(t *testing.T) { + a := newWBAPI(t, []orchestrator.ItemView{ + wbItem("solo", orchestrator.StateBacklog), + wbItem("crank", orchestrator.StateRunning), + }, nil) + restore := []func(){ + setVar(&workBoardRefreshInterval, 50*time.Millisecond), + setVar(&workBoardNow, time.Now), + } + defer func() { + for _, f := range restore { + f() + } + }() + m := newWorkBoardModel(a.srv.URL, "tok") + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(140, 30)) + feed := newWBFeed(t, tm.Output()) + feed.until(t, "Backlog 1") + feed.until(t, "crank") + // The real loop now, not the test's hand: an abort rides the + // program's own send — the spin chain waking through prog.Send, + // the answer landing on the status line, the kicked read moving the + // card. No directly-driven test can prove that wiring. + tm.Send(tea.KeyMsg{Type: tea.KeyRight}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + feed.until(t, `item "crank" stopped`) + feed.until(t, "Backlog 2") + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) +} diff --git a/docs/commands/index.md b/docs/commands/index.md index f7a30547..336b7127 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -18,7 +18,7 @@ help` the usage summary. | [`spinloop fleet`](fleet.md) | Drive the engines on every machine you run: start, stop, deploy, route | | [`spinloop gateway`](gateway.md) | Serve the fleet under one OpenAI-compatible endpoint | | [`spinloop orchestrator`](orchestrator.md) | Work a backlog of items against the fleet, at the fleet's declared pace | -| [`spinloop work`](work.md) | Drive the orchestrator's work list from the shell: add, list, logs, abort, remove | +| [`spinloop work`](work.md) | Drive the orchestrator's work list from the shell: add, list, logs, abort, remove — or watch it live on `board` | | [`spinloop remote`](remote.md) | Run the model on a cloud GPU that stops when you do | | [`spinloop hf`](hf.md) | Write a `Spinloop` for a Hugging Face model, from its page reference | | [`spinloop alias`](alias.md) | Name a `Spinloop` so the name works anywhere a path does | diff --git a/docs/commands/work.md b/docs/commands/work.md index 9046f9b5..2c2f6174 100644 --- a/docs/commands/work.md +++ b/docs/commands/work.md @@ -3,7 +3,8 @@ Work the [orchestrator](orchestrator.md)'s work list — the backlog it works — from the shell, as a client of the [work list API](orchestrator.md#the-work-list-api) the orchestrator serves: add an item, read the work, read an item's kept -output, stop a running item, remove an item. +output, stop a running item, remove an item — or watch the whole run on a +live board. ```sh spinloop work add --url http://127.0.0.1:4010 --id fix-parser --instructions "fix the failing tests" --dir ./parser @@ -11,6 +12,7 @@ spinloop work list --url http://127.0.0.1:4010 spinloop work logs --url http://127.0.0.1:4010 fix-parser -f spinloop work abort --url http://127.0.0.1:4010 fix-parser spinloop work remove --url http://127.0.0.1:4010 docs-refresh +spinloop work board --url http://127.0.0.1:4010 ``` Each subcommand takes `--url`, the API's base address — the one the @@ -129,6 +131,53 @@ A running item cannot be removed: the refusal names it and the abort that goes first. An id the file does not carry is refused, naming it. The API answers once the item is out, and the command reports its answer. +## Watching the board + +```sh +spinloop work board --url http://127.0.0.1:4010 +``` + +The board: the same work list as a live kanban — four columns, +`Backlog`, `Running`, `Done`, `Failed`, a card per item — re-read from +the API on a cadence, so a card moves as the run works it. It is a +client of the API like every other command here: it reads no file and +writes no file, and every action it offers goes through the same paths +the one-shot commands call. + +Each card carries the item's id, its instructions clipped to the card's +width, and its priority where it has one; a running card adds the node +it is on and how long it has been up, counting up as you watch. States +wear the colours `work list` gives them. + +Keys are offered in the footer only where they would do something for +what the cursor stands on: + +- `↑`/`↓`/`←`/`→` move the selection — sideways to the next column + holding a card. +- `enter` opens the item's detail: its full instructions, dir, tags, + timings and failure reason, with its kept output tailed beneath as + `work logs -f` tails it, ending when the item ends or drops out. + `esc` returns; the board cannot be quit from inside the detail. +- `a` aborts a running item, `x` removes one that is not — the removal + asks first, and declining sends nothing. A refusal from the API + reads on the status line the way the API states it. +- `n` opens the add form — the same add `work add` sends, through the + API's add path. Its five fields stand before you at once (id, + instructions and dir marked required); `up`/`down` step the field + cursor, `enter` advances, and on the last field sends. The form + keeps the API's refusal visible for a corrected send; `esc` closes + an empty form and, with anything typed, asks before discarding. + The board keeps moving behind it. +- `r` reads again at once; `q` or `Ctrl+C` leaves. + +When the API goes quiet the board does not go with it: it keeps +drawing its last reading and marks the title bar with its age until a +good read returns. + +The board needs an interactive terminal. Piped or redirected, it +refuses and names `spinloop work list` as the command for the same +work into a pipe. + ## What it does not do - It reads no file and writes no file: the commands never touch the items file, @@ -145,7 +194,7 @@ once the item is out, and the command reports its answer. | Flag | Meaning | | ---- | ------- | -| `--url
` | The work list API's base address — `add`, `list`, `logs`, `abort`, `remove` | +| `--url
` | The work list API's base address — `add`, `list`, `logs`, `abort`, `remove`, `board` | | `--api-token ` | The work list API's bearer token — every subcommand | | `--api-token-file ` | The file the work list API's bearer token stands in — every subcommand | | `--id ` | The item's id — `add` | diff --git a/docs/guides/work-items.md b/docs/guides/work-items.md index 3ebe23d1..161f0f6f 100644 --- a/docs/guides/work-items.md +++ b/docs/guides/work-items.md @@ -122,8 +122,10 @@ backlog through the run's [work list API](../commands/orchestrator.md#the-work-l dash where a value is absent; `work logs ` (`-f` to follow) prints an item's kept agent output; `work abort ` stops a running item and puts it back in the backlog; `work remove ` takes an item out of the file, its -state, and its log. The commands name the API's address with `--url` and -present its token, and a refusal reads the way the API states it. +state, and its log. `work board` draws the same list as a live kanban — a +column per state, with keys to add, abort, remove, and tail an item's +log without leaving the screen. The commands name the API's address +with `--url` and present its token, and a refusal reads the way the API states it. ## Stopping and restarting @@ -146,6 +148,7 @@ giving only the gateway's address. - [`spinloop orchestrator`](../commands/orchestrator.md) — the full command reference, and the [work list API](../commands/orchestrator.md#the-work-list-api) a client works the backlog through - [`spinloop work`](../commands/work.md) — the backlog driven from the shell, - through the run's work list API: add, list, logs, abort, remove + through the run's work list API: add, list, logs, abort, remove, and the + live [`board`](../commands/work.md#watching-the-board) - [The fleet file](../fleet-file.md) — tags, concurrency, and waking - [The gateway](../commands/gateway.md) — the front door the orchestrator reads and routes through diff --git a/go.mod b/go.mod index 5ad6a2a2..7ba9f631 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect @@ -43,6 +44,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect diff --git a/go.sum b/go.sum index 46df6727..cede517f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= @@ -42,6 +44,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= diff --git a/openspec/changes/add-work-board-tui/.openspec.yaml b/openspec/changes/add-work-board-tui/.openspec.yaml new file mode 100644 index 00000000..abd7c5ae --- /dev/null +++ b/openspec/changes/add-work-board-tui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-25 diff --git a/openspec/changes/add-work-board-tui/design.md b/openspec/changes/add-work-board-tui/design.md new file mode 100644 index 00000000..eaa2a566 --- /dev/null +++ b/openspec/changes/add-work-board-tui/design.md @@ -0,0 +1,163 @@ +## Context + +The fleet dashboard (`cmd/spinloop/dashboard*.go`, `fleet_dashboard.go`) is +the CLI's only full-screen view today, and its architecture is the pattern +this board follows: a small command file that gates on a terminal and starts +the program, a pure model whose intervals and clock are package variables, +and renderers that pre-size content to exactly W×H before lipgloss sees it. +The work family (`cmd/spinloop/work.go`, `work_logs.go`) already holds +every piece of client plumbing the board needs: `workAPIFlags`, +`workTarget`, `workRequest` (whose `workAPIErr` keeps the refusal status), +and `workLogFetch`, whose callers diff the whole kept log against what they +last saw — the log endpoint serves the entire kept output, there is no +server-side offset. `orchestrator.ItemView` is the wire shape, carrying +everything a card and a detail need. No server-side change is involved; +see proposal.md for motivation and the two delta specs for behaviour. + +## Goals / Non-Goals + +**Goals:** + +- A board that reads as the same tool as the fleet dashboard: same + decoration contract, same accent/state colour discipline, same + testability (everything but the terminal gate drivable without one). +- The board degrades like the dashboard, not like a crash: a dropped API + ages the reading, it does not close the view. + +**Non-Goals:** + +- No reordering by priority, no dragging, no editing an item from the + board, no user-defined columns or filters, and no `--follow`-style flag: + the board is always live. Adding, aborting and removing are the whole + action set; `work add` stays the scripted way in. +- No widgets for the board itself: the grid, cards, detail and hints stay + hand-rolled under the dashboard's W×H contract — there is no kanban + component to buy, and bought styling would have to be bent to the + cli-ux colour discipline. One component is bought, for the form's text + input alone: `charmbracelet/bubbles`, its `textinput`. + +## Decisions + +**Three files mirroring the dashboard split.** `work_board.go` builds the +Cobra command (registered on `workCmd()` beside its siblings, `--url` and +token flags via `workAPIFlags`, `Args: cobra.NoArgs`), checks +`term.IsTerminal(os.Stdout.Fd())` before anything else — refusing and +naming `spinloop work list`, the way `runFleetDashboard` names +`fleet metrics --watch` — and runs `tea.NewProgram(&m, tea.WithAltScreen())` +with `m.send = prog.Send` so worker goroutines can feed messages. +`work_board_model.go` holds the model, ticks and keys; +`work_board_render.go` the drawing. Alternative: one file — rejected for +the same reason the dashboard's three exist: the renderers are byte-tested +in isolation. + +**One poller, not two, and a race guard.** A self-rescheduling `tea.Tick` +(`workBoardRefreshInterval`, 5s, a package var so tests shorten it) calls +`GET /v1/items` through `workRequest` in a `tea.Cmd` goroutine, one round +in flight at a time. Replies carrying an older stamp than what is on the +board are discarded — the dashboard's guard, copied. A failed read keeps +the last items and marks the title bar with the reading's age once it is +older than `workBoardStaleAfter` (3× the cadence), dimmed, never presented +as current: the cli-ux aged-information rule, met at the title bar rather +than per column. `r` restarts a round immediately. + +**Columns are laid out arithmetically, not by a table widget.** The body +splits its width four ways (`(W − gaps) / 4` per column, floors at 1); +each column is a rounded frame whose header names it and counts its cards; +cards inside are fixed-height blocks of pre-clipped lines (`ansi.CutWc`, +never wrap), the way dashboard tiles are joined line-by-line to stay +rectangular. A column taller than its cards shows a window around the +selection, clamped like `dashClampScroll`. Card order is the API's list +order — file order — matching `work list`; sorting backlog by priority was +considered and rejected so two surfaces do not order the same list +differently, and the priority rides on the card instead. Running cards +draw elapsed time from an injected `workBoardNow` var, so counts-up is +testable and never renders stale. + +**Selection is a (column, row) cursor; the accent marks it alone.** +Left/right step between columns (into the nearest column holding a card, +skipping empties), up/down move within and scroll the column; the selected +card's frame takes `brandAccent`, its state colour stays on the state +word. After each refresh the row is re-clamped — an item that moved +columns or left the list simply shrinks the column the cursor stands in. + +**Detail reuses the board's own data plus the log tail.** Enter shows the +selected `ItemView` from the latest read (there is no single-item GET — +`GET /v1/items/{id}` is a 405 on purpose) and starts a second tick +(`workBoardTailInterval`, 1s, matching `workLogsInterval`) fetching +`workLogFetch` and appending the suffix beyond what it last saw, exactly +`printWorkLogSuffix`'s trick but into the pane; an item ending or leaving +the list stops the tail (404 ends it cleanly, as a follow ends). A +generation counter, copied from `dashboard_detail.go`, discards replies +that arrive after the pane closed or reopened on another item. Escape +returns; the detail pane offers no quit, as the dashboard detail does not. + +**Actions ride the model's action slot, not the tick.** `a` and `x` call +`beginAction`-style goroutines through `m.send`: status line "aborting +item X…" plus the one spinner (`spinnerFrame`) on a 100ms repaint chain +while the call is in flight — abort can block server-side for the stop +grace, and the cli-ux long-operation rule wants motion. `workRequestBound` +(30s) bounds the call; a timeout lands on the status line as a fault and +the board goes on drawing. Refusals come back as `workAPIErr` and are +printed verbatim — the refusal reads the way the API states it, here too. +Removal confirmation is a tiny modal in the footer (y / n / esc, default +no), patterned on the dashboard's keep prompt minus its text input; no +skip-the-question flag, because an unattended run cannot reach a full-screen +view at all — the terminal gate already refuses it, naming `work list`. + +**Adding is a flat form modal, not a sub-screen.** `n` opens a framed +form over the dimmed board, showing all five fields at once — id, +instructions, dir, tags, priority — the three required ones marked, an +accent cursor standing on the field that takes keystrokes. `up`/`down` +step the cursor between fields, typing edits the field under it, `enter` +advances and sends from the last. Only the active field carries a caret, +its value clipped around the caret the way a card clips its instructions. +Each field is a `bubbles/textinput`: caret typing, UTF-8-safe backspace, +word motions and horizontal clipping around the caret are its own — the +honest line is that hand-writing a caret editor for five fields is false +modesty, while the grid around it has no component to buy. `Focus` +follows the field cursor; blink is off, both because a standing surface +should not animate by itself and so test view bytes stay stable. Caret +keys (left/right, home/end) belong to the focused field; `up`/`down` +step the cursor and `tab` also advances. A sub-screen was the alternative; it buys a full width five +short fields do not need and costs a navigation mode the mode stack +already carries. Keys route through that stack — form, then removal +confirm, then detail, then board — and reads keep their cadence behind an +open form, the board repainting under it. Sending rides the action slot: +`POST /v1/items` through `workRequest`, spinner on the status line while +in flight; acceptance closes the form, says added, and kicks a refresh +round at once. A refusal lands inside the form under the fields, the API +its own sole validator as ever, and the operator steps the cursor to the +offending field and sends again. Priority's textinput filters its input to digits and a leading +minus, because a non-number could not even be asked of the API. Escape +on an empty form closes and says so; +with anything entered it asks once, defaulting to keep, the keep answer +leaving the buffers where they stand. + +**Key set.** Board: `q`/`ctrl+c` quit · arrows move · `enter` detail · +`n` new item · `a` abort (running only) · `x` remove (not running) · +`r` refresh. Detail: `esc` back. Form: `up`/`down` field cursor, +`enter` advance/send, `esc` guarded discard. Remove-confirm: +`y`/`n`/`esc`. Footer hints are built per selection state and mode so a +key is named only where it would do something. + +## Risks / Trade-offs + +- **Whole-log fetch per tail tick is O(log size)** → same cost the + existing follow already pays; kept output is bounded by the orchestrator. + A server-side offset endpoint would be the fix if logs ever grow past + that, and is deliberately not built now. +- **The detail pane's item fields age with the board's last read** — an + item can start while its detail stands open and its node line will lag + up to one cadence → acceptable for v1; the tail itself is live, and the + board's aged-reading mark covers a dropped API. Noted, not papered over. +- **Keyboard-only interaction on narrow terminals** → floors of one card + per column and clipped everything, as the dashboard does; below a sane + width columns go one-per-row with the cursor's column filling the body. +- **State drift under the cursor** (item removed by another client while + selected) → actions report the API's 404 as stated and the next read + re-clamps; nothing is cached as truth client-side. + +## Migration Plan + +Additive: a new subcommand over an existing API. Roll back by reverting +the change; no data, no API, no config moves. diff --git a/openspec/changes/add-work-board-tui/proposal.md b/openspec/changes/add-work-board-tui/proposal.md new file mode 100644 index 00000000..0bac3714 --- /dev/null +++ b/openspec/changes/add-work-board-tui/proposal.md @@ -0,0 +1,70 @@ +# Add a kanban board for the work list + +Closes #246. + +## Why + +The work list is only legible one item at a time: `work list` prints a +stateless table, and while it answers "what is there", it cannot answer "how +is the run doing" — how much sits waiting, how many nodes are busy, which +items ended badly — without the operator reading every row. The fleet has a +full-screen view of its own; the work list, the thing the orchestrator is +actually working through, has nothing to watch. + +## What Changes + +- New `spinloop work board` command: an interactive, full-screen kanban + board of the orchestrator's work list. Four fixed columns — Backlog, + Running, Done, Failed — with one card per item, re-read from the work list + API on a cadence so the board moves as the run works. +- Each card shows its item's id, instructions (clipped), priority, node and + timing; the selected item's full detail opens on enter — instructions in + full, tags, the failure reason, and the item's kept log output tailed + live, the way the fleet dashboard's detail pane tails node logs. +- From the board the operator acts through the same work list API the + one-shot commands use: abort a running item, remove a non-running one + (asking first), quit. Keys are offered only where they would do something. +- Adding an item from the board: `n` opens a form modal over the live + board — id, instructions, working directory, tags, priority — sent to + the API's own add path, which stays the only judge of an item's shape; + a refusal lands in the form, worded as the API states it, to be + corrected and resent. `work add` stays the scripted way in. +- The command follows the work family's conventions exactly: `--url` names + the API, the token comes from `--api-token` / `--api-token-file` / + `SPINLOOP_API_TOKEN`, and with no terminal to draw on it refuses, naming + `spinloop work list` as the pipe alternative. +- No server-side change: everything the board shows and does is served by + the existing work list API (`GET /v1/items`, add, abort, remove, log). + +## Capabilities + +### New Capabilities + +- `work-board`: the full-screen kanban view of a running orchestrator's + work list — columns and cards, selection and detail, the keys that act + through the work list API including the form modal that adds an item, + refresh and staleness, and the terminal gate. + +### Modified Capabilities + +- `work-commands`: the group requirement "The work commands as work list + clients" enumerates the subcommands; `board` joins the family as another + client carrying the same `--url` and token conventions. + +## Impact + +- New `cmd/spinloop/work_board.go` (command + program gate), + `work_board_model.go` (model, polling, keys, the add form) and + `work_board_render.go` (columns, cards, detail, the form), following + the split the fleet dashboard uses. +- `cmd/spinloop/commands.go` / `complete.go`: the subcommand and its + completion registration. +- Reuses: the `work` family's flag and token plumbing (`workAPIFlags`, + `workTarget`, `workRequest`), `orchestrator.ItemView` as the wire type, + `palette.go` colours under the cli-ux accent/state split, and the + existing charmbracelet dependencies (bubbletea, lipgloss, x/ansi). One + new direct dependency: `charmbracelet/bubbles`, used for its + `textinput` and nothing else — and in one place after + [#248](https://github.com/spinloop-ai/spinloop/issues/248) folds the + dashboard's keep prompt onto the same pattern. +- No change to the orchestrator, its API, the items file, or the state. diff --git a/openspec/changes/add-work-board-tui/specs/work-board/spec.md b/openspec/changes/add-work-board-tui/specs/work-board/spec.md new file mode 100644 index 00000000..d69c9561 --- /dev/null +++ b/openspec/changes/add-work-board-tui/specs/work-board/spec.md @@ -0,0 +1,260 @@ +## Purpose + +Watches a running orchestrator's work list as a full-screen kanban board — +one column per state, a card per item, kept current as the run works — and +lets the operator add, abort, remove and read items, all through the same +work list API the one-shot work commands use. + +## ADDED Requirements + +### Requirement: The board is a full-screen view of the run's work + +`spinloop work board` SHALL open an interactive, full-screen view of the +work list the named API holds. Everything the board draws and does — every +column, card, action and refusal — SHALL come from the work list API: the +board SHALL NOT read or write the items file, the state, or the logs +directly, and SHALL NOT take any lock beside them. With no terminal to draw +on it SHALL refuse before drawing anything, naming `spinloop work list` as +the command that carries the same information into a pipe. + +#### Scenario: The board opens on the run + +- **WHEN** the operator opens the board naming a running orchestrator's + work list API +- **THEN** the screen shows the run's whole work: every item the list + carries, as it stands at that moment + +#### Scenario: A cold run is openable + +- **WHEN** the operator opens the board against a run whose items file is + empty +- **THEN** the board opens with its empty columns drawn and stays usable, + rather than faulting + +#### Scenario: A piped run is refused + +- **WHEN** the operator runs `work board` with its output piped +- **THEN** it fails before drawing anything, naming `spinloop work list` as + the pipe's command + +### Requirement: Columns are states and cards are items + +The board SHALL draw exactly four columns, standing in this order — +Backlog, Running, Done, Failed — and every item the list carries SHALL +appear as a card in exactly the one column its state names; an item the run +records not at all is backlog. Within a column the cards SHALL stand in the +order the API listed them, and each column heading SHALL name itself and +count its cards. A column fuller than the screen is tall SHALL show the +selected card's neighbourhood rather than drop cards silently. + +#### Scenario: Every item stands in its state's column + +- **WHEN** the run holds items recorded running, done and failed beside + ones with no record +- **THEN** the first stands under Running, the rest under Done, Failed and + Backlog, and each heading shows its count + +#### Scenario: A card moves as the run works + +- **WHEN** the board is open and a running item finishes between two + refreshes +- **THEN** its card stands under Done, with no key pressed + +#### Scenario: More cards than the screen is tall + +- **WHEN** a column holds more items than fit the screen +- **THEN** the cards around the selected one are shown, and moving the + selection brings the others into view + +### Requirement: The card tells the item's situation + +Each card SHALL show its item's id and its instructions, clipped to the +card's width rather than wrapped, and its priority where the item has one. +A running card SHALL also show the node it runs on and the time since it +started, counted up as it is watched. A card's state SHALL be told by the +colour that reports it — the amber of running, the green of done, the red +of failed — as the work list colours the same state, and never by the +brand accent. + +#### Scenario: Long instructions are clipped, not wrapped + +- **WHEN** an item's instructions are longer than its card is wide +- **THEN** the card shows their beginning, clipped, and the card keeps its + shape + +#### Scenario: A running card counts up + +- **WHEN** the operator watches a running card without pressing anything +- **THEN** the time since that item started grows, and the board keeps + drawing + +#### Scenario: States keep the tool's state colours + +- **WHEN** the board draws running, done and failed cards on a terminal + that takes colour +- **THEN** each carries the colour the work list gives that state, and no + card is coloured with the accent for its state + +### Requirement: The selection and the item's detail + +The operator SHALL move the selection between cards with the arrow keys — +between columns sideways, within a column up and down — and the board SHALL +mark the selected card with the brand accent alone. Enter SHALL open that +item's detail: its instructions in full, its directory, its tags, every +time its record carries, and, where it failed, the reason. While the detail +stands open the command SHALL keep asking the API for the item's kept +output and show whatever arrives, an item with no output yet shown as empty +rather than as a fault; once the item has ended, or dropped out of the +list, the tailing SHALL stop. Escape SHALL return to the board; while a +detail stands open the board SHALL not be quit from, and the keys offered +there are only those that do something there. + +#### Scenario: The detail shows the whole item + +- **WHEN** the operator opens the detail of a failed item with long + instructions +- **THEN** the full instructions, its directory, tags, timings and failure + reason are all shown + +#### Scenario: The open detail tails the kept output + +- **WHEN** the detail of a running item stands open and its agent writes + more output +- **THEN** the detail shows the new output as the polls see it + +#### Scenario: An item with no kept output is empty, not a fault + +- **WHEN** the operator opens the detail of an item the run has kept no + output for +- **THEN** its log pane shows empty and the board goes on drawing + +#### Scenario: The way back is Escape + +- **WHEN** a detail stands open and the operator presses escape +- **THEN** the board returns, the selection on the card it was on, and + pressing quit there quits the board + +### Requirement: The board acts through the work list API + +The board SHALL offer the work list API's actions on the selected +item: a key that aborts a running item and a key that removes one that is +not running. A removal SHALL ask on screen for confirmation before it is +sent, defaulting to not proceeding, a declined or abandoned question +sending nothing and saying so. An accepted action SHALL be shown underway +on the status line until the API answers, and the answered action's effect +— the card moving, the card leaving — SHALL follow on the next read that +sees it. Where the API refuses — aborting what is not running, removing a +running item, an id the list no longer carries — the board SHALL show the +refusal on its status line, worded the way the API states it, and keep +drawing. While an action is underway the board SHALL keep a spinner moving, +and the keys it names on screen SHALL be only those that would do +something to what is selected. + +#### Scenario: A running card is aborted + +- **WHEN** the operator aborts a running item's card and the API stops it +- **THEN** the status line says it is stopped and the card stands under + Backlog once the next read sees it + +#### Scenario: A removal asks first + +- **WHEN** the operator asks to remove a backlog card +- **THEN** the board asks, and nothing is sent until yes is given + +#### Scenario: A declined removal sends nothing + +- **WHEN** the operator declines or abandons the removal question +- **THEN** nothing is sent, the board says so, and the card remains + +#### Scenario: A refusal keeps the board + +- **WHEN** an action the API refuses is attempted — a running item + removed, a backlog item aborted — or the API cannot be reached for it +- **THEN** the status line carries the refusal worded as the API states it, + or the fault, and the board keeps drawing + +### Requirement: The board adds an item + +The operator SHALL be able to add an item to the run's work list without +leaving the board: a key SHALL open a form over the board showing the +item's five fields — its id, instructions, working directory, tags, and +priority — marking the three the item cannot do without, and keeping the +field that takes keystrokes visibly the selected one. The form SHALL not +pause the board behind it: reads keep their cadence, and the selection +keeps its place until the form closes. Sending belongs to the work list +API — the same add path the one-shot `work add` sends — and the API SHALL +be the only judge of an item's shape: a refusal SHALL keep the form open, +carry the refusal into it worded the way the API states it, and leave the +operator to correct a field and send again. The one field the form SHALL +guard itself is the priority, which stands only as a number or as nothing. +Where the API accepts, the form SHALL close, the status line shall say the +item is added, and the board SHALL ask the list again at once so the new +card stands under Backlog. Escape SHALL send nothing: an empty form closes +and says nothing was added, and a form holding anything SHALL ask once +before discarding it, defaulting to keep. + +#### Scenario: An item is added end to end + +- **WHEN** the operator opens the form, fills its fields, and sends what + the API accepts +- **THEN** the form closes, the status line says the item is added, and + once the board has read again the new card stands under Backlog + +#### Scenario: A refusal is corrected in the form + +- **WHEN** the operator sends an id the list already carries +- **THEN** the form stays open carrying the refusal as the API states it, + and a second send after correcting the id closes it as added + +#### Scenario: The board lives behind the form + +- **WHEN** the form stands open and a running item ends between two reads +- **THEN** the board behind keeps drawing, and the card stands under Done + where the board was left when the form closes + +#### Scenario: An empty form closes on escape + +- **WHEN** the operator presses escape with nothing entered anywhere +- **THEN** the form closes, nothing is sent, and the board says nothing + was added + +#### Scenario: Typed text is discarded deliberately + +- **WHEN** the operator presses escape with text entered, and declines the + question that follows +- **THEN** the form stands where it was with its text, and nothing has + been sent + +#### Scenario: A non-numeric priority does not stand + +- **WHEN** the operator types text that is not a number into the priority + field +- **THEN** the keystroke does not enter the field, and no API call is + made about it + +### Requirement: The board keeps the run's company + +While the board stands open it SHALL re-read the work list at a fixed +cadence, and `r` SHALL ask for a read at once. Where an API call fails, +the board SHALL NOT exit: it shall keep drawing its last reading, marked +with its age and no longer presented as the present state of anything, and +keep asking until the API answers again. The operator's interrupt, or quit, +SHALL end the board cleanly, restoring the terminal. + +#### Scenario: A dropped API does not drop the board + +- **WHEN** the orchestrator stops answering while the board stands open +- **THEN** the board keeps drawing its last reading, shown with its age, + and recovers without a key press once the API answers again + +#### Scenario: Refresh now + +- **WHEN** the operator presses `r` +- **THEN** the board asks the API again at once, without waiting for the + next tick + +#### Scenario: Quit restores the terminal + +- **WHEN** the operator quits the board +- **THEN** the command ends cleanly and the terminal is the terminal that + was there before diff --git a/openspec/changes/add-work-board-tui/specs/work-commands/spec.md b/openspec/changes/add-work-board-tui/specs/work-commands/spec.md new file mode 100644 index 00000000..0f418bc1 --- /dev/null +++ b/openspec/changes/add-work-board-tui/specs/work-commands/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: The work commands as work list clients + +`spinloop work` SHALL be a top-level command group with the subcommands +add, list, abort, remove, logs and board, each a client of the +orchestrator's work list API. Every subcommand SHALL take a `--url` flag +naming the API's base address, and SHALL present the API's token as a +bearer on every request it makes — resolved from `--api-token`, else +`--api-token-file`, else the `SPINLOOP_API_TOKEN` environment variable, +two of the flags given at once being a refusal naming both. A subcommand +that names no `--url` SHALL fail before it calls the API, naming the flag. +The commands SHALL be clients of the API alone: they SHALL NOT read or +write the items file, the state, or the logs directly, and SHALL NOT take +any lock beside them. + +#### Scenario: The API's address is named + +- **WHEN** the operator runs a work command naming the API's address with + `--url` +- **THEN** it calls that API, presenting the token as a bearer + +#### Scenario: No address is named + +- **WHEN** the operator runs a work command with no `--url` +- **THEN** it fails, naming the `--url` flag, and calls no API + +#### Scenario: Two token flags at once + +- **WHEN** the operator gives both `--api-token` and `--api-token-file` +- **THEN** the command fails, naming both flags, and calls no API + +#### Scenario: The token comes from the environment + +- **WHEN** the operator names the API's address, sets no token flag, and the + `SPINLOOP_API_TOKEN` environment is set +- **THEN** the command presents that value as the bearer diff --git a/openspec/changes/add-work-board-tui/tasks.md b/openspec/changes/add-work-board-tui/tasks.md new file mode 100644 index 00000000..78cba677 --- /dev/null +++ b/openspec/changes/add-work-board-tui/tasks.md @@ -0,0 +1,61 @@ +# Tasks: add-work-board-tui + +## 1. Command surface and gate + +- [x] 1.1 Create `cmd/spinloop/work_board.go`: `workBoardCmd()` with `workAPIFlags`, `Args: cobra.NoArgs`, a lowercase imperative short/long help in the family's voice; register it on `workCmd()` in `work.go` beside its siblings. +- [x] 1.2 Gate on `term.IsTerminal(os.Stdout.Fd())` before anything else: refuse with an error naming `spinloop work list` as the pipe's command, tested through `cmdWork` under `captureStdout`. +- [x] 1.3 Start the program: `tea.NewProgram(&m, tea.WithAltScreen())`, `m.send = prog.Send`; wire `--url`/token through `workTarget` so a missing `--url` fails naming the flag before any call. + +## 2. Model: reads and ticks + +- [x] 2.1 Build `work_board_model.go`: the model struct (items, cursor, detail state, confirm state, status line, busy flag, width/height, `send`), `workBoardRefreshInterval`/`workBoardTailInterval`/`workBoardStaleAfter`/`workBoardNow` as package vars; `Init` kicking the first round. +- [x] 2.2 Poll `GET /v1/items` in a self-rescheduling tick `tea.Cmd` through `workRequest`, one round in flight; decode into `[]orchestrator.ItemView`; discard a reply older than the reading on screen. +- [x] 2.3 Failed round: keep the last reading, mark the title bar with its age once past `workBoardStaleAfter`, dimmed; recover silently on the next good read. `r` restarts a round at once. + +## 3. Rendering: columns and cards + +- [x] 3.1 `work_board_render.go`: four fixed columns (Backlog, Running, Done, Failed) with counted headers, widths `(W − gaps) / 4` with floors, content pre-sized to exactly W×H, lines clipped with `ansi.CutWc`, title bar shared in shape with the dashboard's. +- [x] 3.2 Cards: id, clipped instructions, priority where non-zero; running adds node and elapsed-time-counts-up from `workBoardNow`; done/failed draw their state colour from `workListColouredState`'s same values; selected card framed in `brandAccent` alone. +- [x] 3.3 Column windowing: show the selection's neighbourhood when a column overflows, clamped; re-clamp the cursor after every read. + +## 4. Selection, detail and tail + +- [x] 4.1 Arrow-key cursor as (column, row): up/down within a column, left/right to the nearest column holding a card; `enter` opens detail, `esc` returns; no quit from the detail. +- [x] 4.2 Detail pane from the latest `ItemView`: full instructions, dir, tags, timings, failure reason; empty log pane is empty, not a fault. +- [x] 4.3 Tail via `workLogFetch` on `workBoardTailInterval`, appending only the suffix beyond the last fetch; stop on the item ending or a 404; generation counter discards replies after close/reopen. + +## 5. Actions and status line + +- [x] 5.1 `beginAction`-style in-flight slot: `a` aborts a running card, `x` removes a selected non-running card, each calling the API's path through `workRequest` in a goroutine feeding `m.send`. +- [x] 5.2 Status line underway while in flight with `spinnerFrame` on a repaint chain; answer (or `workAPIErr` refusal, verbatim; or the 30s bound's fault) lands on the status line and the board keeps drawing. +- [x] 5.3 Removal confirm in the footer: `y` sends, `n`/`esc`/anything else declines, defaulting to no; a declined question sends nothing and says so. + +## 6. The add form + +- [x] 6.1 Add `charmbracelet/bubbles` to `go.mod`; import only its `textinput` — nothing else in the board draws from it. +- [x] 6.2 Mode stack: keys route form → removal confirm → detail → board; `n` opens the form; reads keep their cadence and the board repaints behind an open form, the selection untouched until it closes. +- [x] 6.3 The flat form: framed over the dimmed board, five labelled fields with the required three (id, instructions, dir) marked, accent field cursor; each field a `bubbles/textinput` with blink off and `Focus` following the cursor; `up`/`down` step the cursor, caret keys (left/right, home/end) belong to the focused field, `enter`/`tab` advance and the last sends; height clamped to the screen. +- [x] 6.4 The priority textinput filters its input to digits and a leading minus, rejecting the rest without an API call; nothing else is validated client-side. +- [x] 6.5 Send rides the action slot: `POST /v1/items` through `workRequest` with the `workAddBody` shape, spinner while in flight; acceptance closes the form, says added on the status line, and kicks a refresh round at once. +- [x] 6.6 A refusal renders inside the form under the fields, verbatim from the `workAPIErr`, the form held open for a corrected send. +- [x] 6.7 Escape: an empty form closes saying nothing was added; with anything entered, a discard question defaulting to keep — discard closes sending nothing and says so, a kept form stands with its buffers. + +## 7. Keys, hints and completion + +- [x] 7.1 `q`/`ctrl+c` quit cleanly from the board (terminal restored); interrupt handled. +- [x] 7.2 Footer key hints built from the selection's state and the current mode (board, detail, confirm, form), so a key — `n` included — is named only where it would do something. +- [x] 7.3 Confirm `__complete` behaves for `work board` (no positionals, `--url` etc. offered, nothing on stderr). + +## 8. Tests + +- [x] 8.1 Fake work list API over `httptest` serving `/v1/items` (GET and POST, the POST accepting and refusing with the API's shapes — a carried id is a 409), `/v1/items/{id}/log` and `/abort`; drive the model directly with injected `tea.KeyMsg`s and shortened interval vars, fixed `workBoardNow`. +- [x] 8.2 Byte-stable render tests: cold run (four empty columns with zero counts), mixed states, oversized column windowing, clipped instructions, aged title bar; cursor/selection marking; the form at a fixed size, with a refusal standing under its fields. +- [x] 8.3 Behaviour tests: card moves on refresh, tail appends and stops at end/404, abort/remove round-trips incl. refusals kept on screen, confirm decline sends nothing, dropped API ages and recovers without exit. +- [x] 8.4 Add-form tests: end-to-end add with the card appearing under Backlog after the kicked read, refusal corrected in-form and resent, escape empty vs typed-with-decline, priority filter rejecting non-numerics keystroke-wise, board keeps moving behind the open form; the form renders byte-stable with blink off. +- [x] 8.5 One `teatest` program-level smoke test at a fixed term size; keep package coverage ≥80% (`go test ./... -cover`). + +## 9. Docs and spec + +- [x] 9.1 `docs/commands/work.md`: a `spinloop work board` section — keys including the add form, actions, the terminal gate naming `work list`; cross-link from `docs/guides/work-items.md`. +- [x] 9.2 `CHANGELOG.md` entry under unreleased. +- [x] 9.3 `gofmt`, `go vet`, full suite green; `openspec validate add-work-board-tui --strict` clean. From 1e46d5fb37a729122eddf5cc9b82842d8d0028bc Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:27:17 +0100 Subject: [PATCH 2/6] fix(work): stop the board hanging when a key sets an action off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action woken its spinner repaint chain by calling prog.Send from inside Update. The loop reads that channel on the very goroutine running Update, so the send blocked forever and the whole board went deaf — the last Enter on the add form was one way to hit it. The chain is now scheduled as the second command of the action's batch, the pattern the fleet dashboard already rides; the program expands it in a goroutine of its own, and the tick handler keeps it restacking until the call answers. Tests drive commands through one wbLand helper: it unwraps the batch to the call, folds the answer in, and lands the follow-up read; wbAct and wbRound wrap it for their shapes. The teatest smoke now lands an abort through the real loop, where the batch is expanded as in the binary — the wiring the directly-driven tests never reached because they leave send unwired. --- cmd/spinloop/work_board.go | 1 - cmd/spinloop/work_board_model.go | 26 +++++------ cmd/spinloop/work_board_test.go | 75 +++++++++++++++----------------- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/cmd/spinloop/work_board.go b/cmd/spinloop/work_board.go index 94e4198c..c94bedba 100644 --- a/cmd/spinloop/work_board.go +++ b/cmd/spinloop/work_board.go @@ -73,7 +73,6 @@ func runWorkBoard(base, token string) error { // restores the terminal on the way out, whatever key got here. func runWorkBoardProgram(m *workBoardModel) error { prog := tea.NewProgram(m, tea.WithAltScreen()) - m.send = prog.Send if _, err := prog.Run(); err != nil { return fmt.Errorf("work board: %w", err) } diff --git a/cmd/spinloop/work_board_model.go b/cmd/spinloop/work_board_model.go index 2caba94f..00e7f6ef 100644 --- a/cmd/spinloop/work_board_model.go +++ b/cmd/spinloop/work_board_model.go @@ -211,7 +211,6 @@ type workBoardModel struct { confirm bool // a removal stands in front of the board, waiting on its yes - send func(msg tea.Msg) width, height int } @@ -562,26 +561,25 @@ func (m *workBoardModel) beginAction(verb workBoardVerb, id string) tea.Cmd { } base, token := m.base, m.token m.action = workBoardAction{verb: verb, id: id, since: workBoardNow()} - // The repaint chain that animates the spinner runs through the - // program: this message restarts it, and its handler keeps it going - // while a call is out. Driven directly, as a test drives it, there is - // nothing to animate and no chain starts. - if m.send != nil { - m.send(workBoardSpinMsg(workBoardNow())) - } + // The repaint chain that animates the spinner is scheduled as a + // command, not pushed through the program's Send: a Send from inside + // Update deadlocks the loop — it cannot read a message until Update + // returns, and Update is the one sending. The tick handler keeps + // the chain restacking while the call is out. + var run tea.Cmd switch verb { case workAbort: - return func() tea.Msg { + run = func() tea.Msg { _, err := workRequest(base, token, "POST", "/v1/items/"+url.PathEscape(id)+"/abort", nil) return workBoardActionMsg{verb: verb, id: id, err: err} } case workRemove: - return func() tea.Msg { + run = func() tea.Msg { _, err := workRequest(base, token, "DELETE", "/v1/items/"+url.PathEscape(id), nil) return workBoardActionMsg{verb: verb, id: id, err: err} } } - return nil + return tea.Batch(run, workBoardSpinCmd()) } // beginAdd sends the form's item to the API's own add path. The form @@ -595,13 +593,11 @@ func (m *workBoardModel) beginAdd(body workAddBody) tea.Cmd { } base, token := m.base, m.token m.action = workBoardAction{verb: workAdd, id: body.ID, since: workBoardNow()} - if m.send != nil { - m.send(workBoardSpinMsg(workBoardNow())) - } - return func() tea.Msg { + run := func() tea.Msg { _, err := workRequest(base, token, "POST", "/v1/items", body) return workBoardActionMsg{verb: workAdd, id: body.ID, err: err} } + return tea.Batch(run, workBoardSpinCmd()) } // workBoardActionLine is the status line's account of a finished action: diff --git a/cmd/spinloop/work_board_test.go b/cmd/spinloop/work_board_test.go index ad53caef..b127de38 100644 --- a/cmd/spinloop/work_board_test.go +++ b/cmd/spinloop/work_board_test.go @@ -235,28 +235,35 @@ func wbKeys(t *testing.T, m *workBoardModel, keys ...string) tea.Cmd { // wbRound runs one read of the list to its answer and folds it in. func wbRound(t *testing.T, m *workBoardModel) { t.Helper() - cmd := m.startRound() - if cmd == nil { - t.Fatal("no round could start (busy?)") - } - m.Update(cmd()) + wbLand(t, m, m.startRound()) } -// wbAct presses the keys that set an action off, runs the call to its -// answer, and runs the follow-up read the answer triggers — the whole -// round trip the program would play. -func wbAct(t *testing.T, m *workBoardModel, keys ...string) { +// wbLand lands the call a key set off: the answer is folded in, and the +// read the answer kicks is landed too — the round trip the program would +// play. An action's command is a batch when it also starts the spinner's +// repaint chain: the call is the chain's first command, and its timer is +// left unrun — a test would only wait on it. +func wbLand(t *testing.T, m *workBoardModel, cmd tea.Cmd) { t.Helper() - cmd := wbKeys(t, m, keys...) if cmd == nil { - t.Fatal("the keys started no call") + t.Fatal("no call to land (busy?)") } - _, next := m.Update(cmd()) + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + msg = batch[0]() + } + _, next := m.Update(msg) if next != nil { m.Update(next()) } } +// wbAct is press-and-land: keys, then the call they started. +func wbAct(t *testing.T, m *workBoardModel, keys ...string) { + t.Helper() + wbLand(t, m, wbKeys(t, m, keys...)) +} + var wbANSI = regexp.MustCompile("\x1b\\[[0-9;]*m") func wbPlain(s string) string { return wbANSI.ReplaceAllString(s, "") } @@ -330,18 +337,16 @@ func TestWorkBoard_ActionProgressAndSpinChain(t *testing.T) { a := newWBAPI(t, []orchestrator.ItemView{wbItem("crank", orchestrator.StateRunning)}, nil) m := newWBTestModel(t, a) wbRound(t, m) - var sent []tea.Msg - m.send = func(msg tea.Msg) { sent = append(sent, msg) } wbKeys(t, m, "right") cmd := wbKeys(t, m, "a") // the call goes out and stays unanswered if cmd == nil { t.Fatal("the abort started nothing") } - if len(sent) != 1 { - t.Fatalf("the action woke the repaint with %d messages", len(sent)) - } - if _, ok := sent[0].(workBoardSpinMsg); !ok { - t.Errorf("the action sent %T, want the spin message", sent[0]) + // The repaint chain rides a command alongside the call — never the + // program's Send, which from inside Update would deadlock the loop. + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("the abort is %T, want a two-part batch", cmd()) } // While the call is out the chain keeps going… _, cmd = m.Update(workBoardSpinMsg{}) @@ -379,7 +384,7 @@ func TestWorkBoard_SecondActionWhileOneIsOutSendsNothing(t *testing.T) { if !strings.Contains(m.statusLine, "still aborting") { t.Errorf("status = %q, want the still-busy line", m.statusLine) } - m.Update(cmd()) + wbLand(t, m, cmd) } func TestWorkBoard_GeometryKeepsItsFloors(t *testing.T) { @@ -472,10 +477,7 @@ func TestWorkBoard_PriorityThatCouldNotParseIsCaughtBeforeTheAPI(t *testing.T) { if cmd == nil { t.Fatal("the corrected send went nowhere") } - _, next := m.Update(cmd()) - if next != nil { - m.Update(next()) - } + wbLand(t, m, cmd) if a.callCount("POST /v1/items") != 1 { t.Error("the corrected send did not reach the API") } @@ -758,7 +760,7 @@ func TestWorkBoard_RReadsAtOnce(t *testing.T) { if cmd == nil { t.Fatal("r started no round") } - m.Update(cmd()) + wbLand(t, m, cmd) if a.callCount("GET /v1/items") != before+1 { t.Error("r did not ask the API at once") } @@ -1034,10 +1036,7 @@ func TestWorkBoard_FormAddsEndToEnd(t *testing.T) { if cmd == nil { t.Fatal("the last enter sent nothing") } - _, next := m.Update(cmd()) - if next != nil { - m.Update(next()) // the read the accepted add kicks - } + wbLand(t, m, cmd) if a.callCount("POST /v1/items") != 1 { t.Fatal("the send reached no POST") } @@ -1067,10 +1066,7 @@ func TestWorkBoard_FormRefusalIsCorrectedInPlace(t *testing.T) { if cmd == nil { t.Fatal("the first enter sent nothing") } - _, next := m.Update(cmd()) - if next != nil { - m.Update(next()) // the refused send still spends its follow-up read - } + wbLand(t, m, cmd) // a refused send still spends its follow-up read if !m.formOpen { t.Fatal("a refusal closed the form") } @@ -1085,10 +1081,7 @@ func TestWorkBoard_FormRefusalIsCorrectedInPlace(t *testing.T) { if cmd == nil { t.Fatal("the second enter sent nothing") } - _, next = m.Update(cmd()) - if next != nil { - m.Update(next()) - } + wbLand(t, m, cmd) if m.formOpen { t.Error("the corrected send did not close the form") } @@ -1252,10 +1245,10 @@ func TestWorkBoard_ProgramSmoke(t *testing.T) { feed := newWBFeed(t, tm.Output()) feed.until(t, "Backlog 1") feed.until(t, "crank") - // The real loop now, not the test's hand: an abort rides the - // program's own send — the spin chain waking through prog.Send, - // the answer landing on the status line, the kicked read moving the - // card. No directly-driven test can prove that wiring. + // The real loop now, not the test's hand: an abort's call and its + // spinner tick ride a batch, which only the program itself expands + // — the answer lands on the status line and the kicked read moves + // the card through the very loop the binary runs. tm.Send(tea.KeyMsg{Type: tea.KeyRight}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) feed.until(t, `item "crank" stopped`) From 13f999658fb5b3982d233fabaa17de97b01de3b7 Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:33:40 +0100 Subject: [PATCH 3/6] fix(work): wrap the failure reason in the board's detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The why row was emitted as one line and clipped at the frame, so a long reason lost its tail — the part an operator opens the detail to read. It now wraps under the label's room, the red kept on every row; the log pane gives the height back through the same field count that sizes it. --- cmd/spinloop/work_board_render.go | 15 ++++++++++++-- cmd/spinloop/work_board_test.go | 34 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/cmd/spinloop/work_board_render.go b/cmd/spinloop/work_board_render.go index 46a7172a..4909db2f 100644 --- a/cmd/spinloop/work_board_render.go +++ b/cmd/spinloop/work_board_render.go @@ -487,8 +487,19 @@ func (m workBoardModel) detailFields() []string { add("node", v.Node) add("started", workBoardClock(v.StartedAt)) add("ended", workBoardClock(v.EndedAt)) - if v.State == orchestrator.StateFailed { - add("why", ansiRed+v.Why+ansiReset) + if v.State == orchestrator.StateFailed && v.Why != "" { + // The reason has something to say, unlike the one-line fields: + // the label carries its first line and the rest wraps beneath + // the label's room, its red kept on every row. + const indent = " " // as wide as "why ", the label's room + w := m.effWidth() - len(indent) + for i, line := range workBoardWrap(v.Why, w) { + if i == 0 { + out = append(out, label("why ")+ansiRed+line+ansiReset) + } else { + out = append(out, indent+ansiRed+line+ansiReset) + } + } } if m.readingAge(workBoardNow()) != "" { add("reading", dim.Render(m.readingAge(workBoardNow()))) diff --git a/cmd/spinloop/work_board_test.go b/cmd/spinloop/work_board_test.go index b127de38..1e538e13 100644 --- a/cmd/spinloop/work_board_test.go +++ b/cmd/spinloop/work_board_test.go @@ -13,6 +13,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" teatest "github.com/charmbracelet/x/exp/teatest" "github.com/spinloop-ai/spinloop/internal/orchestrator" @@ -807,6 +808,39 @@ func TestWorkBoard_DetailShowsTheWholeFailedItem(t *testing.T) { } } +func TestWorkBoard_DetailWrapsTheFailureReason(t *testing.T) { + failed := wbItem("bust", orchestrator.StateFailed) + // A reason far wider than the frame: its tail must still be readable, + // not chopped at the edge — the whole point of wrapping it. + failed.Why = "the agent gave up because " + strings.Repeat("the harness demanded more ", 20) + "ENDREASON" + a := newWBAPI(t, []orchestrator.ItemView{failed}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + lines := strings.Split(wbPlain(m.View()), "\n") + if !strings.Contains(strings.Join(lines, "\n"), "ENDREASON") { + t.Fatalf("the reason was cut off at the frame's edge:\n%s", strings.Join(lines, "\n")) + } + // The reason holds together on its own rows under the label, and no + // line overruns the frame it is clipped to. + var reasonRows int + for _, l := range lines { + if strings.HasPrefix(l, "why") || strings.HasPrefix(l, " ") { + if strings.Contains(l, "harness demanded") || strings.Contains(l, "agent gave up") || strings.Contains(l, "ENDREASON") { + reasonRows++ + } + } + } + if reasonRows < 2 { + t.Errorf("the reason did not wrap across rows (saw %d):\n%s", reasonRows, strings.Join(lines, "\n")) + } + for _, l := range lines { + if n := lipgloss.Width(l); n > m.effWidth() { + t.Errorf("a detail line ran to %d columns, past the frame of %d:\n%q", n, m.effWidth(), l) + } + } +} + func TestWorkBoard_DetailEscReturnsAndRefusesQuit(t *testing.T) { a := newWBAPI(t, []orchestrator.ItemView{wbItem("bust", orchestrator.StateFailed)}, nil) m := newWBTestModel(t, a) From 86d654c0b1b892c0c304eb0375074b6e54563a21 Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:39:12 +0100 Subject: [PATCH 4/6] fix(work): keep a tall detail inside the board's frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instructions wrapped to the width but nothing capped the field section's height: a long enough instruction pushed the log pane and the esc-back footer past the terminal's bottom edge, unreachable in a view without scrolling. The fields now answer the same discipline the log pane already kept — keep what fits, and a dim note counts the rows left behind rather than hiding them unseen. One detailLayout computation feeds the view and the tail's buffer, so neither can hold what the frame cannot draw. --- cmd/spinloop/work_board_render.go | 46 +++++++++++++++++++++++-------- cmd/spinloop/work_board_test.go | 26 +++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/cmd/spinloop/work_board_render.go b/cmd/spinloop/work_board_render.go index 4909db2f..4d8504fc 100644 --- a/cmd/spinloop/work_board_render.go +++ b/cmd/spinloop/work_board_render.go @@ -429,19 +429,28 @@ func (m workBoardModel) boardKeys() string { // detailView is the full-screen item: the fields the reading carries, the // instructions whole, and the kept log tailed into what remains. +// detailLayout is the detail's one arithmetic: the fields that fit the +// frame, and the log rows the rest of the frame can show. The view and +// the tail both ask here, so the buffer never holds lines the pane +// could not draw. +func (m workBoardModel) detailLayout() (fields []string, logAvail int) { + fields = workBoardFitFields(m.detailFields(), m.effHeight()) + logAvail = m.effHeight() - 5 - len(fields) + if logAvail < 1 { + logAvail = 1 + } + return fields, logAvail +} + func (m workBoardModel) detailView() string { - w, h := m.effWidth(), m.effHeight() + w := m.effWidth() v := m.detailItem - fields := m.detailFields() + fields, logAvail := m.detailLayout() state := workListColouredState(v.State, true) header := dashTitleBar("work board · "+v.ID, state, w) divider := strings.Repeat("─", w) - logAvail := h - 5 - len(fields) // header, divider, divider, divider, footer - if logAvail < 1 { - logAvail = 1 - } logLines := strings.Split(strings.TrimRight(m.detailLog, "\n"), "\n") if m.detailLog == "" { note := m.detailNote @@ -509,15 +518,30 @@ func (m workBoardModel) detailFields() []string { return out } +// workBoardFitFields keeps the field section inside the frame. The log +// pane already trims itself to what it can show; the fields owe the +// terminal the same honesty — what fits is drawn, and a dim note counts +// the rows the frame left behind rather than letting them pile past the +// bottom edge unseen. +func workBoardFitFields(fields []string, h int) []string { + room := h - 6 // header, three dividers, a line of log, footer + if room < 1 { + room = 1 + } + if len(fields) <= room { + return fields + } + dim := lipgloss.NewStyle().Foreground(lipgloss.Color(brandInkDim)) + note := dim.Render("⋯ +" + fmt.Sprintf("%d", len(fields)-(room-1)) + " lines") + return append(append([]string{}, fields[:room-1]...), note) +} + // detailCapacity is how many log lines the pane can show — the same // figure the tail trims its buffer to, so the buffer never holds what // the view could never draw. func (m workBoardModel) detailCapacity() int { - h := m.effHeight() - 5 - len(m.detailFields()) - if h < 1 { - return 1 - } - return h + _, logAvail := m.detailLayout() + return logAvail } // formFieldWidth is the width each textinput is given inside the form: diff --git a/cmd/spinloop/work_board_test.go b/cmd/spinloop/work_board_test.go index 1e538e13..8a3d2b14 100644 --- a/cmd/spinloop/work_board_test.go +++ b/cmd/spinloop/work_board_test.go @@ -808,6 +808,32 @@ func TestWorkBoard_DetailShowsTheWholeFailedItem(t *testing.T) { } } +func TestWorkBoard_DetailKeepsTallInstructionsInsideTheFrame(t *testing.T) { + big := wbItem("bust", orchestrator.StateFailed) + big.Instructions = strings.Repeat("sentence that keeps going and going. ", 120) + "ENDSTOP" + a := newWBAPI(t, []orchestrator.ItemView{big}, nil) + m := newWBTestModel(t, a) + wbRound(t, m) + wbKeys(t, m, "right", "right", "right", "enter") + view := wbPlain(m.View()) + lines := strings.Split(view, "\n") + // Nothing may fall past the bottom edge unseen: the frame closes at + // the terminal's height, footer and all. + if len(lines) != m.effHeight() { + t.Errorf("the detail drew %d lines in a frame of %d:\n%s", len(lines), m.effHeight(), view) + } + for _, want := range []string{"esc back", "./bust", "⋯ +"} { + if !strings.Contains(view, want) { + t.Errorf("detail missing %q — the frame did not close honestly:\n%s", want, view) + } + } + // What does not fit is counted, not silently shown in part: the + // tail of the instructions yields to the note. + if strings.Contains(view, "ENDSTOP") { + t.Error("the overflow past the note still showed its tail") + } +} + func TestWorkBoard_DetailWrapsTheFailureReason(t *testing.T) { failed := wbItem("bust", orchestrator.StateFailed) // A reason far wider than the frame: its tail must still be readable, From 073609a315fba6344ad3607bf95fc8e7f7ce5656 Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:47:04 +0100 Subject: [PATCH 5/6] docs(openspec): fold the board's fixes into the spec and archive the change The detail's contract now says what the pane actually owes: instructions and failure reason wrapped to the frame rather than clipped, a record taller than the terminal truncated honestly with a note counting the rows left behind, and the keys kept on screen. design and tasks describe the shipped Batch pattern, not the prog.Send chain that deadlocked the loop; internals records that trap for the next TUI. --- docs/maintainer/internals.md | 2 + .../.openspec.yaml | 0 .../2026-09-26-add-work-board-tui}/design.md | 16 +- .../proposal.md | 0 .../specs/work-board/spec.md | 20 +- .../specs/work-commands/spec.md | 0 .../2026-09-26-add-work-board-tui}/tasks.md | 4 +- openspec/specs/work-board/spec.md | 274 ++++++++++++++++++ openspec/specs/work-commands/spec.md | 24 +- 9 files changed, 317 insertions(+), 23 deletions(-) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/.openspec.yaml (100%) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/design.md (93%) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/proposal.md (100%) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/specs/work-board/spec.md (92%) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/specs/work-commands/spec.md (100%) rename openspec/changes/{add-work-board-tui => archive/2026-09-26-add-work-board-tui}/tasks.md (94%) create mode 100644 openspec/specs/work-board/spec.md diff --git a/docs/maintainer/internals.md b/docs/maintainer/internals.md index f5a263e8..510972b9 100644 --- a/docs/maintainer/internals.md +++ b/docs/maintainer/internals.md @@ -43,6 +43,8 @@ These are mistakes already made here; each was silent rather than loud, which is **`internal/daemon` must not depend on a cloud package.** What an engine should serve is `inference.DeployConfig`, in `internal/inference` — a leaf that imports only the standard library. Both the daemon and the cloud control plane speak it, so neither has to import the other to be described: a daemon is handed one over its control API, and `internal/remote` persists one against an environment. The dependency used to run the other way, `internal/daemon` importing `internal/remote` for the type and for a config-directory helper that only forwarded to `internal/config.Dir`, which made the package that knows nothing about AWS depend on the package that is nothing but AWS. Keep new shared vocabulary in `internal/inference` only where it describes an engine's workload and needs nothing of ours to express; anything cloud-shaped — `IsInstanceType` and the rest of the EC2 vocabulary — stays in `internal/remote`. +**Calling `prog.Send` from inside `Update` deadlocks the program.** The event loop reads its own channel on the goroutine that calls `Update`, so a command that hands a message back through `prog.Send` while `Update` is on the stack waits for a reader that is waiting for it. The board hung on exactly this: pressing Enter on the form's last field, or `a`/`x` on a card, and the whole TUI went still — no spinner, no keystrokes, Ctrl-C ignored. Every message from work that starts inside `Update` must *return* through the loop instead: `tea.Batch(workCmd, spinCmd)` delivers each command's messages normally, and a repaint chain re-arms through its own returned `tea.Tick`. The tests guard this by landing commands through the real program loop, not by calling `Update` and discarding the command. + ## Dashboard (`fleet_dashboard.go` and friends) A few Bubble Tea/lipgloss specifics that are easy to break by "simplifying": diff --git a/openspec/changes/add-work-board-tui/.openspec.yaml b/openspec/changes/archive/2026-09-26-add-work-board-tui/.openspec.yaml similarity index 100% rename from openspec/changes/add-work-board-tui/.openspec.yaml rename to openspec/changes/archive/2026-09-26-add-work-board-tui/.openspec.yaml diff --git a/openspec/changes/add-work-board-tui/design.md b/openspec/changes/archive/2026-09-26-add-work-board-tui/design.md similarity index 93% rename from openspec/changes/add-work-board-tui/design.md rename to openspec/changes/archive/2026-09-26-add-work-board-tui/design.md index eaa2a566..6b576277 100644 --- a/openspec/changes/add-work-board-tui/design.md +++ b/openspec/changes/archive/2026-09-26-add-work-board-tui/design.md @@ -43,9 +43,11 @@ Cobra command (registered on `workCmd()` beside its siblings, `--url` and token flags via `workAPIFlags`, `Args: cobra.NoArgs`), checks `term.IsTerminal(os.Stdout.Fd())` before anything else — refusing and naming `spinloop work list`, the way `runFleetDashboard` names -`fleet metrics --watch` — and runs `tea.NewProgram(&m, tea.WithAltScreen())` -with `m.send = prog.Send` so worker goroutines can feed messages. -`work_board_model.go` holds the model, ticks and keys; +`fleet metrics --watch` — and runs `tea.NewProgram(&m, tea.WithAltScreen())`. Background work +reaches the loop only through the `tea.Cmd`s `Update` returns — the model +carries no `prog.Send` handle, because calling `Send` from inside +`Update` deadlocks the event loop (the loop reads its own channel on the +`Update` goroutine). `work_board_model.go` holds the model, ticks and keys; `work_board_render.go` the drawing. Alternative: one file — rejected for the same reason the dashboard's three exist: the renderers are byte-tested in isolation. @@ -92,9 +94,11 @@ that arrive after the pane closed or reopened on another item. Escape returns; the detail pane offers no quit, as the dashboard detail does not. **Actions ride the model's action slot, not the tick.** `a` and `x` call -`beginAction`-style goroutines through `m.send`: status line "aborting -item X…" plus the one spinner (`spinnerFrame`) on a 100ms repaint chain -while the call is in flight — abort can block server-side for the stop +`beginAction`, which returns `tea.Batch(workCmd, workBoardSpinCmd())`: +status line "aborting item X…" plus the one spinner (`spinnerFrame`) on a +100ms repaint chain. The spin chain re-arms through the Batch, never +through `prog.Send` from inside `Update` — that deadlocks the loop. This +runs while the call is in flight — abort can block server-side for the stop grace, and the cli-ux long-operation rule wants motion. `workRequestBound` (30s) bounds the call; a timeout lands on the status line as a fault and the board goes on drawing. Refusals come back as `workAPIErr` and are diff --git a/openspec/changes/add-work-board-tui/proposal.md b/openspec/changes/archive/2026-09-26-add-work-board-tui/proposal.md similarity index 100% rename from openspec/changes/add-work-board-tui/proposal.md rename to openspec/changes/archive/2026-09-26-add-work-board-tui/proposal.md diff --git a/openspec/changes/add-work-board-tui/specs/work-board/spec.md b/openspec/changes/archive/2026-09-26-add-work-board-tui/specs/work-board/spec.md similarity index 92% rename from openspec/changes/add-work-board-tui/specs/work-board/spec.md rename to openspec/changes/archive/2026-09-26-add-work-board-tui/specs/work-board/spec.md index d69c9561..a2f63a70 100644 --- a/openspec/changes/add-work-board-tui/specs/work-board/spec.md +++ b/openspec/changes/archive/2026-09-26-add-work-board-tui/specs/work-board/spec.md @@ -100,8 +100,13 @@ brand accent. The operator SHALL move the selection between cards with the arrow keys — between columns sideways, within a column up and down — and the board SHALL mark the selected card with the brand accent alone. Enter SHALL open that -item's detail: its instructions in full, its directory, its tags, every -time its record carries, and, where it failed, the reason. While the detail +item's detail: its instructions — wrapped to the frame's width rather than +clipped — its directory, its tags, every time its record carries, and, +where it failed, the reason, likewise wrapped rather than cut at the +edge. The detail SHALL keep its frame inside the terminal: where a record +is taller than the room the frame has, the fields show what fits and a +note counts the rows left behind, and the keys offered there stay +reachable on screen. While the detail stands open the command SHALL keep asking the API for the item's kept output and show whatever arrives, an item with no output yet shown as empty rather than as a fault; once the item has ended, or dropped out of the @@ -114,7 +119,16 @@ there are only those that do something there. - **WHEN** the operator opens the detail of a failed item with long instructions - **THEN** the full instructions, its directory, tags, timings and failure - reason are all shown + reason are all shown, each wrapped to the frame where it is wider than + the frame is wide + +#### Scenario: A record taller than the terminal is truncated honestly + +- **WHEN** the operator opens the detail of an item whose instructions fill + more rows than the terminal has +- **THEN** the frame closes inside the terminal with its keys on screen, + what does not fit is replaced by a note counting its rows, and nothing + falls past the bottom edge unseen #### Scenario: The open detail tails the kept output diff --git a/openspec/changes/add-work-board-tui/specs/work-commands/spec.md b/openspec/changes/archive/2026-09-26-add-work-board-tui/specs/work-commands/spec.md similarity index 100% rename from openspec/changes/add-work-board-tui/specs/work-commands/spec.md rename to openspec/changes/archive/2026-09-26-add-work-board-tui/specs/work-commands/spec.md diff --git a/openspec/changes/add-work-board-tui/tasks.md b/openspec/changes/archive/2026-09-26-add-work-board-tui/tasks.md similarity index 94% rename from openspec/changes/add-work-board-tui/tasks.md rename to openspec/changes/archive/2026-09-26-add-work-board-tui/tasks.md index 78cba677..65db9c64 100644 --- a/openspec/changes/add-work-board-tui/tasks.md +++ b/openspec/changes/archive/2026-09-26-add-work-board-tui/tasks.md @@ -4,7 +4,7 @@ - [x] 1.1 Create `cmd/spinloop/work_board.go`: `workBoardCmd()` with `workAPIFlags`, `Args: cobra.NoArgs`, a lowercase imperative short/long help in the family's voice; register it on `workCmd()` in `work.go` beside its siblings. - [x] 1.2 Gate on `term.IsTerminal(os.Stdout.Fd())` before anything else: refuse with an error naming `spinloop work list` as the pipe's command, tested through `cmdWork` under `captureStdout`. -- [x] 1.3 Start the program: `tea.NewProgram(&m, tea.WithAltScreen())`, `m.send = prog.Send`; wire `--url`/token through `workTarget` so a missing `--url` fails naming the flag before any call. +- [x] 1.3 Start the program: `tea.NewProgram(&m, tea.WithAltScreen())` — no `prog.Send` handle on the model, background work returns through the `tea.Cmd`s `Update` yields; wire `--url`/token through `workTarget` so a missing `--url` fails naming the flag before any call. ## 2. Model: reads and ticks @@ -26,7 +26,7 @@ ## 5. Actions and status line -- [x] 5.1 `beginAction`-style in-flight slot: `a` aborts a running card, `x` removes a selected non-running card, each calling the API's path through `workRequest` in a goroutine feeding `m.send`. +- [x] 5.1 `beginAction`-style in-flight slot: `a` aborts a running card, `x` removes a selected non-running card, each calling the API's path through `workRequest` in a `tea.Cmd` goroutine batched with the spinner chain (`tea.Batch`, fed back through the queue — not `prog.Send` from `Update`). - [x] 5.2 Status line underway while in flight with `spinnerFrame` on a repaint chain; answer (or `workAPIErr` refusal, verbatim; or the 30s bound's fault) lands on the status line and the board keeps drawing. - [x] 5.3 Removal confirm in the footer: `y` sends, `n`/`esc`/anything else declines, defaulting to no; a declined question sends nothing and says so. diff --git a/openspec/specs/work-board/spec.md b/openspec/specs/work-board/spec.md new file mode 100644 index 00000000..596b1d76 --- /dev/null +++ b/openspec/specs/work-board/spec.md @@ -0,0 +1,274 @@ +# work-board Specification + +## Purpose +Watches a running orchestrator's work list as a full-screen kanban board — +one column per state, a card per item, kept current as the run works — and +lets the operator add, abort, remove and read items, all through the same +work list API the one-shot work commands use. +## Requirements +### Requirement: The board is a full-screen view of the run's work + +`spinloop work board` SHALL open an interactive, full-screen view of the +work list the named API holds. Everything the board draws and does — every +column, card, action and refusal — SHALL come from the work list API: the +board SHALL NOT read or write the items file, the state, or the logs +directly, and SHALL NOT take any lock beside them. With no terminal to draw +on it SHALL refuse before drawing anything, naming `spinloop work list` as +the command that carries the same information into a pipe. + +#### Scenario: The board opens on the run + +- **WHEN** the operator opens the board naming a running orchestrator's + work list API +- **THEN** the screen shows the run's whole work: every item the list + carries, as it stands at that moment + +#### Scenario: A cold run is openable + +- **WHEN** the operator opens the board against a run whose items file is + empty +- **THEN** the board opens with its empty columns drawn and stays usable, + rather than faulting + +#### Scenario: A piped run is refused + +- **WHEN** the operator runs `work board` with its output piped +- **THEN** it fails before drawing anything, naming `spinloop work list` as + the pipe's command + +### Requirement: Columns are states and cards are items + +The board SHALL draw exactly four columns, standing in this order — +Backlog, Running, Done, Failed — and every item the list carries SHALL +appear as a card in exactly the one column its state names; an item the run +records not at all is backlog. Within a column the cards SHALL stand in the +order the API listed them, and each column heading SHALL name itself and +count its cards. A column fuller than the screen is tall SHALL show the +selected card's neighbourhood rather than drop cards silently. + +#### Scenario: Every item stands in its state's column + +- **WHEN** the run holds items recorded running, done and failed beside + ones with no record +- **THEN** the first stands under Running, the rest under Done, Failed and + Backlog, and each heading shows its count + +#### Scenario: A card moves as the run works + +- **WHEN** the board is open and a running item finishes between two + refreshes +- **THEN** its card stands under Done, with no key pressed + +#### Scenario: More cards than the screen is tall + +- **WHEN** a column holds more items than fit the screen +- **THEN** the cards around the selected one are shown, and moving the + selection brings the others into view + +### Requirement: The card tells the item's situation + +Each card SHALL show its item's id and its instructions, clipped to the +card's width rather than wrapped, and its priority where the item has one. +A running card SHALL also show the node it runs on and the time since it +started, counted up as it is watched. A card's state SHALL be told by the +colour that reports it — the amber of running, the green of done, the red +of failed — as the work list colours the same state, and never by the +brand accent. + +#### Scenario: Long instructions are clipped, not wrapped + +- **WHEN** an item's instructions are longer than its card is wide +- **THEN** the card shows their beginning, clipped, and the card keeps its + shape + +#### Scenario: A running card counts up + +- **WHEN** the operator watches a running card without pressing anything +- **THEN** the time since that item started grows, and the board keeps + drawing + +#### Scenario: States keep the tool's state colours + +- **WHEN** the board draws running, done and failed cards on a terminal + that takes colour +- **THEN** each carries the colour the work list gives that state, and no + card is coloured with the accent for its state + +### Requirement: The selection and the item's detail + +The operator SHALL move the selection between cards with the arrow keys — +between columns sideways, within a column up and down — and the board SHALL +mark the selected card with the brand accent alone. Enter SHALL open that +item's detail: its instructions — wrapped to the frame's width rather than +clipped — its directory, its tags, every time its record carries, and, +where it failed, the reason, likewise wrapped rather than cut at the +edge. The detail SHALL keep its frame inside the terminal: where a record +is taller than the room the frame has, the fields show what fits and a +note counts the rows left behind, and the keys offered there stay +reachable on screen. While the detail +stands open the command SHALL keep asking the API for the item's kept +output and show whatever arrives, an item with no output yet shown as empty +rather than as a fault; once the item has ended, or dropped out of the +list, the tailing SHALL stop. Escape SHALL return to the board; while a +detail stands open the board SHALL not be quit from, and the keys offered +there are only those that do something there. + +#### Scenario: The detail shows the whole item + +- **WHEN** the operator opens the detail of a failed item with long + instructions +- **THEN** the full instructions, its directory, tags, timings and failure + reason are all shown, each wrapped to the frame where it is wider than + the frame is wide + +#### Scenario: A record taller than the terminal is truncated honestly + +- **WHEN** the operator opens the detail of an item whose instructions fill + more rows than the terminal has +- **THEN** the frame closes inside the terminal with its keys on screen, + what does not fit is replaced by a note counting its rows, and nothing + falls past the bottom edge unseen + +#### Scenario: The open detail tails the kept output + +- **WHEN** the detail of a running item stands open and its agent writes + more output +- **THEN** the detail shows the new output as the polls see it + +#### Scenario: An item with no kept output is empty, not a fault + +- **WHEN** the operator opens the detail of an item the run has kept no + output for +- **THEN** its log pane shows empty and the board goes on drawing + +#### Scenario: The way back is Escape + +- **WHEN** a detail stands open and the operator presses escape +- **THEN** the board returns, the selection on the card it was on, and + pressing quit there quits the board + +### Requirement: The board acts through the work list API + +The board SHALL offer the work list API's actions on the selected +item: a key that aborts a running item and a key that removes one that is +not running. A removal SHALL ask on screen for confirmation before it is +sent, defaulting to not proceeding, a declined or abandoned question +sending nothing and saying so. An accepted action SHALL be shown underway +on the status line until the API answers, and the answered action's effect +— the card moving, the card leaving — SHALL follow on the next read that +sees it. Where the API refuses — aborting what is not running, removing a +running item, an id the list no longer carries — the board SHALL show the +refusal on its status line, worded the way the API states it, and keep +drawing. While an action is underway the board SHALL keep a spinner moving, +and the keys it names on screen SHALL be only those that would do +something to what is selected. + +#### Scenario: A running card is aborted + +- **WHEN** the operator aborts a running item's card and the API stops it +- **THEN** the status line says it is stopped and the card stands under + Backlog once the next read sees it + +#### Scenario: A removal asks first + +- **WHEN** the operator asks to remove a backlog card +- **THEN** the board asks, and nothing is sent until yes is given + +#### Scenario: A declined removal sends nothing + +- **WHEN** the operator declines or abandons the removal question +- **THEN** nothing is sent, the board says so, and the card remains + +#### Scenario: A refusal keeps the board + +- **WHEN** an action the API refuses is attempted — a running item + removed, a backlog item aborted — or the API cannot be reached for it +- **THEN** the status line carries the refusal worded as the API states it, + or the fault, and the board keeps drawing + +### Requirement: The board adds an item + +The operator SHALL be able to add an item to the run's work list without +leaving the board: a key SHALL open a form over the board showing the +item's five fields — its id, instructions, working directory, tags, and +priority — marking the three the item cannot do without, and keeping the +field that takes keystrokes visibly the selected one. The form SHALL not +pause the board behind it: reads keep their cadence, and the selection +keeps its place until the form closes. Sending belongs to the work list +API — the same add path the one-shot `work add` sends — and the API SHALL +be the only judge of an item's shape: a refusal SHALL keep the form open, +carry the refusal into it worded the way the API states it, and leave the +operator to correct a field and send again. The one field the form SHALL +guard itself is the priority, which stands only as a number or as nothing. +Where the API accepts, the form SHALL close, the status line shall say the +item is added, and the board SHALL ask the list again at once so the new +card stands under Backlog. Escape SHALL send nothing: an empty form closes +and says nothing was added, and a form holding anything SHALL ask once +before discarding it, defaulting to keep. + +#### Scenario: An item is added end to end + +- **WHEN** the operator opens the form, fills its fields, and sends what + the API accepts +- **THEN** the form closes, the status line says the item is added, and + once the board has read again the new card stands under Backlog + +#### Scenario: A refusal is corrected in the form + +- **WHEN** the operator sends an id the list already carries +- **THEN** the form stays open carrying the refusal as the API states it, + and a second send after correcting the id closes it as added + +#### Scenario: The board lives behind the form + +- **WHEN** the form stands open and a running item ends between two reads +- **THEN** the board behind keeps drawing, and the card stands under Done + where the board was left when the form closes + +#### Scenario: An empty form closes on escape + +- **WHEN** the operator presses escape with nothing entered anywhere +- **THEN** the form closes, nothing is sent, and the board says nothing + was added + +#### Scenario: Typed text is discarded deliberately + +- **WHEN** the operator presses escape with text entered, and declines the + question that follows +- **THEN** the form stands where it was with its text, and nothing has + been sent + +#### Scenario: A non-numeric priority does not stand + +- **WHEN** the operator types text that is not a number into the priority + field +- **THEN** the keystroke does not enter the field, and no API call is + made about it + +### Requirement: The board keeps the run's company + +While the board stands open it SHALL re-read the work list at a fixed +cadence, and `r` SHALL ask for a read at once. Where an API call fails, +the board SHALL NOT exit: it shall keep drawing its last reading, marked +with its age and no longer presented as the present state of anything, and +keep asking until the API answers again. The operator's interrupt, or quit, +SHALL end the board cleanly, restoring the terminal. + +#### Scenario: A dropped API does not drop the board + +- **WHEN** the orchestrator stops answering while the board stands open +- **THEN** the board keeps drawing its last reading, shown with its age, + and recovers without a key press once the API answers again + +#### Scenario: Refresh now + +- **WHEN** the operator presses `r` +- **THEN** the board asks the API again at once, without waiting for the + next tick + +#### Scenario: Quit restores the terminal + +- **WHEN** the operator quits the board +- **THEN** the command ends cleanly and the terminal is the terminal that + was there before + diff --git a/openspec/specs/work-commands/spec.md b/openspec/specs/work-commands/spec.md index 9877d014..9f756ad4 100644 --- a/openspec/specs/work-commands/spec.md +++ b/openspec/specs/work-commands/spec.md @@ -4,21 +4,20 @@ Work the items of a running orchestrator from the shell — add an item, read the backlog, stop a running item, remove an item — as a client of the orchestrator's work list API: the commands name the API's address, present its token, and the run's view of the items is the source of truth. - ## Requirements - ### Requirement: The work commands as work list clients -`spinloop work` SHALL be a top-level command group with the subcommands add, -list, abort, remove and logs, each a client of the orchestrator's work list API. Every -subcommand SHALL take a `--url` flag naming the API's base address, and SHALL -present the API's token as a bearer on every request it makes — resolved from -`--api-token`, else `--api-token-file`, else the `SPINLOOP_API_TOKEN` -environment variable, two of the flags given at once being a refusal naming -both. A subcommand that names no `--url` SHALL fail before it calls the API, -naming the flag. The commands SHALL be clients of the API alone: they SHALL NOT -read or write the items file, the state, or the logs directly, and SHALL NOT -take any lock beside them. +`spinloop work` SHALL be a top-level command group with the subcommands +add, list, abort, remove, logs and board, each a client of the +orchestrator's work list API. Every subcommand SHALL take a `--url` flag +naming the API's base address, and SHALL present the API's token as a +bearer on every request it makes — resolved from `--api-token`, else +`--api-token-file`, else the `SPINLOOP_API_TOKEN` environment variable, +two of the flags given at once being a refusal naming both. A subcommand +that names no `--url` SHALL fail before it calls the API, naming the flag. +The commands SHALL be clients of the API alone: they SHALL NOT read or +write the items file, the state, or the logs directly, and SHALL NOT take +any lock beside them. #### Scenario: The API's address is named @@ -233,3 +232,4 @@ in: the API's call is the whole ask, and it answers once the item is out. - **WHEN** the operator removes an id the items file does not carry - **THEN** the API refuses, naming the id, and the command fails naming it + From 03d6081d58458a633adf45356d4b396ec9933fd8 Mon Sep 17 00:00:00 2001 From: spinloop-agent Date: Sat, 26 Sep 2026 01:50:52 +0100 Subject: [PATCH 6/6] chore: nudge CI on the docs-only head