Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,53 @@ Notes:
`k` stays bound to kill per FR-TUI-05, so list navigation uses arrow keys (the
htop default) rather than vi keys.

## Layout & visual language (FR-TUI-P)

```
+-----------------------------------------------------------------------------+
| [DevAgent * RUNNING · daemon:embedded] <- title bar |
| queue [##--------] 2p/1c/3d - runs 1a - activity(5s) ... <- metric strip |
| * TASK-abc * working - 5m |
| worker omp - pulse [###---] <- hero (running work, or "next:" idle) |
| iteration 176 - phase: task - ... |
| +-- TASK-abc ---+ +-- TASK-xyz + <- cards 2-up at >=80 cols; |
| +---------------+ +-----------+ full-width stacked below 80 (P-08) |
| [n] goal [1] workers [2] sessions [3] log - ... [?] help [q] quit <- footer |
+-----------------------------------------------------------------------------+
```

- **Muted palette (FR-TUI-P-09)** - pilot's chart colors, no rainbow dumps:
running `#7eb8da` steel, ok/done `#7ec699` sage, failed `#d48a8a` rose,
stale/warn `#e0af68` amber, border `#3d4450` slate, accents gray. Applied
as truecolor when `COLORTERM` is `truecolor`/`24bit`; a 16-color fallback
(steel=cyan, sage=green, rose=red, amber=yellow, slate/gray=faint)
otherwise. Magenta is retired.
- **Hero (FR-TUI-P-05)** - one focused line for the running work: id, phase,
elapsed, worker, and an indeterminate pulse bar animated from the spinner
frame (no fabricated percent - the snapshot carries no fraction). When
idle it shows the next action instead.
- **Metric strip (FR-TUI-P-06)** - one dense row under the title bar: queue
meter + `Np/Nc/Nd`, live run counts, activity sparkline. `failed_recent`
is rendered as a historical `Nf recent` count (amber); it never paints the
aggregate state FAILED - only an open circuit breaker does (FR-TUI-P-03).
Uptime/herdr/visibility demote to the dim tail.
- **Redraw guarantees (FR-TUI-P-10)** - between interactive frames the
renderer diffs rows and rewrites only changed lines (identical frames
produce zero row writes, never a `2J` full clear). A terminal resize
re-probes the size on SIGWINCH (FR-TUI-P-04) and performs exactly one
sanctioned full clear - the only clears are initial entry, post-attach,
and reflow.
- **Attach-resume contract (FR-TUI-P-01)** - pressing `a` suspends the
dashboard (alternate screen left, raw mode drained) and hands the terminal
to the herdr attach child. Child exit - clean detach, nonzero exit, signal
death, or an environment crash - always resumes the dashboard: alternate
screen re-entered, full repaint, raw input re-armed, fresh poll. The child
dying never process-exits the TUI.
- **Footer & help (FR-TUI-P-12)** - the footer is one line whose hint folds
by width (full at 116+ cols, mid at 96+, compact at 62+, else help/quit)
so `[q] quit` is never clamped off. The `?` overlay keeps its bottom rows,
so the quit row is visible even at 24x80.

## The three views

- **Workers** — boxed worker cards (task id, status chip, elapsed, engine,
Expand All @@ -65,12 +112,12 @@ htop default) rather than vi keys.
follow-tail by default, ~1k-line ring buffer, auto-reconnect with
`Last-Event-ID` resume.

The header is shared by all views: an inverse title strip with the aggregate
`RUNNING/IDLE/FAILED` status and a braille spinner (animated only while work
is live), the iteration card (`iteration 82 · phase: task — …` from the newest
`loop-phase` ledger row), and a metrics line — uptime, active/failed runs, a
queue-depth meter, an activity sparkline sampled once per poll (~2 min window),
circuit state, herdr session, spawn visibility.
The header is shared by all views (see *Layout & visual language* above): an
inverse title strip with the aggregate `RUNNING/IDLE/FAILED` status and a
braille spinner (animated only while work is live), the dense metric strip,
the hero line (running work with an indeterminate pulse, or the next action
when idle), and the loop-phase row (`iteration 82 · phase: task — …` from the
newest `loop-phase` ledger row).

## Design notes — what was borrowed from the reference TUIs

Expand All @@ -95,7 +142,7 @@ daemon on port 0 and tears it down on every exit path).
| `internal/tui/tui.go` | views, overlays, key handling, interactive loop, one-shot mode, daemon resolution (attach/embed) |
| `internal/tui/transport.go` | bearer-token HTTP + SSE subscriber (reconnect, Last-Event-ID resume) |
| `internal/tui/input.go` | raw-stdin key decoding (arrows/PgUp/Home as whole escape sequences) |
| `internal/tui/frame.go` | incremental frame differ: rewrites only changed rows, never clears the screen |
| `internal/tui/frame.go` | incremental frame differ: rewrites only changed rows, never clears the screen (resize reflow full-clear is the loop's one sanctioned clear) |
| `internal/tui/viz.go` | sparkline, meter bar, log-line parse/format primitives |

Rendering pipeline: `renderLines()` builds a plain line array → `renderFrame()`
Expand Down
101 changes: 85 additions & 16 deletions internal/tui/ansi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,99 @@
// this package.
package tui

import "strings"
import (
"fmt"
"os"
"strings"
)

// ANSI colors: dim lines are the quiet majority (pilot-style dashboard).
// Byte-identical to the C block in src/tui/tui.ts.
const (
// Muted dashboard palette (FR-TUI-P-09, pilot's chart colors):
//
// running → steel #7eb8da ok → sage #7ec699
// fail → rose #d48a8a warn → amber #e0af68
// border → slate #3d4450 dim/accents → gray
//
// Truecolor when COLORTERM advertises it (truecolor / 24bit), a 16-color
// fallback otherwise. The exported names keep their historical slots so
// every call site (and the §20.8 human cards that share this package) keeps
// one visual language; only the resolved values differ per mode:
//
// Green → sage Red → rose Yellow → amber
// Cyan → accent (steel) Steel → running Border → slate
// Dim → gray Magenta is retired (rainbow color).
var (
Reset = "\x1b[0m"
Dim = "\x1b[2m"
Bold = "\x1b[1m"
Green = "\x1b[32m"
Yellow = "\x1b[33m"
Red = "\x1b[31m"
Cyan = "\x1b[36m"
Magenta = "\x1b[35m"
Steel = "\x1b[36m"
Border = "\x1b[2m"
Inverse = "\x1b[7m"
)

// StatusColor mirrors statusColor().
// TruecolorSGR renders a hex color (#rrggbb) as an SGR truecolor prefix.
func TruecolorSGR(hex string) string {
if len(hex) != 7 || hex[0] != '#' {
return ""
}
var r, g, b int
_, _ = fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
}

// applyPalette rebinds the SGR vars to the muted map for one color mode.
func applyPalette(truecolor bool) {
if truecolor {
Steel = TruecolorSGR("#7eb8da")
Green = TruecolorSGR("#7ec699")
Red = TruecolorSGR("#d48a8a")
Yellow = TruecolorSGR("#e0af68")
Cyan = TruecolorSGR("#7eb8da") // accent tracks the running steel
Dim = TruecolorSGR("#828a97")
Border = TruecolorSGR("#3d4450")
return
}
// 16-color fallback: nearest ANSI slots (steel ≈ cyan, amber ≈ yellow,
// slate ≈ bright black, gray ≈ faint).
Steel = "\x1b[36m"
Green = "\x1b[32m"
Red = "\x1b[31m"
Yellow = "\x1b[33m"
Cyan = "\x1b[36m"
Dim = "\x1b[2m"
Border = "\x1b[2m"
}

// paletteFor pins the palette for one env lookup (pure, testable).
func paletteFor(getenv func(string) string) bool {
return getenv("COLORTERM") == "truecolor" || getenv("COLORTERM") == "24bit"
}

func init() {
applyPalette(paletteFor(os.Getenv))
}

// SetTruecolor forces the palette mode (tests); the returned func restores
// the process-detected mode.
func SetTruecolor(on bool) (restore func()) {
prev := paletteFor(os.Getenv)
applyPalette(on)
return func() { applyPalette(prev) }
}

// StatusColor maps a state onto the muted palette.
func StatusColor(status string) string {
switch strings.ToLower(status) {
case "running":
return Steel
case "ok", "pass", "done":
return Green
case "idle":
case "stale", "warn":
return Yellow
case "stale":
return Magenta
case "failed":
case "failed", "fail":
return Red
default:
return Dim
Expand All @@ -48,24 +115,26 @@ func Truncate(s string, n int) string {
return string(r[:n-1]) + "…"
}

// DimText wraps s in the dim color.
// DimText wraps s in the dim (gray) color.
func DimText(s string) string { return Dim + s + Reset }

// CyanText wraps s in the cyan accent (attach hints and other §20.8
// CyanText wraps s in the muted accent (attach hints and other §20.8
// emphasis).
func CyanText(s string) string { return Cyan + s + Reset }

// ChipFor renders the status chip: colored dot + label, e.g. "● running"
// (Pilot-style).
// (Pilot-style), colored from the muted map.
func ChipFor(state string, label string) string {
var dot string
switch state {
case "running", "ok":
case "running":
dot = Steel
case "ok", "done":
dot = Green
case "failed":
dot = Red
case "stale":
dot = Magenta
case "stale", "warn":
dot = Yellow
default:
dot = Yellow
}
Expand Down
11 changes: 9 additions & 2 deletions internal/tui/ansi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ func TestChipFor(t *testing.T) {
if !strings.Contains(ok, Green+"●"+Reset) {
t.Fatalf("ok chip missing green dot: %q", ok)
}
if !strings.Contains(ok, Dim+"git"+Reset) {
t.Fatalf("ok chip label should be dim (statusColor default): %q", ok)
if !strings.Contains(ok, Green+"git"+Reset) {
t.Fatalf("ok chip label takes the state color (FR-TUI-P-09): %q", ok)
}
failed := ChipFor("failed", "worker")
if !strings.Contains(failed, Red+"●"+Reset) || !strings.Contains(failed, Red+"worker"+Reset) {
t.Fatalf("failed chip wrong: %q", failed)
}
stale := ChipFor("stale", "old")
if strings.Contains(stale, "\x1b[35m") {
t.Fatalf("magenta retired (rainbow color): %q", stale)
}
if !strings.Contains(stale, Yellow+"●"+Reset) {
t.Fatalf("stale chip must use amber, not magenta: %q", stale)
}
}

func TestTruncate(t *testing.T) {
Expand Down
13 changes: 7 additions & 6 deletions internal/tui/loop_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,13 @@ func newBufEnv() *bufEnv {

func (e *bufEnv) Out() io.Writer { return writerFunc(e.buf.Write) }

func (e *bufEnv) In() io.Reader { return e.in }
func (e *bufEnv) Size() (int, int) { return e.rows, e.cols }
func (e *bufEnv) EnterRaw() error { e.rawEnters++; return nil }
func (e *bufEnv) RestoreTerm() error { e.restores++; return nil }
func (e *bufEnv) Sigint() <-chan os.Signal { return nil }
func (e *bufEnv) screenLeft() bool { return strings.Contains(e.buf.String(), "\x1b[?1049l\x1b[?25h") }
func (e *bufEnv) In() io.Reader { return e.in }
func (e *bufEnv) Size() (int, int) { return e.rows, e.cols }
func (e *bufEnv) EnterRaw() error { e.rawEnters++; return nil }
func (e *bufEnv) RestoreTerm() error { e.restores++; return nil }
func (e *bufEnv) Sigint() <-chan os.Signal { return nil }
func (e *bufEnv) Sigwinch() <-chan os.Signal { return nil }
func (e *bufEnv) screenLeft() bool { return strings.Contains(e.buf.String(), "\x1b[?1049l\x1b[?25h") }
func (e *bufEnv) screenEntered() bool {
return strings.Contains(e.buf.String(), "\x1b[?1049h\x1b[?25l")
}
Expand Down
1 change: 1 addition & 0 deletions internal/tui/loop_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ func (nullEnv) EnterRaw() error { return nil }
func (nullEnv) RestoreTerm() error { return nil }
func (nullEnv) SuspendAttach(string, string, string) int { return 1 }
func (nullEnv) Sigint() <-chan os.Signal { return nil }
func (nullEnv) Sigwinch() <-chan os.Signal { return nil }

// Regression (issue #252 bring-up): the ticker calls AggregateStatus on
// every tick with whatever the last poll produced — including a snapshot
Expand Down
61 changes: 56 additions & 5 deletions internal/tui/loopimpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
SuspendAttach(paneID, taskID, repoPath string) int
// Sigint delivers external SIGINT (kill -INT, PTY teardown); may be nil.
Sigint() <-chan os.Signal
// Sigwinch delivers terminal resizes (FR-TUI-P-04); may be nil. On
// delivery the loop re-probes Size and repaints (one sanctioned full
// clear on width change).
Sigwinch() <-chan os.Signal
}

// NewLoop builds the interactive dashboard loop on the Loop seam. daemonMode
Expand Down Expand Up @@ -173,6 +177,28 @@
inputDone := make(chan struct{})
go l.inputLoop(inputDone)

// SIGWINCH (FR-TUI-P-04): re-probe the size and repaint promptly
// instead of waiting for the next poll. Repeated for every resize.
if winch := l.env.Sigwinch(); winch != nil {
go func() {
for {
select {
case <-l.quitCh:
return
case _, ok := <-winch:
if !ok {
return
}
l.mu.Lock()
if !l.suspended && !l.stopped {
l.safeDrawLocked()
}
l.mu.Unlock()
}
}
}()
}

// External SIGINT must quit cleanly even though raw mode turned ISIG
// off (kill -INT, PTY teardown).
if sig := l.env.Sigint(); sig != nil {
Expand Down Expand Up @@ -746,18 +772,35 @@
// so the child owns a clean interactive terminal.
_ = l.env.RestoreTerm()
_, _ = io.WriteString(l.env.Out(), "\x1b[?1049l\x1b[?25h")
code := l.env.SuspendAttach(pane.PaneID, pane.TaskID, l.repoPath())
// Resume: re-enter the alternate screen, redraw from scratch, re-arm
// raw input.
// FR-TUI-P-01: the child dying — nonzero exit, signal death, or a panic
// inside the env — must never process-exit the dashboard. Every path
// below runs the same resume: re-enter the alternate screen, redraw from
// scratch, re-arm raw input, refresh.
code := 1
attachNote := ""
func() {
defer func() {
if r := recover(); r != nil {
attachNote = fmt.Sprintf("attach crashed: %v", r)
}
}()
code = l.env.SuspendAttach(pane.PaneID, pane.TaskID, l.repoPath())
}()
_, _ = io.WriteString(l.env.Out(), "\x1b[?1049h\x1b[?25l")
_ = l.env.EnterRaw()
l.suspended = false
l.prevFrame = nil
if code == 0 {
switch {
case code == 0:
l.note = "detached from " + pane.TaskID
} else {
case code < 0:
l.note = fmt.Sprintf("attach child died (signal %d)", -code)
case attachNote != "":
l.note = attachNote
default:
l.note = fmt.Sprintf("attach exited (%d)", code)
}
l.safeDrawLocked()
l.pollNowLocked()
}

Expand Down Expand Up @@ -1042,6 +1085,14 @@
return sig
}

// Sigwinch subscribes to terminal resizes (FR-TUI-P-04) so the loop can
// re-probe the geometry and repaint promptly instead of at the next poll.
func (e *TermEnv) Sigwinch() <-chan os.Signal {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGWINCH)

Check failure on line 1092 in internal/tui/loopimpl.go

View workflow job for this annotation

GitHub Actions / windows-build

undefined: syscall.SIGWINCH

Check failure on line 1092 in internal/tui/loopimpl.go

View workflow job for this annotation

GitHub Actions / windows-cross

undefined: syscall.SIGWINCH
return sig
}

// SuspendAttach runs `herdr --session <s> agent attach <paneId>` with
// inherited stdio, exactly what `devagent attach <task> --exec` does
// (FR-VIS-02). The resolved pane id is recorded in the orchestration ledger
Expand Down
Loading
Loading