diff --git a/README.md b/README.md index bb8648a0..0f40ba51 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ driga inbox --since yesterday Service installation starts a per-user collector. The first import marks older events as already seen. Reading an inbox item never acknowledges it; use -`driga inbox ack ` or the Inbox controls in the TUI. +`driga inbox ack ` or the Flow Activity controls in the TUI. Use `driga web` for the local browser dashboard, `driga top --project .` to focus on the current project, and `driga --help` for command-specific flags. diff --git a/docs/specs/2026-10-23-flow-board-design.md b/docs/specs/2026-10-23-flow-board-design.md new file mode 100644 index 00000000..764071e1 --- /dev/null +++ b/docs/specs/2026-10-23-flow-board-design.md @@ -0,0 +1,107 @@ +# Flow — Final v2 + +Flow monitors stored agent work and resolves interruptions. The approved Final v2 +Map and Timeline replace the previous timeline board and family signal map. + +## Navigation and ownership + +Both interfaces show three destinations: **0 Flow**, **1 Projects**, **2 Machines**. +Flow opens on Map. The web segmented control and TUI `m` / `t` / `s` select Map, +Timeline and Sessions. In the terminal, Map uses the same project tree as Timeline. +`/` finds, arrows or `j`/`k` select, Enter opens context, and Esc returns. `M` changes +TUI machine scope. Sessions retains the existing sortable, searchable directory; +`S` sorts in the TUI. Projects owns repository/check-out evidence. Machines owns +health, process review and disk reclamation (`h` / `p` / `d` in the TUI). + +Chat is a contextual panel on `c`, with the current session, project and machine. +Esc closes it and retains the draft and origin. Wide terminals keep the destination +beside Chat; narrow terminals use the available reading width for Chat. CLI command +names and schema-2 session/family APIs are unchanged. + +Notification and retained event history live in Flow's expandable Activity section; +TUI `h` opens history. A completed turn is history, not a live wait or a session end. + +## Shared model and honest evidence + +`LoadBoardSnapshot` loads sessions, events, observation minutes, inbox spans and +session files in bulk for each scoped machine. `Board` projects Map and Timeline in +one pass, using a fixed last-hour display window. There is no per-session query loop. +Live waits, pending permissions and the state verdict produce one Needs you list, +oldest first; its length equals the waiting state count. Session identity includes +machine identity. Ended sessions cannot be revived by an old open inbox span. + +Only stored `ParentID` establishes a spawn edge. Same-file overlap requires the +same full stored path on the same machine, with both sessions still live. Equal +basenames and equal paths on different hosts do not establish overlap. A shared +file uses a deterministic star, avoiding quadratic all-pairs edges. + +Historical working slices require measured transcript/CPU activity or real tool +telemetry. A current verdict applies only at the right edge. Unknown history remains +neutral; recorded waits are amber and stalled state caps are hatched. Tool ticks +and counts require Claude `otlp:tool_result` or Copilot `postToolUse` events and are +labelled **telemetry**. Codex/Gemini observation growth never becomes a tool tick. +Cost appears only with stored cost metadata and is labelled **retained window**. +Missing usage, tools, files and agent badges are omitted. No handoff edges, +particles, streams, Sankey, replay or inferred outcomes are rendered. + +## Map + +The Go model produces SVG coordinates. Project/machine regions occupy a two-column +grid; height follows family/session count. Paired-machine regions are dashed. Leads +are ordered by start time and identity down the left column. Workers occupy the +adjacent column, with purple elbow spawn edges. Four workers are visible per family; +remaining workers fold into a `+N` control that opens Timeline. Timeline retains the +complete forest. Missing parents and cycles cannot hide sessions. + +Nodes have radius 18, a state glyph, a small agent-letter badge and a title below. +Working is green `▶`, waiting amber `◆`, stalled red `‖`, idle blue `○`, and ended +muted `✓`. Stalled strokes are dashed; ended strokes are dotted and faded. Recent +stored activity controls fill opacity. Only waiting/stalled nodes show an age. +Waiting has a static amber ring; selection has a purple ring. Regions and coordinates +never depend on current activity or verdict, so refreshes do not move sessions. + +Same-file links are red dashed lines with a filename pill. Nodes are focusable SVG +buttons with title/state/age/agent labels. Click and Enter select the shared inspector. +A legend explains glyphs, spawn, overlap, brightness and telemetry. The header shows +state counts, a small SVG events/minute sparkline and elapsed time since observation. +The empty state explains where the next session will appear. + +## Timeline and inspector + +Timeline is a project-grouped ARIA tree with roving tabindex and arrow-key navigation. +Rows show branch, state, title, agent, sixty minute slices, real telemetry ticks, +file-overlap markers and age. The selected family repeats the same evidence in a +compact tree in the inspector. The inspector conditionally shows now, files, +shared-file annotations, tools and retained-window cost. + +Needs you stays above the family. The oldest item has a full wrapping command; +compact following rows and overflow preserve access to other waits. Allow/Deny use +the existing authenticated, CSRF-protected prompt-answer endpoint. A new permission +never takes focus. Buttons arm after 600 ms; terminal answers require explicit shelf +focus and the same review delay. Enter can open the full, scrollable prompt inspector. +Terminal uses the existing jump resolver; Open session uses the session inspector; +Ask Chat opens the panel with selected context. Process termination lives in Machines. + +## Refresh and libraries + +The console's existing SSE notifications trigger htmx fragment refreshes. Vendored +**Idiomorph 0.7.4**, including its htmx extension, supplies `morph:innerHTML` swaps; +its BSD Zero Clause license is recorded in `static/vendor/LICENSES.txt`. Stable DOM +IDs and client state preserve session identity, view, search, caret, keyboard focus +and scroll. No secondary JSON delta stream or client-rendered Map exists. + +Lip Gloss v2 `tree` supplies terminal connectors, bubbles supplies the session table, +filter, text composer and inspectors, and the existing event-count spark renderer +supplies compact terminal history. No force-layout library or JS framework is added. +Motion is static by default and the CSS also honours reduced-motion preferences. + +## Verification + +Model tests cover input-order/activity-independent slots, worker folding at forty +sessions, complete Timeline membership, parent links, same-path scope, paired-region +styling, attention parity/order, unknown history and telemetry honesty. A 200-session +benchmark exercises the combined Map and Timeline model. Handler/CSP/escaping and +Node DOM tests cover refresh state, keyboard movement and prompt arming. A synthetic +loopback Playwright harness exercises the actual htmx/Idiomorph swap and captures +light/dark Map/Timeline and empty/quiet layouts. TUI goldens cover 80, 100, 140 and +200 columns, colour-independent geometry, and Windows newline normalization. diff --git a/docs/usage.md b/docs/usage.md index ed06c6c7..9fc3a4dc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,7 +8,7 @@ driga agents --json --project . driga agents show # numeric ID from the agents table or --json driga projects show driga inbox # current waits and recent completions; "inbox list" also works -driga inbox --all # complete history, including acks and ended waits +driga inbox list --all # complete history, including acks and ended waits driga inbox ack # explicit acknowledgment driga conflicts driga orphans # "orphans list" also works @@ -50,35 +50,44 @@ Hosted Codex threads use heuristic labels prefixed with `~`; a shared host proce does not establish individual thread liveness. Unknown agent versions are parsed tolerantly and reported by `driga doctor`. -`driga top` opens on Home (`0`): what needs you, agent states, machine health, the last -hour's activity, projects and leftovers. On terminals 140 columns or wider the Agents tab -shows the selected agent beside the list. `driga activity` prints the same 60-minute -history (machine samples and events per minute; minutes without a sample are gaps). - -In the TUI, use `0`–`8` for tabs (Home, Agents, Projects, Inbox, Orphans, Machine, Cleanup, Flow, Chat), Enter for detail, Esc to return, `?` for help, -`m` to cycle machine scope, and `q` to quit. Viewing details and issuing JSON -queries never acknowledge inbox items. Use `x`/Space in Inbox or `driga inbox ack`. Without a running collector, -acknowledgments are queued for the next collection. Orphan kills recheck process -identity and the descendant set immediately before signaling; young candidates -are refused. `--force` escalates survivors to SIGKILL (TerminateProcess on Windows), with a second confirmation unless `--yes/-y` is supplied. -The Machine tab and web Machine page lead with agents' memory and CPU, session -costs, agent processes without a session, and likely work shared with an editor. -Sessions expand into processes with attribution proofs and their launch app; -Other apps starts collapsed around the existing top-ten memory and CPU lists. -`driga machines show --json` and MCP `get_machine` add the same buckets to schema 2. -Low-disk and sustained critical-memory-pressure warnings open Machine, with -pressure text naming agents' share and observed growth. Open it directly with -`driga top --tab machine`. See [machine health](machine-health.md) for rules, -measurements, JSON fields, and deferred editor integrations. - -The default inbox puts current waits first, followed by up to 50 unacknowledged -completions from the last 24 hours. `--since` selects another completion window; -`driga inbox --all` removes the default window/count bound and includes acknowledged -and ended events. Waits for sessions that ended, moved on, or aged out of the -visible agents are labelled **historical wait** in history. Reading or hiding -history never acknowledges it. TUI waiting counts use the same visible session -states as Agents, including waiting sessions without a captured inbox event. -The web inbox has a **show all history** link and pages of at most 100 rows. +`driga top` opens on **Flow (`0`)**. The three tabs are `0 Flow`, `1 Projects` +and `2 Machines`. Flow offers Map (`m`, the default), Timeline (`t`) and Sessions +(`s`). Map and Timeline use a grouped project tree in the terminal; the web Map is +a deterministic SVG of projects, leads and workers. More than four Map workers +fold into a `+N` control. Timeline and Sessions retain access to every session. + +Use arrows or `j`/`k` to select, `/` to find, Enter to open context, Esc to return, +`?` for help, `M` to change TUI machine scope, and `q` to quit. `S` sorts Sessions. +Flow's Needs you shelf is oldest first and has the same count as the waiting header. +Tab explicitly focuses it; `y`/`n` answer a pending permission after a short review +delay. Commands wrap in full; Enter opens a scrollable inspector. `J` opens the +selected session's terminal through its stored jump destination. `K` (web: Stop) +stops a local session after you confirm; Driga first rechecks that it is the same +idle process. Sessions on a paired machine must be stopped on that machine. + +`c` opens contextual Chat as a panel. Esc closes it, retaining the draft and current +selection. Wide terminals show Chat beside the destination; narrow terminals give +it the available reading width. Web Ask Chat prefills the selected session context. +`Alt+0`/`Alt+1`/`Alt+2` switch TUI tabs while typing. + +Flow's expandable Activity section keeps notification and event history; TUI `h` +opens history and returns to Flow. Reading never acknowledges events. Use `x`/Space +on an eligible history item or `driga inbox ack`; unresolved permissions require an +answer. `driga inbox list --all` includes acknowledged/ended history. The CLI commands, +JSON schemas and collection/capture policies are unchanged. + +Projects owns repository and worktree context. Machines owns health and reclamation: +`h` health, `p` process review, `d` disk candidates. Process termination rechecks +identity and descendants; young candidates are refused. `--force` escalates +survivors with a second confirmation unless `--yes/-y` is supplied. Remote processes +must be handled on their machine. Machine session memory is labelled separately +from Flow's monetary cost. + +Flow shows only stored evidence: purple spawn links use session ParentID; red dashed +same-file links require full paths on the same machine. Tool counts/ticks are labelled +**telemetry** and appear only for actual Claude/Copilot tool events. Cost is labelled +**retained window**. Unknown history remains neutral. `driga activity` still prints +machine samples and events per minute; missing samples are gaps. Machine text and JSON default to ten rows per evidence list and three nested process/command/install entries per row. MCP trends read only the last 24 hours; @@ -573,7 +582,7 @@ driga cleanup remove --kind worktree --all # every ready worktree; skips anythin Every check runs again just before acting; if anything changed, the action is refused with the reason. Each attempt is appended to `cleanup.log` in the data directory; if that write fails after the action succeeded, the action reports a -warning instead of an error. The TUI has a Cleanup tab, and `list_cleanup_candidates` is a +warning instead of an error. Machines → Reclaim → Disk is the TUI home for cleanup, and `list_cleanup_candidates` is a read-only MCP tool. ## Summaries and Chat @@ -611,19 +620,17 @@ and marked **answered by Claude Code**. A timed indicator shows thinking, writin or the current read tool. Streaming follows the bottom until you scroll up; reduced motion uses a static dot. -In `driga top`, press **8** or **c** for Chat with the current selection. Enter -sends; Shift+Enter adds a line, or Alt+Enter in terminals that cannot report -Shift+Enter. Escape stops an active reply; otherwise -it enters **navigate** mode and keeps the draft. In navigate mode, normal keys -work: **0–8** switch tabs, **q** quits, **?** opens help and **/** filters. Press -**c** or **Enter** to type again. **Alt+0–Alt+8** switch tabs from either mode; -returning to Chat restores the draft. The footer shows the current mode. +In `driga top`, press **c** for Chat with the current selection. Enter sends; +Shift+Enter adds a line, or Alt+Enter in terminals without Shift+Enter reporting. +Escape closes the panel and retains the draft. **Alt+0**, **Alt+1** and **Alt+2** +switch to Flow, Projects and Machines while typing. Blur the composer to use +Chat's navigation controls; **c** or **Enter** focuses the composer again. Help scrolls with arrows, PgUp/PgDown or the mouse wheel. Applied list filters stay visible in the status line; Escape clears the filter when no overlay is open. Session details load when selected, rather than for every overview row. PgUp/PgDown scroll the visible transcript with a one-line overlap and stop at its ends. Alt+Up recalls the last draft, Ctrl+H cycles history, Ctrl+N starts a conversation, Ctrl+T toggles tool details, Ctrl+X clears context and -Ctrl+0 returns Home. Thinking/writing/tool status includes elapsed time and a +Alt+0 returns to Flow. Thinking/writing/tool status includes elapsed time and a streaming cursor; NoColor uses a static indicator. Flow's copy-resume key is **C**. Chat can acknowledge inbox items, jump to a session terminal, kill an agent or @@ -706,8 +713,9 @@ driga web --open # also opens it It listens on loopback only (`--listen` refuses other addresses), runs as its own process so the collector's footprint is unchanged, and updates live as the collector writes. The token in the printed URL sets a cookie for that browser; restarting -`driga web` issues a new token. Pages: Home, Agents, Projects, Inbox, Orphans, -Machines, Cleanup (listing only), Flow and Chat. Chat provides local actions through approval cards. +`driga web` issues a new token. The three tabs are Flow, Projects and Machines. Flow has Map, Timeline and Sessions views, +with Needs you and retained Activity history. Chat opens as a contextual panel on `c`; +Machines includes process and disk reclamation. Chat provides local actions through approval cards. ## MCP registration diff --git a/internal/boardtest/fixture.go b/internal/boardtest/fixture.go new file mode 100644 index 00000000..d7507a36 --- /dev/null +++ b/internal/boardtest/fixture.go @@ -0,0 +1,76 @@ +package boardtest + +import ( + "fmt" + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/view" + "time" +) + +// BoardFixture is entirely synthetic and never launches or inspects an agent. +// Busy has seventy sessions across twelve projects, two machines and families. +func BoardFixture(kind string, now time.Time) view.BoardSnapshot { + count := 70 + if kind == "quiet" { + count = 2 + } + if kind == "empty" { + count = 0 + } + b := view.BoardSnapshot{Remote: map[string]bool{"studio-mac": true}, Files: map[string][]model.FileTouch{}, Nodes: []view.FlowNode{}, Load: []int{1, 3, 2, 4, 7, 3, 2, 3, 6, 8, 4, 5}, Memory: "mem 82%"} + names := []string{"driga", "echo-pro", "monk", "inloop", "canon", "atlas", "studio", "orbit", "relay", "小さな道具", "northstar", "platform"} + titles := []string{"Add board view", "Fix retry after reconnect", "Permission boundary audit", "Polish onboarding copy", "流れを整える 🌊", "Investigate cache invalidation", "Ship the release pipeline safely", "An intentionally long title with CJK 東京 and emoji 🛰️ — verify alignment at every width"} + for i := 0; i < count; i++ { + state := []string{"working", "working", "waiting", "stalled", "working", "idle", "working", "finished", "working", "idle"}[i%10] + if kind == "quiet" { + state = "idle" + } + id := int64(i + 1) + adapter := []string{"claude", "codex", "copilot", "gemini"}[i%4] + machine := "local" + if i%5 == 4 { + machine = "studio-mac" + } + n := view.FlowNode{ID: id, MachineID: machine, ExternalID: fmt.Sprint("synthetic-", id), Adapter: adapter, Title: titles[i%len(titles)], Project: names[i%12], Branch: "main", Cwd: "/synthetic/" + names[i%12], StartedAt: now.Add(-time.Duration(45+i) * time.Minute), LastActivityAt: now.Add(-time.Duration(i%12) * time.Minute), Label: state, Mixer: model.StateMixer{Verdict: state, Evidence: "telemetry: tool_result", ObservedAt: now.Add(-2 * time.Second)}, Events: []model.Event{}, Waits: []view.FlowWait{}} + if i%8 == 0 { + n.StartedFrom = "Editor" + n.Branch = "feat/board" + n.Cwd += "-worktree" + } + if i >= 48 && i < 60 { + p := int64(i - 47) + n.ParentID = &p + n.Project = names[(i-48)%12] + n.MachineID = "local" + } + if state == "waiting" { + n.Waits = []view.FlowWait{{ID: fmt.Sprint("wait-", id), StartedAt: now.Add(-time.Duration(3+i%21) * time.Minute), Reason: "Permission · Bash", Detail: "go test ./..."}} + n.Mixer.Evidence = "open permission prompt" + } + if state == "stalled" { + n.Mixer.Evidence = "no transcript growth for 18m" + } + if state == "finished" { + end := now.Add(-time.Duration(4+i%25) * time.Minute) + n.EndedAt = &end + } + if state != "idle" { + o := &view.FlowObserved{FromAt: now.Truncate(time.Minute).Add(-59 * time.Minute), LogBytesPerMin: make([]int64, 60), CPUMSPerMin: make([]int64, 60)} + for c := range o.LogBytesPerMin { + if c%13 < 8 && c > i%20 { + o.LogBytesPerMin[c] = int64((c+i)%7+1) * 100 + o.CPUMSPerMin[c] = 30 + } + } + n.Observed = o + cost := float64(i%7+1) * .07 + in, out := int64(120000+i*200), int64(8400+i*17) + n.Events = []model.Event{{Adapter: adapter, TS: now.Add(-6 * time.Minute), Kind: "otlp:api_request", Metadata: &model.EventMetadata{InputTokens: &in, OutputTokens: &out, CostUSD: &cost}}, {Adapter: adapter, TS: now.Add(-2 * time.Minute), Kind: "otlp:tool_result", Summary: "go test ./...", Metadata: &model.EventMetadata{Tool: "Bash"}}} + } + if i == 0 || i == 12 { + b.Files[view.BoardKey(n)] = []model.FileTouch{{Path: "/synthetic/driga/board.go", TS: now.Add(-time.Minute)}} + } + b.Nodes = append(b.Nodes, n) + } + return b +} diff --git a/internal/cli/top.go b/internal/cli/top.go index 06537001..71f568cd 100644 --- a/internal/cli/top.go +++ b/internal/cli/top.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "io" "os" @@ -56,7 +57,12 @@ func (c TopCmd) Run(g *Globals) error { return err } defer closeChat() - opts := tui.Options{InitialTab: c.Tab, Theme: cfg.UI.Theme, Chat: chatClient, ChatEphemeral: ephemeral, Machine: g.Machine, Project: g.Project, NoColor: g.NoColor, OpenCmd: cfg.OpenCmd, DataDir: config.DataDir(), Context: ctx} + opts := tui.Options{SessionAction: func(ctx context.Context, action string, id int64) error { + raw, _ := json.Marshal(map[string]any{"session_id": id}) + actionGlobals := *g + actionGlobals.Stdout = io.Discard + return runChatAction(ctx, &actionGlobals, action, raw) + }, InitialTab: c.Tab, Theme: cfg.UI.Theme, Chat: chatClient, ChatEphemeral: ephemeral, Machine: g.Machine, Project: g.Project, NoColor: g.NoColor, OpenCmd: cfg.OpenCmd, DataDir: config.DataDir(), Context: ctx} opts.Tmux = os.Getenv("TMUX") opts.JumpTerminal = cfg.Inbox.JumpTerminal opts.MotionOff = cfg.Motion == "off" diff --git a/internal/cli/usage_documentation_test.go b/internal/cli/usage_documentation_test.go index 1e3fae11..d9ec4061 100644 --- a/internal/cli/usage_documentation_test.go +++ b/internal/cli/usage_documentation_test.go @@ -13,7 +13,7 @@ func TestUsageDocumentsTabsAndInboxConfig(t *testing.T) { } // Windows checkouts may convert the docs to CRLF line endings. doc := strings.ReplaceAll(string(b), "\r\n", "\n") - for _, want := range []string{"`0`–`8`", "Flow, Chat", "$XDG_CONFIG_HOME/driga/config.toml", "[inbox]\nalerts = \"off\"", "`inbox.alert_after`", "`inbox.alert_repeat`"} { + for _, want := range []string{"`0 Flow`", "`1 Projects`", "`2 Machines`", "`c` opens contextual Chat", "$XDG_CONFIG_HOME/driga/config.toml", "[inbox]\nalerts = \"off\"", "`inbox.alert_after`", "`inbox.alert_repeat`"} { if !strings.Contains(doc, want) { t.Errorf("usage missing %s", want) } diff --git a/internal/cli/web.go b/internal/cli/web.go index a7a16e2c..2c63dc54 100644 --- a/internal/cli/web.go +++ b/internal/cli/web.go @@ -2,8 +2,10 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" + "io" "net" "net/http" "time" @@ -58,7 +60,12 @@ func (c WebCmd) run(ctx context.Context, g *Globals) error { if ephemeral && g.Stderr != nil { fmt.Fprintln(g.Stderr, "Chat runs in-process; conversations end with it.") } - dashboard, err := web.New(source, web.Options{Chat: chatClient, ChatEphemeral: ephemeral, Host: listener.Addr().String(), Machine: g.Machine, Activity: webHeartbeat, CleanupList: func(ctx context.Context) ([]cleanup.Candidate, error) { + dashboard, err := web.New(source, web.Options{SessionAction: func(ctx context.Context, action string, id int64) error { + raw, _ := json.Marshal(map[string]any{"session_id": id}) + actionGlobals := local + actionGlobals.Stdout = io.Discard + return runChatAction(ctx, &actionGlobals, action, raw) + }, Chat: chatClient, ChatEphemeral: ephemeral, Host: listener.Addr().String(), Machine: g.Machine, Activity: webHeartbeat, CleanupList: func(ctx context.Context) ([]cleanup.Candidate, error) { envelope, err := cleanupRead(ctx, &local, "", false, true) if err != nil { return nil, err diff --git a/internal/mcp/flow_surface_parity_test.go b/internal/mcp/flow_surface_parity_test.go index e4f4f7ec..20db14f1 100644 --- a/internal/mcp/flow_surface_parity_test.go +++ b/internal/mcp/flow_surface_parity_test.go @@ -68,7 +68,7 @@ func TestFlowSnapshotMatchesAllSurfaces(t *testing.T) { } login := httptest.NewRecorder() server.ServeHTTP(login, httptest.NewRequest("GET", server.URL(), nil)) - r := httptest.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:4321/flow?id=%d", id), nil) + r := httptest.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:4321/flow/%d", id), nil) for _, c := range login.Result().Cookies() { r.AddCookie(c) } @@ -94,13 +94,20 @@ func TestFlowSnapshotMatchesAllSurfaces(t *testing.T) { } m, _ = m.Update(initMsg) m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 100}) - m, cmd := m.Update(tea.KeyPressMsg{Code: '7', Text: "7"}) + m, cmd := m.Update(tea.KeyPressMsg{Code: '0', Text: "0"}) + if cmd != nil { + m, _ = m.Update(cmd()) + } + if !strings.Contains(m.View().Content, "NEEDS YOU") { + t.Fatal("TUI did not open board") + } + m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd == nil { - t.Fatal("TUI did not load Flow") + t.Fatal("TUI did not open family from board") } m, _ = m.Update(cmd()) frame := m.View().Content - if !strings.Contains(frame, fmt.Sprintf("verdict finished · %d of 4 agree", expected.Agreement)) || !strings.Contains(frame, "+13 quiet threads") { + if !strings.Contains(frame, fmt.Sprintf("verdict finished · %d of 4 agree", expected.Agreement)) { t.Errorf("TUI agreement/hidden count differs from fixture: %s", frame) } for _, row := range expected.Rows { diff --git a/internal/store/observations.go b/internal/store/observations.go index 13058588..3fe14cec 100644 --- a/internal/store/observations.go +++ b/internal/store/observations.go @@ -1,6 +1,9 @@ package store -import "time" +import ( + "github.com/labstack/driga/internal/model" + "time" +) // ObservationMinute records positive collector measurements, never transcript content. // Samples counts passes with positive bytes or CPU, rather than idle polls. @@ -44,3 +47,34 @@ func (q *querier) ObservationMinutes(sessionID int64, until time.Time) ([]Observ return r, err }) } + +// BoardEvidence loads the selected machine window with two queries, independent +// of session count. Event volume is bounded to the newest 12000 stored events. +func (q *querier) BoardEvidence(since, until time.Time) ([]model.Event, map[int64][]ObservationMinute, error) { + events, err := readMany(q, `SELECT machine_id,adapter,external_id,ts,kind,summary,metadata FROM events WHERE machine_id=? AND ts>=? AND ts<=? ORDER BY ts DESC,id DESC LIMIT 12000`, []any{q.machineID, unix(since), unix(until)}, scanEvent) + if err != nil { + return nil, nil, err + } + type entry struct { + id int64 + row ObservationMinute + } + rows, err := readMany(q, `SELECT session_id,minute,log_bytes,cpu_ms,samples,last_growth_at,last_growth_bytes FROM observation_minutes WHERE machine_id=? AND minute>=? AND minute<=? ORDER BY minute`, []any{q.machineID, since.Truncate(time.Minute).UnixMilli(), until.Truncate(time.Minute).UnixMilli()}, func(s scanner) (entry, error) { + var e entry + var minute, at int64 + err := s.Scan(&e.id, &minute, &e.row.LogBytes, &e.row.CPUMS, &e.row.Samples, &at, &e.row.LastGrowthBytes) + e.row.Minute = time.UnixMilli(minute).UTC() + if at != 0 { + e.row.LastGrowthAt = time.UnixMilli(at).UTC() + } + return e, err + }) + if err != nil { + return nil, nil, err + } + observed := map[int64][]ObservationMinute{} + for _, e := range rows { + observed[e.id] = append(observed[e.id], e.row) + } + return events, observed, nil +} diff --git a/internal/store/read_machine.go b/internal/store/read_machine.go index c212092c..1adc478b 100644 --- a/internal/store/read_machine.go +++ b/internal/store/read_machine.go @@ -28,6 +28,7 @@ type machineReads interface { LatestSample() (model.MachineSample, error) Samples(time.Time) ([]model.MachineSample, error) EventCounts(time.Time, time.Time) ([]EventCount, error) + BoardEvidence(time.Time, time.Time) ([]model.Event, map[int64][]ObservationMinute, error) ObservationMinutes(int64, time.Time) ([]ObservationMinute, error) ScanMCPHistory(time.Time, time.Time, func(model.MCPObservation) error) error Sources() ([]model.SourceStatus, error) diff --git a/internal/tui/app.go b/internal/tui/app.go index 6bef9e88..673f8bb0 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -35,6 +35,7 @@ import ( // Options configures scope, appearance, and injectable action dependencies. type Options struct { + SessionAction func(context.Context, string, int64) error Chat chat.Client ChatEphemeral bool InitialTab string @@ -90,20 +91,23 @@ type app struct { chatPageHeight int chatMaxOffset int - flow view.FlowView - flowID int64 - flowMinutes int + flowViewMode string + boardPromptFocus string + boardPromptAt time.Time + chatOrigin int + chatOriginDetail bool + boardPinned string + board view.BoardView + boardOrder []string + boardGroups []string + boardCursor int + boardStop int64 // session awaiting y/n stop confirmation + boardMachine bool + boardFilter int + boardIdle bool flowAttention bool flowAskCursor int - flowSelectID int64 flowOffset int - flowExpanded bool - flowPulse map[string]time.Time - flowBranchPulse map[string]time.Time - flowSeen map[string]bool - flowGrowthSeen map[string]time.Time - flowGrowthPulse map[string]flowGrowthPulse - flowAnimating bool machineScope string src Source opts Options @@ -113,8 +117,6 @@ type app struct { version int64 tab int cursors [9]int - homeOffset int - homeNeedsTop int cleanupRequested bool expanded map[string]bool filter textinput.Model @@ -199,13 +201,16 @@ func New(src Source, opts Options) tea.Model { input.Prompt = "/ " input.Placeholder = "filter" input.SetStyles(textinput.Styles{}) - m := &app{chatInput: newChatInput(), chatSpinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), chatEphemeral: opts.ChatEphemeral, tab: homeTab, machineScope: opts.Machine, src: src, opts: opts, width: 80, height: 24, expanded: map[string]bool{}, filter: input, table: table.New(table.WithFocused(true)), viewport: viewport.New()} + m := &app{chatInput: newChatInput(), chatSpinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), chatEphemeral: opts.ChatEphemeral, tab: flowTab, machineScope: opts.Machine, src: src, opts: opts, width: 80, height: 24, expanded: map[string]bool{}, filter: input, table: table.New(table.WithFocused(true)), viewport: viewport.New()} for i, name := range []string{"agents", "projects", "inbox", "orphans", "machine", "cleanup", "home", "flow", "chat"} { if opts.InitialTab == name { m.tab = i break } } + if m.tab == 6 { + m.tab = flowTab + } m.rebuild() return m } @@ -395,67 +400,6 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.readChat() - case flowObservationFrameMsg: - for key, pulse := range m.flowGrowthPulse { - if !m.opts.Now().Before(pulse.until) { - delete(m.flowGrowthPulse, key) - } else { - pulse.frame++ - m.flowGrowthPulse[key] = pulse - } - } - m.flowAnimating = false - m.rebuild() - return m, m.flowAnimation() - case flowEventMsg: - m.recordFlowEvent(msg.event) - case flowMsg: - if msg.id != m.flowID || msg.scope != m.machineScope { - return m, nil - } - if msg.err != nil { - m.status = "flow: " + msg.err.Error() - return m, nil - } - var selectedID int64 - if i := m.cursors[flowTab]; i >= 0 && i < len(m.flowWorkers().Nodes) { - selectedID = m.flowWorkers().Nodes[i].ID - } - if m.flowSelectID != 0 { - selectedID = m.flowSelectID - m.flowSelectID = 0 - } - m.recordFlowObservations(msg.flow) - if m.flow.Lead != nil && msg.flow.Lead != nil && m.flow.Lead.ID == msg.flow.Lead.ID { - for _, n := range msg.flow.Children { - if n.ParentID != nil && n.StartedAt.After(m.flow.ObservedAt) && !n.StartedAt.After(m.opts.Now()) { - if m.flowBranchPulse == nil { - m.flowBranchPulse = map[string]time.Time{} - } - m.flowBranchPulse[growthKey(n)] = m.opts.Now().Add(time.Second) - } - } - } - m.flow = msg.flow - if m.flowSeen == nil { - m.flowSeen = map[string]bool{} - } - for _, e := range m.flow.Tail { - m.flowSeen[e.Key] = true - } - for i, n := range m.flowWorkers().Nodes { - if n.ID == selectedID { - m.cursors[flowTab] = i - } - } - if m.snap.AgentDetails == nil { - m.snap.AgentDetails = map[int64]view.AgentView{} - } - for id, d := range msg.details { - m.snap.AgentDetails[id] = d - } - m.rebuild() - return m, m.flowAnimation() case tea.WindowSizeMsg: m.width = max(1, msg.Width) m.height = max(1, msg.Height) @@ -505,17 +449,6 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.snap.Cleanup == nil { msg.snap.Cleanup = m.snap.Cleanup } - if m.loaded && m.tab == flowTab { - seen := map[string]bool{} - for _, e := range m.snap.Tail { - seen[e.Key] = true - } - for _, e := range msg.snap.Tail { - if !seen[e.Key] { - m.recordFlowEvent(e) - } - } - } m.snap = msg.snap m.version = msg.version m.loaded = true @@ -524,13 +457,6 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showAckPending() m.rebuild() } - if msg.changed && m.tab == flowTab { - return m, m.loadFlow() - } - if m.loaded && m.tab == homeTab && !m.cleanupRequested && m.snap.Cleanup == nil && m.opts.CleanupList != nil && !m.busy { - m.cleanupRequested = true - return m, m.loadCleanup() - } return m, nil case cleanupMsg: m.busy = false @@ -588,13 +514,20 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } // Modified tab keys bypass focused composers, filters and overlays. - if msg.Mod == tea.ModAlt && msg.Code >= '0' && msg.Code <= '8' { + if msg.Mod == tea.ModAlt && msg.Code >= '0' && msg.Code <= '2' { return m, m.selectTab(byte(msg.Code)) } if m.chatFullAsk && k != "m" { m.chatFullAsk = false m.chatStatus = "Full auto not enabled." } + if m.tab == chatTab && k == "esc" && !m.help && !m.chatActive { + m.tab = m.chatOrigin + m.detail = m.chatOriginDetail + m.chatInput.Blur() + m.rebuild() + return m, nil + } if m.tab == chatTab && !m.filtering && !m.help { if k == "esc" || m.chatInput.Focused() { return m, m.chatKey(msg) @@ -607,7 +540,7 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.chatKey(msg) } } - if (k == "c" || k == "8") && !m.filtering && m.confirm == nil { + if k == "c" && !m.filtering && m.confirm == nil { return m, m.openChat() } if m.filtering { @@ -664,104 +597,50 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - if k >= "0" && k <= "7" { + if k >= "0" && k <= "2" { return m, m.selectTab(k[0]) } - if m.tab == flowTab && !m.detail { - if k == "tab" && len(m.flow.Attention.Waits) > 0 { - m.flowAttention = !m.flowAttention - return m, nil - } - if m.flowAttention && len(m.flow.Attention.Waits) > 0 { - count := len(m.flow.Attention.Waits) - m.flowAskCursor = min(m.flowAskCursor, count-1) - w := m.flow.Attention.Waits[m.flowAskCursor] - switch k { - case "j", "down": - m.flowAskCursor = (m.flowAskCursor + 1) % count - return m, nil - case "k", "up": - m.flowAskCursor = (m.flowAskCursor + count - 1) % count - return m, nil - case "C": - m.status = attention.JumpCommand(w.Jump) - return m, nil - case "enter": - return m, m.jumpFlowAttention(w) - } - } - switch k { - case "[", "]": - delta := 1 - if k == "[" { - delta = -1 - } - m.flowSession(delta) - return m, m.loadFlow() - case "-", "+", "=": - zooms := []int{15, 60, 240} - i := 0 - for j, z := range zooms { - if z == m.flowZoom() { - i = j - } - } - if k == "-" { - i = min(2, i+1) + if !m.detail { + if (m.tab == flowTab || m.tab == 0 || m.tab == 2) && k == "h" { + if m.tab == 2 { + m.tab = flowTab } else { - i = max(0, i-1) - } - m.flowMinutes = zooms[i] - return m, m.loadFlow() - case "C": - if n := m.flowSelected(); n != nil { - m.status = attention.JumpCommand(n.Jump) + m.tab = 2 } + m.rebuild() return m, nil - case "enter": - w := m.flowWorkers() - if w.Quiet > 0 && m.cursors[flowTab] == len(w.Nodes) { - m.flowExpanded = true + } + if m.tab == 0 && (k == "m" || k == "t") { + m.tab = flowTab + return m, m.selectFlowView(k) + } + if m.tab == 4 || m.tab == 3 || m.tab == 5 { + switch k { + case "p": + m.tab = 3 m.rebuild() - m.flowEnsureVisible() return m, nil - } - return m, m.jumpFlow() - case "j", "down", "k", "up": - w := m.flowWorkers() - count := len(w.Nodes) - if w.Quiet > 0 { - count++ - } - if count > 0 { - delta := 1 - if k == "k" || k == "up" { - delta = -1 + case "d": + m.tab = 5 + m.rebuild() + if m.opts.CleanupList != nil { + return m, m.loadCleanup() } - m.cursors[flowTab] = (m.cursors[flowTab] + delta + count) % count + return m, nil + case "h": + m.tab = 4 m.rebuild() - m.flowEnsureVisible() - } - return m, nil - case "pgdown", "pgup": - delta := m.bodyHeight() - if k == "pgup" { - delta = -delta + return m, nil } - m.flowOffset = max(0, m.flowOffset+delta) - return m, nil + } + } + if m.tab == flowTab && !m.detail { + if cmd, handled := m.boardKey(k); handled { + return m, cmd } } switch k { case "pgdown", "pgup": - if m.tab == homeTab && !m.detail { - delta := m.bodyHeight() - if k == "pgup" { - delta = -delta - } - m.homeOffset = max(0, m.homeOffset+delta) - return m, nil - } var cmd tea.Cmd if m.detail { m.viewport, cmd = m.viewport.Update(msg) @@ -777,6 +656,11 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadCleanup() } case "S": + if m.tab == 0 { + m.sort = (m.sort + 1) % 4 + m.rebuild() + return m, nil + } if m.tab == 1 && !m.busy { if row, ok := m.selected(); ok { if m.remote(row.machine) { @@ -803,18 +687,8 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - case "m": + case "M": m.cycleMachine() - if m.tab == flowTab { - m.flowID = 0 - m.flow = view.FlowView{} - m.flowExpanded = false - m.flowOffset = 0 - m.flowPulse = nil - m.flowSeen = nil - m.rebuild() - return m, m.loadFlow() - } m.detail = false m.showAckPending() m.rebuild() @@ -848,13 +722,10 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - if (m.tab == 2 || m.tab == homeTab) && !m.busy { + if (m.tab == 2) && !m.busy { if r, ok := m.selected(); ok && r.inbox != nil { return m, m.ack([]model.InboxItem{*r.inbox}) } - if m.tab == homeTab { - m.status = "no inbox event to acknowledge" - } } case "X": if m.tab == 2 && !m.busy { @@ -914,9 +785,6 @@ func (m *app) update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.table, cmd = m.table.Update(msg) m.cursors[m.tab] = max(0, m.table.Cursor()) - if m.tab == homeTab { - m.homeOffset = 0 - } m.rebuild() return m, cmd } @@ -1103,7 +971,7 @@ type selectedDetailMsg struct { func (m *app) loadSelection() tea.Cmd { source, ok := m.src.(viewer.DetailSource) - if !ok || !m.loaded || m.tab == chatTab || m.tab == flowTab { + if !ok || !m.loaded || m.tab == chatTab || m.tab == flowTab && !m.detail { return nil } row, ok := m.selected() diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index e75606f3..8f9e5b4e 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -37,10 +37,11 @@ func (s *fakeSource) DataVersion(context.Context) (int64, error) { return s.vers func fixture() *fakeSource { return &fakeSource{version: 1, snap: Snapshot{ - Envelope: view.Envelope{Mode: "daemon", ObservedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}, - Hostname: "test-host", Summary: view.SummaryView{Agents: 1}, + Envelope: view.Envelope{Mode: "daemon", ObservedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}, + BoardData: view.BoardSnapshot{Nodes: []view.FlowNode{{ID: 1, Adapter: "claude", Label: "working", Title: "test title", Project: "repo", Cwd: "/tmp/repo with spaces"}}}, + Hostname: "test-host", Summary: view.SummaryView{Agents: 1}, Agents: view.AgentsView{Rows: []view.AgentRow{{ID: 1, Adapter: "claude", Label: "working", Title: "test title", Project: "repo"}}}, - Inbox: view.InboxView{Rows: []view.InboxRow{{InboxItem: model.InboxItem{ID: "event-1", SessionID: 1, Kind: "waiting", Summary: "approve change"}}}}, + Inbox: view.InboxView{Rows: []view.InboxRow{{InboxItem: model.InboxItem{ID: "event-1", SessionID: 1, Kind: "finished", Summary: "approve change"}}}}, Orphans: view.OrphansView{Rows: []view.OrphanRow{{Orphan: model.Orphan{ID: "100:1", RootPID: 100, PIDs: []int32{100, 101}, RSSTotal: 1024, Verified: true, Evidence: []string{"detached helper"}}}}}, AgentDetails: map[int64]view.AgentView{1: {Session: model.Session{ID: 1, SessionObs: model.SessionObs{Adapter: "claude", ExternalID: "external-1", PID: 30, Cwd: "/tmp/repo with spaces"}, Evidence: "hook notification"}, Resume: "claude --resume external-1"}}, }} @@ -80,28 +81,37 @@ func execute(t *testing.T, m tea.Model, cmd tea.Cmd) tea.Model { } func TestTabSwitching(t *testing.T) { - m := setup(t, fixture(), Options{}) - m = key(m, '3') - if m.(*app).tab != 2 || !strings.Contains(m.View().Content, "3 Inbox 1") { - t.Fatal(m.View().Content) - } - for _, c := range []rune{'1', '2', '4', '5'} { - m = key(m, c) - if m.(*app).tab != int(c-'1') || !strings.Contains(m.View().Content, string(c)+" ") { + m := setup(t, fixture(), Options{}).(*app) + for _, tc := range []struct { + key rune + tab int + label string + }{{'0', flowTab, "0 Flow"}, {'1', 1, "1 Projects"}, {'2', 4, "2 Machines"}} { + m.Update(tea.KeyPressMsg{Code: tc.key}) + if m.tab != tc.tab || !strings.Contains(m.tabBar(), tc.label) { t.Fatal(m.View().Content) } } + for _, key := range []rune{'3', '4', '5', '6', '7', '8'} { + m.Update(tea.KeyPressMsg{Code: key}) + if m.tab != 4 { + t.Fatal("removed tab key navigated", key) + } + } + if strings.Contains(m.tabBar(), "Inbox") || strings.Contains(m.tabBar(), "Chat") { + t.Fatal(m.tabBar()) + } } func TestDetailDoesNotAck(t *testing.T) { calls := 0 m := setup(t, fixture(), Options{Spool: func(model.HookEvent) error { calls++; return nil }}) - m = key(m, '3') + m = key(key(m, '0'), 'h') m = key(m, tea.KeyEnter) if calls != 0 || !strings.Contains(m.View().Content, "approve change") || !strings.Contains(m.View().Content, "hook notification") { t.Fatal(m.View().Content, calls) } m = key(m, tea.KeyEscape) - if m.(*app).tab != 2 || !strings.Contains(m.View().Content, "3 Inbox 1") { + if m.(*app).tab != 2 || !strings.Contains(m.View().Content, "0 Flow") { t.Fatal(m.View().Content) } } @@ -111,7 +121,7 @@ func TestAckWritesSpool(t *testing.T) { dir := t.TempDir() s := fixture() m := setup(t, s, Options{Spool: func(e model.HookEvent) error { return spool.Append(dir, e) }}) - m = key(m, '3') + m = key(key(m, '0'), 'h') m, cmd := press(m, k) before, _, _ := spool.Drain(dir, nil) if len(before) != 0 { @@ -140,7 +150,7 @@ func TestKillNeedsConfirmAndRefusesYoung(t *testing.T) { return kill.Result{Signaled: []int32{o.RootPID}}, nil }} m := setup(t, fixture(), opts) - m = key(m, '4') + m = key(key(m, '2'), 'p') m = key(m, 'k') if calls != 0 || !strings.Contains(m.View().Content, "[y/N]") || !strings.Contains(m.View().Content, "101") { t.Fatal(m.View().Content, calls) @@ -162,7 +172,7 @@ func TestKillNeedsConfirmAndRefusesYoung(t *testing.T) { s := fixture() s.snap.Orphans.Rows[0].Young = true m = setup(t, s, opts) - m = key(m, '4') + m = key(key(m, '2'), 'p') m = key(m, 'k') if !strings.Contains(m.View().Content, "too young to kill") || strings.Contains(m.View().Content, "[y/N]") { t.Fatal(m.View().Content) @@ -181,7 +191,7 @@ func TestKillRefusesUnverified(t *testing.T) { t.Fatal("unverified candidate was killed") return kill.Result{}, nil }}) - m = key(m, '4') + m = key(key(m, '2'), 'p') m = key(m, action) if !strings.Contains(m.View().Content, "unverified — run: driga orphans kill") || strings.Contains(m.View().Content, "[y/N]") { t.Fatal(m.View().Content) @@ -195,7 +205,7 @@ func TestKillRefusesUnverified(t *testing.T) { func TestReloadOnlyOnDataVersionChange(t *testing.T) { s := fixture() m := setup(t, s, Options{}) - m = key(m, '1') + m = key(key(m, '0'), 's') poll := func() { t.Helper(); var cmd tea.Cmd; m, cmd = m.Update(tickMsg{}); m = execute(t, m, cmd) } poll() if s.loads != 1 { @@ -225,10 +235,10 @@ func TestProjectHidesEmptyTabs(t *testing.T) { s.snap.Orphans.Rows = nil m := setup(t, s, Options{Project: "/tmp/repo"}) text := m.(*app).tabBar() - if strings.Contains(text, "2 Projects") || strings.Contains(text, "4 Orphans") || !strings.Contains(text, "3 Inbox 1") { + if strings.Contains(text, "2 Projects") || strings.Contains(text, "4 Orphans") || !strings.Contains(text, "0 Flow") { t.Fatal(text) } - m = key(m, '4') + m = key(key(m, '2'), 'p') if m.(*app).tab == 3 { t.Fatal(m.View().Content) } @@ -344,7 +354,7 @@ func TestDetailActionKeepsDisplayedTargetAfterReload(t *testing.T) { s := fixture() var ack string m := setup(t, s, Options{Spool: func(e model.HookEvent) error { ack = e.Message; return nil }}) - m = key(m, '3') + m = key(key(m, '0'), 'h') m = key(m, tea.KeyEnter) s.version++ s.snap.Inbox.Rows = []view.InboxRow{{InboxItem: model.InboxItem{ID: "new-event", SessionID: 1, Summary: "different item"}}} @@ -362,7 +372,7 @@ func TestFamiliesFilterSortAndCursors(t *testing.T) { s.snap.Agents.Rows[0].Children = []view.AgentRow{{ID: 2, Adapter: "codex", Label: "working", Title: "worker title"}} s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: 3, Adapter: "codex", Label: "waiting", Title: "other title", Mem: 100}) m := setup(t, s, Options{}) - m = key(m, '1') + m = key(key(m, '0'), 's') if strings.Contains(m.View().Content, "worker title") { t.Fatal("family starts expanded") } @@ -373,8 +383,8 @@ func TestFamiliesFilterSortAndCursors(t *testing.T) { t.Fatal(m.View().Content) } cursor := m.(*app).cursors[0] - m = key(m, '3') - m = key(m, '1') + m = key(key(m, '0'), 'h') + m = key(key(m, '0'), 's') if m.(*app).cursors[0] != cursor { t.Fatal("lost cursor") } @@ -608,7 +618,7 @@ func TestForceRequiresSecondConfirmation(t *testing.T) { calls++ return kill.Result{}, nil }}) - m = key(m, '4') + m = key(key(m, '2'), 'p') m = key(m, 'K') m, cmd := press(m, 'y') if cmd != nil || calls != 0 || !strings.Contains(m.View().Content, "Really SIGKILL") { diff --git a/internal/tui/applied_filters_test.go b/internal/tui/applied_filters_test.go index 9fb6daea..647ed803 100644 --- a/internal/tui/applied_filters_test.go +++ b/internal/tui/applied_filters_test.go @@ -9,7 +9,7 @@ import ( func TestAppliedFilterVisibleAndEscapeClears(t *testing.T) { m := setup(t, fixture(), Options{}).(*app) - key(m, '1') + key(key(m, '0'), 's') key(m, '/') m.filter.SetValue("qa-no-match-界🙂") m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) diff --git a/internal/tui/board.go b/internal/tui/board.go new file mode 100644 index 00000000..91be3bb3 --- /dev/null +++ b/internal/tui/board.go @@ -0,0 +1,529 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "fmt" + "github.com/labstack/driga/internal/view" + "sort" + "strings" + "time" +) + +func (m *app) syncBoard() { + previous := "" + oldRows := m.boardRows() + if m.boardCursor >= 0 && m.boardCursor < len(oldRows) { + previous = oldRows[m.boardCursor].Key + } + oldNeed := "" + if m.flowAskCursor >= 0 && m.flowAskCursor < len(m.board.Needs) { + oldNeed = m.board.Needs[m.flowAskCursor].Key + } + data := m.snap.BoardData + data.Nodes = nil + for _, n := range m.snap.BoardData.Nodes { + if m.machineVisible(n.MachineID) { + data.Nodes = append(data.Nodes, n) + } + } + for _, p := range m.prompts { + for i := range data.Nodes { + n := &data.Nodes[i] + if n.ID == p.SessionID && n.MachineID == m.localMachine() && n.EndedAt == nil { + n.Waits = append(append([]view.FlowWait(nil), n.Waits...), view.FlowWait{ID: p.ID, StartedAt: p.CreatedAt, Tool: p.Tool, Detail: p.Detail(), Reason: "permission"}) + } + } + } + b := view.Board(data, time.Hour, m.opts.Now()) + prompts := map[string]string{} + for _, p := range m.prompts { + prompts[fmt.Sprintf("%s/%d", m.localMachine(), p.SessionID)] = p.ID + } + b.WithPrompts(prompts) + for _, p := range m.prompts { + for i := range b.Needs { + if b.Needs[i].PromptID == p.ID { + b.Needs[i].Command = p.Detail() + } + } + } + order := map[string]int{} + for i, k := range m.boardOrder { + order[k] = i + } + groupOrder := map[string]int{} + for i, k := range m.boardGroups { + groupOrder[k] = i + } + for gi := range b.Groups { + g := &b.Groups[gi] + sort.SliceStable(g.Rows, func(i, j int) bool { + a, aok := order[g.Rows[i].Key] + c, cok := order[g.Rows[j].Key] + if aok != cok { + return aok + } + return aok && a < c + }) + } + sort.SliceStable(b.Groups, func(i, j int) bool { + a, aok := groupOrder[b.Groups[i].Key] + c, cok := groupOrder[b.Groups[j].Key] + if aok != cok { + return aok + } + return aok && a < c + }) + m.boardPinned = previous + m.board = b + for i, r := range m.boardRows() { + if r.Key == previous { + m.boardCursor = i + break + } + } + for i, r := range b.Needs { + if r.Key == oldNeed { + m.flowAskCursor = i + break + } + } + m.boardOrder = nil + m.boardGroups = nil + for _, g := range b.Groups { + m.boardGroups = append(m.boardGroups, g.Key) + for _, r := range g.Rows { + m.boardOrder = append(m.boardOrder, r.Key) + } + } +} +func (m *app) boardAccept(r view.BoardRow) bool { + if !m.machineVisible(r.Node.MachineID) { + return false + } + + states := []string{"", "waiting", "working", "stalled"} + if m.boardFilter > 0 && r.State != states[m.boardFilter] { + return false + } + q := strings.ToLower(m.filter.Value()) + return q == "" || strings.Contains(strings.ToLower(r.Title+" "+r.Node.Project), q) +} +func (m *app) boardRows() []view.BoardRow { + rows := []view.BoardRow{} + for _, g := range m.board.Groups { + for _, r := range g.Rows { + if m.boardAccept(r) { + rows = append(rows, r) + } + } + } + if m.flowViewMode == "Sessions" { + sort.SliceStable(rows, func(i, j int) bool { + switch m.sort { + case 1: + return rows[i].State < rows[j].State + case 2: + return rows[i].Node.Project < rows[j].Node.Project + } + return rows[i].Title < rows[j].Title + }) + } + if m.boardMachine { + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Node.MachineID < rows[j].Node.MachineID }) + } + return rows +} +func (m *app) boardSelected() *view.BoardRow { + m.syncBoard() + if m.flowAttention && len(m.board.Needs) > 0 { + m.flowAskCursor = min(m.flowAskCursor, len(m.board.Needs)-1) + r := m.board.Needs[m.flowAskCursor] + return &r + } + rows := m.boardRows() + if len(rows) == 0 { + return nil + } + m.boardCursor = max(0, min(m.boardCursor, len(rows)-1)) + r := rows[m.boardCursor] + return &r +} +func (m *app) boardKey(k string) (tea.Cmd, bool) { + if m.boardStop != 0 && k != "y" && k != "n" { + m.boardStop = 0 + m.status = "Cancelled" + } + m.syncBoard() + switch k { + case "tab", "shift+tab": + m.flowAttention = !m.flowAttention && len(m.board.Needs) > 0 + m.armBoardPrompt() + return nil, true + case "j", "down", "k", "up", "pgdown", "pgup": + if m.flowAttention && (k == "pgdown" || k == "pgup") { + delta := max(1, m.bodyHeight()/2) + if k == "pgup" { + delta = -delta + } + m.flowOffset = max(0, m.flowOffset+delta) + return nil, true + } + m.flowOffset = 0 + d := 1 + if k == "k" || k == "up" || k == "pgup" { + d = -1 + } + if k == "pgdown" || k == "pgup" { + d *= max(1, m.bodyHeight()/2) + } + if m.flowAttention { + m.flowAskCursor = max(0, min(len(m.board.Needs)-1, m.flowAskCursor+d)) + } else { + m.boardCursor = max(0, min(len(m.boardRows())-1, m.boardCursor+d)) + } + m.armBoardPrompt() + return nil, true + case "m", "t", "s": + return m.selectFlowView(k), true + case "S": + m.sort = (m.sort + 1) % 3 + return nil, true + case "enter": + if r := m.boardSelected(); r != nil { + row := tabRow{machine: r.Node.MachineID, agent: r.Node.ID, key: r.Key, cwd: r.Cwd} + if m.flowAttention { + for _, p := range m.prompts { + if p.ID == r.PromptID { + copy := p + row.prompt = © + } + } + } + m.openDetail(row) + return m.loadSelection(), true + } + return nil, true + case "J": + return m.jumpFlow(), true + case "K": + r := m.boardSelected() + switch { + case r == nil || r.State == "finished": + case !r.Local: + m.status = "Stop this session on " + m.machineName(r.Node.MachineID) + case m.opts.SessionAction == nil: + m.status = fmt.Sprintf("driga agents kill %d · confirm in terminal", r.Node.ID) + default: + m.boardStop = r.Node.ID + m.status = fmt.Sprintf("Stop %q? y stop · n cancel", r.Title) + } + return nil, true + case "y", "n": + if id := m.boardStop; id != 0 { + m.boardStop = 0 + if k == "n" { + m.status = "Cancelled" + return nil, true + } + ctx, run := m.opts.Context, m.opts.SessionAction + return func() tea.Msg { return actionMsg{status: "Session stopped", err: run(ctx, "kill", id)} }, true + } + if r := m.boardSelected(); m.flowAttention && r != nil && r.PromptID != "" { + if m.boardPromptFocus != r.PromptID || m.opts.Now().Sub(m.boardPromptAt) < 600*time.Millisecond { + m.status = "Review prompt before answering" + return nil, true + } + for _, p := range m.prompts { + if p.ID == r.PromptID { + return m.answerPrompt(p, k == "y"), true + } + } + } + m.status = "Jump to the terminal to answer this wait" + return nil, true + } + return nil, false +} +func boardPad(s string, w int) string { + s = truncate(clean(s), w) + return s + strings.Repeat(" ", max(0, w-lipgloss.Width(s))) +} +func boardMoney(v *float64) string { + if v == nil { + return "—" + } + return fmt.Sprintf("$%.2f", *v) +} +func (m *app) armBoardPrompt() { + if !m.flowAttention || len(m.board.Needs) == 0 { + m.boardPromptFocus = "" + return + } + r := m.board.Needs[min(m.flowAskCursor, len(m.board.Needs)-1)] + if r.PromptID != m.boardPromptFocus { + m.boardPromptFocus = r.PromptID + m.boardPromptAt = m.opts.Now() + } +} +func (m *app) boardLayout() (string, []flowHit) { + m.syncBoard() + b := m.board + w := max(4, m.width) + panelW := 0 + if w >= 120 { + panelW = min(52, w/3) + w -= panelW + 3 + } + h := max(12, m.bodyHeight()) + lines := []string{} + hits := []flowHit{} + add := func(s string) { lines = append(lines, truncate(s, w)) } + mode := m.flowViewMode + if mode == "" { + mode = "Map" + } + add("Flow [m Map] [t Timeline] [s Sessions] · " + mode + " · last 60m") + add(fmt.Sprintf("◆ %d needs you ▶ %d ‖ %d ○ %d ✓ %d", len(b.Needs), b.Pulse["working"], b.Pulse["stalled"], b.Pulse["idle"], b.Pulse["finished"])) + add(view.BoardSpark(b.Spark) + " events/min · updated " + duration(max(time.Duration(0), m.opts.Now().Sub(b.ObservedAt))) + " ago") + add(fmt.Sprintf("NEEDS YOU · %d · oldest first", len(b.Needs))) + idx := 0 + if m.flowAttention { + idx = min(m.flowAskCursor, max(0, len(b.Needs)-1)) + } + if len(b.Needs) == 0 { + add(" Nothing needs you") + } else { + r := b.Needs[idx] + pre := " " + if m.flowAttention { + pre = "› " + } + add(pre + "◆ " + r.Title + " · " + r.Node.Project + " · " + r.Age) + command := r.Command + if command == "" { + command = r.WaitReason + } + // The complete command is available in a scrollable inspector on Enter; + // the shelf wraps it without ellipsis, including at terminal widths of 80. + for _, line := range strings.Split(wrapText(command, w-2), "\n") { + add(" " + line) + } + action := "Enter open · J Terminal" + if r.PromptID != "" { + action = "y Allow · n Deny · Enter open full command" + } + add(" " + action) + hits = append(hits, flowHit{y: 4, width: w, height: max(1, len(lines)-3), index: -idx - 1}) + shown := 1 + for i, r := range b.Needs { + if i == idx || shown >= 3 { + continue + } + add(" ◆ " + r.Title + " · " + r.Node.Project + " · " + r.Age) + shown++ + } + if len(b.Needs) > shown { + add(fmt.Sprintf(" +%d more · Tab then j/k", len(b.Needs)-shown)) + } + } + add(strings.Repeat("─", w)) + rows := m.boardRows() + m.boardCursor = max(0, min(m.boardCursor, len(rows)-1)) + type entry struct { + text string + index int + } + entries := []entry{} + titleW := min(38, max(20, w/3)) + trackW := max(4, w-titleW-25) + render := func(r view.BoardRow, i int) string { + pre := " " + if i == m.boardCursor && !m.flowAttention { + pre = "›" + } + title := boardPad(pre+" "+r.Glyph+" "+r.Title, max(5, titleW-r.Depth*3)) + if mode == "Sessions" { + return title + " " + boardPad(r.Node.Adapter, 8) + " " + boardPad(r.Node.Project, max(8, w-titleW-20)) + " " + r.Age + } + overlap := "" + if len(r.Overlaps) > 0 { + overlap = " ┄ " + r.Overlaps[0] + } + return view.TokenStyle(r.State, m.opts.NoColor).Render(title) + " " + boardPad(r.Node.Adapter, 7) + " " + m.boardTrack(view.BoardStrip(r, trackW)) + " " + r.Age + overlap + } + if mode == "Sessions" { + + for i, r := range rows { + entries = append(entries, entry{render(r, i), i}) + } + } else { + // Lip Gloss owns the tree connectors; the same preorder supplies hit targets. + for _, g := range b.Groups { + t := tree.New().Root(g.Name) + branch := map[string]*tree.Tree{} + indices := []int{} + for i, r := range rows { + if r.Node.Project != g.Name && !(r.Node.Project == "" && g.Name == "Unattached") { + continue + } + n := tree.New().Root(render(r, i)) + branch[r.Key] = n + if p := branch[r.Parent]; p != nil { + p.Child(n) + } else { + t.Child(n) + } + indices = append(indices, i) + } + text := strings.Split(t.String(), "\n") + for i, line := range text { + index := -1 + if i > 0 && i-1 < len(indices) { + index = indices[i-1] + } + entries = append(entries, entry{line, index}) + } + } + } + selected := 0 + for i, e := range entries { + if e.index == m.boardCursor { + selected = i + break + } + } + reserved := 6 + if panelW > 0 { + reserved = 0 + } + room := max(1, h-len(lines)-reserved) + start := max(0, selected-room+1) + start = min(start, max(0, len(entries)-room)) + for i := start; i < min(len(entries), start+room); i++ { + e := entries[i] + add(e.text) + if e.index >= 0 { + hits = append(hits, flowHit{y: len(lines) - 1, width: w, height: 1, index: e.index}) + } + } + if len(entries) == 0 { + add(" No sessions in this window") + } + detail := []string{} + if r := m.boardSelected(); r != nil { + detail = append(detail, r.Title+" · "+view.FlowJoin(r.Node.Project, r.Node.Adapter)+" · "+r.State) + family := tree.New().Root("−60m now") + familyNodes := map[string]*tree.Tree{} + shown := 0 + total := 0 + for _, g := range b.Groups { + for _, member := range g.Rows { + if member.Root != r.Root { + continue + } + total++ + if shown >= 5 { + continue + } + shown++ + text := member.Glyph + " " + member.Title + "\n" + view.BoardStrip(member, max(8, panelW-10)) + node := tree.New().Root(text) + familyNodes[member.Key] = node + if parent := familyNodes[member.Parent]; parent != nil { + parent.Child(node) + } else { + family.Child(node) + } + } + } + detail = append(detail, family.String()) + if total > shown { + detail = append(detail, fmt.Sprintf("+%d more · open session", total-shown)) + } + if r.Node.LastTool != "" { + detail = append(detail, "now "+r.Node.LastTool) + } + if len(r.Files) > 0 { + names := []string{} + for _, f := range r.Files { + names = append(names, f.Path) + } + detail = append(detail, "files "+strings.Join(names, " · ")) + for _, name := range r.Overlaps { + detail = append(detail, "┄ "+name+" · same file") + } + } + if tools := view.BoardTools(*r); tools != "" { + detail = append(detail, "tools "+tools+" · last hour") + } + if r.Cost != nil { + detail = append(detail, "cost "+boardMoney(r.Cost)+" · retained window") + } + } else { + detail = append(detail, "Select a session to see its family") + } + detail = append(detail, "J Terminal · Enter Open · c Ask Chat · K Stop") + if panelW > 0 { + left := strings.Join(lines, "\n") + right := framedPanel("Selected family", "", wrapText(strings.Join(detail, "\n"), panelW-4), panelW, m.opts.NoColor) + joined := strings.Split(lipgloss.JoinHorizontal(lipgloss.Top, lipgloss.NewStyle().Width(w).Render(left), " │ ", right), "\n") + for i := range joined { + joined[i] = strings.TrimRight(joined[i], " ") + } + return strings.Join(joined, "\n"), hits + } + add(strings.Repeat("─", w)) + if r := m.boardSelected(); r != nil { + add(r.Title + " · " + r.Node.Adapter + " · " + r.State) + if r.Node.LastTool != "" { + add("now " + r.Node.LastTool) + } + if tools := view.BoardTools(*r); tools != "" { + add(tools + " · last hour") + } + if r.Cost != nil { + add("cost " + boardMoney(r.Cost) + " · retained window") + } + } + add("Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find") + + return strings.Join(lines, "\n"), hits +} + +func (m *app) boardTrack(track string) string { + var out strings.Builder + for _, ch := range track { + token := "faint" + switch ch { + case '━': + token = "working" + case '◆': + token = "waiting" + case '─': + token = "idle" + case '✓', '●': + token = "finished" + case '┿': + token = "accent" + case '░': + token = "faint" + } + out.WriteString(view.TokenStyle(token, m.opts.NoColor).Render(string(ch))) + } + return out.String() +} + +func (m *app) selectFlowView(key string) tea.Cmd { + m.flowViewMode = map[string]string{"m": "Map", "t": "Timeline", "s": "Sessions"}[key] + if key == "s" { + m.tab = 0 + } else { + m.tab = flowTab + } + m.flowOffset = 0 + m.rebuild() + return nil +} diff --git a/internal/tui/board_test.go b/internal/tui/board_test.go new file mode 100644 index 00000000..09d1e620 --- /dev/null +++ b/internal/tui/board_test.go @@ -0,0 +1,302 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "context" + "encoding/json" + "fmt" + "github.com/charmbracelet/x/ansi" + "github.com/labstack/driga/internal/boardtest" + "github.com/labstack/driga/internal/chat" + "github.com/labstack/driga/internal/flowtest" + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/view" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func boardApp(t *testing.T, kind string) *app { + t.Helper() + m := setup(t, fixture(), Options{NoColor: true, Now: func() time.Time { return flowtest.Now }}).(*app) + m.snap.BoardData = boardtest.BoardFixture(kind, flowtest.Now) + m.tab = flowTab + m.width = 140 + m.height = 40 + m.loaded = true + m.rebuild() + return m +} +func TestBoardKeysViewportSelection(t *testing.T) { + m := boardApp(t, "busy") + original := m.boardSelected().Key + for i := 0; i < 32; i++ { + m.Update(tea.KeyPressMsg{Code: 'j'}) + } + chosen := m.boardSelected().Key + if chosen == original { + t.Fatal("selection did not move") + } + text, _ := m.boardLayout() + if !strings.Contains(text, "›") { + t.Fatal("selection invisible") + } + for _, w := range []int{80, 100, 140, 200} { + m.width = w + for _, h := range []int{24, 40, 60} { + m.height = h + body, _ := m.boardLayout() + if !strings.Contains(body, "›") { + t.Fatal("selected row offscreen", w, h, body) + } + if lipgloss.Height(body) > m.bodyHeight() { + t.Fatal("height overflow", w, h, lipgloss.Height(body), m.bodyHeight()) + } + for _, line := range strings.Split(body, "\n") { + if lipgloss.Width(line) > w { + t.Fatal("width overflow", w, line) + } + } + } + } + m.width = 140 + m.height = 40 + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + for i := 0; i < 20; i++ { + m.Update(tea.KeyPressMsg{Code: 'j'}) + } + r := m.boardSelected() + text, _ = m.boardLayout() + if r == nil || !strings.Contains(text, r.Title) || !strings.Contains(text, "› ◆") { + t.Fatal("selected wait hidden", text) + } + for _, tc := range []struct { + key rune + mode string + }{{'m', "Map"}, {'t', "Timeline"}, {'s', "Sessions"}} { + m.Update(tea.KeyPressMsg{Code: tc.key}) + if m.flowViewMode != tc.mode { + t.Fatal("view key", tc) + } + } + +} +func TestBoardDoesNotReorderSelection(t *testing.T) { + m := boardApp(t, "busy") + m.boardCursor = 8 + selected := m.boardSelected().Key + order := strings.Join(m.boardOrder, ",") + for i := range m.snap.BoardData.Nodes { + m.snap.BoardData.Nodes[i].LastActivityAt = flowtest.Now.Add(time.Duration(i) * time.Second) + } + m.syncBoard() + next := strings.Join(m.boardOrder, ",") + before := strings.Split(order, ",") + after := strings.Split(next, ",") + oldSet, newSet := map[string]bool{}, map[string]bool{} + for _, k := range before { + oldSet[k] = true + } + for _, k := range after { + newSet[k] = true + } + commonBefore, commonAfter := []string{}, []string{} + for _, k := range before { + if newSet[k] { + commonBefore = append(commonBefore, k) + } + } + for _, k := range after { + if oldSet[k] { + commonAfter = append(commonAfter, k) + } + } + if strings.Join(commonBefore, ",") != strings.Join(commonAfter, ",") || m.boardSelected().Key != selected { + t.Fatal("existing rows or selection reordered") + } + +} +func TestBoardGoldenFrames(t *testing.T) { + for _, kind := range []string{"busy", "quiet", "empty"} { + for _, width := range []int{80, 100, 140, 200} { + name := fmt.Sprintf("board-%s-%d", kind, width) + t.Run(name, func(t *testing.T) { + m := boardApp(t, kind) + m.width = width + m.height = 40 + got := m.flowView() + "\n" + if strings.Contains(got, "\x1b") { + t.Fatal("no-color emitted escapes") + } + path := filepath.Join("testdata", name+".golden") + if os.Getenv("UPDATE_FLOW_GOLDEN") == "1" { + if err := os.WriteFile(path, []byte(got), 0644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.ReplaceAll(string(want), "\r\n", "\n") != got { + t.Fatal("golden drift", path) + } + if dir := os.Getenv("FLOW_SHOTS_DIR"); dir != "" { + if err := os.WriteFile(filepath.Join(dir, name+".txt"), []byte(got), 0644); err != nil { + t.Fatal(err) + } + } + }) + } + } +} + +func TestBoardThemesAndMotionKeepGeometry(t *testing.T) { + defer func() { _ = view.SetTheme("auto") }() + for _, width := range []int{80, 100, 140, 200} { + m := boardApp(t, "busy") + m.width = width + plain := m.flowView() + for _, theme := range []string{"latte", "mocha"} { + if err := view.SetTheme(theme); err != nil { + t.Fatal(err) + } + m.opts.NoColor = false + for _, off := range []bool{false, true} { + m.opts.MotionOff = off + got := m.flowView() + if ansi.Strip(got) != plain { + a, b := strings.Split(plain, "\n"), strings.Split(ansi.Strip(got), "\n") + for i := range a { + if i < len(b) && a[i] != b[i] { + t.Fatalf("theme geometry %s %d line %d: %q => %q", theme, width, i, a[i], b[i]) + } + } + t.Fatal("theme or motion changed board geometry", theme, width) + } + } + } + } +} + +func TestBoardPromptSafetyAndMachineReview(t *testing.T) { + m := boardApp(t, "busy") + now := flowtest.Now + m.opts.Now = func() time.Time { return now } + m.snap.LocalMachineID = "local" + r := m.board.Needs[0] + client := &promptClient{} + m.opts.Chat = client + m.prompts = []chat.Prompt{{ID: "board-prompt", SessionID: r.Node.ID}} + m.Update(tea.KeyPressMsg{Code: 'y'}) + if client.id != "" { + t.Fatal("unfocused prompt answered") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m.Update(tea.KeyPressMsg{Code: 'y'}) + if client.id != "" { + t.Fatal("prompt bypassed arming") + } + now = now.Add(time.Second) + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y'}) + execute(t, m, cmd) + if client.id != "board-prompt" || !client.allow { + t.Fatal("answer not dispatched") + } +} +func TestBoardStopNeedsConfirmation(t *testing.T) { + m := boardApp(t, "busy") + var stopped []int64 + m.opts.SessionAction = func(_ context.Context, action string, id int64) error { + if action == "kill" { + stopped = append(stopped, id) + } + return nil + } + for m.boardSelected().State == "finished" || !m.boardSelected().Local { + m.Update(tea.KeyPressMsg{Code: 'j'}) + } + id := m.boardSelected().Node.ID + m.Update(tea.KeyPressMsg{Code: 'K'}) + m.Update(tea.KeyPressMsg{Code: 'j'}) + if m.boardStop != 0 || len(stopped) != 0 { + t.Fatal("another key must cancel a pending stop") + } + m.Update(tea.KeyPressMsg{Code: 'k'}) + m.Update(tea.KeyPressMsg{Code: 'K'}) + m.Update(tea.KeyPressMsg{Code: 'n'}) + if len(stopped) != 0 { + t.Fatal("n stopped the session") + } + m.Update(tea.KeyPressMsg{Code: 'K'}) + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y'}) + execute(t, m, cmd) + if len(stopped) != 1 || stopped[0] != id { + t.Fatal("confirmed stop not dispatched", stopped, id) + } + for m.boardSelected().Local { + m.Update(tea.KeyPressMsg{Code: 'j'}) + } + m.Update(tea.KeyPressMsg{Code: 'K'}) + if m.boardStop != 0 { + t.Fatal("remote session offered a local stop") + } +} + +func TestBoardMachineScopeCounts(t *testing.T) { + m := boardApp(t, "busy") + m.machineScope = "studio-mac" + m.syncBoard() + total := 0 + for _, v := range m.board.Pulse { + total += v + } + if total != 11 { + t.Fatal("machine scope counts", total) + } + for _, g := range m.board.Groups { + for _, r := range g.Rows { + if r.Node.MachineID != "studio-mac" { + t.Fatal("machine scope leaked row") + } + } + } +} + +func TestFlowEnterOpensAndTerminalUsesStoredDestination(t *testing.T) { + m := boardApp(t, "quiet") + calls := 0 + m.opts.Exec = func([]string) error { calls++; return nil } + m.snap.LocalMachineID = "local" + m.snap.BoardData.Nodes[0].MachineID = "local" + m.snap.BoardData.Nodes[0].Jump = fixtureLocalJump(t, model.InboxJump{Kind: "tmux", Server: "/tmp/fixture", Target: "dev:1.2", Command: "tmux attach -t 'dev:1.2'"}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.detail || calls != 0 { + t.Fatal("Enter executed a jump") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + _, cmd := m.Update(tea.KeyPressMsg{Code: 'J'}) + if cmd != nil { + execute(t, m, cmd) + } + if calls == 0 { + t.Fatal("Terminal did not use stored destination", m.status) + } +} + +func TestFlowFullPromptInspector(t *testing.T) { + m := boardApp(t, "busy") + m.snap.LocalMachineID = "local" + r := m.board.Needs[0] + command := strings.Repeat("long-command ", 120) + "end-marker" + raw, _ := json.Marshal(map[string]string{"command": command}) + m.prompts = []chat.Prompt{{ID: "full", SessionID: r.Node.ID, Input: raw, Tool: "Bash"}} + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.detail || !strings.Contains(m.detailText, "end-marker") || strings.Contains(m.detailText, "…") { + t.Fatal("prompt command truncated") + } +} diff --git a/internal/tui/chat.go b/internal/tui/chat.go index 729955b7..4f985d95 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -51,7 +51,9 @@ func newChatInput() textarea.Model { } func (m *app) openChat() tea.Cmd { if m.tab != chatTab { - m.chatContext = chat.Context{Page: map[int]string{homeTab: "home", 0: "agents", 1: "projects", 2: "inbox", 3: "orphans", 4: "machines", 5: "cleanup", flowTab: "flow", chatTab: "chat"}[m.tab], Machine: m.machineScope, Project: m.opts.Project} + m.chatOrigin = m.tab + m.chatOriginDetail = m.detail + m.chatContext = chat.Context{Page: map[int]string{0: "agents", 1: "projects", 2: "inbox", 3: "orphans", 4: "machines", 5: "cleanup", flowTab: "flow", chatTab: "chat"}[m.tab], Machine: m.machineScope, Project: m.opts.Project} if m.tab == flowTab { if n := m.flowSelected(); n != nil { m.chatContext.SessionID = n.ID @@ -368,10 +370,7 @@ func (m *app) chatContextLine() string { } find(m.snap.Agents.Rows) if title == "" { - nodes := m.flow.Children - if m.flow.Lead != nil { - nodes = append([]view.FlowNode{*m.flow.Lead}, nodes...) - } + nodes := m.snap.BoardData.Nodes for _, node := range nodes { if node.ID == scope.SessionID && (scope.Machine == "" || node.MachineID == "" || scope.Machine == node.MachineID || scope.Machine == "local" && !m.remote(node.MachineID)) { title = node.Title @@ -405,15 +404,9 @@ func nextChatTick(generation uint64) tea.Cmd { // selectTab is shared by normal navigation and Alt+N, including focused inputs. func (m *app) selectTab(number byte) tea.Cmd { - if number == '8' { - return m.openChat() - } - tab := int(number - '1') - if number == '0' { - tab = homeTab - } - if number == '7' { - tab = flowTab + tab, ok := map[byte]int{'0': flowTab, '1': 1, '2': 4}[number] + if !ok { + return nil } if !m.tabVisible(tab) { return nil @@ -426,21 +419,10 @@ func (m *app) selectTab(number byte) tea.Cmd { m.filtering = false m.filter.Blur() m.help = false - if tab == flowTab { - m.flowID = 0 - m.flowExpanded = false - m.flowOffset = 0 - m.cursors[flowTab] = 0 - m.flowPulse, m.flowSeen = nil, nil - } m.tab = tab - m.homeOffset = 0 m.detail = false m.showAckPending() m.rebuild() - if tab == flowTab { - return m.loadFlow() - } if tab == 5 && m.opts.CleanupList != nil && !m.busy { return m.loadCleanup() } @@ -776,3 +758,27 @@ type chatBodyRender struct { width int noColor, dark bool } + +// chatPanelView keeps the current destination visible beside contextual Chat. +// Narrow terminals devote the available reading width to the panel. +func (m *app) chatPanelView() string { + if m.width < 120 { + return m.chatView() + } + width := m.width + panelWidth := min(80, max(48, width/2)) + m.width = panelWidth + right := m.chatView() + m.width = width - panelWidth - 3 + tab := m.tab + m.tab = m.chatOrigin + left := m.table.View() + if m.tab == flowTab { + left, _ = m.boardLayout() + } else if m.tab == 4 { + left = m.machineTable(left) + } + m.tab = tab + m.width = width + return lipgloss.JoinHorizontal(lipgloss.Top, lipgloss.NewStyle().Width(width-panelWidth-3).Render(left), " │ ", lipgloss.NewStyle().Width(panelWidth).Render(right)) +} diff --git a/internal/tui/chat_test.go b/internal/tui/chat_test.go index df41bb6e..b6fa12f0 100644 --- a/internal/tui/chat_test.go +++ b/internal/tui/chat_test.go @@ -35,7 +35,7 @@ func TestChatSelectionAndAsync(t *testing.T) { } defer b.Close() m := setup(t, fixture(), Options{Chat: b}).(*app) - m = key(m, '1').(*app) + m = key(key(m, '0'), 's').(*app) model, cmd := press(m, 'c') if model.(*app).tab != chatTab || cmd == nil { t.Fatal("Chat did not open asynchronously") @@ -213,78 +213,24 @@ func TestChatFocusNavigation(t *testing.T) { t.Fatal("typing routed to navigation") } m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.chatInput.Focused() { - t.Fatal("idle Escape must blur") - } - if !strings.Contains(m.footer(), "c type · m trust mode · N tabs · ? help · q quit") { - t.Fatal(m.footer()) - } - key(m, '?') - if !m.help { - t.Fatal("navigate help unreachable") - } - m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.help { - t.Fatal("help did not close") - } - key(m, '/') - if !m.filtering { - t.Fatal("navigate filter unreachable") - } - m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if !m.chatInput.Focused() { - t.Fatal("Enter must type") - } - if !strings.Contains(m.footer(), "esc navigate · enter send · shift+enter new line · alt+N tabs") { - t.Fatal(m.footer()) + if m.tab != flowTab || m.chatInput.Focused() { + t.Fatal("Escape must close panel") } for _, typing := range []bool{true, false} { - for n := '0'; n <= '8'; n++ { + for n := '0'; n <= '2'; n++ { m.openChat() if !typing { m.chatInput.Blur() } m.Update(tea.KeyPressMsg{Code: n, Mod: tea.ModAlt}) - want := int(n - '1') - if n == '0' { - want = homeTab - } - if n == '7' { - want = flowTab - } - if n == '8' { - want = chatTab - } - if m.tab != want { - t.Fatalf("alt+%c typing=%v: tab %d", n, typing, m.tab) + if m.tab != map[rune]int{'0': flowTab, '1': 1, '2': 4}[n] { + t.Fatal("alt tab", n, m.tab) } if m.chatInput.Value() != "draft textq" { - t.Fatal("tab switch lost draft") + t.Fatal("draft lost") } } } - m.openChat() - m.chatInput.Blur() - key(m, '1') - if m.tab != 0 { - t.Fatal("navigate tab unreachable") - } - key(m, '8') - if m.chatInput.Value() != "draft textq" || !m.chatInput.Focused() { - t.Fatal("return did not restore draft") - } - m.chatInput.Blur() - _, cmd := m.Update(tea.KeyPressMsg{Code: 'q'}) - if cmd == nil { - t.Fatal("navigate quit unreachable") - } - m.openChat() - m.chatInput.Blur() - key(m, 'c') - if !m.chatInput.Focused() { - t.Fatal("c must type") - } } func TestChatActivityStates(t *testing.T) { @@ -335,48 +281,20 @@ func TestChatActivityStates(t *testing.T) { } } -func TestChatEscapeStopsBeforeBlurring(t *testing.T) { - st, err := store.Open("", store.Memory) - if err != nil { - t.Fatal(err) - } - defer st.Close() - a := partialTUIAgent{make(chan struct{}, 2), make(chan struct{}, 2)} - b, err := chat.New(context.Background(), st, a, chat.Options{DataDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - defer b.Close() - m := setup(t, fixture(), Options{Chat: b}).(*app) - m = execute(t, m, m.openChat()).(*app) - if err := b.Send(context.Background(), m.chatID, "test", chat.Context{}); err != nil { - t.Fatal(err) - } - select { - case <-a.emitted: - case <-time.After(3 * time.Second): - t.Fatal("no reply") - } - m = execute(t, m, m.readChat()).(*app) +func TestChatEscapeClosesAndRetainsDraft(t *testing.T) { + m := setup(t, fixture(), Options{}).(*app) + m.openChat() m.chatInput.SetValue("keep me") - _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - if cmd == nil || !m.chatInput.Focused() { - t.Fatal("active Escape must stop before blurring") - } - execute(t, m, cmd) - deadline := time.Now().Add(3 * time.Second) - for m.chatActive && time.Now().Before(deadline) { - m = execute(t, m, m.readChat()).(*app) - if m.chatActive { - time.Sleep(time.Millisecond) - } - } - if m.chatActive { - t.Fatal("reply did not stop") + m.chatActive = true + // Esc first stops a running reply, as the footer says; it does not close. + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.tab != chatTab { + t.Fatal("Esc closed Chat while a reply was running") } + m.chatActive = false m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.chatInput.Focused() || m.chatInput.Value() != "keep me" { - t.Fatal("second Escape must blur and retain draft") + if m.tab != flowTab || m.chatInput.Focused() || m.chatInput.Value() != "keep me" { + t.Fatal("close lost context or draft") } } @@ -527,7 +445,7 @@ func TestChatNavigateControlsAndAltOverlay(t *testing.T) { m.chatInput.Blur() key(m, '?') m.Update(tea.KeyPressMsg{Code: '1', Mod: tea.ModAlt}) - if m.help || m.tab != 0 { + if m.help || m.tab != 1 { t.Fatal("Alt tabs did not dismiss help") } m.openChat() @@ -542,7 +460,7 @@ func TestChatNavigateControlsAndAltOverlay(t *testing.T) { t.Fatal("navigate chat controls") } m.confirm = &confirmation{} - m.Update(tea.KeyPressMsg{Code: '8', Mod: tea.ModAlt}) + m.Update(tea.KeyPressMsg{Code: '0', Mod: tea.ModAlt}) if m.confirm != nil { t.Fatal("Alt tabs kept a stale confirmation") } diff --git a/internal/tui/cleanup_test.go b/internal/tui/cleanup_test.go index b89408bc..0fc273a5 100644 --- a/internal/tui/cleanup_test.go +++ b/internal/tui/cleanup_test.go @@ -17,8 +17,8 @@ func TestCleanupTab(t *testing.T) { calls++ return cleanup.Result{}, nil }}) - m = key(m, '6') - if !strings.Contains(m.View().Content, "Cleanup") || !strings.Contains(m.View().Content, "ready") { + m = key(key(m, '2'), 'd') + if !strings.Contains(m.View().Content, "reclaim disk") || !strings.Contains(m.View().Content, "ready") { t.Fatal(m.View().Content) } next, cmd := press(m, tea.KeyEnter) diff --git a/internal/tui/components.go b/internal/tui/components.go new file mode 100644 index 00000000..e030dde1 --- /dev/null +++ b/internal/tui/components.go @@ -0,0 +1,201 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/labstack/driga/internal/textfmt" + + "charm.land/lipgloss/v2" + "github.com/NimbleMarkets/ntcharts/v2/barchart" + "github.com/labstack/driga/internal/view" +) + +func (m *app) eachAgent(fn func(view.AgentRow)) { + var walk func([]view.AgentRow) + walk = func(rows []view.AgentRow) { + for _, r := range rows { + if m.machineVisible(r.MachineID) { + fn(r) + } + walk(r.Children) + } + } + walk(m.snap.Agents.Rows) +} + +// panel keeps detail panes consistent with the shared rounded frames. +func panel(title, body string, width int) string { return framedPanel(title, "", body, width, true) } + +func framedPanel(title, hint, body string, width int, noColor bool) string { + width = max(4, width) + inner := width - 4 + border := view.TokenStyle("line", noColor) + heading := " " + truncate(clean(title), max(1, width-6)) + " " + hint = clean(hint) + room := width - 2 - lipgloss.Width(heading) - 2 + right := "" + if room > 4 && hint != "" { + right = " " + truncate(hint, room-2) + " " + } + headingStyle := view.TokenStyle("ink", noColor) + if !noColor { + headingStyle = headingStyle.Bold(true) + } + top := border.Render("╭") + headingStyle.Render(heading) + border.Render(strings.Repeat("─", max(0, width-2-lipgloss.Width(heading)-lipgloss.Width(right)))) + view.TokenStyle("faint", noColor).Render(right) + border.Render("╮") + lines := []string{top} + for _, line := range strings.Split(body, "\n") { + line = truncate(line, inner) + lines = append(lines, border.Render("│")+" "+line+strings.Repeat(" ", max(0, inner-lipgloss.Width(line)))+" "+border.Render("│")) + } + lines = append(lines, border.Render("╰"+strings.Repeat("─", max(0, width-2))+"╯")) + return strings.Join(lines, "\n") +} + +func (m *app) stateText(text string, label ...string) string { + state := text + if len(label) > 0 { + state = label[0] + } else if fields := strings.Fields(text); len(fields) > 0 { + state = fields[0] + } + return view.StateStyle(state, m.opts.NoColor).Render(text) +} + +func bar(value, total float64, width int, style lipgloss.Style) string { + b := barchart.New(max(1, width), 1, barchart.WithHorizontalBars(), barchart.WithNoAxis(), barchart.WithNoAutoMaxValue(), barchart.WithMaxValue(max(1, total)), barchart.WithBarGap(0)) + b.Push(barchart.BarData{Values: []barchart.BarValue{{Value: min(value, total), Style: style}}}) + b.Draw() + return b.View() +} + +// gaugeBar draws the full capacity, including the unused track at zero usage. +func gaugeBar(used, total uint64, width int, noColor bool) string { + width = max(1, width) + filled := min(width, int(view.GaugePercent(used, total)*float64(width)/100)) + track := strings.Repeat("░", width-filled) + if !noColor { + track = lipgloss.NewStyle().Faint(true).Render(track) + } + return view.GaugeStyle(used, total, noColor).Render(strings.Repeat("█", filled)) + track +} + +// activityMinuteColumn maps a minute to its first column, or its peak bin. +// Extra columns go to the oldest minutes first. +func activityMinuteColumn(minute, minutes, width int) int { + if width >= minutes { + return minute*(width/minutes) + min(minute, width%minutes) + } + return ((minute+1)*width - 1) / minutes +} + +// activitySpark fills the width with minute blocks, binning peaks when space is tight. +// A bin containing a missing minute remains a dim dot rather than inventing history. +func activitySpark(buckets []view.ActivityBucket, width int, load bool) string { + return styledSpark(buckets, width, load, true) +} + +func styledSpark(buckets []view.ActivityBucket, width int, load, noColor bool) string { + count := min(max(1, width), len(buckets)) + ceiling := 1.0 + for _, b := range buckets { + if load && b.Sample != nil { + ceiling = max(ceiling, b.Sample.Load1, float64(b.Sample.Cores)) + } else if !load { + ceiling = max(ceiling, float64(b.Events)) + } + } + cells := make([]textfmt.SparkCell, 0, count) + for x := 0; x < count; x++ { + from, to := x*len(buckets)/count, (x+1)*len(buckets)/count + span := activityMinuteColumn(x+1, count, max(1, width)) - activityMinuteColumn(x, count, max(1, width)) + value, valid, critical := 0.0, true, false + for _, b := range buckets[from:to] { + if load { + if b.Sample == nil || load && b.Sample.Load1 < 0 { + valid = false + continue + } + value = max(value, b.Sample.Load1) + critical = critical || b.Sample.Cores > 0 && b.Sample.Load1 > float64(b.Sample.Cores) + } else { + if b.Sample == nil && b.Events == 0 { + valid = false + } + value = max(value, float64(b.Events)) + } + } + if !valid { + cells = append(cells, textfmt.SparkCell{Missing: true, Span: span, Token: "faint"}) + continue + } + token := "idle" + if load { + token = "ink" + if critical { + token = "crit" + } + } + cells = append(cells, textfmt.SparkCell{Value: value, Span: span, Token: token}) + } + return textfmt.Spark(cells, ceiling, '▁', textfmt.SparkFloor, func(token, text string) string { return view.TokenStyle(token, noColor).Render(text) }) +} + +func activityRow(buckets []view.ActivityBucket, width int, load, noColor bool) string { + label, current, peak, token := "events ", "—", 0.0, "idle" + for _, b := range buckets { + if load { + if b.Sample != nil { + peak = max(peak, b.Sample.Load1) + } + } else { + peak = max(peak, float64(b.Events)) + } + } + if len(buckets) > 0 { + last := buckets[len(buckets)-1] + if load { + if last.Sample != nil { + current = view.FormatLoad(last.Sample.Load1, "%.1f") + if last.Sample.Cores > 0 && last.Sample.Load1 > float64(last.Sample.Cores) { + token = "crit" + } + } + } else if last.Sample != nil || last.Events > 0 { + current = fmt.Sprintf("%d/min", last.Events) + } + } + summary := fmt.Sprintf("%s max %.0f", current, peak) + if load { + label = "load " + if token != "crit" { + token = "ink" + } + summary = fmt.Sprintf("%s max %.1f", current, peak) + } + // Reserve the same value column for both rows and the shared time axis. + spark := styledSpark(buckets, max(1, width-8-max(18, lipgloss.Width(summary))-1), load, noColor) + return view.TokenStyle("faint", noColor).Render(label) + spark + " " + view.TokenStyle(token, noColor).Render(summary) +} + +func (m *app) splitDetail() bool { return m.tab == 0 && m.width >= 140 && !m.detail } + +func (m *app) agentsSplitView() string { + left := m.width / 2 + right := m.width - left - 3 + text := "Select an agent" + if r, ok := m.selected(); ok { + if d, found := m.snap.AgentDetails[r.agent]; found { + text = m.styledAgentDetail(d, max(1, right-4)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, lipgloss.NewStyle().Width(left).Render(m.table.View()), " │ ", panel("Agent detail", text, right)) +} + +func (m *app) styledAgentDetail(d view.AgentView, width int) string { + return m.styleAgentState(agentDetail(d, width), d.Label) +} + +func (m *app) styleAgentState(text, label string) string { + return strings.Replace(text, "State: "+clean(label), "State: "+view.StateStyle(label, m.opts.NoColor).Render(clean(label)), 1) +} diff --git a/internal/tui/design_test.go b/internal/tui/design_test.go index f4326020..09aa7677 100644 --- a/internal/tui/design_test.go +++ b/internal/tui/design_test.go @@ -4,9 +4,7 @@ import ( "strings" "testing" - tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/labstack/driga/internal/cleanup" "github.com/labstack/driga/internal/model" "github.com/labstack/driga/internal/view" ) @@ -37,69 +35,31 @@ func TestHeaderGaugeLevels(t *testing.T) { } } -func TestTabCountsAndInboxColor(t *testing.T) { +func TestThreeTabsOnEveryDestination(t *testing.T) { m := setup(t, fixture(), Options{}).(*app) m.width = 160 - for _, want := range []string{"1 Agents 1", "2 Projects", "3 Inbox 1", "4 Orphans", "6 Cleanup 0"} { - if !strings.Contains(m.tabBar(), want) { - t.Errorf("missing %q: %s", want, m.tabBar()) - } - } - m.opts.NoColor = false - if !strings.Contains(m.tabBar(), view.StateStyle("waiting", false).Render("1")) { - t.Fatal("Inbox count lacks amber color") - } -} - -func TestHomeDesignWidthsAndSourceStatus(t *testing.T) { - for _, noColor := range []bool{true, false} { - for _, width := range []int{80, 120, 160} { - s := fixture() - s.snap.Freshness = map[string]view.Fresh{"spool": {LastOKAt: s.snap.ObservedAt}, "otlp": {Status: "not_yet_receiving"}} - s.snap.Machine.Sample = model.MachineSample{TS: s.snap.ObservedAt, MemUsed: 85, MemTotal: 100, SwapUsed: 98, SwapTotal: 100, Load1: 16.9, Cores: 14} - s.snap.Summary.Sample = s.snap.Machine.Sample - s.snap.Activity.Machines = []view.ActivityMachine{{Buckets: make([]view.ActivityBucket, 60)}} - s.snap.Activity.Machines[0].Buckets[59] = view.ActivityBucket{Sample: &s.snap.Machine.Sample, Events: 8} - m := setup(t, s, Options{}).(*app) - m.opts.NoColor = noColor - m.Update(tea.WindowSizeMsg{Width: width, Height: 44}) - for _, text := range []string{m.header(), m.tabBar(), m.homeView(), m.View().Content} { - for _, line := range strings.Split(text, "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("width=%d noColor=%v: %q", width, noColor, line) - } - } - if noColor && strings.Contains(text, "\x1b") { - t.Fatal("NO_COLOR emitted ANSI") - } + for _, tab := range []int{flowTab, 0, 1, 2, 3, 4, 5, chatTab} { + m.tab = tab + bar := m.tabBar() + for _, name := range []string{"0 Flow", "1 Projects", "2 Machines"} { + if !strings.Contains(bar, name) { + t.Fatal(tab, bar) } - for _, want := range []string{"hooks", "telemetry", "waiting for first event", "−60m", "now", "14 cores"} { - if !strings.Contains(m.homeView(), want) { - t.Errorf("width %d missing %s", width, want) - } - } - if width == 160 { - for _, want := range []string{"Needs you", "Agents", "Machine", "Activity", "Projects", "Leftovers"} { - if !strings.Contains(m.View().Content, want) { - t.Errorf("160x44 missing panel %s", want) - } - } + } + for _, name := range []string{"Home", "Agents", "Inbox", "Orphans", "Cleanup", "Chat"} { + if strings.Contains(bar, name) { + t.Fatal("removed tab", bar) } } } } -func TestTabCleanupCountFollowsMachineScope(t *testing.T) { - s := fleetSource() - s.snap.LocalMachineID = "local" - s.snap.Cleanup = []cleanup.Candidate{{ID: "one"}, {ID: "two"}} - m := setup(t, s, Options{}).(*app) +func TestMachineScopeKeepsThreeDestinations(t *testing.T) { + m := setup(t, fleetSource(), Options{}).(*app) m.width = 160 - if !strings.Contains(m.tabBar(), "6 Cleanup 2") { - t.Fatal(m.tabBar()) - } + before := m.tabBar() m.machineScope = "remote" - if !strings.Contains(m.tabBar(), "6 Cleanup 0") { - t.Fatal(m.tabBar()) + if m.tabBar() != before { + t.Fatal("scope removed navigation") } } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index b2d7daab..09b26df6 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -60,6 +60,15 @@ func (m *app) openDetail(r tabRow) { if d, ok := m.snap.AgentDetails[r.agent]; ok { render(d) } + for _, n := range m.snap.BoardData.Nodes { + if n.ID == r.agent && (r.machine == "" || n.MachineID == r.machine) { + fmt.Fprintf(&buf, "\nState evidence\nverdict %s · %d of 4 agree\n%s\n", n.Mixer.Verdict, n.Mixer.Agreement, clean(n.Mixer.Evidence)) + for _, signal := range n.Mixer.Rows { + fmt.Fprintf(&buf, "%s: %s\n", signal.Name, clean(signal.Reading)) + } + break + } + } case r.project != 0: if d, ok := m.snap.ProjectDetails[r.project]; ok { render(d) diff --git a/internal/tui/fleet_test.go b/internal/tui/fleet_test.go index d03fdf32..8aa2c6b9 100644 --- a/internal/tui/fleet_test.go +++ b/internal/tui/fleet_test.go @@ -20,7 +20,7 @@ func fleetSource() *fakeSource { } func TestTUIHeaderAndMachineColumn(t *testing.T) { m := setup(t, fleetSource(), Options{}) - m = key(m, '1') + m = key(key(m, '0'), 's') text := m.View().Content for _, want := range []string{"+1", "vm-1 stale 12m", "MACHINE"} { if !strings.Contains(text, want) { @@ -32,11 +32,11 @@ func TestTUIHeaderAndMachineColumn(t *testing.T) { t.Fatalf("wide: %q", line) } } - m = key(m, 'm') + m = key(m, 'M') if strings.Contains(m.View().Content, "remote title") { t.Fatal("local scope contains remote row") } - m = key(m, 'm') + m = key(m, 'M') if strings.Contains(m.View().Content, "test title") { t.Fatal("remote scope contains local row") } @@ -44,7 +44,7 @@ func TestTUIHeaderAndMachineColumn(t *testing.T) { func TestStaleRowsDimmed(t *testing.T) { t.Setenv("NO_COLOR", "1") m := setup(t, fleetSource(), Options{}).(*app) - key(m, '1') + key(key(m, '0'), 's') m.opts.NoColor = false m.rebuild() text := m.View().Content @@ -58,7 +58,7 @@ func TestTUIKillRefusesRemoteOrphan(t *testing.T) { s.snap.Orphans.Rows[0].Orphan.MachineID = "remote" calls := 0 m := setup(t, s, Options{Spool: func(model.HookEvent) error { calls++; return nil }}) - m = key(m, '4') + m = key(key(m, '2'), 'p') m = key(m, 'k') if !strings.Contains(m.View().Content, "orphan is on vm-1; run driga orphans kill there") || m.(*app).confirm != nil || calls != 0 { t.Fatal(m.View().Content) @@ -70,12 +70,12 @@ func TestTUIMachineScopeCountsAndHealth(t *testing.T) { s.snap.Summary.Agents = 2 s.snap.Machine = view.MachineView{MachineID: "local", Sample: model.MachineSample{MemUsed: 100 << 20, MemTotal: 200 << 20}, Machines: []view.MachineView{{MachineID: "local", Sample: model.MachineSample{MemUsed: 100 << 20, MemTotal: 200 << 20}}, {MachineID: "remote", Sample: model.MachineSample{MemUsed: 300 << 20, MemTotal: 400 << 20}}}} m := setup(t, s, Options{}) - m = key(m, 'm') - if !strings.Contains(m.View().Content, "1 Agents 1") { + m = key(m, 'M') + if !strings.Contains(m.View().Content, "0 Flow") { t.Fatal(m.View().Content) } - m = key(m, 'm') - m = key(m, '5') + m = key(m, 'M') + m = key(m, '2') if !strings.Contains(m.View().Content, "300.0M/400.0M") || strings.Contains(m.View().Content, "100.0M/200.0M") { t.Fatal(m.View().Content) } diff --git a/internal/tui/flow.go b/internal/tui/flow.go index 6e0f0968..73cb8c27 100644 --- a/internal/tui/flow.go +++ b/internal/tui/flow.go @@ -1,50 +1,15 @@ package tui import ( - "fmt" - "strings" - "time" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/labstack/driga/internal/attention" "github.com/labstack/driga/internal/model" "github.com/labstack/driga/internal/store" "github.com/labstack/driga/internal/view" - "github.com/labstack/driga/internal/viewer" ) -const flowTab = 7 - -type flowMsg struct { - id int64 - scope string - flow view.FlowView - details map[int64]view.AgentView - err error -} -type flowEventMsg struct{ event view.TailEvent } +const flowTab = 7 // Internal pane identity; the public tab number is 0. type flowHit struct{ x, y, width, height, index int } -func (m *app) loadFlow() tea.Cmd { - source, ok := m.src.(viewer.FlowSource) - if !ok { - return nil - } - id, scope, ctx := m.flowID, m.flowScope(), m.opts.Context - minutes := m.flowZoom() - scopeKey := m.machineScope - return func() tea.Msg { - var f view.FlowView - var err error - if window, ok := m.src.(viewer.FlowWindowSource); ok && minutes != 60 { - f, err = window.LoadFlowWindow(ctx, scope, id, minutes) - } else { - f, err = source.LoadFlow(ctx, scope, id) - } - return flowMsg{id: id, scope: scopeKey, flow: f, err: err} - } -} func (m *app) flowScope() store.Scope { scope := store.Scope{} if m.machineScope != "" && m.machineScope != "all" { @@ -56,90 +21,10 @@ func (m *app) flowScope() store.Scope { } return scope } -func (m *app) flowSession(delta int) { - roots := []view.AgentRow{} - for _, r := range m.snap.Agents.Rows { - if m.machineVisible(r.MachineID) && !strings.HasPrefix(r.Origin, "hosted") { - roots = append(roots, r) - } - } - if len(roots) == 0 { - return - } - i := 0 - if m.flow.Lead != nil { - for n, r := range roots { - if r.ID == m.flow.Lead.ID { - i = n - break - } - } - } - i = (i + delta + len(roots)) % len(roots) - m.flowID = roots[i].ID - m.flowSelectID = 0 - m.flowExpanded = false - m.cursors[flowTab] = 0 - m.flowPulse = nil - m.flowBranchPulse = nil - m.flowSeen = nil - m.flowOffset = 0 -} -func (m *app) recordFlowEvent(e view.TailEvent) { - if m.flow.Lead == nil || m.flow.Lead.MachineID != e.MachineID || m.flowSeen[e.Key] { - return - } - nodes := append([]view.FlowNode{*m.flow.Lead}, m.flow.Children...) - for _, n := range nodes { - if e.Adapter == n.Adapter && e.ExternalID == n.ExternalID { - if m.flowPulse == nil { - m.flowPulse = map[string]time.Time{} - } - if m.flowSeen == nil || len(m.flowSeen) > 400 { - m.flowSeen = map[string]bool{} - } - m.flowSeen[e.Key] = true - m.flowPulse[e.Adapter+":"+e.ExternalID] = m.opts.Now().Add(time.Second) - return - } - } -} -func (m *app) flowPanel(title string, lines []string, width int, selected bool) string { - width = max(4, width) - inner := width - 4 - header := "╭─ " + truncate(clean(title), width-6) + " " - header += strings.Repeat("─", max(0, width-lipgloss.Width(header)-1)) + "╮" - out := []string{header} - for _, line := range lines { - line = strings.ReplaceAll(line, "\t", " ") - line = truncate(line, inner) - out = append(out, "│ "+line+strings.Repeat(" ", max(0, inner-lipgloss.Width(line)))+" │") - } - out = append(out, "╰"+strings.Repeat("─", width-2)+"╯") - text := strings.Join(out, "\n") - if selected && !m.opts.NoColor { - text = view.TokenStyle("accent", false).Render(text) - } - return text -} -func (m *app) flowWorkers() view.FlowWorkers { - w := m.flow.Workers(0, m.flowExpanded, m.opts.Now()) - if m.flow.Lead != nil { - w.Nodes = append([]view.FlowNode{*m.flow.Lead}, w.Nodes...) - } - return w -} -func (m *app) flowZoom() int { - if m.flowMinutes == 0 { - return 60 - } - return m.flowMinutes -} + func (m *app) flowSelected() *view.FlowNode { - w := m.flowWorkers() - i := m.cursors[flowTab] - if i >= 0 && i < len(w.Nodes) { - return &w.Nodes[i] + if r := m.boardSelected(); r != nil { + return &r.Node } return nil } @@ -148,359 +33,12 @@ func (m *app) jumpFlow() tea.Cmd { if n == nil { return nil } - j := n.Jump - for _, w := range m.flow.Attention.Waits { - if w.SessionID == n.ID && w.MachineID == n.MachineID && w.Jump != nil { - j = w.Jump - break - } - } - return m.jumpInbox(tabRow{machine: n.MachineID, inbox: &model.InboxItem{Jump: j}}) -} -func (m *app) jumpFlowAttention(w view.InboxRow) tea.Cmd { - jump := m.jumpInbox(tabRow{machine: w.MachineID, inbox: &w.InboxItem}) - m.flowAttention = false - for i, n := range m.flowWorkers().Nodes { - if n.ID == w.SessionID && n.MachineID == w.MachineID { - m.cursors[flowTab] = i - return jump - } - } - m.flowID = w.SessionID - m.flowSelectID = w.SessionID - m.machineScope = w.MachineID - m.cursors[flowTab] = 0 - return tea.Batch(jump, m.loadFlow()) -} - -func (m *app) flowDetailLines() []string { - if m.flowAttention && len(m.flow.Attention.Waits) > 0 { - w := m.flow.Attention.Waits[min(m.flowAskCursor, len(m.flow.Attention.Waits)-1)] - return []string{clean(w.Title) + " · " + w.Adapter, "◆ needs you · " + duration(attention.Waited(w.InboxItem, m.opts.Now())), clean(flowWaitReason(w)), "Copy resume: " + clean(attention.JumpCommand(w.Jump)), ""} - } - - n := m.flowSelected() - if n == nil { - return []string{"Select a session · Enter expands quiet threads"} - } - state := n.Mixer.Verdict - if state == "" { - state = n.Label - } - lines := []string{clean(n.DisplayTitle()) + " · " + n.Adapter, view.FlowGlyph(state) + " " + view.FlowStateName(state) + " · as of " + flowAge(n.Mixer.ObservedAt, m.opts.Now()), "why: " + clean(n.Mixer.Evidence), "running " + agentAge(flowElapsed(*n, m.opts.Now()))} - tools := 0 - for _, e := range n.Events { - if view.FlowEventMark(n.Adapter, e.Kind) == "tool" { - tools++ - } - } - if n.Adapter != "claude" && n.Adapter != "copilot" { - lines = append(lines, "no tool events for "+n.Adapter, "no prompt events for "+n.Adapter) - } else { - available := tools > 0 - for _, r := range n.Mixer.Rows { - if (r.Name == "telemetry" || n.Adapter == "copilot" && r.Name == "hooks") && r.Strength > 0 { - available = true - } - } - if available { - lines = append(lines, fmt.Sprintf("tool calls %d · retained window", tools)) - } else { - lines = append(lines, "tool calls — · telemetry unavailable") - } - } - if n.Adapter == "gemini" { - lines = append(lines, "no wait source for gemini") - } - if n.InputTokens != nil { - lines = append(lines, fmt.Sprintf("input %d tokens", *n.InputTokens)) - } - if n.OutputTokens != nil { - lines = append(lines, fmt.Sprintf("output %d tokens", *n.OutputTokens)) - } - if n.CostUSD != nil { - lines = append(lines, fmt.Sprintf("cost $%.4f · retained events", *n.CostUSD)) - } - lines = append(lines, fmt.Sprintf("verdict %s · %d of 4 agree", state, n.Mixer.Agreement)) - for _, r := range n.Mixer.Rows { - lines = append(lines, clean(r.Name+": "+r.Reading)) - } - if command := attention.JumpCommand(n.Jump); command != "" { - lines = append(lines, "Copy resume: "+clean(command)) - } - if n.Observed != nil && !n.Observed.LastGrowthAt.IsZero() { - lines = append(lines, "last growth "+transcriptSize(n.Observed.LastGrowthBytes)+" · "+flowAge(n.Observed.LastGrowthAt, m.opts.Now())) - } - lines = append(lines, "Recent events · this session family") - for _, e := range n.Events[max(0, len(n.Events)-4):] { - lines = append(lines, e.TS.Local().Format("15:04:05")+" "+clean(e.Kind+" "+e.Summary)) - } - if len(n.Events) == 0 { - lines = append(lines, "No retained events in window.") - } - return lines -} -func flowHeadCap(state string) string { - switch state { - case "working": - return "━━▶" - case "waiting": - return "══◆" - case "idle": - return "··○" - case "stalled": - return "┄┄‖" - case "finished", "ended", "dead": - return "──●" - default: - return "┈┈?" - } -} -func flowWaitAge(d time.Duration) string { - if d >= time.Hour { - return fmt.Sprintf("%dh", int(d/time.Hour)) - } - if d >= time.Minute { - return fmt.Sprintf("%dm", int(d/time.Minute)) - } - return fmt.Sprintf("%ds", int(max(0, d)/time.Second)) -} -func flowWaitReason(w view.InboxRow) string { - if w.Reason == "permission" { - reason := "◆ Permission" - if w.Tool != "" { - reason += " · " + w.Tool - } - if w.Detail != "" { - reason += ": " + w.Detail - } - return reason - } - return "◆ Asked for input · what it asked wasn't captured" -} -func flowAge(at, now time.Time) string { - if at.IsZero() { - return "unknown" - } - return duration(max(0, now.Sub(at))) + " ago" -} - -// flowLayout lays out attention, a bounded map, and the selected session details. -func (m *app) flowLayout() (string, []flowHit) { - width := max(4, m.width) - badge := fmt.Sprintf("NEEDS YOU %d", len(m.flow.Attention.Waits)) - if !m.opts.NoColor { - badge = view.TokenStyle("waiting", false).Reverse(true).Bold(true).Render(badge) - } - needs := []string{badge} - for i, w := range m.flow.Attention.Waits { - prefix := "" - if m.flowAttention && i == m.flowAskCursor { - prefix = "› " - } - needs = append(needs, prefix+view.TokenStyle("waiting", m.opts.NoColor).Render("◆ "+flowWaitAge(attention.Waited(w.InboxItem, m.opts.Now())))+" "+clean(w.Title)+" "+strings.TrimPrefix(strings.TrimPrefix(clean(flowWaitReason(w)), "◆ Asked for input · "), "◆ ")) - } - if len(m.flow.Attention.Waits) == 0 { - needs = append(needs, fmt.Sprintf("Nothing needs you · %d working", m.flow.Attention.Working)) - } - body := m.flowPanel("Needs you · machine-wide", needs, width, m.flowAttention) + "\n" - if m.flow.Lead == nil { - return body + "No live interactive session.", nil - } - label := 20 - stateWidth := 0 - if width >= 100 { - stateWidth = 12 - } - columns := max(1, width-label-stateWidth-20) - f := m.flow - f.ObservedAt = m.opts.Now() - f.WindowMinutes = m.flowZoom() - g := view.FlowGeometry(f, columns, m.flowExpanded) - lines := []string{fmt.Sprintf("%dm · %s–now · head = verdict as of", g.Minutes, g.FromAt.Local().Format("15:04"))} - if g.EarlierMinutes > 0 { - lines = append(lines, fmt.Sprintf("%dm earlier · %d asks", g.EarlierMinutes, g.EarlierAsks)) - } - hits := []flowHit{} - first := max(0, m.cursors[flowTab]-7) - first = min(first, max(0, len(g.Rows)-8)) - rowY := strings.Count(body, "\n") + 1 + len(lines) - for i := first; i < min(len(g.Rows), first+8); i++ { - r := g.Rows[i] - n := r.Node - state := n.Mixer.Verdict - if state == "" { - state = n.Label - } - prefix := " " - if i == m.cursors[flowTab] { - prefix = "›" - } - title := truncate(clean(n.DisplayTitle()), label-2) - title += strings.Repeat(" ", max(0, label-2-lipgloss.Width(title))) - line := prefix + title + " " - if width < 60 { - cap := view.FlowGlyph(state) + " " + view.FlowStateName(state) - line = prefix + truncate(clean(n.DisplayTitle()), max(0, width-7-lipgloss.Width(cap))) + " · " + cap - } else { - if stateWidth > 0 { - st := view.FlowGlyph(state) + " " + view.FlowStateName(state) - line += st + strings.Repeat(" ", max(1, stateWidth-lipgloss.Width(st))) - } - track := make([]rune, g.Columns) - cpu := make([]rune, g.Columns) - for c := range track { - track[c] = ' ' - cpu[c] = ' ' - } - last := len(track) - if n.EndedAt != nil { - last = max(r.Start, min(last, int(n.EndedAt.Sub(g.FromAt)/time.Minute)+1)) - } - for c := r.Start; c < last; c++ { - track[c] = '─' - } - for _, h := range r.Hatched { - for c := h.From; c < min(h.To, len(track)); c++ { - track[c] = '░' - } - } - var peak, cp int64 - for c, x := range r.Log { - peak = max(peak, x) - cp = max(cp, r.CPU[c]) - } - for c, x := range r.Log { - if x > 0 { - track[c] = []rune("▁▂▃▄▅▆▇█")[min(7, int(float64(x)/float64(peak)*7))] - } - if r.CPU[c] > 0 && cp > 0 { - cpu[c] = []rune("▁▂▃▄▅▆▇█")[min(7, int(float64(r.CPU[c])/float64(cp)*7))] - } - } - for _, w := range r.Waits { - for c := w.From; c < min(w.To, len(track)); c++ { - track[c] = '═' - } - } - for _, b := range r.Tools { - track[b.Column] = '┿' - } - for _, mark := range r.Marks { - ch := '◆' - switch mark.Kind { - case "prompt": - ch = '○' - case "end": - ch = '●' - } - track[mark.Column] = ch - } - if n.ParentID != nil && !n.StartedAt.Before(g.FromAt) { - track[r.Start] = '╰' - if !m.opts.NoColor && !m.opts.MotionOff && m.opts.Now().Before(m.flowBranchPulse[growthKey(n)]) { - track[r.Start] = '✦' - } - } - // Parent links run only through otherwise blank cells, including the - // selected CPU line, and end at the child's own stored start. - for childIndex, child := range g.Rows { - if child.Node.ParentID == nil || child.Node.StartedAt.Before(g.FromAt) || childIndex <= i { - continue - } - parentIndex := -1 - for pi, p := range g.Rows { - if p.Node.ID == *child.Node.ParentID { - parentIndex = pi - break - } - } - if parentIndex < 0 || parentIndex > i { - continue - } - c := child.Start - if parentIndex < i && track[c] == ' ' { - track[c] = '│' - } - if cpu[c] == ' ' { - cpu[c] = '│' - } - } - cap := flowHeadCap(state) - if state == "waiting" && !m.opts.NoColor && !m.opts.MotionOff { - for _, w := range n.Waits { - age := m.opts.Now().Sub(w.StartedAt) - if w.EndedAt == nil && age >= 0 && age < 8*time.Second && m.opts.Now().Unix()%4 < 2 { - cap = "══◇" - } - } - } - growth := m.flowGrowthPulse[growthKey(n)] - if !m.opts.NoColor && !m.opts.MotionOff && (m.opts.Now().Before(m.flowPulse[n.Adapter+":"+n.ExternalID]) || m.opts.Now().Before(growth.until)) { - cap = string([]rune(cap)[:2]) + "◉" - } - capRunes := []rune(cap) - headStart := max(0, len(track)-len(capRunes)) - // Each mark has its own semantic token; the verdict never colours history. - var rendered strings.Builder - for _, ch := range track[:headStart] { - token := "faint" - switch { - case strings.ContainsRune("▁▂▃▄▅▆▇█", ch): - token = "observation" - case ch == '═' || ch == '◆': - token = "waiting" - case ch == '┿': - token = "tool" - case ch == '○': - token = "accent" - case ch == '●': - token = "finished" - } - rendered.WriteString(view.TokenStyle(token, m.opts.NoColor).Render(string(ch))) - } - line += rendered.String() + view.StateStyle(state, m.opts.NoColor).Render(string(capRunes[len(capRunes)-min(len(track), len(capRunes)):])) + view.TokenStyle("faint", m.opts.NoColor).Render(" "+flowAge(n.Mixer.ObservedAt, m.opts.Now())) - lines = append(lines, line) - height := 1 - if i == m.cursors[flowTab] { - height = 2 - lines = append(lines, strings.Repeat(" ", label+stateWidth-4)+view.TokenStyle("faint", m.opts.NoColor).Render("cpu ")+view.TokenStyle("observation", m.opts.NoColor).Faint(!m.opts.NoColor).Render(string(cpu))) - } - hits = append(hits, flowHit{x: 0, y: rowY, width: width, height: height, index: i}) - rowY += height - continue - } - lines = append(lines, line) - hits = append(hits, flowHit{x: 0, y: rowY, width: width, height: 1, index: i}) - rowY++ - } - if g.Quiet > 0 { - lines = append(lines, fmt.Sprintf("+%d quiet threads · enter to expand", g.Quiet)) - hits = append(hits, flowHit{x: 0, y: rowY, width: width, height: 1, index: len(m.flowWorkers().Nodes)}) - } - if len(g.Rows) > 8 { - lines = append(lines, fmt.Sprintf("rows %d–%d of %d · j/k scroll", first+1, min(first+8, len(g.Rows)), len(g.Rows))) - } - if width >= 60 { - legend := []string{} - for _, mark := range []struct{ glyph, token, label string }{{"▁", "observation", "growth / CPU"}, {"┿", "tool", "tool"}, {"◆", "waiting", "wait"}, {"○", "accent", "prompt"}, {"●", "finished", "end"}, {"░", "faint", "no data"}} { - legend = append(legend, view.TokenStyle(mark.token, m.opts.NoColor).Render(mark.glyph)+" "+mark.label) - } - lines = append(lines, strings.Join(legend, " · ")) - } - body += m.flowPanel("Session map", lines, width, false) + "\n" + m.flowPanel("Session detail · signal mixer", m.flowDetailLines(), width, false) - return body, hits -} -func (m *app) flowView() string { text, _ := m.flowLayout(); return text } -func (m *app) flowWorkerHits() []flowHit { _, hits := m.flowLayout(); return hits } -func (m *app) flowEnsureVisible() { m.flowOffset = 0 } - -func flowElapsed(n view.FlowNode, now time.Time) time.Duration { - if n.StartedAt.IsZero() { - return n.Elapsed - } - if n.EndedAt != nil { - now = *n.EndedAt + if n.Jump == nil { + m.status = "No stored jump destination" + return nil } - return max(0, now.Sub(n.StartedAt)) + return m.jumpInbox(tabRow{machine: n.MachineID, inbox: &model.InboxItem{Jump: n.Jump}}) } +func (m *app) flowLayout() (string, []flowHit) { return m.boardLayout() } +func (m *app) flowView() string { text, _ := m.boardLayout(); return text } +func (m *app) flowWorkerHits() []flowHit { _, hits := m.boardLayout(); return hits } diff --git a/internal/tui/flow_attention_hints_test.go b/internal/tui/flow_attention_hints_test.go deleted file mode 100644 index d18ff0bb..00000000 --- a/internal/tui/flow_attention_hints_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package tui - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/labstack/driga/internal/flowtest" - "github.com/labstack/driga/internal/store" - "github.com/labstack/driga/internal/view" -) - -func TestFlowAttentionCardsHaveSessionHints(t *testing.T) { - st, err := store.Open("", store.Memory) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { st.Close() }) - if err := st.Tx(context.Background(), flowtest.SeedAmbiguousAttention); err != nil { - t.Fatal(err) - } - for _, policy := range []string{"titles", "none"} { - f, err := view.Flow(st, view.Filter{CaptureText: policy}, 0, flowtest.Now) - if err != nil { - t.Fatal(err) - } - m := setup(t, fixture(), Options{Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow, m.width = f, 100 - got := m.flowView() - want := []string{"sample · Fix cache", "sample · Fix parser", "sample · #ccc333", "sample · #ddd444"} - if policy == "none" { - want[0], want[1] = "sample · work/cache", "sample · work/parser" - } - for _, hint := range want { - if !strings.Contains(got, hint) { - t.Errorf("%s missing %q: %s", policy, hint, got) - } - } - } -} diff --git a/internal/tui/flow_event_time_test.go b/internal/tui/flow_event_time_test.go deleted file mode 100644 index 6cceed88..00000000 --- a/internal/tui/flow_event_time_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - "github.com/labstack/driga/internal/model" -) - -func TestFlowRecentEventsUseLocalTime(t *testing.T) { - original := time.Local - time.Local = time.FixedZone("fixture local", -7*60*60) - t.Cleanup(func() { time.Local = original }) - m := setup(t, fixture(), Options{}).(*app) - m.flow = flowFixture() - m.flow.Lead.Events = []model.Event{{TS: time.Date(2026, 10, 20, 15, 26, 46, 0, time.UTC), Kind: "local-clock-fixture"}} - got := strings.Join(m.flowDetailLines(), "\n") - if !strings.Contains(got, "08:26:46 local-clock-fixture") || strings.Contains(got, "15:26:46") { - t.Fatalf("recent event must use local time: %s", got) - } -} diff --git a/internal/tui/flow_golden_test.go b/internal/tui/flow_golden_test.go deleted file mode 100644 index 4f195070..00000000 --- a/internal/tui/flow_golden_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package tui - -import ( - "charm.land/lipgloss/v2" - "context" - "fmt" - "github.com/charmbracelet/x/ansi" - "github.com/labstack/driga/internal/flowtest" - "github.com/labstack/driga/internal/store" - "github.com/labstack/driga/internal/view" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestFlowGoldenFrames(t *testing.T) { - // Golden clocks stay deterministic; local-time conversion is covered separately. - originalLocal := time.Local - time.Local = time.UTC - t.Cleanup(func() { time.Local = originalLocal }) - now := flowtest.Now - st, err := store.Open("", store.Memory) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { st.Close() }) - var id int64 - if err = st.Tx(context.Background(), func(tx *store.Tx) (e error) { id, e = flowtest.SeedMap(tx, now, true); return e }); err != nil { - t.Fatal(err) - } - f, err := view.Flow(st, view.Filter{FlowMinutes: 240}, id, now) - if err != nil { - t.Fatal(err) - } - f.Lead.StartedFrom = "Editor" - original := view.HasDarkBackground() - view.SetDarkBackground(true) - defer view.SetDarkBackground(original) - for _, width := range []int{80, 100, 140} { - for _, color := range []bool{false, true} { - name := fmt.Sprintf("flow-%d-color-%t.golden", width, color) - t.Run(name, func(t *testing.T) { - m := setup(t, fixture(), Options{Now: func() time.Time { return now }}).(*app) - m.opts.NoColor = !color - m.opts.MotionOff = true - m.flow = f - m.flowMinutes = 240 - m.width = width - m.height = 60 - m.loaded = true - m.tab = flowTab - got := m.flowView() + "\n" - for _, line := range strings.Split(got, "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("overflow: %q", line) - } - } - if !color && strings.Contains(got, "\x1b") { - t.Fatal("NO_COLOR emitted ANSI") - } - path := filepath.Join("testdata", name) - if os.Getenv("UPDATE_FLOW_GOLDEN") == "1" { - t.Logf("regenerating %s (ANSI retained in colour golden):\n%s", name, ansi.Strip(got)) - if err := os.MkdirAll("testdata", 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(got), 0644); err != nil { - t.Fatal(err) - } - } - want, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if got != strings.ReplaceAll(string(want), "\r\n", "\n") { - t.Fatalf("golden drift: %s", path) - } - }) - } - } -} diff --git a/internal/tui/flow_map_test.go b/internal/tui/flow_map_test.go deleted file mode 100644 index 2650f206..00000000 --- a/internal/tui/flow_map_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package tui - -import ( - tea "charm.land/bubbletea/v2" - "github.com/labstack/driga/internal/flowtest" - "github.com/labstack/driga/internal/model" - "github.com/labstack/driga/internal/view" - "runtime" - "strings" - "testing" - "time" -) - -func TestFlowAttentionJumpAndCopy(t *testing.T) { - for _, remote := range []bool{false, true} { - calls := 0 - m := setup(t, fixture(), Options{Tmux: "/tmp/fixture,1,0", Exec: func(args []string) error { - calls++ - if runtime.GOOS == "windows" { - requireFixtureTerminal(t, args) - } else if args[0] != "tmux" { - t.Fatal(args) - } - return nil - }, Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.tab = flowTab - m.width = 100 - m.loaded = true - m.snap.LocalMachineID = "local" - machine := "local" - if remote { - machine = "peer" - } - m.flow.Children[0].MachineID = machine - m.flow.Attention.Waits = []view.InboxRow{{MachineID: machine, Title: "Need approval", InboxItem: model.InboxItem{MachineID: machine, SessionID: 2, Kind: "waiting", TS: flowtest.Now.Add(-time.Minute), Jump: fixtureLocalJump(t, model.InboxJump{Kind: "tmux", Server: "/tmp/fixture", Target: "dev:1.2", Command: "tmux attach -t 'dev:1.2'"})}}} - m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - if !m.flowAttention { - t.Fatal("tab did not focus attention") - } - if text := m.flowView(); !strings.Contains(text, "Copy resume:") || !strings.Contains(text, "tmux attach") { - t.Fatal("resume not in details") - } - _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd != nil { - cmd() - } - wantCalls := 0 - if !remote { - wantCalls = 2 - if runtime.GOOS == "windows" { - wantCalls = 1 - } - } - if calls != wantCalls { - t.Fatal("unexpected jump execution", remote, calls) - } - if !strings.Contains(m.status, "tmux attach") { - t.Fatal(m.status) - } - if m.flowSelected().ID != 2 { - t.Fatal("jump did not select map row") - } - } -} -func TestFlowZoomKeys(t *testing.T) { - m := setup(t, fixture(), Options{}).(*app) - m.tab = flowTab - for _, tc := range []struct { - key rune - minutes int - }{{'-', 240}, {'+', 60}, {'+', 15}, {'+', 15}, {'-', 60}} { - m.Update(tea.KeyPressMsg{Code: tc.key, Text: string(tc.key)}) - if m.flowZoom() != tc.minutes { - t.Fatal(tc, m.flowZoom()) - } - } -} diff --git a/internal/tui/flow_navigation_hints_test.go b/internal/tui/flow_navigation_hints_test.go deleted file mode 100644 index c40ffc81..00000000 --- a/internal/tui/flow_navigation_hints_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - "github.com/labstack/driga/internal/flowtest" -) - -func TestFlowFooterAndHelpDescribeMapKeys(t *testing.T) { - m := setup(t, fixture(), Options{}).(*app) - m.tab, m.width, m.height = flowTab, 140, 60 - m.flow = flowFixture() - for _, text := range []string{m.footer()} { - for _, want := range []string{"j/k session", "enter jump", "[/] family", "-/+ zoom"} { - if !strings.Contains(text, want) { - t.Errorf("missing %q: %s", want, text) - } - } - } - for _, want := range []string{"esc back", "? help", "q quit"} { - if !strings.Contains(m.footer(), want) { - t.Error("footer missing", want) - } - } - m.help = true - for _, stale := range []string{"j k worker", "j/k worker", "enter open", "[ / ] session"} { - if strings.Contains(m.footer(), stale) || strings.Contains(m.View().Content, "7 Flow · [ / ] session") { - t.Error("stale Flow help:", stale) - } - } -} - -func TestFlowHintsAppearOnceInFooter(t *testing.T) { - m := setup(t, fixture(), Options{NoColor: true, Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.tab = flowTab - m.loaded = true - m.width = 140 - m.height = 80 - text := m.View().Content - if strings.Count(text, "enter jump") != 1 { - t.Fatal("flow repeats its keyboard hints", text) - } -} diff --git a/internal/tui/flow_test.go b/internal/tui/flow_test.go deleted file mode 100644 index e4843a03..00000000 --- a/internal/tui/flow_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "runtime" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/labstack/driga/internal/flowtest" - "github.com/labstack/driga/internal/model" - "github.com/labstack/driga/internal/store" - "github.com/labstack/driga/internal/view" - "github.com/labstack/driga/internal/viewer" -) - -func TestRenderedFramesNeverContainTabs(t *testing.T) { - for _, color := range []bool{false, true} { - m := setup(t, fixture(), Options{}).(*app) - m.opts.NoColor = !color - m.flow = flowFixture() - m.flow.Mixer.Rows[0].Name = "registry\t" - if strings.ContainsRune(m.flowPanel("+18 quiet threads", []string{"Enter / click to expand\t\t\t"}, 60, false), '\t') { - t.Error("quiet panel emits literal tabs") - } - for i := 0; i < 18; i++ { - m.flow.Children = append(m.flow.Children, view.FlowNode{ID: int64(100 + i), Kind: "hosted", Label: "idle", Title: "quiet worker"}) - } - for _, width := range []int{80, 120, 160} { - for tab := 0; tab <= flowTab; tab++ { - t.Run(fmt.Sprintf("color=%t/width=%d/tab=%d", color, width, tab), func(t *testing.T) { - m.tab = tab - m.Update(tea.WindowSizeMsg{Width: width, Height: 60}) - m.rebuild() - if text := m.View().Content; strings.ContainsRune(text, '\t') { - t.Errorf("frame contains literal tab: %q", text) - } - if tab == flowTab && strings.ContainsRune(m.flowView(), '\t') { - t.Error("flow layout contains literal tabs") - } - // Details can include tabwriter output and retained text. - m.detail = true - m.viewport.SetContent("Enter / click to expand\t\t\t│") - if strings.ContainsRune(m.View().Content, '\t') { - t.Error("detail frame contains literal tabs") - } - m.detail = false - }) - } - } - } -} - -func flowFixture() view.FlowView { - now := flowtest.Now - mixer := model.StateMixer{ObservedAt: now, Verdict: "working", Evidence: "stored verdict", Rows: []model.StateSignal{{Name: "registry", Reading: "busy"}, {Name: "hooks", Reading: "Stop"}, {Name: "telemetry", Reading: "1 / min"}, {Name: "process", Reading: "alive"}}} - lead := view.FlowNode{ID: 1, Kind: "lead", MachineID: "local", Adapter: "claude", ExternalID: "lead", Title: "Lead", Label: "working", Mixer: mixer, StartedAt: now.Add(-time.Hour), RetainedFromAt: now.Add(-40 * time.Minute), Events: []model.Event{{Kind: "otlp:user_prompt", TS: now.Add(-10 * time.Minute)}}} - f := view.FlowView{Lead: &lead, Mixer: mixer, ObservedAt: now, WindowMinutes: 60} - for i := 0; i < 3; i++ { - id := int64(i + 2) - parent := int64(1) - f.Children = append(f.Children, view.FlowNode{ID: id, MachineID: "local", ExternalID: fmt.Sprint("worker-", i), Adapter: "codex", Title: []string{strings.Repeat("worker ", 40), "Hosted", "Finished"}[i], Label: "working", Mixer: mixer, ParentID: &parent, StartedAt: now.Add(time.Duration(i-50) * time.Minute), Events: []model.Event{{Kind: "retained", TS: now.Add(-time.Minute)}}, Jump: &model.InboxJump{Kind: "resume", Command: "codex resume 'fixture'"}}) - } - return f -} -func TestFlowWidthsAndPulse(t *testing.T) { - now := time.Now() - m := setup(t, fixture(), Options{Now: func() time.Time { return now }}).(*app) - m.tab = flowTab - m.opts.NoColor = false - m.flow = flowFixture() - m.loaded = true - for _, width := range []int{160, 120, 80} { - m.Update(tea.WindowSizeMsg{Width: width, Height: 44}) - text := m.flowView() - if lipgloss.Height(m.View().Content) > 44 { - t.Fatalf("%d overflows terminal height", width) - } - for _, line := range strings.Split(m.View().Content, "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("%d full view overflow", width) - } - } - if width == 160 && !strings.Contains(m.View().Content, "this session family") { - t.Fatal("tail hidden at 160x44") - } - for _, line := range strings.Split(text, "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("%d: %q", width, line) - } - } - for _, want := range []string{"Lead", "signal mixer", "registry", "hooks", "telemetry", "process", "this session family"} { - if !strings.Contains(text, want) { - t.Errorf("%d missing %s", width, want) - } - } - if strings.Contains(text, "──●") { - t.Fatal("pulse without event") - } - } - m.width = 160 - m.Update(flowEventMsg{view.TailEvent{Key: "new", MachineID: "other", Adapter: "codex", ExternalID: "worker-0"}}) - if strings.Contains(m.flowView(), "◉") { - t.Fatal("pulse from another machine") - } - m.Update(flowEventMsg{view.TailEvent{Key: "new", MachineID: "local", Adapter: "codex", ExternalID: "worker-0"}}) - if !strings.Contains(m.flowView(), "◉") { - t.Fatal("missing event pulse") - } - now = now.Add(2 * time.Second) - if strings.Contains(m.flowView(), "◉") { - t.Fatal("pulse did not expire") - } -} -func TestFlowWorkerKeysAndMouse(t *testing.T) { - calls := 0 - m := setup(t, fixture(), Options{Exec: func(args []string) error { - calls++ - requireFixtureTerminal(t, args) - return nil - }}).(*app) - m.tab = flowTab - m.flow = flowFixture() - m.flow.Children[0].Jump = fixtureLocalJump(t, *m.flow.Children[0].Jump) - m.width = 160 - m.height = 44 - m.loaded = true - m.rebuild() - m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) - if m.cursors[flowTab] != 1 { - t.Fatal(m.cursors) - } - m.Update(tea.KeyPressMsg{Code: 'k', Text: "k"}) - if m.cursors[flowTab] != 0 { - t.Fatal(m.cursors) - } - hits := m.flowWorkerHits() - if len(hits) != 4 { - t.Fatal(hits) - } - h := hits[1] - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: h.x, Y: h.y + lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 1}) - if m.cursors[flowTab] != 1 { - t.Fatal("click did not select worker") - } - _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd != nil { - cmd() - } - wantCalls := 0 - if runtime.GOOS == "windows" { - wantCalls = 1 - } - if calls != wantCalls || m.detail || !strings.Contains(m.status, "codex resume") { - t.Fatal("Enter did not plan selected session jump", m.status) - } -} - -func TestFlowRefreshKeepsSelectionAndOnlyNewEventsPulse(t *testing.T) { - now := time.Now() - m := setup(t, fixture(), Options{Now: func() time.Time { return now }}).(*app) - m.tab = flowTab - m.flow = flowFixture() - m.flowID = 1 - m.width, m.height = 160, 44 - m.cursors[flowTab] = 1 - m.loaded = true - next := flowFixture() - next.Children[0], next.Children[1] = next.Children[1], next.Children[0] - m.Update(flowMsg{id: 1, flow: next}) - if m.flowWorkers().Nodes[m.cursors[flowTab]].ID != 2 { - t.Fatal("worker selection changed during reorder") - } - snapshot := m.snap - snapshot.Tail = []view.TailEvent{{Key: "one", MachineID: "local", Adapter: "claude", ExternalID: "lead"}} - m.snap.Tail = snapshot.Tail - m.Update(refreshMsg{changed: true, snap: snapshot}) - if len(m.flowPulse) != 0 { - t.Fatal("refresh alone pulsed") - } - snapshot.Tail = append(snapshot.Tail, view.TailEvent{Key: "two", MachineID: "local", Adapter: "claude", ExternalID: "lead"}) - m.Update(refreshMsg{changed: true, snap: snapshot}) - if len(m.flowPulse) != 1 { - t.Fatal("new event did not pulse") - } -} - -func TestFlowEntryUsesDefaultAndSessionKeys(t *testing.T) { - m := setup(t, fixture(), Options{}).(*app) - m.tab = 0 - m.rows = []tabRow{{agent: 42, cells: []string{"", "working", "selected"}}} - m.cursors[0] = 0 - m.Update(tea.KeyPressMsg{Code: '7', Text: "7"}) - if m.tab != flowTab || m.flowID != 0 { - t.Fatal("plain 7 must request the shared default, not the highlighted row") - } - m.flow = flowFixture() - m.snap.Agents.Rows = []view.AgentRow{{ID: 1}, {ID: 9}} - m.Update(tea.KeyPressMsg{Code: ']', Text: "]"}) - if m.flowID != 9 { - t.Fatal("next session did not switch") - } - m.flow.Lead.ID = 9 - m.Update(tea.KeyPressMsg{Code: '[', Text: "["}) - if m.flowID != 1 { - t.Fatal("previous session did not switch") - } -} - -func TestFlowSharedDefault(t *testing.T) { - st, err := store.Open("", store.Memory) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { st.Close() }) - var want int64 - if err := st.Tx(context.Background(), func(tx *store.Tx) (err error) { want, err = flowtest.SeedDefault(tx); return err }); err != nil { - t.Fatal(err) - } - src := viewer.NewStoreSource(st, t.TempDir(), view.Filter{}) - src.Now = func() time.Time { return flowtest.Now } - m := New(src, Options{DataDir: t.TempDir(), NoColor: true, Now: src.Now}).(*app) - execute(t, m, m.Init()) - m.tab = 0 - m.rebuild() - _, cmd := press(m, '7') - execute(t, m, cmd) - if m.flow.Lead == nil || m.flow.Lead.ID != want || m.flowID != 0 { - t.Fatalf("default lead = %+v, requested id %d; want %d via id 0", m.flow.Lead, m.flowID, want) - } - // Refresh must keep requesting a default, rather than pinning the first lead. - execute(t, m, m.loadFlow()) - if m.flowID != 0 { - t.Fatal("default became an explicit selection") - } - // Explicit session cycling remains pinned until plain 7 is pressed again. - m.flowSession(1) - explicit := m.flowID - execute(t, m, m.loadFlow()) - if explicit == 0 || m.flowID != explicit { - t.Fatal("explicit selection lost") - } - _, cmd = press(m, '7') - execute(t, m, cmd) - if m.flowID != 0 || m.flow.Lead == nil || m.flow.Lead.ID != want { - t.Fatal("plain 7 did not restore the default") - } -} - -func TestFlowMapRowsAndParent(t *testing.T) { - m := setup(t, fixture(), Options{Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.flowMinutes = 60 - for _, width := range []int{40, 80, 100, 140} { - m.width = width - text, hits := m.flowLayout() - if len(hits) != 4 { - t.Fatal(hits) - } - if width >= 100 && !strings.Contains(text, "╰") { - t.Fatal("missing parent start mark") - } - for _, line := range strings.Split(text, "\n") { - if lipgloss.Width(line) > width { - t.Fatal(width, line) - } - } - } -} - -func TestFlowQuietSummaryExpands(t *testing.T) { - now := time.Now() - m := setup(t, fixture(), Options{NoColor: true, Now: func() time.Time { return now }}).(*app) - m.tab, m.width, m.height, m.loaded = flowTab, 160, 44, true - m.flow = flowFixture() - for i := 0; i < 11; i++ { - m.flow.Children = append(m.flow.Children, view.FlowNode{ID: int64(100 + i), Adapter: "codex", Kind: "hosted", Label: "idle", Title: "quiet worker", LastActivityAt: now.Add(-2 * time.Hour)}) - } - text, hits := m.flowLayout() - if !strings.Contains(text, "quiet threads") || strings.Contains(text, "quiet worker") { - t.Fatal("quiet hosted threads must start collapsed") - } - if len(hits) > 5 { - t.Fatal("too many panels for terminal", hits) - } - m.cursors[flowTab] = hits[len(hits)-1].index - m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if m.detail || !strings.Contains(m.flowView(), "quiet worker") { - t.Fatal("Enter must expand summary") - } - m.flowExpanded = false - m.rebuild() - hits = m.flowWorkerHits() - h := hits[len(hits)-1] - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: h.x, Y: h.y + lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 1}) - if !m.flowExpanded { - t.Fatal("click must expand summary") - } -} - -func TestFlowSelectedMixerAndWidths(t *testing.T) { - m := setup(t, fixture(), Options{NoColor: true, Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.tab = flowTab - m.cursors[flowTab] = 1 - m.flow.Children[0].Mixer.Evidence = "worker own stored reason" - for _, width := range []int{140, 100, 80, 59, 40, 20, 8} { - m.width = width - text := m.flowView() - if width >= 60 && !strings.Contains(text, "worker own stored reason") { - t.Fatal("wrong mixer") - } - for _, line := range strings.Split(text, "\n") { - if lipgloss.Width(line) > width { - t.Fatal(width, line) - } - } - } -} diff --git a/internal/tui/flow_tracks_test.go b/internal/tui/flow_tracks_test.go deleted file mode 100644 index e232d891..00000000 --- a/internal/tui/flow_tracks_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - "github.com/labstack/driga/internal/flowtest" -) - -func TestFlowTrackSemantics(t *testing.T) { - m := setup(t, fixture(), Options{NoColor: true, MotionOff: true, Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.width = 140 - text, hits := m.flowLayout() - if strings.Contains(text, "╱") || strings.Contains(text, "▒") { - t.Fatal("legacy hatch", text) - } - if strings.Count(text, "cpu ") != 1 { - t.Fatal("CPU should appear only on selected row", text) - } - if strings.Count(text, "head = verdict as of") != 1 { - t.Fatal("missing shared head explanation", text) - } - if !strings.Contains(text, "━━▶") || !strings.Contains(text, "░") { - t.Fatal("head or no data missing", text) - } - if hits[0].height != 2 || hits[1].height != 1 { - t.Fatal("row heights", hits) - } - for _, line := range strings.Split(text, "\n") { - if strings.Contains(line, "cpu ") && strings.Contains(line, "░") { - t.Fatal("hatch spilled into CPU", line) - } - } - for st, want := range map[string]string{"working": "━━▶", "waiting": "══◆", "idle": "··○", "stalled": "┄┄‖", "finished": "──●", "unknown": "┈┈?"} { - m.flow.Lead.Mixer.Verdict = st - if !strings.Contains(m.flowView(), want) { - t.Error(st, want) - } - } -} - -func TestFlowParentConnectorThroughBlankCells(t *testing.T) { - m := setup(t, fixture(), Options{NoColor: true, Now: func() time.Time { return flowtest.Now }}).(*app) - m.flow = flowFixture() - m.width = 140 - // The parent connection crosses a sibling which already ended. - end := flowtest.Now.Add(-49 * time.Minute) - m.flow.Children[0].EndedAt = &end - text, hits := m.flowLayout() - lines := strings.Split(text, "\n") - row := lines[hits[1].y] - // Panel borders use │ too; the interior track must contain its own connector. - if strings.Count(row, "│") < 3 { - t.Fatal("parent connector lost in blank track cells", row) - } -} diff --git a/internal/tui/header.go b/internal/tui/header.go index 91394e2b..988c9fd9 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -71,16 +71,9 @@ func (m *app) header() string { } identity := badge.Render(" driga ") + " " + clean(host) + m.fleetHeader() + " · " + status identityParts := []string{identity} - if m.tab == homeTab || m.tab == flowTab { - var rows []view.InboxRow - for _, i := range m.snap.Inbox.Rows { - if m.machineVisible(i.MachineID) { - rows = append(rows, i) - } - } - var agents []view.AgentRow - m.eachAgent(func(a view.AgentRow) { a.Children = nil; agents = append(agents, a) }) - identityParts = append(identityParts, view.AgentWaitingHeader(rows, agents, m.opts.Now())) + if m.tab == flowTab { + m.syncBoard() + identityParts = append(identityParts, fmt.Sprintf("%d waiting", len(m.board.Needs))) } if state, _ := m.intake(); state != "" { identityParts = append(identityParts, "intake · "+view.TokenStyle(map[string]string{"receiving": "working", "waiting": "muted", "attention": "warn"}[state], m.opts.NoColor).Render(state)) @@ -110,17 +103,14 @@ func (m *app) footer() string { if m.help { return truncate("↑↓/pgup/pgdown/wheel scroll · ?/esc close", m.width) } - hints := []string{"0 Home", "m machine", "enter detail", "/ filter", "s sort", "g group by project", "o open terminal"} + hints := []string{"0 Flow", "M machine", "enter detail", "/ filter", "s sort", "g group by project", "o open terminal"} if m.tab == flowTab { - hints = []string{"7 flow", "j/k session", "enter jump", "[/] family", "-/+ zoom", "tab needs", "C resume", "c Chat"} - } - if m.tab == homeTab { - hints = []string{"0-8 tabs · c Chat", "↑↓ move", "enter open", "/ filter", "m machine"} + hints = []string{"m/t/s views", "j/k select", "enter open", "/ find", "tab needs", "J terminal", "c Chat"} } if m.tmuxTarget() != "" { hints = append(hints, "a attach tmux") } - if m.tab == 2 || m.tab == homeTab { + if m.tab == 2 { hints = append(hints, "x/space ack") } if m.tab == 2 { @@ -129,9 +119,6 @@ func (m *app) footer() string { } hints = append(hints, "X ack all") } - if m.tab == homeTab { - hints = append(hints, "pgup/pgdown scroll") - } if m.tab == 1 { hints = append([]string{"S summarize"}, hints...) } @@ -144,13 +131,13 @@ func (m *app) footer() string { if m.tab == chatTab { // One line, most important first: hints that do not fit are dropped // rather than wrapped, so the layout height never changes. - hints := []string{"c type", "m trust mode", "N tabs", "? help", "q quit"} + hints := []string{"c type", "m trust mode", "0-2 tabs", "? help", "q quit"} if m.chatInput.Focused() { escape := "esc navigate" if m.chatActive || m.pendingChat() != nil { escape = "esc stop" } - hints = []string{escape, "enter send", "shift+enter new line", "alt+N tabs", "alt+↑ edit last", "ctrl+x clear context"} + hints = []string{escape, "enter send", "shift+enter new line", "alt+0/1/2 tabs", "alt+↑ edit last", "ctrl+x clear context"} } if m.pendingChat() != nil { hint := "y/n allow/deny" @@ -173,6 +160,12 @@ func (m *app) footer() string { return view.TokenStyle("muted", m.opts.NoColor).Render(line) } hints = append(hints, "esc back", "? help", "q quit") + if m.tab == 4 || m.tab == 3 || m.tab == 5 { + hints = append([]string{"h health", "p reclaim processes", "d reclaim disk"}, hints...) + } + if m.tab == 0 || m.tab == 2 { + hints = append([]string{"m/t/s Flow views", "h activity"}, hints...) + } if m.tab == 5 { hints = append([]string{"x clean (confirm)", "r refresh candidates"}, hints...) } @@ -250,7 +243,18 @@ func (m *app) intake() (string, []string) { } func (m *app) bodyHeight() int { - overhead := lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + lipgloss.Height(m.footer()) + 3 + measure := func() int { + return lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + lipgloss.Height(m.footer()) + 3 + } + overhead := measure() + // Plain selection brackets and colored state counts can wrap chrome at + // different boundaries. Reserve the larger frame for identical board geometry. + if m.tab == flowTab { + original := m.opts.NoColor + m.opts.NoColor = !original + overhead = max(overhead, measure()) + m.opts.NoColor = original + } return max(2, m.height-overhead) } func (m *app) confirmationText() string { @@ -291,11 +295,7 @@ func (m *app) View() tea.View { if len(m.rows) == 0 { body = m.emptyText() } - if m.tab == homeTab { - lines := strings.Split(m.homeView(), "\n") - offset := min(m.homeOffset, max(0, len(lines)-m.bodyHeight())) - body = strings.Join(lines[offset:], "\n") - } else if m.tab == flowTab { + if m.tab == flowTab { lines := strings.Split(m.flowView(), "\n") offset := min(m.flowOffset, max(0, len(lines)-m.bodyHeight())) body = strings.Join(lines[offset:], "\n") @@ -309,7 +309,7 @@ func (m *app) View() tea.View { } } if m.tab == chatTab { - body = m.chatView() + body = m.chatPanelView() } if m.detail { body = m.viewport.View() @@ -370,7 +370,7 @@ func (m *app) emptyText() string { } func (m *app) helpText() string { - body := wrapText("0 Home · 1–8 tabs · ↑/↓ or j/k move · enter detail · esc back\nChat: esc stops a reply; otherwise navigate · c/enter type · alt+0–8 tabs\nChat: y/n allow/deny · alt+y/alt+n while typing · m trust mode (navigate)\nenter send · shift+enter or alt+enter new line · ctrl+n new · ctrl+h history · ctrl+t tools · ctrl+x clear context\n7 Flow · 8 Chat · j/k session · enter jump · [/] family · -/+ zoom · tab needs · C resume · c Chat\nm cycle machine scope · / filter · enter apply · esc clear · s sort: state, age, mem, project · S summarize selected project\ng group by project · →/← expand/collapse families and worktrees\nx/space acknowledge Needs you / Inbox · X acknowledge all Inbox (confirm)\nclick select · click selected row to open · wheel scroll\npgup/pgdown scroll Home · k kill orphan (confirm) · K force (two confirmations)\no open terminal · ? close help · q / ctrl+c quit", m.width) + body := wrapText("0 Flow · 1 Projects · 2 Machines · c Chat panel · Esc closes panel\nFlow: m Map · t Timeline · s Sessions · h activity / return\nMap and Timeline use a project tree in the terminal. Tab focuses Needs you.\nj/k or arrows select · Enter opens · / find · Esc returns\ny/n answer explicitly focused prompt after review · J Terminal · K Stop\nMachines: h health · p reclaim processes · d reclaim disk\nSessions: S sort · right/left expand families\nChat: Enter sends · Shift+Enter new line · Ctrl+n new · Ctrl+h history\nChat: y/n allow/deny · Alt+y/Alt+n while typing · m trust mode\nAlt+0/1/2 switches tabs while typing · Ctrl+x clears Chat context\n? help · q / ctrl+c quit", m.width) if m.tmuxTarget() != "" { body += "\na attach tmux" } diff --git a/internal/tui/home.go b/internal/tui/home.go deleted file mode 100644 index e1bfee12..00000000 --- a/internal/tui/home.go +++ /dev/null @@ -1,566 +0,0 @@ -package tui - -import ( - "fmt" - "sort" - "strings" - - "github.com/labstack/driga/internal/textfmt" - - "charm.land/lipgloss/v2" - "github.com/NimbleMarkets/ntcharts/v2/barchart" - "github.com/labstack/driga/internal/cleanup" - "github.com/labstack/driga/internal/view" -) - -const homeTab = 6 - -func (m *app) needsYou() []tabRow { - inbox := []view.InboxRow{} - for _, r := range m.snap.Inbox.Rows { - if m.machineVisible(r.MachineID) { - inbox = append(inbox, r) - } - } - agents := []view.AgentRow{} - m.eachAgent(func(r view.AgentRow) { r.Children = nil; agents = append(agents, r) }) - rows := []tabRow{} - for _, a := range view.NeedsYou(inbox, agents, m.snap.AgentDetails) { - key := fmt.Sprintf("attention:%s:%d", a.MachineID, a.ID) - row := tabRow{attention: &a, machine: a.MachineID, key: key, agent: a.ID, cells: []string{"", a.State, a.Project, a.Summary}} - if a.Inbox != nil { - item := a.Inbox.InboxItem - item.MachineID = a.MachineID - row.inbox = &item - row.key = "inbox:" + a.MachineID + ":" + item.ID - } - rows = append(rows, row) - } - return rows -} - -func (m *app) eachAgent(fn func(view.AgentRow)) { - var walk func([]view.AgentRow) - walk = func(rows []view.AgentRow) { - for _, r := range rows { - if m.machineVisible(r.MachineID) { - fn(r) - } - walk(r.Children) - } - } - walk(m.snap.Agents.Rows) -} - -func homeColumns(width int) int { - if width >= 150 { - return 3 - } - if width >= 110 { - return 2 - } - return 1 -} - -// panel keeps detail panes consistent with Home's rounded frames. -func panel(title, body string, width int) string { return framedPanel(title, "", body, width, true) } - -func framedPanel(title, hint, body string, width int, noColor bool) string { - width = max(4, width) - inner := width - 4 - border := view.TokenStyle("line", noColor) - heading := " " + truncate(clean(title), max(1, width-6)) + " " - hint = clean(hint) - room := width - 2 - lipgloss.Width(heading) - 2 - right := "" - if room > 4 && hint != "" { - right = " " + truncate(hint, room-2) + " " - } - headingStyle := view.TokenStyle("ink", noColor) - if !noColor { - headingStyle = headingStyle.Bold(true) - } - top := border.Render("╭") + headingStyle.Render(heading) + border.Render(strings.Repeat("─", max(0, width-2-lipgloss.Width(heading)-lipgloss.Width(right)))) + view.TokenStyle("faint", noColor).Render(right) + border.Render("╮") - lines := []string{top} - for _, line := range strings.Split(body, "\n") { - line = truncate(line, inner) - lines = append(lines, border.Render("│")+" "+line+strings.Repeat(" ", max(0, inner-lipgloss.Width(line)))+" "+border.Render("│")) - } - lines = append(lines, border.Render("╰"+strings.Repeat("─", max(0, width-2))+"╯")) - return strings.Join(lines, "\n") -} - -func (m *app) homeView() string { - cols := homeColumns(m.width) - needsW, statesW, machineW := m.width, m.width, m.width - if cols == 3 { - needsW = m.width / 2 - statesW = (m.width - needsW - 2) * 2 / 5 - machineW = m.width - needsW - statesW - 2 - } - if cols == 2 { - needsW = (m.width - 1) * 3 / 5 - statesW = m.width - needsW - 1 - machineW = statesW - } - dim := view.TokenStyle("faint", m.opts.NoColor) - mid := view.TokenStyle("muted", m.opts.NoColor) - needs := []string{} - start := m.homeNeedsTop - for i := start; i < min(len(m.rows), start+6); i++ { - r := m.rows[i] - a := r.attention - state := r.cells[1] - title := r.cells[3] - location := r.cells[2] - adapter := "" - age := "" - if a != nil { - if a.Title != "" { - title = a.Title - } - adapter = a.Adapter - if d, ok := m.snap.AgentDetails[a.ID]; ok && d.Session.Branch != "" { - location += " · " + d.Session.Branch - } - if !a.At.IsZero() { - age = duration(m.snap.ObservedAt.Sub(a.At)) - } - } - inner := needsW - 4 - prefix := m.stateText("▌", state) + " " + m.stateText(fmt.Sprintf("%-8s", state), state) + " " + mid.Render(fmt.Sprintf("%-6s", truncate(clean(adapter), 6))) + " " - inline := needsW >= 76 - locationWidth := 0 - if inline { - locationWidth = min(24, max(0, (inner-lipgloss.Width(prefix)-lipgloss.Width(age))/2)) - } - available := max(1, inner-lipgloss.Width(prefix)-lipgloss.Width(age)-locationWidth-2) - text := truncate(clean(title), available) - line := prefix + text - if inline { - line += strings.Repeat(" ", max(1, available-lipgloss.Width(text)+1)) + dim.Render(truncate(clean(location), locationWidth)) - } - line += strings.Repeat(" ", max(1, inner-lipgloss.Width(line+age))) + m.stateText(age, state) - if i == m.cursors[homeTab] && !m.opts.NoColor { - line = lipgloss.NewStyle().Background(view.ThemeColor("sunk")).Render(line) - } - needs = append(needs, line) - if !inline { - needs = append(needs, dim.Render(" "+truncate(clean(location), inner-2))) - } - } - if len(needs) == 0 { - needs = append(needs, "Nothing needs you") - } - if len(m.rows) > 6 { - needs = append(needs, dim.Render(fmt.Sprintf("%d more · 3 Inbox", len(m.rows)-6))) - } - counts := map[string]int{} - total := 0 - m.eachAgent(func(a view.AgentRow) { counts[strings.TrimSuffix(strings.TrimPrefix(a.Label, "~"), "?")]++; total++ }) - stateOrder := []string{"working", "waiting", "stalled", "idle", "finished", "ended", "dead", "unknown"} - segmented := "" - legend := []string{} - used := 0 - seen := 0 - for _, state := range stateOrder { - n := counts[state] - seen += n - end := seen * max(0, statesW-4) / max(1, total) - if n > 0 { - segmented += m.stateText(strings.Repeat("█", end-used), state) - used = end - } - if n > 0 || state != "ended" && state != "dead" && state != "unknown" { - legend = append(legend, m.stateText(fmt.Sprintf("● %d %s", n, state), state)) - } - } - if total == 0 { - segmented = dim.Render(strings.Repeat("░", max(0, statesW-4))) - } - states := segmented + "\n" + styledPack(legend, statesW-4, " ") - machine := []string{} - hosts := m.snap.Machine.Machines - if len(hosts) == 0 { - hosts = []view.MachineView{m.snap.Machine} - } - for _, host := range hosts { - if !m.machineVisible(host.MachineID) { - continue - } - sample := host.Sample - if len(hosts) > 1 { - machine = append(machine, mid.Render(clean(m.machineName(host.MachineID)))) - } - if sample.TS.IsZero() { - machine = append(machine, "Health unavailable") - continue - } - for _, metric := range []struct { - name string - used, total uint64 - }{{"memory", sample.MemUsed, sample.MemTotal}, {"swap", sample.SwapUsed, sample.SwapTotal}} { - if metric.total == 0 { - machine = append(machine, mid.Render(metric.name+" unavailable")) - continue - } - n := max(1, machineW-4-14) - machine = append(machine, mid.Render(fmt.Sprintf("%-7s", metric.name))+gaugeBar(metric.used, metric.total, n, m.opts.NoColor)+" "+view.GaugeStyle(metric.used, metric.total, m.opts.NoColor).Render(fmt.Sprintf("%3.0f%%", view.GaugePercent(metric.used, metric.total))), dim.Render(fmt.Sprintf(" %s/%s", view.HumanBytes(metric.used), view.HumanBytes(metric.total)))) - } - token := "ok" - if view.LoadLevel(sample.Load1, sample.Cores) == "critical" { - token = "crit" - } else if view.LoadLevel(sample.Load1, sample.Cores) == "warning" { - token = "warn" - } - machine = append(machine, mid.Render("load ")+view.TokenStyle(token, m.opts.NoColor).Render(view.FormatLoad(sample.Load1, "%.1f"))+dim.Render(fmt.Sprintf(" / %d cores", sample.Cores)), mid.Render("disk ")+view.HumanBytes(sample.DiskFree)+dim.Render(" free")) - } - if len(machine) == 0 { - machine = append(machine, "Health unavailable") - } - activity := []string{} - sparkW := max(1, m.width-4-8-18-1) - for _, a := range m.snap.Activity.Machines { - if !m.machineVisible(a.MachineID) { - continue - } - if len(m.snap.Activity.Machines) > 1 { - activity = append(activity, mid.Render(clean(m.machineName(a.MachineID)))) - } - activity = append(activity, activityRow(a.Buckets, m.width-4, false, m.opts.NoColor), activityRow(a.Buckets, m.width-4, true, m.opts.NoColor)) - cores := 0 - first := -1 - for i, b := range a.Buckets { - if b.Sample != nil { - cores = b.Sample.Cores - } - if first < 0 && (b.Sample != nil || b.Events > 0) { - first = i - } - } - if cores > 0 { - activity = append(activity, view.TokenStyle("crit", m.opts.NoColor).Render(fmt.Sprintf(" ┄ %d cores reference", cores))) - } - if first > 0 { - activity = append(activity, dim.Render(fmt.Sprintf(" no history before collector start · %dm collected", len(a.Buckets)-first))) - } - if !view.ActivityHasSamples(a.Buckets) { - activity = append(activity, dim.Render(view.HistoryEmpty)) - } - if !view.ActivityHasEvents(a.Buckets) { - activity = append(activity, dim.Render(view.EventsEmpty)) - } - } - if len(activity) == 0 { - activity = append(activity, dim.Render(view.HistoryEmpty), dim.Render(view.EventsEmpty)) - } - axis := []string{"−60m", "−45m", "−30m", "−15m", "now"} - axisLine := "" - for i, label := range axis { - minute := min(i*15, 59) - pos := activityMinuteColumn(minute, 60, sparkW) - axisLine += strings.Repeat(" ", max(0, pos-lipgloss.Width(axisLine))) + label - } - activity = append(activity, dim.Render(" "+axisLine)) - projectW := m.width - leftW := m.width - if cols == 3 { - projectW = needsW + statesW + 1 - leftW = machineW - } - projects := []string{dim.Render("PROJECT AGENTS BRANCH DIRTY ↑ LATEST")} - for _, p := range view.SortedProjects(m.snap.Projects.Rows) { - if !m.machineVisible(p.MachineID) { - continue - } - agents := []view.AgentRow{} - branch, title := "", "" - m.eachAgent(func(a view.AgentRow) { - if a.MachineID == p.MachineID && a.RepoID == p.Status.Repo.ID { - agents = append(agents, a) - if branch == "" { - branch = a.Branch - } - if title == "" { - title = a.Title - } - } - }) - detail := m.snap.ProjectDetails[p.Status.Repo.ID] - if branch == "" && len(detail.WorktreeRows) > 0 { - branch = detail.WorktreeRows[0].Branch - } - if len(detail.Handoffs) > 0 { - title = detail.Handoffs[0].Text - } else if len(p.Status.Where) > 0 { - title = p.Status.Where[0].Text - } - mini := "" - parts := []string{} - segments := 0 - for _, state := range view.StateCounts(agents) { - n := min(state.Count, max(0, 6-segments)) - mini += m.stateText(strings.Repeat("▮", n), state.Label) - segments += n - parts = append(parts, fmt.Sprintf("%d %s", state.Count, state.Label)) - } - mini += dim.Render(strings.Repeat("▯", max(0, 6-segments))) - summary := strings.Join(parts, " · ") - if summary == "" { - summary = "quiet" - } - name := fmt.Sprintf("%-14s", truncate(clean(p.Status.Repo.Name), 14)) - line := name + " " + mini + " " + fmt.Sprintf("%-22s", truncate(summary, 22)) + " " + fmt.Sprintf("%-15s", truncate(clean(branch), 15)) + fmt.Sprintf(" %5d %3d ", p.Dirty, p.Unpushed) + clean(title) - if projectW < 90 { - line = name + " " + mini + " " + summary + "\n " + clean(branch) + fmt.Sprintf(" · dirty %d · ↑%d · ", p.Dirty, p.Unpushed) + clean(title) - } - if p.Status.LiveAgents == 0 && p.Status.Activity == "idle" && !m.opts.NoColor { - line = lipgloss.NewStyle().Faint(true).Render(line) - } - projects = append(projects, line) - } - if len(projects) == 1 { - projects = append(projects, "No projects") - } - summary := m.visibleSummary() - leftovers := []string{fmt.Sprintf("orphans %d · ~%s reclaimable", summary.Orphans, view.HumanBytes(summary.Reclaim))} - ready, refused := 0, 0 - var history int64 - for _, c := range m.snap.Cleanup { - if !m.machineVisible(m.snap.LocalMachineID) { - continue - } - if c.Kind == cleanup.History { - history += c.Size - } - if c.Kind == cleanup.Worktree { - if c.Status == "ready" { - ready++ - } else { - refused++ - } - } - } - if m.snap.Cleanup == nil { - leftovers = append(leftovers, "worktrees not loaded · 6 Cleanup") - } else { - leftovers = append(leftovers, fmt.Sprintf("worktrees %d ready · %d kept", ready, refused)) - } - if history > 0 { - leftovers = append(leftovers, fmt.Sprintf("history %.1f MB measured", float64(history)/(1<<20))) - } else { - leftovers = append(leftovers, dim.Render("history not measured · r in Cleanup")) - } - sourceNames := []string{} - for name := range m.snap.Freshness { - if strings.Contains(name, "hook") || strings.Contains(name, "spool") || strings.Contains(name, "otlp") { - sourceNames = append(sourceNames, name) - } - } - sort.Strings(sourceNames) - for _, name := range sourceNames { - f := m.snap.Freshness[name] - status := "source healthy" - token := "working" - if f.Status == "not_yet_receiving" { - status = "waiting for first event" - token = "waiting" - } else if f.Stale || f.LastError != "" { - status = "stale" - token = "waiting" - } else if f.LastOKAt.IsZero() { - status = "no events received" - token = "finished" - } - label := "hooks" - if strings.Contains(name, "otlp") { - label = "telemetry" - } - leftovers = append(leftovers, mid.Render(label+" ")+view.TokenStyle(token, m.opts.NoColor).Render("● "+clean(name)+" · "+status)) - } - frame := func(title, hint, body string, width int) string { - return framedPanel(title, hint, body, width, m.opts.NoColor) - } - needsPanel := frame(fmt.Sprintf("Needs you %d", len(m.rows)), "enter open · x ack", strings.Join(needs, "\n"), needsW) - statesPanel := frame(fmt.Sprintf("Agents %d", total), "by state", states, statesW) - machinePanel := frame("Machine", "", strings.Join(machine, "\n"), machineW) - rows := []string{} - switch cols { - case 3: - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, needsPanel, " ", statesPanel, " ", machinePanel)) - case 2: - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, needsPanel, " ", statesPanel+"\n"+machinePanel)) - default: - rows = append(rows, needsPanel, statesPanel, machinePanel) - } - rows = append(rows, frame("Activity · 60 min", "events/min · load · ┄ cores", strings.Join(activity, "\n"), m.width)) - lp := frame("Leftovers", "6 Cleanup", strings.Join(leftovers, "\n"), leftW) - // Budget actual rendered lines, including wrapped project rows and borders. - available := m.bodyHeight() - lipgloss.Height(strings.Join(rows, "\n")) - 1 - if cols != 3 { - available -= lipgloss.Height(lp) + 1 - } - projectLines := strings.Split(strings.Join(projects, "\n"), "\n") - limit := max(1, available-2) - if len(projectLines) > limit { - projectLines = projectLines[:limit] - if limit > 1 { - projectLines[limit-1] = dim.Render("More projects · 2 Projects for all") - } - } - pp := frame("Projects", "2 Projects for all", strings.Join(projectLines, "\n"), projectW) - if cols == 3 { - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, pp, " ", lp)) - } else { - rows = append(rows, pp, lp) - } - return strings.Join(rows, "\n") -} - -func (m *app) stateText(text string, label ...string) string { - state := text - if len(label) > 0 { - state = label[0] - } else if fields := strings.Fields(text); len(fields) > 0 { - state = fields[0] - } - return view.StateStyle(state, m.opts.NoColor).Render(text) -} - -func bar(value, total float64, width int, style lipgloss.Style) string { - b := barchart.New(max(1, width), 1, barchart.WithHorizontalBars(), barchart.WithNoAxis(), barchart.WithNoAutoMaxValue(), barchart.WithMaxValue(max(1, total)), barchart.WithBarGap(0)) - b.Push(barchart.BarData{Values: []barchart.BarValue{{Value: min(value, total), Style: style}}}) - b.Draw() - return b.View() -} - -// gaugeBar draws the full capacity, including the unused track at zero usage. -func gaugeBar(used, total uint64, width int, noColor bool) string { - width = max(1, width) - filled := min(width, int(view.GaugePercent(used, total)*float64(width)/100)) - track := strings.Repeat("░", width-filled) - if !noColor { - track = lipgloss.NewStyle().Faint(true).Render(track) - } - return view.GaugeStyle(used, total, noColor).Render(strings.Repeat("█", filled)) + track -} - -// activityMinuteColumn maps a minute to its first column, or its peak bin. -// Extra columns go to the oldest minutes first. -func activityMinuteColumn(minute, minutes, width int) int { - if width >= minutes { - return minute*(width/minutes) + min(minute, width%minutes) - } - return ((minute+1)*width - 1) / minutes -} - -// activitySpark fills the width with minute blocks, binning peaks when space is tight. -// A bin containing a missing minute remains a dim dot rather than inventing history. -func activitySpark(buckets []view.ActivityBucket, width int, load bool) string { - return styledSpark(buckets, width, load, true) -} - -func styledSpark(buckets []view.ActivityBucket, width int, load, noColor bool) string { - count := min(max(1, width), len(buckets)) - ceiling := 1.0 - for _, b := range buckets { - if load && b.Sample != nil { - ceiling = max(ceiling, b.Sample.Load1, float64(b.Sample.Cores)) - } else if !load { - ceiling = max(ceiling, float64(b.Events)) - } - } - cells := make([]textfmt.SparkCell, 0, count) - for x := 0; x < count; x++ { - from, to := x*len(buckets)/count, (x+1)*len(buckets)/count - span := activityMinuteColumn(x+1, count, max(1, width)) - activityMinuteColumn(x, count, max(1, width)) - value, valid, critical := 0.0, true, false - for _, b := range buckets[from:to] { - if load { - if b.Sample == nil || load && b.Sample.Load1 < 0 { - valid = false - continue - } - value = max(value, b.Sample.Load1) - critical = critical || b.Sample.Cores > 0 && b.Sample.Load1 > float64(b.Sample.Cores) - } else { - if b.Sample == nil && b.Events == 0 { - valid = false - } - value = max(value, float64(b.Events)) - } - } - if !valid { - cells = append(cells, textfmt.SparkCell{Missing: true, Span: span, Token: "faint"}) - continue - } - token := "idle" - if load { - token = "ink" - if critical { - token = "crit" - } - } - cells = append(cells, textfmt.SparkCell{Value: value, Span: span, Token: token}) - } - return textfmt.Spark(cells, ceiling, '▁', textfmt.SparkFloor, func(token, text string) string { return view.TokenStyle(token, noColor).Render(text) }) -} - -func activityRow(buckets []view.ActivityBucket, width int, load, noColor bool) string { - label, current, peak, token := "events ", "—", 0.0, "idle" - for _, b := range buckets { - if load { - if b.Sample != nil { - peak = max(peak, b.Sample.Load1) - } - } else { - peak = max(peak, float64(b.Events)) - } - } - if len(buckets) > 0 { - last := buckets[len(buckets)-1] - if load { - if last.Sample != nil { - current = view.FormatLoad(last.Sample.Load1, "%.1f") - if last.Sample.Cores > 0 && last.Sample.Load1 > float64(last.Sample.Cores) { - token = "crit" - } - } - } else if last.Sample != nil || last.Events > 0 { - current = fmt.Sprintf("%d/min", last.Events) - } - } - summary := fmt.Sprintf("%s max %.0f", current, peak) - if load { - label = "load " - if token != "crit" { - token = "ink" - } - summary = fmt.Sprintf("%s max %.1f", current, peak) - } - // Reserve the same value column for both rows and the shared time axis. - spark := styledSpark(buckets, max(1, width-8-max(18, lipgloss.Width(summary))-1), load, noColor) - return view.TokenStyle("faint", noColor).Render(label) + spark + " " + view.TokenStyle(token, noColor).Render(summary) -} - -func (m *app) splitDetail() bool { return m.tab == 0 && m.width >= 140 && !m.detail } - -func (m *app) agentsSplitView() string { - left := m.width / 2 - right := m.width - left - 3 - text := "Select an agent" - if r, ok := m.selected(); ok { - if d, found := m.snap.AgentDetails[r.agent]; found { - text = m.styledAgentDetail(d, max(1, right-4)) - } - } - return lipgloss.JoinHorizontal(lipgloss.Top, lipgloss.NewStyle().Width(left).Render(m.table.View()), " │ ", panel("Agent detail", text, right)) -} - -func (m *app) styledAgentDetail(d view.AgentView, width int) string { - return m.styleAgentState(agentDetail(d, width), d.Label) -} - -func (m *app) styleAgentState(text, label string) string { - return strings.Replace(text, "State: "+clean(label), "State: "+view.StateStyle(label, m.opts.NoColor).Render(clean(label)), 1) -} diff --git a/internal/tui/home_test.go b/internal/tui/home_test.go deleted file mode 100644 index 96c71f90..00000000 --- a/internal/tui/home_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/labstack/driga/internal/cleanup" - "github.com/labstack/driga/internal/model" - "github.com/labstack/driga/internal/view" -) - -func TestHomeInitialAndKeyZero(t *testing.T) { - m := setup(t, fixture(), Options{}) - if m.(*app).tab != homeTab || !strings.Contains(m.View().Content, "0 Home") { - t.Fatal(m.View().Content) - } - m = key(m, '1') - m = key(m, '0') - if m.(*app).tab != homeTab || !strings.Contains(m.View().Content, "0 Home") { - t.Fatal(m.View().Content) - } - m = key(m, '?') - if !strings.Contains(m.View().Content, "0 Home") { - t.Fatal(m.View().Content) - } -} - -func TestHomeWidths(t *testing.T) { - for _, width := range []int{80, 120, 160} { - s := fixture() - s.snap.Machine.Sample = model.MachineSample{TS: s.snap.ObservedAt, MemUsed: 40 << 20, MemTotal: 80 << 20, SwapUsed: 1 << 20, SwapTotal: 4 << 20, Load1: 2, Cores: 4, DiskFree: 80 << 30} - s.snap.Activity.Machines = []view.ActivityMachine{{Buckets: make([]view.ActivityBucket, 60)}} - s.snap.Activity.Machines[0].Buckets[59] = view.ActivityBucket{Sample: &s.snap.Machine.Sample, Events: 3} - m := setup(t, s, Options{}) - m, _ = m.Update(tea.WindowSizeMsg{Width: width, Height: 100}) - text := m.View().Content - for _, title := range []string{"Needs you", "Agents", "Machine", "Activity", "Projects", "Leftovers"} { - if !strings.Contains(text, title) { - t.Errorf("width %d missing %s", width, title) - } - } - for _, line := range strings.Split(text, "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("width %d: %q", width, line) - } - } - if strings.Contains(text, "\x1b") { - t.Fatal("ANSI with NoColor") - } - if !strings.Contains(text, "····") { - t.Fatal("sample gaps lost", text) - } - } -} - -func TestHomeWaitingAndStalledWithoutInbox(t *testing.T) { - s := fixture() - s.snap.Inbox.Rows = nil - s.snap.Agents.Rows[0].Label = "waiting" - s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: 2, Label: "~stalled", Title: "stalled agent"}) - s.snap.AgentDetails[2] = view.AgentView{Session: model.Session{ID: 2, SessionObs: model.SessionObs{LastActivityAt: s.snap.ObservedAt}}} - m := setup(t, s, Options{Spool: func(model.HookEvent) error { t.Fatal("invented ack event"); return nil }}).(*app) - if len(m.rows) != 2 || m.rows[0].agent != 2 { - t.Fatalf("rows: %+v", m.rows) - } - _, cmd := press(m, 'x') - if cmd != nil || !strings.Contains(m.status, "no inbox event") { - t.Fatal(m.status) - } -} - -func TestHomeNeedsYouNewestFirstAndActions(t *testing.T) { - s := fixture() - now := s.snap.ObservedAt - s.snap.Inbox.Rows = []view.InboxRow{ - {InboxItem: model.InboxItem{ID: "old", SessionID: 1, Kind: "finished", TS: now.Add(-time.Hour), Summary: "old completion"}}, - {InboxItem: model.InboxItem{ID: "new", SessionID: 1, Kind: "waiting", TS: now, Summary: "new attention"}}, - {InboxItem: model.InboxItem{ID: "acked", Kind: "finished", TS: now, AckedAt: &now}}, - } - var ack string - m := setup(t, s, Options{Spool: func(e model.HookEvent) error { ack = e.Message; return nil }}) - a := m.(*app) - if len(a.rows) != 1 || a.rows[0].inbox.ID != "new" { - t.Fatalf("rows: %+v", a.rows) - } - m = key(m, tea.KeyEnter) - if ack != "" || !strings.Contains(m.View().Content, "hook notification") { - t.Fatal(m.View().Content, ack) - } - m, cmd := press(m, 'x') - execute(t, m, cmd) - if ack != "new" { - t.Fatal(ack) - } -} - -func TestAgentsSplitThresholdAndCursor(t *testing.T) { - s := fixture() - s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: 2, Label: "working", Title: "second"}) - s.snap.AgentDetails[2] = view.AgentView{Session: model.Session{ID: 2, Evidence: "second evidence"}} - for _, width := range []int{139, 140, 160} { - m := setup(t, s, Options{}) - m = key(m, '1') - m, _ = m.Update(tea.WindowSizeMsg{Width: width, Height: 60}) - if strings.Contains(m.View().Content, "Evidence:") != (width >= 140) { - t.Fatalf("width %d: %s", width, m.View().Content) - } - if width >= 140 { - m = key(m, 'j') - if !strings.Contains(m.View().Content, "second evidence") { - t.Fatal(m.View().Content) - } - } else { - m = key(m, tea.KeyEnter) - if !strings.Contains(m.View().Content, "hook notification") { - t.Fatal(m.View().Content) - } - m = key(m, tea.KeyEscape) - if strings.Contains(m.View().Content, "Evidence:") { - t.Fatal(m.View().Content) - } - } - } -} - -func TestHomePanelsFitWithoutClipping(t *testing.T) { - s := fixture() - s.snap.Agents.Rows[0].Title = strings.Repeat("界é", 100) - s.snap.Inbox.Rows[0].Summary = strings.Repeat("界é", 100) - for _, width := range []int{80, 109, 110, 120, 139, 140, 149, 150, 160} { - m := setup(t, s, Options{}).(*app) - m.Update(tea.WindowSizeMsg{Width: width, Height: 100}) - for _, line := range strings.Split(m.homeView(), "\n") { - if lipgloss.Width(line) > width { - t.Fatalf("width %d: %q", width, line) - } - } - if strings.Count(strings.Split(m.homeView(), "\n")[0], "╭") != homeColumns(width) { - t.Fatal("wrong column count", width) - } - if strings.Contains(bar(1, 1, 20, lipgloss.NewStyle()), "\n") { - t.Fatal("bar wraps onto another line") - } - } -} - -func TestAgentDetailUsesStateColors(t *testing.T) { - s := fixture() - d := s.snap.AgentDetails[1] - d.Label = "stalled" - s.snap.AgentDetails[1] = d - m := setup(t, s, Options{}).(*app) - m.opts.NoColor = false - key(m, tea.KeyEnter) - if !strings.Contains(m.View().Content, view.StateStyle("stalled", false).Render("stalled")) { - t.Fatalf("detail state lacks red: %q", m.View().Content) - } -} - -func TestHomeHonorsNO_COLOR(t *testing.T) { - t.Setenv("NO_COLOR", "1") - m := New(fixture(), Options{DataDir: t.TempDir()}).(*app) - m.Update(refreshMsg{snap: fixture().snap, changed: true}) - if !m.opts.NoColor || strings.Contains(m.View().Content, "\x1b") { - t.Fatal("NO_COLOR ignored") - } -} - -func TestNOColorEmptyMeansUnset(t *testing.T) { - for _, value := range []string{"", "0", "1"} { - t.Run(value, func(t *testing.T) { - t.Setenv("NO_COLOR", value) - m := New(&fakeSource{}, Options{}).(*app) - if m.opts.NoColor != (value != "") { - t.Fatalf("NO_COLOR=%q disables color: %v", value, m.opts.NoColor) - } - }) - } -} - -func TestHomeFleetAttentionKeepsFields(t *testing.T) { - s := fleetSource() - s.snap.Inbox.Rows[0].MachineID = "local" - s.snap.Inbox.Rows = append(s.snap.Inbox.Rows, view.InboxRow{MachineID: "remote", Project: "peer-project", InboxItem: model.InboxItem{ID: "peer", SessionID: 2, Kind: "waiting", Summary: "peer needs input", TS: s.snap.ObservedAt}}) - m := setup(t, s, Options{}).(*app) - if len(m.rows) != 2 || m.rows[0].cells[1] != "waiting" || m.rows[0].cells[2] != "peer-project" { - t.Fatalf("attention fields shifted: %+v", m.rows) - } - if !strings.Contains(m.View().Content, "remote title") || !strings.Contains(m.View().Content, "peer-project") { - t.Fatal(m.View().Content) - } -} - -func TestHomeCurrentAttentionNotHiddenByCompletion(t *testing.T) { - s := fixture() - s.snap.Inbox.Rows[0].Kind = "finished" - s.snap.Agents.Rows[0].Label = "~stalled" - m := setup(t, s, Options{}).(*app) - if len(m.rows) != 1 || m.rows[0].cells[1] != "~stalled" { - t.Fatalf("session must appear once with its current stalled state: %+v", m.rows) - } -} - -func TestHomeLoadsLeftoversAndScrolls(t *testing.T) { - s := fixture() - m := New(s, Options{DataDir: t.TempDir(), NoColor: true, CleanupList: func(context.Context) ([]cleanup.Candidate, error) { - return []cleanup.Candidate{{Kind: cleanup.Worktree, Status: "ready"}, {Kind: cleanup.Worktree, Status: "refused"}}, nil - }}) - m, cmd := m.Update(m.(*app).poll()()) - if cmd != nil { - m = execute(t, m, cmd) - } - if m.(*app).snap.Cleanup == nil { - t.Fatal("Home did not load leftovers") - } - m = key(m, tea.KeyPgDown) - m = key(m, tea.KeyPgDown) - if !strings.Contains(m.View().Content, "1 ready · 1 kept") { - t.Fatal(m.View().Content) - } -} - -func TestHomeGaugeTrackAndPercent(t *testing.T) { - s := fixture() - s.snap.Machine.Sample = model.MachineSample{TS: s.snap.ObservedAt, MemUsed: 90, MemTotal: 100, SwapUsed: 3, SwapTotal: 4} - m := setup(t, s, Options{}).(*app) - text := m.homeView() - for _, part := range []string{"90%", "75%", "░"} { - if !strings.Contains(text, part) { - t.Errorf("missing gauge %q", part) - } - } -} - -func TestActivityFullWidthAndEmptyNotes(t *testing.T) { - buckets := make([]view.ActivityBucket, 60) - buckets[59] = view.ActivityBucket{Events: 3, Sample: &model.MachineSample{Load1: 2}} - if got := lipgloss.Width(activitySpark(buckets, 90, true)); got != 90 { - t.Errorf("spark width %d, want full available width 90", got) - } - for _, withBuckets := range []bool{false, true} { - s := fixture() - if withBuckets { - s.snap.Activity.Machines = []view.ActivityMachine{{Buckets: make([]view.ActivityBucket, 60)}} - } - m := setup(t, s, Options{}).(*app) - for _, note := range []string{"History builds while the collector runs", "Events need agent hooks: driga hooks install"} { - if !strings.Contains(m.homeView(), note) { - t.Errorf("missing note %q", note) - } - } - } -} - -func TestFooterCwdsAndPendingOTLP(t *testing.T) { - s := fixture() - s.snap.Freshness = map[string]view.Fresh{"cwds": {LastOKAt: s.snap.ObservedAt.Add(-20 * time.Second)}} - m := setup(t, s, Options{}).(*app) - if strings.Contains(m.footer(), "stale cwds") { - t.Fatal("cwd source uses procs cadence") - } -} - -func TestHomeProjectsOrderAndAggregation(t *testing.T) { - s := fixture() - s.snap.Projects.Rows = []view.ProjectRow{ - {Status: view.ProjectStatus{Repo: model.Repo{ID: 1, Name: "Quiet project"}, Activity: "idle"}}, - {Status: view.ProjectStatus{Repo: model.Repo{ID: 2, Name: "Recent project"}, Where: []view.WherePart{{At: s.snap.ObservedAt}}}}, - {Status: view.ProjectStatus{Repo: model.Repo{ID: 3, Name: "Live project"}, LiveAgents: 6}}, - } - s.snap.Agents.Rows = nil - for i := 0; i < 6; i++ { - state := "idle" - if i < 2 { - state = "working" - } - s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: int64(i + 1), Label: state, Project: "Live project", RepoID: 3}) - } - m := setup(t, s, Options{}).(*app) - m.width, m.height = 160, 44 - m.rebuild() - text := m.homeView() - if strings.Index(text, "Live project") > strings.Index(text, "Recent project") || strings.Index(text, "Recent project") > strings.Index(text, "Quiet project") { - t.Error("wrong project order") - } - if !strings.Contains(text, "2 working · 4 idle") { - t.Error("missing aggregated states") - } - m.opts.NoColor = false - if !strings.Contains(m.homeView(), "\x1b[2mQuiet project") { - t.Error("quiet project is not dimmed") - } -} - -func TestHomeProjectsFitAvailableHeight(t *testing.T) { - for _, size := range [][2]int{{160, 44}, {120, 40}} { - src := fixture() - for i := 0; i < 50; i++ { - src.snap.Projects.Rows = append(src.snap.Projects.Rows, view.ProjectRow{Status: view.ProjectStatus{Repo: model.Repo{ID: int64(i + 1), Name: fmt.Sprint("project", i)}}}) - } - m := setup(t, src, Options{}).(*app) - m.width, m.height = size[0], size[1] - m.rebuild() - body := m.homeView() - if lipgloss.Height(body) > m.bodyHeight() { - t.Fatalf("%dx%d: body height %d > %d", m.width, m.height, lipgloss.Height(body), m.bodyHeight()) - } - out := m.View().Content - if !strings.Contains(out, "╰") { - t.Fatalf("bottom border missing: %s", out) - } - } -} - -func TestHomeSameNamedReposStaySeparate(t *testing.T) { - src := fixture() - src.snap.Inbox.Rows = nil - src.snap.Projects.Rows = []view.ProjectRow{{Status: view.ProjectStatus{Repo: model.Repo{ID: 1, Name: "app", Path: "/a/app"}}}, {Status: view.ProjectStatus{Repo: model.Repo{ID: 2, Name: "app", Path: "/b/app"}}}} - src.snap.Agents.Rows = []view.AgentRow{{ID: 1, RepoID: 1, Project: "app", Branch: "first-branch", Label: "working"}, {ID: 2, RepoID: 2, Project: "app", Branch: "second-branch", Label: "waiting"}} - m := setup(t, src, Options{}).(*app) - m.width = 160 - m.height = 44 - m.rebuild() - body := m.homeView() - if !strings.Contains(body, "second-branch") { - t.Fatalf("mixed repos: %s", body) - } -} - -func TestEventDrivenHelpReportsLastEvent(t *testing.T) { - s := fixture() - at := s.snap.ObservedAt.Add(-24 * time.Hour) - s.snap.Freshness = map[string]view.Fresh{"otlp": {LastOKAt: at, Status: "last_event"}, "sockets": {LastOKAt: at, Status: "last_event"}} - m := setup(t, s, Options{}).(*app) - key(m, '?') - if strings.Contains(m.footer(), "last event") || !strings.Contains(m.header(), "intake · receiving") || !strings.Contains(m.View().Content, "last event") || strings.Contains(m.View().Content, "stale") { - t.Fatal(m.View().Content) - } -} - -func TestHomeTinyWidths(t *testing.T) { - for _, width := range []int{-1, 0, 1, 2, 3, 4, 8, 16, 30} { - for _, empty := range []bool{false, true} { - s := fixture() - if empty { - s.snap.Agents.Rows = nil - } - m := setup(t, s, Options{}) - m, _ = m.Update(tea.WindowSizeMsg{Width: width, Height: 10}) - _ = m.View() - } - } - m := setup(t, fixture(), Options{}) - m = key(m, '?') - if !strings.Contains(m.View().Content, "1–8 tabs") { - t.Fatal(m.View().Content) - } -} diff --git a/internal/tui/machine.go b/internal/tui/machine.go index 3a487c6f..f8682315 100644 --- a/internal/tui/machine.go +++ b/internal/tui/machine.go @@ -25,7 +25,7 @@ func (m *app) machineRows(add func(tabRow)) { } textRow(view.MachineSummary(host)) textRow("Memory measure: " + host.AgentShare.MemoryMeasure + " · " + view.FootprintNote) - textRow("Sessions by cost") + textRow("Sessions by memory") for _, a := range view.MachineSessionApps(host) { m.machineAppRows(add, host.MachineID, "session", a) if _, surface, ok := strings.Cut(a.Name, " · started from "); ok { diff --git a/internal/tui/machine_golden_test.go b/internal/tui/machine_golden_test.go index 46a33dad..b91c3b7c 100644 --- a/internal/tui/machine_golden_test.go +++ b/internal/tui/machine_golden_test.go @@ -74,6 +74,11 @@ func TestMachineGoldenFrames(t *testing.T) { } m.rebuild() got := m.View().Content + "\n" + lines := strings.Split(got, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + got = strings.Join(lines, "\n") for _, line := range strings.Split(got, "\n") { if lipgloss.Width(line) > width { t.Fatal("overflow", line) diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 9651f671..efb8eb4f 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -18,21 +18,7 @@ func (m *app) mouseClick(msg tea.MouseClickMsg) tea.Cmd { _, hits := m.tabLayout() for _, h := range hits { if msg.Y == lipgloss.Height(m.header())+h.y && msg.X >= h.x && msg.X < h.x+h.width { - // Leave the composer before routing a mouse tab to its navigation key. - if m.tab == chatTab { - if h.tab == chatTab { - return nil - } - m.tab = homeTab - m.chatInput.Blur() - } - // Keep tab switching, cleanup loading and cursor retention identical to keys. - code := rune('0' + mapTabNumber(h.tab)) - if h.tab == homeTab { - code = '0' - } - _, cmd := m.Update(tea.KeyPressMsg{Code: code, Text: string(code)}) - return cmd + return m.selectTab(byte('0' + mapTabNumber(h.tab))) } } if m.tab == chatTab { @@ -46,12 +32,14 @@ func (m *app) mouseClick(msg tea.MouseClickMsg) tea.Cmd { y := msg.Y - base + min(m.flowOffset, max(0, lipgloss.Height(m.flowView())-m.bodyHeight())) for _, h := range m.flowWorkerHits() { if msg.X >= h.x && msg.X < h.x+h.width && y >= h.y && y < h.y+h.height { - m.cursors[flowTab] = h.index - window := m.flowWorkers() - if window.Quiet > 0 && h.index == len(window.Nodes) { - m.flowExpanded = true + if h.index < 0 { + m.flowAttention = true + m.flowAskCursor = -h.index - 1 + m.armBoardPrompt() + } else { + m.flowAttention = false + m.boardCursor = h.index } - m.rebuild() return nil } } @@ -75,27 +63,6 @@ func (m *app) mouseRow(x, y int) (int, bool) { if y < 0 || y >= m.bodyHeight() { return 0, false } - if m.tab == homeTab { - width := m.width - switch homeColumns(m.width) { - case 3: - width = m.width / 2 - case 2: - width = (m.width - 1) * 3 / 5 - } - if x < 2 || x >= width-2 { - return 0, false - } - lines := strings.Split(m.homeView(), "\n") - y += min(m.homeOffset, max(0, len(lines)-m.bodyHeight())) - stride := 1 - if width < 76 { - stride = 2 - } - start := m.homeNeedsTop - row := start + (y-1)/stride - return row, y >= 1 && y < 1+min(6, len(m.rows)-start)*stride && row < len(m.rows) - } if m.splitDetail() && x >= m.width/2 { return 0, false } @@ -143,13 +110,6 @@ func (m *app) mouseWheel(msg tea.MouseWheelMsg) tea.Cmd { m.flowOffset = max(0, m.flowOffset+delta) return nil } - if m.tab == homeTab { - if _, ok := m.mouseRow(msg.X, msg.Y); !ok { - lines := lipgloss.Height(m.homeView()) - m.homeOffset = min(max(0, m.homeOffset+delta), max(0, lines-m.bodyHeight())) - return nil - } - } key := tea.KeyDown if delta < 0 { key = tea.KeyUp diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 0cf72996..131d00e3 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -18,11 +18,11 @@ func TestMouseTabClick(t *testing.T) { m := setup(t, fixture(), Options{}).(*app) m.Update(tea.WindowSizeMsg{Width: width, Height: 44}) for y, line := range strings.Split(ansi.Strip(m.tabBar()), "\n") { - if x := strings.Index(line, "3 Inbox"); x >= 0 { + if x := strings.Index(line, "1 Projects"); x >= 0 { m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x + 2, Y: lipgloss.Height(m.header()) + y}) } } - if m.tab != 2 { + if m.tab != 1 { t.Errorf("width %d: tab %d", width, m.tab) } if m.View().MouseMode != tea.MouseModeCellMotion { @@ -46,110 +46,3 @@ func mouseFixture() *fakeSource { } return s } - -func TestMouseRowSelectAndOpen(t *testing.T) { - for _, tab := range []int{homeTab, 0, 1, 2, 3, 5} { - for _, width := range []int{80, 120, 160} { - t.Run(fmt.Sprintf("tab%d-width%d", tab, width), func(t *testing.T) { - m := setup(t, mouseFixture(), Options{}).(*app) - m.tab = tab - m.Update(tea.WindowSizeMsg{Width: width, Height: 44}) - bodyY := lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 1 - y := bodyY + 2 // table header, then the second row - if tab == homeTab { - needsW := width - if width >= 150 { - needsW = width / 2 - } else if width >= 110 { - needsW = (width - 1) * 3 / 5 - } - if needsW < 76 { - y++ - } // Home locations wrap onto a second line. - } - msg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: y} - m.Update(msg) - if m.cursors[tab] != 1 || m.detail { - t.Errorf("first click: cursor=%d detail=%v", m.cursors[tab], m.detail) - } - m.Update(msg) // Commands are deliberately not run: no scratch measurement IO. - if !m.detail || m.detailRow == nil || m.detailRow.key != m.rows[1].key { - t.Errorf("second click did not open selected row") - } - }) - } - } -} - -func TestMouseWheelMovesLists(t *testing.T) { - for _, tab := range []int{homeTab, 0, 1, 2, 3, 5} { - m := setup(t, mouseFixture(), Options{}).(*app) - m.tab = tab - m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - y := lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 3 - m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 3, Y: y}) - if m.cursors[tab] != 1 { - t.Errorf("tab %d wheel down: %d", tab, m.cursors[tab]) - } - m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp, X: 3, Y: y}) - if m.cursors[tab] != 0 { - t.Errorf("tab %d wheel up: %d", tab, m.cursors[tab]) - } - } -} - -func TestMouseScrolledTableAndHome(t *testing.T) { - for _, tab := range []int{0, homeTab} { - s := mouseFixture() - for i := 4; i < 80; i++ { - s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: int64(i), Label: "waiting", Title: fmt.Sprint("agent", i)}) - } - m := setup(t, s, Options{}).(*app) - m.tab = tab - m.Update(tea.WindowSizeMsg{Width: 120, Height: 24}) - for i := 0; i < 40; i++ { - key(m, tea.KeyDown) - } - bodyY := lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 1 - y, want := bodyY+1, m.cursors[tab]-5 - if tab == 0 { - for i, line := range strings.Split(ansi.Strip(m.table.View()), "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "›") { - y = bodyY + i - 1 - want = m.cursors[tab] - 1 - break - } - } - } else { - m.homeOffset = 1 // clicking after Home scroll must account for the clipped frame - y-- - } - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: y}) - if m.cursors[tab] != want { - t.Errorf("tab %d scrolled click: got %d want %d", tab, m.cursors[tab], want) - } - } -} - -func TestMouseRepeatedClickAfterScroll(t *testing.T) { - for _, tab := range []int{0, homeTab} { - s := mouseFixture() - for i := 4; i < 80; i++ { - s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: int64(i), Label: "waiting", Title: fmt.Sprint("agent", i)}) - } - m := setup(t, s, Options{}).(*app) - m.tab = tab - m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - for i := 0; i < 40; i++ { - key(m, tea.KeyDown) - } - bodyY := lipgloss.Height(m.header()) + lipgloss.Height(m.tabBar()) + 1 - msg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: bodyY + 1} - m.Update(msg) - want := m.rows[m.cursors[tab]].key - m.Update(msg) - if !m.detail || m.detailRow == nil || m.detailRow.key != want { - t.Errorf("tab %d: repeated click after scrolling did not open %s", tab, want) - } - } -} diff --git a/internal/tui/needs_you_test.go b/internal/tui/needs_you_test.go deleted file mode 100644 index 7b5b1227..00000000 --- a/internal/tui/needs_you_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package tui - -import ( - "runtime" - "strings" - "testing" - "time" - - "github.com/labstack/driga/internal/model" -) - -func TestNeedsYouInboxJumpAndHeader(t *testing.T) { - f := fixture() - f.snap.Agents.Rows[0].Label = "waiting" - f.snap.Inbox.Rows[0].Reason = "permission" - f.snap.Inbox.Rows[0].Tool = "Bash" - f.snap.Inbox.Rows[0].WaitingSinceAt = f.snap.ObservedAt.Add(-time.Minute) - f.snap.Inbox.Rows[0].TS = f.snap.Inbox.Rows[0].WaitingSinceAt - f.snap.Inbox.Rows[0].Jump = fixtureLocalJump(t, model.InboxJump{Kind: "tmux", Target: "dev:1.2", Server: "/tmp/fake", Command: "tmux attach -t 'dev:1.2'"}) - calls := 0 - m := setup(t, f, Options{Exec: func(args []string) error { - calls++ - if runtime.GOOS == "windows" { - requireFixtureTerminal(t, args) - } else if strings.Join(args, " ") != "tmux -S /tmp/fake select-pane -t dev:1.2" { - t.Fatal(args) - } - return nil - }}).(*app) - m.tab = homeTab - if !strings.Contains(m.header(), "1 waiting") || !strings.Contains(m.header(), "oldest 1m") { - t.Fatal(m.header()) - } - m.tab = 2 - m.rebuild() - text := m.View().Content - if !strings.Contains(text, "permission") || !strings.Contains(text, "Bash") { - t.Fatal(text) - } - if cmd := m.openSelected(false); cmd != nil { - cmd() - } - if calls != 1 || m.detail || !strings.Contains(m.status, "tmux attach") { - t.Fatal("Enter did not offer jump", m.status) - } -} - -func TestNeedsYouJumpSwitchesOnlySameLocalServer(t *testing.T) { - for _, remote := range []bool{false, true} { - f := fixture() - f.snap.LocalMachineID = "local" - row := &f.snap.Inbox.Rows[0] - row.MachineID = "local" - if remote { - row.MachineID = "peer" - } - row.Jump = fixtureLocalJump(t, model.InboxJump{Kind: "tmux", Server: "/tmp/fake-socket", Target: "dev:1.2", Command: "tmux attach -t 'dev:1.2'"}) - calls := 0 - m := setup(t, f, Options{Tmux: "/tmp/fake-socket,123,0", Exec: func(args []string) error { - calls++ - if runtime.GOOS == "windows" { - requireFixtureTerminal(t, args) - return nil - } - action := "switch-client" - if calls == 2 { - action = "select-pane" - } - if strings.Join(args, " ") != "tmux -S /tmp/fake-socket "+action+" -t dev:1.2" { - t.Fatal(args) - } - return nil - }}).(*app) - m.tab = 2 - m.rebuild() - if cmd := m.openSelected(false); cmd != nil { - cmd() - } - wantCalls := 0 - if !remote { - wantCalls = 2 - if runtime.GOOS == "windows" { - wantCalls = 1 - } - } - if calls != wantCalls { - t.Fatal(remote, calls, m.status) - } - if !strings.Contains(m.status, "tmux attach") { - t.Fatal(m.status) - } - } -} diff --git a/internal/tui/observations.go b/internal/tui/observations.go deleted file mode 100644 index 88ac366c..00000000 --- a/internal/tui/observations.go +++ /dev/null @@ -1,90 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/labstack/driga/internal/view" -) - -type flowObservationFrameMsg struct{} -type flowGrowthPulse struct { - until time.Time - frame int - bytes int64 -} - -func growthKey(n view.FlowNode) string { - return fmt.Sprintf("%s/%d/%s:%s", n.MachineID, n.ID, n.Adapter, n.ExternalID) -} - -func (m *app) recordFlowObservations(f view.FlowView) { - if f.Lead == nil { - return - } - if m.flowGrowthSeen == nil { - m.flowGrowthSeen = map[string]time.Time{} - } - if m.flowGrowthPulse == nil { - m.flowGrowthPulse = map[string]flowGrowthPulse{} - } - for _, n := range append([]view.FlowNode{*f.Lead}, f.Children...) { - key := growthKey(n) - prev, seen := m.flowGrowthSeen[key] - at := time.Time{} - if n.Observed != nil { - at = n.Observed.LastGrowthAt - } - if !seen || at.After(prev) { - m.flowGrowthSeen[key] = at - } - if seen && at.After(prev) && n.Observed.LastGrowthBytes > 0 { - m.flowGrowthPulse[key] = flowGrowthPulse{until: m.opts.Now().Add(time.Second), bytes: n.Observed.LastGrowthBytes} - } - } -} - -// The regular one-second clock expires event-bound accents. No frame timer. -func (m *app) flowAnimation() tea.Cmd { return nil } - -func (m *app) growthWire(n view.FlowNode, wire string) string { - pulse, ok := m.flowGrowthPulse[growthKey(n)] - if !ok || !m.opts.Now().Before(pulse.until) { - return wire - } - chars := []rune(wire) - // Skip the rail/junction prefix and the terminal arrow. - start := 0 - if len(chars) > 6 { - start = 3 - } - pos := min(len(chars)-2, start+pulse.frame) - chars[pos] = '●' - return view.TokenStyle("observation", m.opts.NoColor).Render(string(chars)) -} - -func (m *app) observationLines(n view.FlowNode, width int) []string { - label := "transcript · 60m" - if pulse, ok := m.flowGrowthPulse[growthKey(n)]; ok && m.opts.Now().Before(pulse.until) { - label = "+" + transcriptSize(pulse.bytes) + " transcript" - } - if n.Observed == nil { - return []string{"transcript: not observed"} - } - return []string{label, view.TokenStyle("observation", m.opts.NoColor).Render(view.ObservationSpark(n.Observed.LogBytesPerMin, width))} -} - -func transcriptSize(bytes int64) string { - n := float64(max(0, bytes)) - units := []string{"B", "KB", "MB", "GB", "TB"} - i := 0 - for n >= 1024 && i < len(units)-1 { - n /= 1024 - i++ - } - text := fmt.Sprintf("%.1f", n) - text = strings.TrimSuffix(text, ".0") - return text + " " + units[i] -} diff --git a/internal/tui/observations_test.go b/internal/tui/observations_test.go deleted file mode 100644 index 183ad603..00000000 --- a/internal/tui/observations_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package tui - -import ( - "github.com/labstack/driga/internal/view" - "strings" - "testing" - "time" -) - -func TestFlowMotionOff(t *testing.T) { - for _, opts := range []Options{{NoColor: true}, {MotionOff: true}} { - now := time.Now() - opts.Now = func() time.Time { return now } - m := setup(t, fixture(), opts).(*app) - m.flow = flowFixture() - m.width = 100 - m.recordFlowEvent(view.TailEvent{Key: "new", MachineID: "local", Adapter: "claude", ExternalID: "lead"}) - if strings.Contains(m.flowView(), "◉") { - t.Fatal("motion enabled") - } - m.flowGrowthPulse = map[string]flowGrowthPulse{"one": {until: now.Add(time.Second)}} - if m.flowAnimation() != nil { - t.Fatal("scheduled motion with motion disabled") - } - } -} diff --git a/internal/tui/polish_test.go b/internal/tui/polish_test.go index 9c4bd119..a52eb10c 100644 --- a/internal/tui/polish_test.go +++ b/internal/tui/polish_test.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "testing" "time" @@ -67,32 +66,6 @@ func TestBlockActivitySpark(t *testing.T) { } } -func TestActivityCurrentAndMaxFit(t *testing.T) { - s := fixture() - s.snap.Activity.Machines = []view.ActivityMachine{{Buckets: []view.ActivityBucket{ - {Events: 99, Sample: &model.MachineSample{Load1: 12, Cores: 4}}, - {Events: 3, Sample: &model.MachineSample{Load1: 2, Cores: 4}}, - }}} - for _, width := range []int{40, 80, 120, 160} { - m := setup(t, s, Options{}).(*app) - m.width, m.height = width, 100 - for _, line := range strings.Split(m.homeView(), "\n") { - if strings.Contains(line, "events ") || strings.Contains(line, "load ") && strings.ContainsAny(line, "▁▂▃▄▅▆▇█") { - if lipgloss.Width(line) != width { - t.Errorf("width %d: %q", width, line) - } - if !strings.Contains(line, "max") { - t.Errorf("max missing: %s", line) - } - } - } - text := m.homeView() - if !strings.Contains(text, "3/min max 99") || !strings.Contains(text, "2.0 max 12.0") { - t.Errorf("current/max missing: %s", text) - } - } -} - func TestLoadSparkAboveCoresIsCritical(t *testing.T) { b := []view.ActivityBucket{{Sample: &model.MachineSample{Load1: 2, Cores: 4}}, {Sample: &model.MachineSample{Load1: 8, Cores: 4}}} for _, width := range []int{2, 5} { @@ -128,59 +101,3 @@ func TestActivityExpandedMinutesAndGaps(t *testing.T) { } } } - -func TestActivityRowsFillWidthAndAxisMatchesMinutes(t *testing.T) { - buckets := make([]view.ActivityBucket, 60) - for i := range buckets { - buckets[i] = view.ActivityBucket{Events: 10, Sample: &model.MachineSample{Load1: 2, Cores: 4}} - } - for _, tc := range []struct { - width int - starts [5]int // Start columns of minutes 0, 15, 30, 45 and 59. - }{{160, [5]int{0, 39, 69, 99, 127}}, {120, [5]int{0, 30, 59, 74, 88}}, {80, [5]int{0, 13, 25, 37, 48}}} { - t.Run(fmt.Sprint(tc.width), func(t *testing.T) { - s := fixture() - s.snap.Activity.Machines = []view.ActivityMachine{{Buckets: buckets}} - m := setup(t, s, Options{}).(*app) - m.width, m.height = tc.width, 44 - rows, axes := 0, 0 - for _, line := range strings.Split(m.homeView(), "\n") { - for _, summary := range []string{"10/min max 10", "2.0 max 2.0"} { - value := strings.Index(line, summary) - if value < 0 { - continue - } - rows++ - prefix := []rune(line[:value]) - end := len(prefix) - for end > 0 && prefix[end-1] == ' ' { - end-- - } - if len(prefix)-end > 1 { - t.Errorf("spark leaves %d blank columns before value: %s", len(prefix)-end, line) - } - if end == 0 || !strings.ContainsRune("▁▂▃▄▅▆▇█", prefix[end-1]) { - t.Errorf("spark missing before value: %s", line) - } - if got := lipgloss.Width(line[:value]); got != tc.width-20 { - t.Errorf("value column at %d, want %d", got, tc.width-20) - } - } - if strings.Contains(line, "−60m") { - axes++ - for i, label := range []string{"−60m", "−45m", "−30m", "−15m", "now"} { - at := strings.Index(line, label) - if at < 0 { - t.Errorf("missing axis label %s", label) - } else if got, want := lipgloss.Width(line[:at]), 10+tc.starts[i]; got != want { - t.Errorf("%s at column %d, want minute start %d", label, got, want) - } - } - } - } - if rows != 2 || axes != 1 { - t.Fatalf("found %d activity rows and %d axes", rows, axes) - } - }) - } -} diff --git a/internal/tui/prompts_test.go b/internal/tui/prompts_test.go index 43c67bfa..a9168d0c 100644 --- a/internal/tui/prompts_test.go +++ b/internal/tui/prompts_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - tea "charm.land/bubbletea/v2" "github.com/labstack/driga/internal/chat" ) @@ -22,78 +21,6 @@ func (c *promptClient) AnswerPrompt(_ context.Context, id string, allow bool) er c.id, c.allow = id, allow return nil } -func TestInboxPromptKeys(t *testing.T) { - for _, allow := range []bool{false, true} { - t.Run(map[bool]string{false: "deny", true: "allow"}[allow], func(t *testing.T) { - p := chat.Prompt{ID: "one", Session: "session", Tool: "Bash", Input: json.RawMessage(`{"command":"exact command"}`)} - c := &promptClient{prompts: []chat.Prompt{p}} - m := setup(t, fixture(), Options{Chat: c}).(*app) - m.tab = 2 - msg := m.poll()().(refreshMsg) - m.Update(msg) - if len(m.rows) < 2 || m.rows[0].prompt == nil || m.rows[1].inbox == nil { - t.Fatal("prompt must sit above existing wait", m.rows) - } - if !strings.Contains(strings.Join(m.rows[0].cells, " "), "exact command") { - t.Fatal(m.rows[0]) - } - if !strings.Contains(m.footer(), "y allow / n deny") { - t.Fatal(m.footer()) - } - m.openDetail(m.rows[0]) - if !strings.Contains(m.viewport.View(), "exact command") { - t.Fatal(m.viewport.View()) - } - key := 'n' - if allow { - key = 'y' - } - _, cmd := m.Update(tea.KeyPressMsg{Code: key}) - if cmd == nil { - t.Fatal("missing answer command") - } - msg2 := cmd() - if batch, ok := msg2.(tea.BatchMsg); ok { - for _, cmd := range batch { - if cmd != nil { - cmd() - } - } - } - if c.id != "one" || c.allow != allow { - t.Fatal(c) - } - }) - } -} -func TestPromptRefreshWithoutDatabaseChange(t *testing.T) { - c := &promptClient{} - m := setup(t, fixture(), Options{Chat: c}).(*app) - m.tab = 2 - m.loaded = true - m.version = 1 - c.prompts = []chat.Prompt{{ID: "arrived", Session: "s", Tool: "Write", Input: json.RawMessage(`{"file_path":"f","content":"abc"}`)}} - msg := m.poll()().(refreshMsg) - if msg.changed { - t.Fatal("fixture should not change") - } - m.Update(msg) - if len(m.rows) == 0 || m.rows[0].prompt == nil || m.rows[0].prompt.Detail() != "f · 3 bytes" { - t.Fatal(m.rows) - } - m.openDetail(m.rows[0]) - c.prompts = nil - m.Update(m.poll()()) - if m.detail || m.detailRow != nil { - t.Fatal("retired prompt detail retained") - } - for _, r := range m.rows { - if r.prompt != nil { - t.Fatal("retired prompt retained") - } - } -} - func TestPromptDetailKeepsFullInput(t *testing.T) { m := setup(t, fixture(), Options{}).(*app) input, _ := json.Marshal(map[string]string{"command": strings.Repeat("long-command ", 80) + "end-marker"}) diff --git a/internal/tui/remote_ack_test.go b/internal/tui/remote_ack_test.go index 459ae7ab..17cc0953 100644 --- a/internal/tui/remote_ack_test.go +++ b/internal/tui/remote_ack_test.go @@ -22,7 +22,7 @@ func TestTUIShowsAckPending(t *testing.T) { } var events []model.HookEvent m := setup(t, s, Options{DataDir: dir, Spool: func(ev model.HookEvent) error { events = append(events, ev); return nil }}) - m = key(m, '3') + m = key(key(m, '0'), 'h') m, cmd := press(m, 'x') if cmd == nil { t.Fatalf("missing remote action: %s", m.View().Content) @@ -35,7 +35,7 @@ func TestTUIShowsAckPending(t *testing.T) { // Pending is visible after loading an existing request, too. s.snap.Inbox.Rows[0].AckPendingAt = &s.snap.ObservedAt m = setup(t, s, Options{DataDir: dir}) - m = key(m, '3') + m = key(key(m, '0'), 'h') if !strings.Contains(m.View().Content, "ack pending → vm-1") { t.Fatal(m.View().Content) } diff --git a/internal/tui/sessions_test.go b/internal/tui/sessions_test.go new file mode 100644 index 00000000..528a56ea --- /dev/null +++ b/internal/tui/sessions_test.go @@ -0,0 +1,92 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/view" +) + +func TestFlowInitialAndKeyZero(t *testing.T) { + m := setup(t, fixture(), Options{}) + if m.(*app).tab != flowTab || !strings.Contains(m.View().Content, "0 Flow") { + t.Fatal(m.View().Content) + } + m = key(m, '1') + m = key(m, '0') + if m.(*app).tab != flowTab { + t.Fatal("zero must open Flow") + } +} + +func TestAgentsSplitThresholdAndCursor(t *testing.T) { + s := fixture() + s.snap.Agents.Rows = append(s.snap.Agents.Rows, view.AgentRow{ID: 2, Label: "working", Title: "second"}) + s.snap.AgentDetails[2] = view.AgentView{Session: model.Session{ID: 2, Evidence: "second evidence"}} + for _, width := range []int{139, 140, 160} { + m := setup(t, s, Options{}) + m = key(key(m, '0'), 's') + m, _ = m.Update(tea.WindowSizeMsg{Width: width, Height: 60}) + if strings.Contains(m.View().Content, "Evidence:") != (width >= 140) { + t.Fatalf("width %d: %s", width, m.View().Content) + } + if width >= 140 { + m = key(m, 'j') + if !strings.Contains(m.View().Content, "second evidence") { + t.Fatal(m.View().Content) + } + } else { + m = key(m, tea.KeyEnter) + if !strings.Contains(m.View().Content, "hook notification") { + t.Fatal(m.View().Content) + } + m = key(m, tea.KeyEscape) + if strings.Contains(m.View().Content, "Evidence:") { + t.Fatal(m.View().Content) + } + } + } +} + +func TestAgentDetailUsesStateColors(t *testing.T) { + s := fixture() + d := s.snap.AgentDetails[1] + d.Label = "stalled" + s.snap.AgentDetails[1] = d + m := setup(t, s, Options{}).(*app) + m.tab = 0 + m.rebuild() + m.opts.NoColor = false + key(m, tea.KeyEnter) + if !strings.Contains(m.View().Content, view.StateStyle("stalled", false).Render("stalled")) { + t.Fatalf("detail state lacks red: %q", m.View().Content) + } +} + +func TestNOColorEmptyMeansUnset(t *testing.T) { + for _, value := range []string{"", "0", "1"} { + t.Run(value, func(t *testing.T) { + t.Setenv("NO_COLOR", value) + m := New(&fakeSource{}, Options{}).(*app) + if m.opts.NoColor != (value != "") { + t.Fatalf("NO_COLOR=%q disables color: %v", value, m.opts.NoColor) + } + }) + } +} + +func TestEventDrivenHelpReportsLastEvent(t *testing.T) { + s := fixture() + at := s.snap.ObservedAt.Add(-24 * time.Hour) + s.snap.Freshness = map[string]view.Fresh{"otlp": {LastOKAt: at, Status: "last_event"}, "sockets": {LastOKAt: at, Status: "last_event"}} + m := setup(t, s, Options{}).(*app) + m.tab = 0 + m.rebuild() + key(m, '?') + if strings.Contains(m.footer(), "last event") || !strings.Contains(m.header(), "intake · receiving") || !strings.Contains(m.View().Content, "last event") || strings.Contains(m.View().Content, "stale") { + t.Fatal(m.View().Content) + } +} diff --git a/internal/tui/started_from_test.go b/internal/tui/started_from_test.go index 84266e56..550dbcf8 100644 --- a/internal/tui/started_from_test.go +++ b/internal/tui/started_from_test.go @@ -28,9 +28,8 @@ func TestStartedFromAgentsDetailAndFlow(t *testing.T) { if strings.Contains(agentDetail(d, 140), " · from Editor") != (surface != "") { t.Fatal("detail missing surface") } - m.flow = flowFixture() - m.flow.Lead.Title = "Task" - m.flow.Lead.StartedFrom = surface + m.snap.BoardData.Nodes[0].Title = "Task" + m.snap.BoardData.Nodes[0].StartedFrom = surface if strings.Contains(m.flowView(), "Task · from Editor") != (surface != "") { t.Fatal("Flow label/detail missing surface", m.flowView()) } diff --git a/internal/tui/summary_test.go b/internal/tui/summary_test.go index b58218b5..c03dd65f 100644 --- a/internal/tui/summary_test.go +++ b/internal/tui/summary_test.go @@ -23,7 +23,7 @@ func TestSummaryKeyRunsOnlyOnDemand(t *testing.T) { } return nil }}) - m = key(m, '2') + m = key(m, '1') m = key(m, 's') if calls != 0 { t.Fatal("background summary") diff --git a/internal/tui/tabs.go b/internal/tui/tabs.go index 04c0dd21..60c087ed 100644 --- a/internal/tui/tabs.go +++ b/internal/tui/tabs.go @@ -41,7 +41,7 @@ func (m *app) tabVisible(tab int) bool { return true } switch tab { - case homeTab, flowTab, chatTab: + case flowTab, chatTab: return true case 0: return len(m.snap.Agents.Rows) > 0 @@ -64,70 +64,30 @@ func (m *app) tabBar() string { } func (m *app) tabLayout() (string, []tabHit) { - s := m.visibleSummary() - projects, inbox, orphans := 0, 0, 0 - for _, r := range m.snap.Projects.Rows { - if m.machineVisible(r.MachineID) { - projects++ - } - } - for _, r := range m.snap.Inbox.Rows { - if m.machineVisible(r.MachineID) { - inbox++ - } + selected := m.tab + if selected == chatTab { + selected = m.chatOrigin } - for _, r := range m.snap.Orphans.Rows { - if m.machineVisible(r.MachineID) { - orphans++ - } + if selected == 0 || selected == 2 { + selected = flowTab } - if m.machineVisible(m.localMachine()) { - inbox += len(m.prompts) + if selected == 3 || selected == 5 { + selected = 4 } - cleanupCount := 0 - if m.machineVisible(m.localMachine()) { - cleanupCount = len(m.snap.Cleanup) - } - names := []string{fmt.Sprintf("Agents %d", s.Agents), fmt.Sprintf("Projects %d", projects), "Inbox", fmt.Sprintf("Orphans %d", orphans), "Machine", fmt.Sprintf("Cleanup %d", cleanupCount), "Flow", "Chat"} + labels := []string{"0 Flow", "1 Projects", "2 Machines"} + indices := []int{flowTab, 1, 4} tabs := []string{} - indices := []int{homeTab} - active := func(label string, selected bool) string { - style := view.TokenStyle("muted", m.opts.NoColor) - if selected && m.opts.NoColor { - return "[" + label + "]" - } - if selected { - style = view.TokenStyle("ink", m.opts.NoColor) - if !m.opts.NoColor { - style = style.Bold(true).Underline(true) - } - } - return style.Render(label) - } - tabs = append(tabs, active("0 Home", m.tab == homeTab)) - for i, name := range names { - if name == "Chat" { - i = chatTab - } - if name == "Flow" { - i = flowTab - } - if !m.tabVisible(i) { - continue - } - label := active(fmt.Sprintf("%d %s", mapTabNumber(i), name), m.tab == i) - if i == 2 && m.opts.NoColor { - label = active(fmt.Sprintf("%d %s %d", i+1, name, inbox), m.tab == i) - } - if i == 2 && !m.opts.NoColor { - token := "faint" - if inbox > 0 { - token = "waiting" + for i, label := range labels { + if selected == indices[i] { + if m.opts.NoColor { + label = "[" + label + "]" + } else { + label = view.TokenStyle("ink", false).Bold(true).Underline(true).Render(label) } - label += " " + view.TokenStyle(token, m.opts.NoColor).Render(fmt.Sprint(inbox)) + } else { + label = view.TokenStyle("muted", m.opts.NoColor).Render(label) } tabs = append(tabs, label) - indices = append(indices, i) } hits := []tabHit{} x, y := 0, 0 @@ -200,26 +160,13 @@ func (m *app) rebuild() { } switch m.tab { case flowTab: - titles = []string{"", "STATE", "WORKER"} + m.syncBoard() + titles = []string{"", "STATE", "SESSION"} weights = []int{2, 10, 40} - window := m.flowWorkers() - nodes := window.Nodes - if len(nodes) == 0 && window.Quiet == 0 && m.flow.Lead != nil { - nodes = []view.FlowNode{*m.flow.Lead} - } - for _, n := range nodes { - add(tabRow{machine: n.MachineID, key: fmt.Sprint(n.ID), agent: n.ID, cells: []string{"", n.Label, n.Title}}) - } - if window.Quiet > 0 { - add(tabRow{key: "flow-quiet", cells: []string{"", "", fmt.Sprintf("+%d quiet threads", window.Quiet)}}) + for _, r := range m.boardRows() { + add(tabRow{machine: r.Node.MachineID, key: r.Key, agent: r.Node.ID, cwd: r.Cwd, cells: []string{"", r.State, r.Title}}) } - case homeTab: - titles = []string{"", "STATE", "PROJECT", "SUMMARY"} - weights = []int{2, 10, 14, 40} - for _, row := range m.needsYou() { - add(row) - } case 5: titles = []string{"", "KIND", "STATUS", "PATH"} weights = []int{2, 10, 18, 50} @@ -303,17 +250,10 @@ func (m *app) rebuild() { case 2: titles = []string{"", "REASON", "TOOL", "WAIT / AGE", "PROJECT · BRANCH", "TITLE / DETAIL"} weights = []int{2, 18, 8, 10, 18, 32} - for _, p := range m.prompts { - if m.opts.Project != "" && p.Cwd != m.opts.Project { + for _, r := range m.snap.Inbox.Rows { + if attention.Active(r.InboxItem) && !r.Historical { continue } - session := p.Session - if p.SessionID > 0 { - session = fmt.Sprintf("#%d", p.SessionID) - } - add(tabRow{machine: m.localMachine(), key: "prompt:" + p.ID, agent: p.SessionID, prompt: &p, cwd: p.Cwd, cells: []string{"", "permission " + session, p.Tool, duration(m.opts.Now().Sub(p.CreatedAt)), p.Cwd, p.Detail()}}) - } - for _, r := range m.snap.Inbox.Rows { item := r.InboxItem reason := r.Reason if reason == "" { @@ -362,7 +302,7 @@ func (m *app) rebuild() { ids[r.machine] = true } } - if len(ids) > 1 && m.tab != homeTab { + if len(ids) > 1 { titles = append([]string{titles[0], "MACHINE"}, titles[1:]...) weights = append([]int{weights[0], 14}, weights[1:]...) for i := range rows { @@ -371,10 +311,6 @@ func (m *app) rebuild() { } m.rows = rows m.cursors[m.tab] = min(max(0, m.cursors[m.tab]), max(0, len(rows)-1)) - if m.tab == homeTab { - m.homeNeedsTop = max(0, min(m.homeNeedsTop, m.cursors[homeTab])) - m.homeNeedsTop = max(m.homeNeedsTop, m.cursors[homeTab]-5) - } columns := make([]table.Column, len(titles)) sum := 0 for _, w := range weights { @@ -474,11 +410,13 @@ func agentAge(d time.Duration) string { } func mapTabNumber(tab int) int { - if tab == chatTab { - return 8 - } - if tab == flowTab { - return 7 + switch tab { + case flowTab: + return 0 + case 1: + return 1 + case 4: + return 2 } - return tab + 1 + return -1 } diff --git a/internal/tui/testdata/board-busy-100.golden b/internal/tui/testdata/board-busy-100.golden new file mode 100644 index 00000000..d77008c4 --- /dev/null +++ b/internal/tui/testdata/board-busy-100.golden @@ -0,0 +1,30 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 + ██ events/min · updated 0s ago +NEEDS YOU · 7 · oldest first + ◆ Ship the release pipeline safely · monk · 23m + Permission · Bash · go test ./... + Enter open · J Terminal + ◆ 流れを整える 🌊 · driga · 15m + ◆ Add board view · from Editor · relay · 14m + +4 more · Tab then j/k +──────────────────────────────────────────────────────────────────────────────────────────────────── +atlas +├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m +├── ▶ Fix retry after reconnect codex ░░━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━━ 1h 26m +├── ○ Investigate cache invalidati… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m +├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░░░━━░░░━━━━━━░░░✓━━━━━ ━━━━━ 1h 2m +└── ○ Investigate cache invalidati… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m + └── ‖ Investigate cache invalid… codex ░░░░░░░░░░━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━░ 5m +canon +├── ▶ Add board view · from Editor claude ░░░░━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 49m +├── ◆ 流れを整える 🌊 claude ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━◆◆◆◆◆◆◆◆◆◆ 13m +├── ▶ Add board view · from Editor claude ░━━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 25m +├── ▶ 流れを整える 🌊 claude ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 13m +├── ▶ Add board view · from Editor claude ░░░░░░░░░░░░━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 1m +└── ▶ 流れを整える 🌊 claude ━━ ░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 49m +driga +├── ▶ 流れを整える 🌊 claude ░━━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 45m +──────────────────────────────────────────────────────────────────────────────────────────────────── +Fix retry after reconnect · codex · idle +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-busy-140.golden b/internal/tui/testdata/board-busy-140.golden new file mode 100644 index 00000000..11e8b2e3 --- /dev/null +++ b/internal/tui/testdata/board-busy-140.golden @@ -0,0 +1,34 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ───────────────────────────╮ +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Fix retry after reconnect · atlas · codex │ + ██ events/min · updated 0s ago │ · idle │ +NEEDS YOU · 7 · oldest first │ −60m now │ + ◆ Ship the release pipeline safely · monk · 23m │ └── ○ Fix retry after reconnect │ + Permission · Bash · go test ./... │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ + Enter open · J Terminal │ J Terminal · Enter Open · c Ask Chat · K │ + ◆ 流れを整える 🌊 · driga · 15m │ Stop │ + ◆ Add board view · from Editor · relay · 14m ╰────────────────────────────────────────────╯ + +4 more · Tab then j/k +─────────────────────────────────────────────────────────────────────────────────────────── +atlas +├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m +├── ▶ Fix retry after reconnect codex ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 1h 26m +├── ○ Investigate cache invalid… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m +├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░━━░░░━━━━━░░✓━━━━━ ━━━━━ 1h 2m +└── ○ Investigate cache invalid… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m + └── ‖ Investigate cache inva… codex ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━░ 5m +canon +├── ▶ Add board view · from Edi… claude ░░░━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 49m +├── ◆ 流れを整える 🌊 claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━◆◆◆◆◆◆◆◆ 13m +├── ▶ Add board view · from Edi… claude ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 25m +├── ▶ 流れを整える 🌊 claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 13m +├── ▶ Add board view · from Edi… claude ░░░░░░░░░░━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 1m +└── ▶ 流れを整える 🌊 claude ━━ ░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 49m +driga +├── ▶ 流れを整える 🌊 claude ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 45m +├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 21m +├── ▶ Add board view · from Edi… claude ░░░━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 9m +├── ◆ 流れを整える 🌊 claude ░░░░░░░━━━━━░░░━━━━━░░━━━◆◆◆◆◆◆◆◆◆◆ 15m ┄ boar… +└── ▶ Add board view · from Edi… claude ━━━━ ━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 45m ┄ boar… + └── ▶ Add board view · from … claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 33m +echo-pro +├── ▶ Investigate cache invalid… codex ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 1h 46m diff --git a/internal/tui/testdata/board-busy-200.golden b/internal/tui/testdata/board-busy-200.golden new file mode 100644 index 00000000..8eb3b9b7 --- /dev/null +++ b/internal/tui/testdata/board-busy-200.golden @@ -0,0 +1,34 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ─────────────────────────────────╮ +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Fix retry after reconnect · atlas · codex · idle │ + ██ events/min · updated 0s ago │ −60m now │ +NEEDS YOU · 7 · oldest first │ └── ○ Fix retry after reconnect │ + ◆ Ship the release pipeline safely · monk · 23m │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ + Permission · Bash · go test ./... │ J Terminal · Enter Open · c Ask Chat · K Stop │ + Enter open · J Terminal ╰──────────────────────────────────────────────────╯ + ◆ 流れを整える 🌊 · driga · 15m + ◆ Add board view · from Editor · relay · 14m + +4 more · Tab then j/k +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +atlas +├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m +├── ▶ Fix retry after reconnect codex ░░░━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 1h 26m +├── ○ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m +├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░✓✓━━━━━━━━━━━ ━━━━━━━━━━ 1h 2m +└── ○ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m + └── ‖ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━░ 5m +canon +├── ▶ Add board view · from Editor claude ░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 49m +├── ◆ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 13m +├── ▶ Add board view · from Editor claude ░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 25m +├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 13m +├── ▶ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░░░░░░░━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 1m +└── ▶ 流れを整える 🌊 claude ━━━━ ░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 49m +driga +├── ▶ 流れを整える 🌊 claude ░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 45m +├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░░░░░░░━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 21m +├── ▶ Add board view · from Editor claude ░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 9m +├── ◆ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 15m ┄ boar… +└── ▶ Add board view · from Editor claude ━━━━━━━━━ ━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 45m ┄ boar… + └── ▶ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 33m +echo-pro +├── ▶ Investigate cache invalidation codex ░░░━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 1h 46m diff --git a/internal/tui/testdata/board-busy-80.golden b/internal/tui/testdata/board-busy-80.golden new file mode 100644 index 00000000..7d23fcfe --- /dev/null +++ b/internal/tui/testdata/board-busy-80.golden @@ -0,0 +1,30 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 + ██ events/min · updated 0s ago +NEEDS YOU · 7 · oldest first + ◆ Ship the release pipeline safely · monk · 23m + Permission · Bash · go test ./... + Enter open · J Terminal + ◆ 流れを整える 🌊 · driga · 15m + ◆ Add board view · from Editor · relay · 14m + +4 more · Tab then j/k +──────────────────────────────────────────────────────────────────────────────── +atlas +├── › ○ Fix retry after recon… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m +├── ▶ Fix retry after recon… codex ░━━━░░━━━━━░░━━━━░░━━━━░░━━━━ 1h 26m +├── ○ Investigate cache inv… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m +├── ✓ Fix retry after recon… codex ░░░░░░░░░━━░░━━━━░✓━━━━ ━━━━ 1h 2m +└── ○ Investigate cache inv… codex ░░░░░░░░░░░░░░░░░░░░░░░░─ 50m + └── ‖ Investigate cache … codex ░░░░░░░━━━━░░━━━━░░━━━━░░━━━░ 5m +canon +├── ▶ Add board view · from… claude ░░━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 49m +├── ◆ 流れを整える 🌊 claude ░░░░░░━━━━━░░━━━━░░━━━◆◆◆◆◆◆◆ 13m +├── ▶ Add board view · from… claude ━━━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 25m +├── ▶ 流れを整える 🌊 claude ░░░░░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 13m +├── ▶ Add board view · from… claude ░░░░░░░░━━━░░━━━━░░━━━━░░━━━┿ 1h 1m +└── ▶ 流れを整える 🌊 claude ━━ ░━━━━━░░━━━━░░━━━━░░━━━┿ 49m +driga +├── ▶ 流れを整える 🌊 claude ━━━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 45m +──────────────────────────────────────────────────────────────────────────────── +Fix retry after reconnect · codex · idle +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-empty-100.golden b/internal/tui/testdata/board-empty-100.golden new file mode 100644 index 00000000..efc6f12b --- /dev/null +++ b/internal/tui/testdata/board-empty-100.golden @@ -0,0 +1,9 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 0 needs you ▶ 0 ‖ 0 ○ 0 ✓ 0 + events/min · updated 0s ago +NEEDS YOU · 0 · oldest first + Nothing needs you +──────────────────────────────────────────────────────────────────────────────────────────────────── + No sessions in this window +──────────────────────────────────────────────────────────────────────────────────────────────────── +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-empty-140.golden b/internal/tui/testdata/board-empty-140.golden new file mode 100644 index 00000000..b4978e6e --- /dev/null +++ b/internal/tui/testdata/board-empty-140.golden @@ -0,0 +1,7 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ───────────────────────────╮ +◆ 0 needs you ▶ 0 ‖ 0 ○ 0 ✓ 0 │ Select a session to see its family │ + events/min · updated 0s ago │ J Terminal · Enter Open · c Ask Chat · K │ +NEEDS YOU · 0 · oldest first │ Stop │ + Nothing needs you ╰────────────────────────────────────────────╯ +─────────────────────────────────────────────────────────────────────────────────────────── + No sessions in this window diff --git a/internal/tui/testdata/board-empty-200.golden b/internal/tui/testdata/board-empty-200.golden new file mode 100644 index 00000000..f9457869 --- /dev/null +++ b/internal/tui/testdata/board-empty-200.golden @@ -0,0 +1,7 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ─────────────────────────────────╮ +◆ 0 needs you ▶ 0 ‖ 0 ○ 0 ✓ 0 │ Select a session to see its family │ + events/min · updated 0s ago │ J Terminal · Enter Open · c Ask Chat · K Stop │ +NEEDS YOU · 0 · oldest first ╰──────────────────────────────────────────────────╯ + Nothing needs you +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + No sessions in this window diff --git a/internal/tui/testdata/board-empty-80.golden b/internal/tui/testdata/board-empty-80.golden new file mode 100644 index 00000000..d7a33fb0 --- /dev/null +++ b/internal/tui/testdata/board-empty-80.golden @@ -0,0 +1,9 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 0 needs you ▶ 0 ‖ 0 ○ 0 ✓ 0 + events/min · updated 0s ago +NEEDS YOU · 0 · oldest first + Nothing needs you +──────────────────────────────────────────────────────────────────────────────── + No sessions in this window +──────────────────────────────────────────────────────────────────────────────── +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-quiet-100.golden b/internal/tui/testdata/board-quiet-100.golden new file mode 100644 index 00000000..995931f1 --- /dev/null +++ b/internal/tui/testdata/board-quiet-100.golden @@ -0,0 +1,13 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 0 needs you ▶ 0 ‖ 0 ○ 2 ✓ 0 + events/min · updated 0s ago +NEEDS YOU · 0 · oldest first + Nothing needs you +──────────────────────────────────────────────────────────────────────────────────────────────────── +driga +└── › ○ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 45m +echo-pro +└── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 46m +──────────────────────────────────────────────────────────────────────────────────────────────────── +Add board view · from Editor · claude · idle +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-quiet-140.golden b/internal/tui/testdata/board-quiet-140.golden new file mode 100644 index 00000000..aa3385e1 --- /dev/null +++ b/internal/tui/testdata/board-quiet-140.golden @@ -0,0 +1,10 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ───────────────────────────╮ +◆ 0 needs you ▶ 0 ‖ 0 ○ 2 ✓ 0 │ Add board view · from Editor · driga · │ + events/min · updated 0s ago │ claude · idle │ +NEEDS YOU · 0 · oldest first │ −60m now │ + Nothing needs you │ └── ○ Add board view · from Editor │ +─────────────────────────────────────────────────────────────────────────────────────────── │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ +driga │ files /synthetic/driga/board.go │ +└── › ○ Add board view · from Edi… claude ░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 45m │ J Terminal · Enter Open · c Ask Chat · K │ +echo-pro │ Stop │ +└── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 46m ╰────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/board-quiet-200.golden b/internal/tui/testdata/board-quiet-200.golden new file mode 100644 index 00000000..9307e434 --- /dev/null +++ b/internal/tui/testdata/board-quiet-200.golden @@ -0,0 +1,10 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ─────────────────────────────────╮ +◆ 0 needs you ▶ 0 ‖ 0 ○ 2 ✓ 0 │ Add board view · from Editor · driga · claude · │ + events/min · updated 0s ago │ idle │ +NEEDS YOU · 0 · oldest first │ −60m now │ + Nothing needs you │ └── ○ Add board view · from Editor │ +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ +driga │ files /synthetic/driga/board.go │ +└── › ○ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 45m │ J Terminal · Enter Open · c Ask Chat · K Stop │ +echo-pro ╰──────────────────────────────────────────────────╯ +└── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 46m diff --git a/internal/tui/testdata/board-quiet-80.golden b/internal/tui/testdata/board-quiet-80.golden new file mode 100644 index 00000000..2e513ff9 --- /dev/null +++ b/internal/tui/testdata/board-quiet-80.golden @@ -0,0 +1,13 @@ +Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m +◆ 0 needs you ▶ 0 ‖ 0 ○ 2 ✓ 0 + events/min · updated 0s ago +NEEDS YOU · 0 · oldest first + Nothing needs you +──────────────────────────────────────────────────────────────────────────────── +driga +└── › ○ Add board view · from… claude ░░░░░░░░░░░░░░░░░░░░░─ 45m +echo-pro +└── ○ Fix retry after recon… codex ░░░░░░░░░░░░░░░░░░░░░░─ 46m +──────────────────────────────────────────────────────────────────────────────── +Add board view · from Editor · claude · idle +Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/chat-80.golden b/internal/tui/testdata/chat-80.golden index 96e7928e..287daa12 100644 --- a/internal/tui/testdata/chat-80.golden +++ b/internal/tui/testdata/chat-80.golden @@ -11,6 +11,7 @@ Conversations end with this client. + You · sent What needs me? diff --git a/internal/tui/testdata/flow-100-color-false.golden b/internal/tui/testdata/flow-100-color-false.golden deleted file mode 100644 index 9a1453c8..00000000 --- a/internal/tui/testdata/flow-100-color-false.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────────────────────────╮ -│ 48m · 11:12–now · head = verdict as of │ -│ 192m earlier · 0 asks │ -│ ›Driga flow map · … ▶ working ▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu ▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry ▶ working ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP ● ended ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check ◆ needs you ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal… ? unknown ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/flow-100-color-true.golden b/internal/tui/testdata/flow-100-color-true.golden deleted file mode 100644 index 3c9b9107..00000000 --- a/internal/tui/testdata/flow-100-color-true.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────────────────────────╮ -│ 48m · 11:12–now · head = verdict as of │ -│ 192m earlier · 0 asks │ -│ ›Driga flow map · … ▶ working ▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu ▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry ▶ working                        ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP ● ended                         ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check ◆ needs you                          ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal… ? unknown                           ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/flow-140-color-false.golden b/internal/tui/testdata/flow-140-color-false.golden deleted file mode 100644 index 6dc8ecbe..00000000 --- a/internal/tui/testdata/flow-140-color-false.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 88m · 10:32–now · head = verdict as of │ -│ 152m earlier · 0 asks │ -│ ›Driga flow map · … ▶ working ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry ▶ working ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP ● ended ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check ◆ needs you ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal… ? unknown ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/flow-140-color-true.golden b/internal/tui/testdata/flow-140-color-true.golden deleted file mode 100644 index d87be453..00000000 --- a/internal/tui/testdata/flow-140-color-true.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 88m · 10:32–now · head = verdict as of │ -│ 152m earlier · 0 asks │ -│ ›Driga flow map · … ▶ working ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu  ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry ▶ working                                                                ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP ● ended                                                                 ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check ◆ needs you                                                                  ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal… ? unknown                                                                   ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/flow-80-color-false.golden b/internal/tui/testdata/flow-80-color-false.golden deleted file mode 100644 index c60961a2..00000000 --- a/internal/tui/testdata/flow-80-color-false.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────╮ -│ 40m · 11:20–now · head = verdict as of │ -│ 200m earlier · 0 asks │ -│ ›Driga flow map · … ▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu ▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal… ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/flow-80-color-true.golden b/internal/tui/testdata/flow-80-color-true.golden deleted file mode 100644 index 513ff8b4..00000000 --- a/internal/tui/testdata/flow-80-color-true.golden +++ /dev/null @@ -1,35 +0,0 @@ -╭─ Needs you · machine-wide ───────────────────────────────────────────────────╮ -│ NEEDS YOU 1 │ -│ ◆ 5m Permission check Permission · Bash │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Session map ────────────────────────────────────────────────────────────────╮ -│ 40m · 11:20–now · head = verdict as of │ -│ 200m earlier · 0 asks │ -│ ›Driga flow map · … ▇█▂▃▄▅▆▇█▂▃▄▅▆▇┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿┿━━▶ 5s ago │ -│ cpu ▇ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃▄▄▅▆▆ ▁▂▂▃▄▄▅▆▆▇ ▁▂▂▃█ │ -│ Implement geometry                ╰░░░░░░░░░░░░◆════░░░┿━━▶ 0s ago │ -│ Review CSP                 ╰░░░░░░░░░░░░░░░░░░░●──● 0s ago │ -│ Permission check                  ╰░░░░░░░░░░░░░░░░░◆═══◆ 0s ago │ -│ Quiet remote anal…                   ╰░░░░░░░░░░░░░░░░░─┈┈? 0s ago │ -│ +12 quiet threads · enter to expand │ -│ ▁ growth / CPU · ┿ tool · ◆ wait · ○ prompt · ● end · ░ no data │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Session detail · signal mixer ──────────────────────────────────────────────╮ -│ Driga flow map · from Editor · claude │ -│ ▶ working · as of 5s ago │ -│ why: because telemetry: OTLP activity 1s ago │ -│ running 8h0m0s │ -│ tool calls 100 · retained window │ -│ verdict working · 2 of 4 agree │ -│ registry: busy │ -│ hooks: unavailable │ -│ telemetry: tool_result │ -│ process: alive · CPU 2.5% │ -│ Copy resume: claude --resume 'eight-hour-lead' │ -│ last growth 3.9 KB · 0s ago │ -│ Recent events · this session family │ -│ 11:59:00 otlp:tool_result │ -│ 11:59:01 otlp:tool_result │ -│ 11:59:02 otlp:tool_result │ -│ 11:59:03 otlp:tool_result │ -╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/tui/testdata/machine-140-expanded-false.golden b/internal/tui/testdata/machine-140-expanded-false.golden index e6b02059..2994270d 100644 --- a/internal/tui/testdata/machine-140-expanded-false.golden +++ b/internal/tui/testdata/machine-140-expanded-false.golden @@ -1,49 +1,49 @@ driga test-host · ● live · daemon · 05:00:00 mem ███░░░ 50% swap ███░░░ 50% load 0.0/4 disk 12.0G -0 Home 1 Agents 1 2 Projects 0 3 Inbox 1 4 Orphans 1 [5 Machine] 6 Cleanup 0 7 Flow 8 Chat +0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM - agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · pressure critical · swap 1.0G/2.0G · disk 12.0G free - Memory measure: physical_footprint · footprint includes compressed and swapped memory, so apps can add up to more than RAM - Sessions by cost -› ▸ claude · fixture · session #251 · start… 70.0% physical_footprint 2.0G - Started from Editor - Agent processes without a session - MCP servers and helper processes - codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 hosts · 2 duplicates - codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M - ▸ Other apps · 1.0G (rss) - Machine vitals - memory 4.0G/8.0G - compressed 1.0G - swap 1.0G/2.0G - pressure level critical - pressure free 0.0% - load (1/5/15) 0.0 / 0.0 / 0.0 - cores 4 - disk free 12.0G - Agent memory by role - - - - - - - - - - - - - - - - - - - - - - - - -0 Home · m machine · enter detail · / filter · s sort · g group by project · o open terminal · esc back · ? help · q quit + METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM + agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · pressure critical · swap 1.0G/2.0G · disk 12.0G free + Memory measure: physical_footprint · footprint includes compressed and swapped memory, so apps can add up to more than RAM + Sessions by memory +› ▸ claude · fixture · session #251 · start… 70.0% physical_footprint 2.0G + Started from Editor + Agent processes without a session + MCP servers and helper processes + codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 hosts · 2 duplicates + codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M + ▸ Other apps · 1.0G (rss) + Machine vitals + memory 4.0G/8.0G + compressed 1.0G + swap 1.0G/2.0G + pressure level critical + pressure free 0.0% + load (1/5/15) 0.0 / 0.0 / 0.0 + cores 4 + disk free 12.0G + Agent memory by role + + + + + + + + + + + + + + + + + + + + + + + +h health · p reclaim processes · d reclaim disk · 0 Flow · M machine · enter detail · / filter · s sort · g group by project +o open terminal · esc back · ? help · q quit diff --git a/internal/tui/testdata/machine-140-expanded-true.golden b/internal/tui/testdata/machine-140-expanded-true.golden index 4a9ecf13..d3673137 100644 --- a/internal/tui/testdata/machine-140-expanded-true.golden +++ b/internal/tui/testdata/machine-140-expanded-true.golden @@ -1,49 +1,49 @@ driga test-host · ● live · daemon · 05:00:00 mem ███░░░ 50% swap ███░░░ 50% load 0.0/4 disk 12.0G -0 Home 1 Agents 1 2 Projects 0 3 Inbox 1 4 Orphans 1 [5 Machine] 6 Cleanup 0 7 Flow 8 Chat +0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM - agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · pressure critical · swap 1.0G/2.0G · disk 12.0G free - Memory measure: physical_footprint · footprint includes compressed and swapped memory, so apps can add up to more than RAM - Sessions by cost -› ▾ claude · fixture · session #251 · start… 70.0% physical_footprint 2.0G - 42 worker 70.0% started by fixture · session #251 2.0G - Started from Editor - Agent processes without a session - MCP servers and helper processes - codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 hosts · 2 duplicates - codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M - ▸ Other apps · 1.0G (rss) - Machine vitals - memory 4.0G/8.0G - compressed 1.0G - swap 1.0G/2.0G - pressure level critical - pressure free 0.0% - load (1/5/15) 0.0 / 0.0 / 0.0 - cores 4 - disk free 12.0G - Agent memory by role - - - - - - - - - - - - - - - - - - - - - - - -0 Home · m machine · enter detail · / filter · s sort · g group by project · o open terminal · esc back · ? help · q quit + METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM + agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · pressure critical · swap 1.0G/2.0G · disk 12.0G free + Memory measure: physical_footprint · footprint includes compressed and swapped memory, so apps can add up to more than RAM + Sessions by memory +› ▾ claude · fixture · session #251 · start… 70.0% physical_footprint 2.0G + 42 worker 70.0% started by fixture · session #251 2.0G + Started from Editor + Agent processes without a session + MCP servers and helper processes + codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 hosts · 2 duplicates + codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M + ▸ Other apps · 1.0G (rss) + Machine vitals + memory 4.0G/8.0G + compressed 1.0G + swap 1.0G/2.0G + pressure level critical + pressure free 0.0% + load (1/5/15) 0.0 / 0.0 / 0.0 + cores 4 + disk free 12.0G + Agent memory by role + + + + + + + + + + + + + + + + + + + + + + +h health · p reclaim processes · d reclaim disk · 0 Flow · M machine · enter detail · / filter · s sort · g group by project +o open terminal · esc back · ? help · q quit diff --git a/internal/tui/testdata/machine-80-expanded-false.golden b/internal/tui/testdata/machine-80-expanded-false.golden index 0e279548..2e322eb6 100644 --- a/internal/tui/testdata/machine-80-expanded-false.golden +++ b/internal/tui/testdata/machine-80-expanded-false.golden @@ -1,49 +1,49 @@ driga test-host · ● live · daemon 05:00:00 mem ███░░░ 50% swap ███░░░ 50% load 0.0/4 disk 12.0G -0 Home 1 Agents 1 2 Projects 0 3 Inbox 1 4 Orphans 1 [5 Machine] -6 Cleanup 0 7 Flow 8 Chat +0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────── - METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM - agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · - pressure critical · swap 1.0G/2.0G · disk 12.0G free - Memory measure: physical_footprint · footprint includes compressed and - swapped memory, so apps can add up to more than RAM - Sessions by cost -› ▸ claude · fixture · s… 70.0% physical_footprint 2.0G - Started from Editor - Agent processes without a session - MCP servers and helper processes + METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM + agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · + pressure critical · swap 1.0G/2.0G · disk 12.0G free + Memory measure: physical_footprint · footprint includes compressed and + swapped memory, so apps can add up to more than RAM + Sessions by memory +› ▸ claude · fixture · s… 70.0% physical_footprint 2.0G + Started from Editor + Agent processes without a session + MCP servers and helper processes codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 … - codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M - ▸ Other apps · 1.0G (rss) - Machine vitals - memory 4.0G/8.0G - compressed 1.0G - swap 1.0G/2.0G - pressure level critical - pressure free 0.0% - load (1/5/15) 0.0 / 0.0 / 0.0 - cores 4 - disk free 12.0G - Agent memory by role - - - - - - - - - - - - - - - - - - - -0 Home · m machine · enter detail · / filter · s sort · g group by project -o open terminal · esc back · ? help · q quit + codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M + ▸ Other apps · 1.0G (rss) + Machine vitals + memory 4.0G/8.0G + compressed 1.0G + swap 1.0G/2.0G + pressure level critical + pressure free 0.0% + load (1/5/15) 0.0 / 0.0 / 0.0 + cores 4 + disk free 12.0G + Agent memory by role + + + + + + + + + + + + + + + + + + + +h health · p reclaim processes · d reclaim disk · 0 Flow · M machine +enter detail · / filter · s sort · g group by project · o open terminal +esc back · ? help · q quit diff --git a/internal/tui/testdata/machine-80-expanded-true.golden b/internal/tui/testdata/machine-80-expanded-true.golden index b88fdec3..245f9e7d 100644 --- a/internal/tui/testdata/machine-80-expanded-true.golden +++ b/internal/tui/testdata/machine-80-expanded-true.golden @@ -1,49 +1,49 @@ driga test-host · ● live · daemon 05:00:00 mem ███░░░ 50% swap ███░░░ 50% load 0.0/4 disk 12.0G -0 Home 1 Agents 1 2 Projects 0 3 Inbox 1 4 Orphans 1 [5 Machine] -6 Cleanup 0 7 Flow 8 Chat +0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────── - METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM - agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · - pressure critical · swap 1.0G/2.0G · disk 12.0G free - Memory measure: physical_footprint · footprint includes compressed and - swapped memory, so apps can add up to more than RAM - Sessions by cost -› ▾ claude · fixture · s… 70.0% physical_footprint 2.0G - 42 worker 70.0% started by fixture · session #251 2.0G - Started from Editor - Agent processes without a session - MCP servers and helper processes + METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM + agents 2.0G of 4.0G (50%) · 70.0% CPU · 1 processes · 3 MCP servers · + pressure critical · swap 1.0G/2.0G · disk 12.0G free + Memory measure: physical_footprint · footprint includes compressed and + swapped memory, so apps can add up to more than RAM + Sessions by memory +› ▾ claude · fixture · s… 70.0% physical_footprint 2.0G + 42 worker 70.0% started by fixture · session #251 2.0G + Started from Editor + Agent processes without a session + MCP servers and helper processes codegraph mcp 3 servers · 300.0M · observed growth ↑ 0.75/h since 02:20 · 1 … - codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M - ▸ Other apps · 1.0G (rss) - Machine vitals - memory 4.0G/8.0G - compressed 1.0G - swap 1.0G/2.0G - pressure level critical - pressure free 0.0% - load (1/5/15) 0.0 / 0.0 / 0.0 - cores 4 - disk free 12.0G - Agent memory by role - - - - - - - - - - - - - - - - - - -0 Home · m machine · enter detail · / filter · s sort · g group by project -o open terminal · esc back · ? help · q quit + codegraph mcp pile-up 3 codex app-server PID 10 · 31h0m0s 300.0M + ▸ Other apps · 1.0G (rss) + Machine vitals + memory 4.0G/8.0G + compressed 1.0G + swap 1.0G/2.0G + pressure level critical + pressure free 0.0% + load (1/5/15) 0.0 / 0.0 / 0.0 + cores 4 + disk free 12.0G + Agent memory by role + + + + + + + + + + + + + + + + + + +h health · p reclaim processes · d reclaim disk · 0 Flow · M machine +enter detail · / filter · s sort · g group by project · o open terminal +esc back · ? help · q quit diff --git a/internal/tui/verified_test.go b/internal/tui/verified_test.go index cb7c87ff..6a39086f 100644 --- a/internal/tui/verified_test.go +++ b/internal/tui/verified_test.go @@ -12,7 +12,7 @@ func TestOrphansVerifiedColumnAndSnapshotFooter(t *testing.T) { s.snap.Mode = "snapshot" m := setup(t, s, Options{}) m, _ = m.Update(tea.WindowSizeMsg{Width: 180, Height: 45}) - m = key(m, '4') + m = key(key(m, '2'), 'p') text := m.View().Content if !strings.Contains(text, "VERIFIED") || !strings.Contains(text, "unverified (snapshot)") { t.Fatal(text) diff --git a/internal/view/board.go b/internal/view/board.go new file mode 100644 index 00000000..75a2374e --- /dev/null +++ b/internal/view/board.go @@ -0,0 +1,500 @@ +package view + +import ( + "fmt" + "github.com/labstack/driga/internal/model" + "sort" + "strings" + "time" +) + +// BoardSnapshot is the shared overview evidence, loaded in bulk, never per row. +// Nodes carry raw titles. Events and waits retain their stored timestamps. +type BoardSnapshot struct { + Files map[string][]model.FileTouch `json:"files,omitempty"` + Remote map[string]bool `json:"remote,omitempty"` + Nodes []FlowNode `json:"nodes"` + Load []int `json:"load"` + Memory string `json:"memory"` +} +type BoardSlice struct { + At time.Time `json:"at"` + State string `json:"state"` + Tools int `json:"tools"` + Events int `json:"events"` + Activity int64 `json:"activity"` +} +type BoardRow struct { + Command string `json:"command,omitempty"` + Depth int `json:"depth"` + Parent string `json:"parent"` + Root string `json:"root"` + Branch string `json:"branch"` + Age string `json:"age"` + Glyph string `json:"glyph"` + Local bool `json:"local"` // on this machine, so local actions apply + Files []model.FileTouch `json:"files,omitempty"` + Overlaps []string `json:"overlaps,omitempty"` + Quiet bool `json:"quiet"` + Key string `json:"key"` + Node FlowNode `json:"node"` + Title string `json:"title"` + State string `json:"state"` + Cwd string `json:"cwd"` + Slices []BoardSlice `json:"slices"` + Workers []string `json:"workers"` + Wait *FlowWait `json:"wait,omitempty"` + PromptID string `json:"prompt_id,omitempty"` + WaitReason string `json:"wait_reason"` + Cost *float64 `json:"cost_usd,omitempty"` +} +type BoardGroup struct { + Key string `json:"key"` + Name string `json:"name"` + Branches []string `json:"branches"` + Worktrees int `json:"worktrees"` + Sessions int `json:"sessions"` + Cost *float64 `json:"cost_usd,omitempty"` + Spark []int `json:"spark"` + Rows []BoardRow `json:"rows"` +} +type BoardView struct { + Map BoardMap `json:"map"` + Spark []int `json:"spark"` + Machines map[string]*BoardGroup `json:"machines"` + ObservedAt time.Time `json:"observed_at"` + FromAt time.Time `json:"from_at"` + WindowMinutes int `json:"window_minutes"` + SliceSeconds int `json:"slice_seconds"` + Pulse map[string]int `json:"pulse"` + Needs []BoardRow `json:"needs"` + Groups []BoardGroup `json:"groups"` + Finished []BoardRow `json:"finished"` + Idle int `json:"idle"` + Hidden int `json:"hidden"` + Load []int `json:"load"` + Memory string `json:"memory"` +} + +func BoardKey(n FlowNode) string { return fmt.Sprintf("%s/%d", n.MachineID, n.ID) } +func boardState(n FlowNode) string { + if n.EndedAt != nil { + return "finished" + } + s := n.Mixer.Verdict + if s == "" { + s = n.Label + } + if s == "ended" || s == "dead" { + return "finished" + } + return s +} +func boardRank(s string) int { + switch s { + case "waiting": + return 0 + case "stalled": + return 1 + case "working": + return 2 + case "idle": + return 3 + case "finished": + return 4 + } + return 3 +} + +// Board projects the overview once, indexes family links and then sorts only +// presentation rows. The total row budget includes folded idle rows and needs. +// Historical working cells require retained measurements; a current verdict is +// applied only at the now edge, never painted backwards over unknown history. +func Board(snapshot BoardSnapshot, window time.Duration, now time.Time) BoardView { + minutes := int(window / time.Minute) + if minutes != 15 && minutes != 60 && minutes != 240 { + minutes = 60 + } + step := time.Duration(minutes) * time.Minute / 60 + edge := now.Truncate(step).Add(step) + b := BoardView{ObservedAt: now, FromAt: edge.Add(-time.Duration(minutes) * time.Minute), WindowMinutes: minutes, SliceSeconds: int(step / time.Second), Machines: map[string]*BoardGroup{}, Pulse: map[string]int{"waiting": 0, "stalled": 0, "working": 0, "idle": 0, "finished": 0}, Needs: []BoardRow{}, Groups: []BoardGroup{}, Finished: []BoardRow{}, Load: snapshot.Load, Memory: snapshot.Memory} + + totals := map[string]*BoardGroup{} + branches := map[string]map[string]bool{} + cwd := map[string]map[string]bool{} + rows := make([]BoardRow, 0, len(snapshot.Nodes)) + for _, n := range snapshot.Nodes { + if n.StartedAt.After(now) || n.EndedAt != nil && n.EndedAt.Before(b.FromAt) { + continue + } + state := boardState(n) + b.Pulse[state]++ + + r := BoardRow{Files: snapshot.Files[BoardKey(n)], Key: BoardKey(n), Node: n, Title: n.DisplayTitle(), State: state, Cwd: n.Cwd, Workers: []string{}, Slices: make([]BoardSlice, 60)} + col := func(at time.Time) int { return int(at.Sub(b.FromAt) / step) } + for i := range r.Slices { + at := b.FromAt.Add(time.Duration(i) * step) + s := "unknown" + if !at.Add(step).After(n.StartedAt) { + s = "absent" + } + if n.EndedAt != nil && !at.Before(*n.EndedAt) { + s = "absent" + } + r.Slices[i] = BoardSlice{At: at, State: s} + } + if n.Observed != nil { + for i, x := range n.Observed.LogBytesPerMin { + at := n.Observed.FromAt.Add(time.Duration(i) * time.Minute) + if at.After(now) || at.Before(b.FromAt) { + continue + } + c := col(at) + if c >= 0 && c < 60 { + r.Slices[c].Activity += x + cpu := i < len(n.Observed.CPUMSPerMin) && n.Observed.CPUMSPerMin[i] > 0 + if x > 0 || cpu { + r.Slices[c].State = "working" + } + } + } + } + var cost float64 + var hasCost bool + var input, output int64 + var hasInput, hasOutput bool + var lastTool time.Time + for _, e := range n.Events { + if e.TS.Before(b.FromAt) || e.TS.After(now) { + continue + } + c := col(e.TS) + if c < 0 || c >= 60 { + continue + } + r.Slices[c].Events++ + if FlowEventMark(n.Adapter, e.Kind) == "tool" { + r.Slices[c].Tools++ + r.Slices[c].State = "working" + tool := "" + if e.Metadata != nil { + tool = e.Metadata.Tool + } + if !e.TS.Before(lastTool) { + r.Node.LastTool = FlowJoin(tool, e.Summary) + lastTool = e.TS + } + } + if e.Metadata != nil { + if e.Metadata.InputTokens != nil { + input += *e.Metadata.InputTokens + hasInput = true + } + if e.Metadata.OutputTokens != nil { + output += *e.Metadata.OutputTokens + hasOutput = true + } + if e.Metadata.CostUSD != nil { + cost += *e.Metadata.CostUSD + hasCost = true + } + } + } + if hasInput { + r.Node.InputTokens = &input + } + if hasOutput { + r.Node.OutputTokens = &output + } + if hasCost { + r.Cost = &cost + } + for i := range n.Waits { + w := n.Waits[i] + if w.StartedAt.After(now) { + continue + } + end := now + if w.EndedAt != nil { + end = *w.EndedAt + } + if end.Before(b.FromAt) { + continue + } + for c := max(0, col(w.StartedAt)); c <= min(59, col(end)); c++ { + r.Slices[c].State = "waiting" + } + if w.EndedAt == nil && (r.Wait == nil || w.StartedAt.Before(r.Wait.StartedAt)) { + copy := w + r.Wait = © + reason := w.Reason + if reason == "permission" { + reason = "Permission" + } + if reason == "idle_prompt" || reason == "unknown" { + reason = "Waiting for your reply" + } + r.WaitReason = FlowJoin(reason, w.Tool, w.Detail) + } + } + if r.Wait != nil && n.EndedAt == nil && state != "finished" { + if state != "waiting" { + b.Pulse[state]-- + b.Pulse["waiting"]++ + } + r.State = "waiting" + } + if r.WaitReason == "" && r.State == "waiting" { + r.WaitReason = "Waiting for your reply · jump to terminal" + } + if n.EndedAt != nil { + c := col(*n.EndedAt) + if c >= 0 && c < 60 { + r.Slices[c].State = "finished" + } + } else { + r.Slices[59].State = r.State + } + + name := n.Project + if name == "" { + name = "Unattached" + } + total := totals[name] + if total == nil { + total = &BoardGroup{Key: name, Name: name, Spark: make([]int, 60), Branches: []string{}} + totals[name] = total + branches[name] = map[string]bool{} + cwd[name] = map[string]bool{} + } + total.Sessions++ + if r.Cost != nil { + if total.Cost == nil { + total.Cost = new(float64) + } + *total.Cost += *r.Cost + } + if n.Branch != "" { + branches[name][n.Branch] = true + } + if n.Cwd != "" { + cwd[name][n.Cwd] = true + } + for i, s := range r.Slices { + total.Spark[i] += s.Tools + if s.Activity > 0 { + total.Spark[i]++ + } + } + machine := b.Machines[n.MachineID] + if machine == nil { + machine = &BoardGroup{Key: n.MachineID, Name: n.MachineID, Spark: make([]int, 60)} + b.Machines[n.MachineID] = machine + } + machine.Sessions++ + if r.Cost != nil { + if machine.Cost == nil { + machine.Cost = new(float64) + } + *machine.Cost += *r.Cost + } + for i, s := range r.Slices { + machine.Spark[i] += s.Tools + if s.Activity > 0 { + machine.Spark[i]++ + } + } + rows = append(rows, r) + } + // A complete forest, sorted only by project and start time. Current state + // never changes a slot. Missing parents and cycles remain visible as roots. + sort.Slice(rows, func(i, j int) bool { + a, c := rows[i], rows[j] + if a.Node.Project != c.Node.Project { + return a.Node.Project < c.Node.Project + } + if !a.Node.StartedAt.Equal(c.Node.StartedAt) { + return a.Node.StartedAt.Before(c.Node.StartedAt) + } + return a.Key < c.Key + }) + index := map[string]int{} + for i, r := range rows { + index[r.Key] = i + } + children := map[string][]int{} + for i := range rows { + r := &rows[i] + r.Glyph = BoardGlyph(r.State) + r.Local = !snapshot.Remote[r.Node.MachineID] + r.Age = BoardAge(*r, now) + if r.Node.ParentID != nil { + key := BoardKey(FlowNode{MachineID: r.Node.MachineID, ID: *r.Node.ParentID}) + if j, ok := index[key]; ok && key != r.Key && rows[j].Node.Project == r.Node.Project { + seen := map[string]bool{r.Key: true} + cursor := j + cycle := false + for { + n := rows[cursor].Node + k := BoardKey(n) + if seen[k] { + cycle = true + break + } + seen[k] = true + if n.ParentID == nil { + break + } + next, ok := index[BoardKey(FlowNode{MachineID: n.MachineID, ID: *n.ParentID})] + if !ok { + break + } + cursor = next + } + if !cycle { + r.Parent = key + children[key] = append(children[key], i) + } + } + } + } + b.Spark = make([]int, 60) + groups := map[string]int{} + var visit func(int, int, string, bool) + visit = func(i, depth int, root string, last bool) { + r := rows[i] + r.Depth = depth + r.Root = root + if depth > 0 { + r.Branch = strings.Repeat(" ", depth-1) + "├" + if last { + r.Branch = strings.Repeat(" ", depth-1) + "└" + } + } + for _, j := range children[r.Key] { + r.Workers = append(r.Workers, rows[j].State) + } + name := r.Node.Project + if name == "" { + name = "Unattached" + } + gi, ok := groups[name] + if !ok { + gi = len(b.Groups) + groups[name] = gi + g := *totals[name] + g.Rows = []BoardRow{} + b.Groups = append(b.Groups, g) + } + b.Groups[gi].Rows = append(b.Groups[gi].Rows, r) + if r.State == "waiting" { + b.Needs = append(b.Needs, r) + } + if r.State == "finished" { + b.Finished = append(b.Finished, r) + } + if r.State == "idle" { + b.Idle++ + } + for c, s := range r.Slices { + b.Spark[c] += s.Events + } + for c, j := range children[r.Key] { + visit(j, depth+1, root, c == len(children[r.Key])-1) + } + } + for i, r := range rows { + if r.Parent == "" { + visit(i, 0, r.Key, true) + } + } + for i := range b.Groups { + g := &b.Groups[i] + for branch := range branches[g.Key] { + g.Branches = append(g.Branches, branch) + } + sort.Strings(g.Branches) + g.Worktrees = max(0, len(cwd[g.Key])-1) + } + sort.SliceStable(b.Needs, func(i, j int) bool { + a, c := b.Needs[i], b.Needs[j] + at, ct := a.Node.LastActivityAt, c.Node.LastActivityAt + if a.Wait != nil { + at = a.Wait.StartedAt + } + if c.Wait != nil { + ct = c.Wait.StartedAt + } + if !at.Equal(ct) { + return at.Before(ct) + } + return a.Key < c.Key + }) + b.buildMap(snapshot.Remote) + + return b +} + +// BoardStrip resamples all sixty slices to the available terminal columns. +func BoardStrip(row BoardRow, columns int) string { + columns = max(1, columns) + out := make([]rune, columns) + for i := range out { + lo := i * len(row.Slices) / columns + hi := max(lo+1, (i+1)*len(row.Slices)/columns) + hi = min(hi, len(row.Slices)) + ch := ' ' + priority := -1 + for _, s := range row.Slices[lo:hi] { + v, p := '░', 0 + switch s.State { + case "absent": + v, p = ' ', 0 + case "idle": + v, p = '─', 1 + case "working": + v, p = '━', 2 + case "stalled": + v, p = '░', 3 + case "waiting": + v, p = '◆', 4 + case "finished": + v, p = '✓', 5 + } + if s.Tools > 0 && p < 4 { + v, p = '┿', 3 + } + if p >= priority { + ch, priority = v, p + } + } + out[i] = ch + } + return string(out) +} + +// WithPrompts associates only proven pending permission requests with a wait. +func (b *BoardView) WithPrompts(prompts map[string]string) { + for gi := range b.Groups { + for ri := range b.Groups[gi].Rows { + r := &b.Groups[gi].Rows[ri] + r.PromptID = prompts[r.Key] + } + } + for i := range b.Needs { + b.Needs[i].PromptID = prompts[b.Needs[i].Key] + } +} + +// BoardSpark keeps project and machine rhythms to a fixed twelve-cell measure. +func BoardSpark(buckets []int) string { + out := make([]int, 12) + if len(buckets) == 0 { + return "────────────" + } + for i, v := range buckets { + c := min(11, i*12/len(buckets)) + out[c] += v + } + return FlowSpark(out) +} diff --git a/internal/view/board_load.go b/internal/view/board_load.go new file mode 100644 index 00000000..1c424abf --- /dev/null +++ b/internal/view/board_load.go @@ -0,0 +1,98 @@ +package view + +import ( + "fmt" + "github.com/labstack/driga/internal/attention" + "github.com/labstack/driga/internal/derive" + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/store" + "time" +) + +// LoadBoardSnapshot reads overview evidence once per machine, in the caller's +// snapshot transaction. No family or session detail builders are invoked. +func LoadBoardSnapshot(r Reader, f Filter, now time.Time) (BoardSnapshot, error) { + b := BoardSnapshot{Nodes: []FlowNode{}, Load: []int{}, Files: map[string][]model.FileTouch{}, Remote: map[string]bool{}} + err := machineReaders(r, f.Scope, func(mr Reader, _ model.Machine) error { + b.Remote[mr.MachineID()] = mr.MachineID() != r.MachineID() + return loadBoardMachine(mr, f, now, &b) + }) + return b, err +} + +func loadBoardMachine(mr Reader, f Filter, now time.Time, b *BoardSnapshot) error { + sessions, err := mr.Sessions(store.SessionFilter{IncludeEnded: true, RepoPath: f.Project}) + if err != nil { + return err + } + repos, err := mr.Repos() + if err != nil { + return err + } + inbox, err := mr.Inbox(time.Time{}, true) + if err != nil { + return err + } + events, observed, err := mr.BoardEvidence(now.Add(-time.Hour), now) + if err != nil { + return err + } + byEvent := map[string][]model.Event{} + for _, e := range events { + key := e.Adapter + ":" + e.ExternalID + byEvent[key] = append(byEvent[key], e) + } + byWait := map[int64][]FlowWait{} + jumps := map[int64]*model.InboxJump{} + for _, item := range inbox { + if item.Kind != "waiting" { + continue + } + item = attention.Policy(item, f.CaptureText) + byWait[item.SessionID] = append(byWait[item.SessionID], flowWait(item)) + if attention.Active(item) { + jumps[item.SessionID] = item.Jump + } + } + files, err := mr.SessionFiles(now.Add(-time.Hour)) + if err != nil { + return err + } + if b.Files == nil { + b.Files = map[string][]model.FileTouch{} + } + for id, touches := range files { + b.Files[BoardKey(FlowNode{MachineID: mr.MachineID(), ID: id})] = touches + } + for _, s := range sessions { + if s.EndedAt != nil && s.EndedAt.Before(now.Add(-time.Hour)) { + continue + } + title := s.Title + if title == "" { + title = s.LastPrompt + } + if f.CaptureText == "none" || title == "" { + title = flowAttentionTitle(s, "", f.CaptureText) + } + n := FlowNode{Cwd: s.Cwd, StartedFrom: s.StartedFrom, MachineID: mr.MachineID(), ID: s.ID, ExternalID: s.ExternalID, ParentID: s.ParentID, StartedAt: s.StartedAt, EndedAt: s.EndedAt, Adapter: s.Adapter, Title: title, Project: projectName(s, repos), Branch: s.Branch, Label: s.Label, Mixer: derive.SessionMixer(s), LastActivityAt: s.LastActivityAt, Events: byEvent[s.Adapter+":"+s.ExternalID], Waits: byWait[s.ID], Observed: flowObserved(observed[s.ID], now, 240), Jump: attention.ResolveJump(s, "", nil)} + if jumps[s.ID] != nil { + n.Jump = jumps[s.ID] + } + b.Nodes = append(b.Nodes, n) + } + samples, err := mr.Samples(now.Add(-time.Hour)) + if err != nil { + return err + } + for _, s := range samples { + b.Load = append(b.Load, int(s.Load1*10)) + } + if len(b.Load) > 60 { + b.Load = b.Load[len(b.Load)-60:] + } + if sample, err := mr.LatestSample(); err == nil && sample.MemTotal > 0 { + b.Memory = fmt.Sprintf("mem %.0f%%", float64(sample.MemUsed)*100/float64(sample.MemTotal)) + } + return nil +} diff --git a/internal/view/board_load_test.go b/internal/view/board_load_test.go new file mode 100644 index 00000000..72840f87 --- /dev/null +++ b/internal/view/board_load_test.go @@ -0,0 +1,72 @@ +package view + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/store" +) + +type boardReadCounter struct { + Reader + bulk, sessions int +} + +func (r *boardReadCounter) BoardEvidence(since, until time.Time) ([]model.Event, map[int64][]store.ObservationMinute, error) { + r.bulk++ + return r.Reader.BoardEvidence(since, until) +} +func (r *boardReadCounter) Sessions(f store.SessionFilter) ([]model.Session, error) { + r.sessions++ + return r.Reader.Sessions(f) +} +func (*boardReadCounter) Events(string, string, time.Time) ([]model.Event, error) { + panic("per-row event query") +} +func (*boardReadCounter) ObservationMinutes(int64, time.Time) ([]store.ObservationMinute, error) { + panic("per-row observation query") +} + +func TestLoadBoardSnapshotUsesBulkEvidence(t *testing.T) { + st, err := store.Open("", store.Memory) + if err != nil { + t.Fatal(err) + } + defer st.Close() + now := time.Now().UTC() + err = st.Tx(context.Background(), func(tx *store.Tx) error { + for i := 0; i < 200; i++ { + id, e := tx.UpsertSession(model.Session{SessionObs: model.SessionObs{Adapter: "claude", ExternalID: fmt.Sprint(i), Title: "Work", StartedAt: now.Add(-time.Hour), LastActivityAt: now}, Label: "working"}) + if e != nil { + return e + } + if e = tx.Observe(id, now, 100, 20); e != nil { + return e + } + if e = tx.InsertEvent(model.Event{Adapter: "claude", ExternalID: fmt.Sprint(i), TS: now, Kind: "otlp:tool_result"}); e != nil { + return e + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + r := &boardReadCounter{Reader: st} + snap := BoardSnapshot{Nodes: []FlowNode{}} + err = loadBoardMachine(r, Filter{}, now, &snap) + if err != nil { + t.Fatal(err) + } + if r.bulk != 1 || r.sessions != 1 || len(snap.Nodes) != 200 { + t.Fatal("not a bulk snapshot", r.bulk, r.sessions, len(snap.Nodes)) + } + for _, n := range snap.Nodes { + if len(n.Events) != 1 || n.Observed == nil { + t.Fatal("lost bulk evidence") + } + } +} diff --git a/internal/view/board_map.go b/internal/view/board_map.go new file mode 100644 index 00000000..d2d8a6f0 --- /dev/null +++ b/internal/view/board_map.go @@ -0,0 +1,314 @@ +package view + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// BoardMap is a deterministic, server-rendered slot layout. Coordinates depend +// exclusively on session membership, project, parent identity and start time. +type BoardMap struct { + Regions []BoardRegion + Nodes []BoardMapNode + Edges []BoardEdge + Badges []BoardBadge +} +type BoardRegion struct { + W, H, LX, LY, RX int + Label, Sub, Dash string +} +type BoardMapNode struct { + BoardRow + Region, X, Y, GY, LX, LY, MY int + Fill, Opacity float64 + Dash, Label, Meta, Aria string +} +type BoardEdge struct { + Path, Kind, File string + A, B string // overlap endpoints, shown while either is selected + Region, X, Y, W int +} +type BoardBadge struct { + Region, X, Y, TX, TY int + Text, Key string +} + +func BoardGlyph(state string) string { + if state == "finished" { + return "✓" + } + return FlowGlyph(state) +} +func BoardAge(r BoardRow, now time.Time) string { + at := r.Node.StartedAt + if r.State == "waiting" { + at = r.Node.LastActivityAt + if r.Wait != nil { + at = r.Wait.StartedAt + } + } + if r.State == "stalled" { + at = r.Node.LastActivityAt + } + if at.IsZero() { + return "" + } + d := max(time.Duration(0), now.Sub(at)) + if d >= time.Hour { + return fmt.Sprintf("%dh %dm", int(d/time.Hour), int(d/time.Minute)%60) + } + if d >= time.Minute { + return fmt.Sprintf("%dm", int(d/time.Minute)) + } + return fmt.Sprintf("%ds", int(d/time.Second)) +} +func boardFileName(path string) string { + parts := strings.Split(strings.ReplaceAll(path, "\\", "/"), "/") + return parts[len(parts)-1] +} +func (b *BoardView) buildMap(remote map[string]bool) { + b.Map = BoardMap{} + positions := map[string]BoardMapNode{} + // Shared paths are evidence only within the same machine. Basenames never + // establish overlap. Ended sessions do not imply a current file conflict. + files := map[string][]string{} + for gi := range b.Groups { + for ri := range b.Groups[gi].Rows { + r := &b.Groups[gi].Rows[ri] + if r.State == "finished" { + continue + } + for _, f := range r.Files { + key := r.Node.MachineID + "\x00" + f.Path + files[key] = append(files[key], r.Key) + } + } + } + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + overlaps := map[string][]string{} + for _, path := range paths { + keys := files[path] + if len(keys) < 2 { + continue + } + name := boardFileName(strings.SplitN(path, "\x00", 2)[1]) + for _, k := range keys { + overlaps[k] = append(overlaps[k], name) + } + } + // Each family is a compact block: the lead on top, its workers indented + // below it, one row each. + type block struct { + rows []BoardRow + fold int + } + type pending struct { + region BoardRegion + blocks []block + } + var layout []pending + for gi := range b.Groups { + g := &b.Groups[gi] + machines := []string{} + byMachine := map[string][]BoardRow{} + for ri := range g.Rows { + r := &g.Rows[ri] + r.Overlaps = overlaps[r.Key] + if _, ok := byMachine[r.Node.MachineID]; !ok { + machines = append(machines, r.Node.MachineID) + } + byMachine[r.Node.MachineID] = append(byMachine[r.Node.MachineID], *r) + } + sort.Strings(machines) + for _, machine := range machines { + families := map[string][]BoardRow{} + roots := []string{} + for _, r := range byMachine[machine] { + if _, ok := families[r.Root]; !ok { + roots = append(roots, r.Root) + } + families[r.Root] = append(families[r.Root], r) + } + p := pending{region: BoardRegion{Label: g.Name, Sub: strings.Join(g.Branches, ", ")}} + if remote[machine] { + p.region.Dash = "6 6" + p.region.Sub = "⇄ " + machine + } + for _, root := range roots { + f := families[root] + blk := block{rows: f[:min(len(f), boardMapWorkers+1)]} + blk.fold = len(f) - len(blk.rows) + p.blocks = append(p.blocks, blk) + } + layout = append(layout, p) + } + } + blockHeight := func(blk block) int { + n := len(blk.rows) + if blk.fold > 0 { + n++ + } + return n * boardMapRow + } + // Each region is its own SVG in local coordinates, one family under the + // next. The page flows regions into columns, so the map fits any width. + for ri, p := range layout { + w := boardMapBlock + 2*boardMapPad + h := boardMapHead + for _, blk := range p.blocks { + for j, r := range blk.rows { + nx, ny := boardMapPad+boardMapRadius+4, h+j*boardMapRow+boardMapRow/2 + if j > 0 { + nx += boardMapIndent + } + n := boardMapNode(r, nx, ny) + n.Region = ri + b.Map.Nodes = append(b.Map.Nodes, n) + positions[r.Key] = n + } + if blk.fold > 0 { + fy := h + len(blk.rows)*boardMapRow + boardMapRow/2 + fx := boardMapPad + boardMapRadius + 4 + boardMapIndent + b.Map.Badges = append(b.Map.Badges, BoardBadge{Region: ri, X: fx - boardMapRadius, Y: fy - 10, TX: fx - boardMapRadius + 20, TY: fy + 4, Text: fmt.Sprintf("+%d", blk.fold), Key: blk.rows[0].Root}) + } + h += blockHeight(blk) + } + h += boardMapPad / 2 + p.region.W, p.region.H = w, h + p.region.LX, p.region.LY, p.region.RX = boardMapPad, 28, w-boardMapPad + b.Map.Regions = append(b.Map.Regions, p.region) + } + for _, n := range b.Map.Nodes { + if p, ok := positions[n.Parent]; ok { + b.Map.Edges = append(b.Map.Edges, BoardEdge{Region: n.Region, Kind: "spawn", Path: fmt.Sprintf("M %d %d V %d H %d", p.X, p.Y+boardMapRadius, n.Y, n.X-boardMapRadius)}) + } + } + for _, path := range paths { + keys := files[path] + if len(keys) < 2 { + continue + } + name := boardFileName(strings.SplitN(path, "\x00", 2)[1]) + sort.Strings(keys) + // A star per shared file avoids quadratic edges at high session counts. + // Centre on the first visible session; folded ones have no position. + centre := -1 + for i, k := range keys { + if _, ok := positions[k]; ok { + centre = i + break + } + } + if centre < 0 { + continue + } + a := positions[keys[centre]] + for _, k := range keys[centre+1:] { + c, ok := positions[k] + if !ok || c.Region != a.Region { + continue // across projects the "┄ file" tag on each session says it + } + b.Map.Edges = append(b.Map.Edges, BoardEdge{Region: a.Region, Kind: "overlap", File: name, A: keys[centre], B: k, W: len([]rune(name))*7 + 16, X: (a.X+c.X)/2 - (len([]rune(name))*7+16)/2, Y: min(a.Y, c.Y) - boardMapRow/2 - 10 + abs(a.Y-c.Y)/4, Path: fmt.Sprintf("M %d %d Q %d %d %d %d", a.X, a.Y, (a.X+c.X)/2, min(a.Y, c.Y)-boardMapRow, c.X, c.Y)}) + } + } + for i := range b.Needs { + b.Needs[i].Overlaps = overlaps[b.Needs[i].Key] + } +} + +const ( + boardMapBlock = 250 // region content width: families stack in one column + boardMapPad = 20 // inside a region + boardMapHead = 44 // region title band + boardMapRow = 48 // one session row + boardMapRadius = 14 + boardMapIndent = 28 // workers sit under their lead, indented + boardMapWorkers = 4 // more fold into a +N badge + boardMapLabel = 24 // runes before a title is cut +) + +func boardMapNode(r BoardRow, x, y int) BoardMapNode { + fill := 0.08 + for _, s := range r.Slices[max(0, len(r.Slices)-5):] { + if s.Activity > 0 || s.Events > 0 { + fill += 0.064 + } + } + opacity, dash := 1.0, "" + switch r.State { + case "finished": + opacity, dash = 0.45, "1 4" + case "stalled": + dash = "4 3" + } + age := "" + if r.State == "waiting" || r.State == "stalled" { + age = r.Age + } + label := r.Title + if runes := []rune(label); len(runes) > boardMapLabel { + label = string(runes[:boardMapLabel-1]) + "…" + } + n := BoardMapNode{BoardRow: r, X: x, Y: y, GY: y + 4, LX: x + boardMapRadius + 10, LY: y - 2, MY: y + 13, Fill: fill, Opacity: opacity, Dash: dash, Label: label, + Meta: FlowJoin(r.Node.Adapter, age), Aria: FlowJoin(r.Title, strings.TrimSpace(r.State+" "+age), r.Node.Adapter)} + n.Age = age + return n +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// BoardTools counts reported tool events (OTLP telemetry or agent hooks) in the +// row's window, never transcript or CPU activity. +func BoardTools(r BoardRow) string { + var from time.Time + if len(r.Slices) > 0 { + from = r.Slices[0].At + } + counts := map[string]int{} + for _, e := range r.Node.Events { + if e.TS.Before(from) || FlowEventMark(r.Node.Adapter, e.Kind) != "tool" { + continue + } + name := "tool" + if e.Metadata != nil && e.Metadata.Tool != "" { + name = e.Metadata.Tool + } + counts[name]++ + } + names := make([]string, 0, len(counts)) + for name := range counts { + names = append(names, name) + } + sort.Strings(names) + out := []string{} + for _, name := range names { + out = append(out, fmt.Sprintf("%s %d", name, counts[name])) + } + return strings.Join(out, " · ") +} + +func BoardSparkPoints(buckets []int) string { + if len(buckets) == 0 { + return "0,20 120,20" + } + peak := 1 + for _, n := range buckets { + peak = max(peak, n) + } + var out strings.Builder + for i, n := range buckets { + fmt.Fprintf(&out, "%.1f,%.1f ", float64(i)*120/float64(max(1, len(buckets)-1)), 20-float64(n)*18/float64(peak)) + } + return strings.TrimSpace(out.String()) +} diff --git a/internal/view/board_test.go b/internal/view/board_test.go new file mode 100644 index 00000000..d9abf142 --- /dev/null +++ b/internal/view/board_test.go @@ -0,0 +1,173 @@ +package view_test + +import ( + "reflect" + "testing" + "time" + + "github.com/labstack/driga/internal/boardtest" + "github.com/labstack/driga/internal/flowtest" + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/view" +) + +func TestBoardDeterministicSlots(t *testing.T) { + s := boardtest.BoardFixture("busy", flowtest.Now) + first := view.Board(s, time.Hour, flowtest.Now) + positions := func(b view.BoardView) map[string][2]int { + out := map[string][2]int{} + for _, n := range b.Map.Nodes { + out[n.Key] = [2]int{n.X, n.Y} + } + return out + } + for i := range s.Nodes { + s.Nodes[i].Label = "idle" + s.Nodes[i].Mixer.Verdict = "idle" + s.Nodes[i].LastActivityAt = flowtest.Now.Add(time.Duration(i) * time.Second) + } + for i, j := 0, len(s.Nodes)-1; i < j; i, j = i+1, j-1 { + s.Nodes[i], s.Nodes[j] = s.Nodes[j], s.Nodes[i] + } + next := view.Board(s, time.Hour, flowtest.Now.Add(time.Second)) + if !reflect.DeepEqual(positions(first), positions(next)) { + t.Fatal("activity or input order moved slots") + } + if len(first.Needs) != first.Pulse["waiting"] { + t.Fatal("needs/header count mismatch") + } + rows := 0 + for _, g := range first.Groups { + rows += len(g.Rows) + } + if rows != len(s.Nodes) { + t.Fatal("sessions silently hidden", rows, len(s.Nodes)) + } +} +func TestBoardFoldsWorkersAndKeepsCompleteTimeline(t *testing.T) { + now := flowtest.Now + parent := int64(1) + s := view.BoardSnapshot{} + for i := 0; i < 40; i++ { + n := view.FlowNode{ID: int64(i + 1), MachineID: "local", Project: "project", Title: "Work", Label: "working", StartedAt: now.Add(time.Duration(i-40) * time.Minute)} + if i > 0 { + n.ParentID = &parent + } + s.Nodes = append(s.Nodes, n) + } + b := view.Board(s, time.Hour, now) + if len(b.Map.Nodes) != 5 || len(b.Map.Badges) != 1 || b.Map.Badges[0].Text != "+35" || len(b.Groups[0].Rows) != 40 { + t.Fatal("folding loses sessions", b.Map.Badges, len(b.Map.Nodes)) + } + if len(b.Map.Edges) != 4 { + t.Fatal("only stored spawn links may appear", b.Map.Edges) + } +} +func TestBoardOverlapRequiresSameMachineAndFullPath(t *testing.T) { + now := flowtest.Now + s := view.BoardSnapshot{Files: map[string][]model.FileTouch{}, Remote: map[string]bool{"peer": true}} + for i, machine := range []string{"local", "local", "peer", "local"} { + n := view.FlowNode{ID: int64(i + 1), MachineID: machine, Project: "project", Label: "working"} + s.Nodes = append(s.Nodes, n) + path := "/project/file.go" + if i == 3 { + path = "/other/file.go" + } + s.Files[view.BoardKey(n)] = []model.FileTouch{{Path: path, TS: now}} + } + b := view.Board(s, time.Hour, now) + overlaps := 0 + for _, e := range b.Map.Edges { + if e.Kind == "overlap" { + overlaps++ + if e.File != "file.go" { + t.Fatal(e) + } + } + } + if overlaps != 1 { + t.Fatal("false overlaps", overlaps) + } + dashed := false + for _, g := range b.Map.Regions { + dashed = dashed || g.Dash != "" + } + if !dashed { + t.Fatal("paired region missing") + } +} +func TestBoardHonestyAndNeedsParity(t *testing.T) { + now := flowtest.Now + s := view.BoardSnapshot{} + for i, adapter := range []string{"claude", "copilot", "codex", "gemini"} { + n := view.FlowNode{ID: int64(i + 1), Adapter: adapter, Label: "waiting", StartedAt: now.Add(-time.Hour), Events: []model.Event{{TS: now.Add(-time.Minute), Kind: "otlp:tool_result"}, {TS: now.Add(-2 * time.Minute), Kind: "postToolUse"}}, Waits: []view.FlowWait{{StartedAt: now.Add(-time.Duration(i+1) * time.Minute)}}} + s.Nodes = append(s.Nodes, n) + } + b := view.Board(s, time.Hour, now) + if b.Pulse["waiting"] != len(b.Needs) || len(b.Needs) != 4 || b.Needs[0].Node.Adapter != "gemini" { + t.Fatal("attention scope/order/parity", b.Needs) + } + for _, g := range b.Groups { + for _, r := range g.Rows { + tools := 0 + for _, c := range r.Slices { + tools += c.Tools + } + if r.Node.Adapter == "codex" || r.Node.Adapter == "gemini" { + if tools != 0 || view.BoardTools(r) != "" { + t.Fatal("invented telemetry", r) + } + } else if tools != 1 { + t.Fatal("lost telemetry", r.Node.Adapter, tools) + } + } + } + n := view.FlowNode{ID: 1, Label: "working", StartedAt: now.Add(-time.Hour)} + unknown := view.Board(view.BoardSnapshot{Nodes: []view.FlowNode{n}}, time.Hour, now).Groups[0].Rows[0] + if unknown.Slices[0].State != "unknown" || unknown.Slices[59].State != "working" { + t.Fatal("current verdict painted backward") + } + for _, e := range b.Map.Edges { + if e.Kind != "spawn" && e.Kind != "overlap" { + t.Fatal("invented link", e.Kind) + } + } +} +func TestBoardEmptyQuietAndTitle(t *testing.T) { + for _, kind := range []string{"empty", "quiet"} { + b := view.Board(boardtest.BoardFixture(kind, flowtest.Now), time.Hour, flowtest.Now) + if len(b.Needs) != 0 { + t.Fatal(kind) + } + if kind == "quiet" && len(b.Map.Nodes) != 2 { + t.Fatal("idle sessions hidden") + } + } + n := view.FlowNode{Title: "Build · from Editor · from Editor", StartedFrom: "Editor"} + if n.DisplayTitle() != "Build · from Editor" { + t.Fatal(n.DisplayTitle()) + } +} +func BenchmarkBoard200(b *testing.B) { + s := boardtest.BoardFixture("busy", flowtest.Now) + for len(s.Nodes) < 200 { + n := s.Nodes[len(s.Nodes)%70] + n.ID += int64(len(s.Nodes) * 100) + s.Nodes = append(s.Nodes, n) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + view.Board(s, time.Hour, flowtest.Now) + } +} +func TestBoardLocalOnlyForThisMachine(t *testing.T) { + b := view.Board(boardtest.BoardFixture("busy", time.Now()), time.Hour, time.Now()) + for _, g := range b.Groups { + for _, r := range g.Rows { + if r.Local == (r.Node.MachineID == "studio-mac") { + t.Fatal("local flag wrong for", r.Node.MachineID) + } + } + } +} diff --git a/internal/view/flow.go b/internal/view/flow.go index c6d8152c..593e7b46 100644 --- a/internal/view/flow.go +++ b/internal/view/flow.go @@ -19,6 +19,7 @@ import ( // FlowNode contains a session and only usage measurements actually retained. type FlowNode struct { + Cwd string `json:"cwd,omitempty"` StartedFrom string `json:"started_from,omitempty"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at"` @@ -169,7 +170,16 @@ func FlowJoin(parts ...string) string { func (n FlowNode) DisplayTitle() string { if title := strings.TrimSpace(n.Title); title != "" { - return title + FromSuffix(n.StartedFrom) + suffix := FromSuffix(n.StartedFrom) + if suffix != "" { + for strings.HasSuffix(title, suffix) { + title = strings.TrimSuffix(title, suffix) + } + } + if strings.EqualFold(n.StartedFrom, n.Adapter) { + return title + } + return title + suffix } if n.Project != "" { return FlowJoin(n.Project, n.Branch) + FromSuffix(n.StartedFrom) diff --git a/internal/view/flow_map.go b/internal/view/flow_map.go index 9b0706f8..536e38a0 100644 --- a/internal/view/flow_map.go +++ b/internal/view/flow_map.go @@ -7,6 +7,7 @@ import ( // FlowWait is an inbox span, including its original start and earliest closure. type FlowWait struct { + Tool string `json:"-"` // Board-only hint; the existing family/MCP wire contract stays unchanged. ID string `json:"id"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at"` @@ -27,7 +28,7 @@ func flowWait(i model.InboxItem) FlowWait { if i.AckedAt != nil && (end == nil || i.AckedAt.Before(*end)) { end = i.AckedAt } - return FlowWait{i.ID, start, end, i.Reason, i.Detail} + return FlowWait{ID: i.ID, StartedAt: start, EndedAt: end, Reason: i.Reason, Detail: i.Detail, Tool: i.Tool} } // Geometry uses minute-sized terminal cells. No state history is inferred. diff --git a/internal/view/theme.go b/internal/view/theme.go index 829e75a7..8ea2505d 100644 --- a/internal/view/theme.go +++ b/internal/view/theme.go @@ -53,6 +53,7 @@ var themeTokens = append([]ThemeToken{ // consoleTokens maps the console's roles onto the same Catppuccin palettes. var consoleTokens = append([]ThemeToken{ + {"flow-canvas", "#EFF1F5", "#0E0E16"}, {"flow-panel", "#E6E9EF", "#10101A"}, {"flow-line", "#BCC0CC", "#262636"}, {"flow-muted", "#5C5F77", "#7F849C"}, {"bg", "#EFF1F5", "#1E1E2E"}, {"pane", "#E6E9EF", "#181825"}, {"raise", "#CCD0DA", "#313244"}, {"line", "#BCC0CC", "#45475A"}, {"fg", "#4C4F69", "#CDD6F4"}, {"mid", "#5C5F77", "#BAC2DE"}, diff --git a/internal/view/view.go b/internal/view/view.go index 3d3fb66b..e0b03065 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -31,6 +31,7 @@ type Reader interface { LatestSample() (model.MachineSample, error) Samples(time.Time) ([]model.MachineSample, error) EventCounts(time.Time, time.Time) ([]store.EventCount, error) + BoardEvidence(time.Time, time.Time) ([]model.Event, map[int64][]store.ObservationMinute, error) ObservationMinutes(int64, time.Time) ([]store.ObservationMinute, error) ScanMCPHistory(time.Time, time.Time, func(model.MCPObservation) error) error Sources() ([]model.SourceStatus, error) diff --git a/internal/viewer/source.go b/internal/viewer/source.go index 9fd94f45..2ade07b8 100644 --- a/internal/viewer/source.go +++ b/internal/viewer/source.go @@ -34,8 +34,9 @@ type ScopedSource interface { // Snapshot holds the shared views and the evidence needed by detail panes. type Snapshot struct { - Tail []view.TailEvent - Cleanup []cleanup.Candidate + BoardData view.BoardSnapshot + Tail []view.TailEvent + Cleanup []cleanup.Candidate view.Envelope LocalMachineID string Machines view.MachinesView @@ -131,6 +132,9 @@ func (s *StoreSource) load(ctx context.Context, mode string, scope store.Scope, if snap.Summary, err = view.Summary(tx, f, now); err != nil { return err } + if snap.BoardData, err = view.LoadBoardSnapshot(tx, f, now); err != nil { + return err + } if snap.Agents, err = view.Agents(tx, f, now); err != nil { return err } diff --git a/internal/web/attention_overflow_test.go b/internal/web/attention_overflow_test.go index acbba5da..17e401e3 100644 --- a/internal/web/attention_overflow_test.go +++ b/internal/web/attention_overflow_test.go @@ -27,15 +27,15 @@ func TestAttentionOverflowDestination(t *testing.T) { t.Fatal(err) } w := request(s, "GET", "/", s.token) - if !strings.Contains(w.Body.String(), "attention=1") { + if !strings.Contains(w.Body.String(), "data-show-needs") { t.Fatal("overflow does not link to attention") } - p, _, err := s.loadPage(httptest.NewRequest("GET", "http://"+s.opts.Host+"/inbox?attention=1", nil)) + p, _, err := s.loadPage(httptest.NewRequest("GET", "http://"+s.opts.Host+"/flow?view=sessions", nil)) if err != nil { t.Fatal(err) } - if len(p.Needs) != 11 { - t.Fatalf("destination has %d needs; want 11", len(p.Needs)) + if len(p.Board.Needs) != 11 { + t.Fatalf("destination has %d needs; want 11", len(p.Board.Needs)) } } diff --git a/internal/web/board_test.go b/internal/web/board_test.go new file mode 100644 index 00000000..bec6ea2b --- /dev/null +++ b/internal/web/board_test.go @@ -0,0 +1,149 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/labstack/driga/internal/boardtest" + "github.com/labstack/driga/internal/flowtest" + "github.com/labstack/driga/internal/view" + "github.com/labstack/driga/internal/viewer" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBoardRoutes(t *testing.T) { + s, _, id, _ := fixture(t) + for _, path := range []string{"/flow", fmt.Sprintf("/flow/%d", id), "/flow?view=timeline", "/flow?view=sessions"} { + p, code, err := s.loadPage(httptest.NewRequest("GET", path, nil)) + if err != nil || code != 200 || !p.IsBoard { + t.Fatal(path, code, err) + } + body := request(s, "GET", path, s.token).Body.String() + for _, want := range []string{"data-flow-board", "fb-map", "role=\"tree\"", "Needs you ·", "morph:innerHTML"} { + if !strings.Contains(body, want) { + t.Error(path, "missing", want) + } + } + } + w := request(s, "GET", "/flow.json?view=board", s.token) + var e struct{ Data view.BoardView } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &e) != nil || len(e.Data.Groups) == 0 { + t.Fatal(w.Code, w.Body.String()) + } +} + +// Set FLOW_SHOTS_DIR to export repeatable synthetic browser evidence. No server, +// collector, database mutation or agent CLI is involved in these fixtures. +func TestBoardFixturePages(t *testing.T) { + dir := os.Getenv("FLOW_SHOTS_DIR") + if dir == "" { + return + } + s, _, _, _ := fixture(t) + if err := os.MkdirAll(filepath.Join(dir, "fixtures"), 0755); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"busy", "quiet", "empty"} { + now := time.Now() + b := view.Board(boardtest.BoardFixture(kind, now), time.Hour, now) + prompts := map[string]string{} + if len(b.Needs) > 0 { + prompts[b.Needs[0].Key] = "synthetic-prompt" + } + b.WithPrompts(prompts) + p := pageData{IsBoard: true, Board: b, Page: "/flow", Title: "Flow", Scope: "all", Nav: navigation, CSRF: "synthetic-only", Snapshot: viewer.Snapshot{LocalMachineID: "local", Hostname: "Synthetic studio", Envelope: view.Envelope{ObservedAt: flowtest.Now, Mode: "snapshot"}}} + p.AgentRows = boardFixtureRows(b) + var out bytes.Buffer + if err := s.templates.ExecuteTemplate(&out, "page", p); err != nil { + t.Fatal(err) + } + raw := out.Bytes() + if strings.Contains(string(raw), "ZgotmplZ") { + t.Fatal("template error") + } + if err := os.WriteFile(filepath.Join(dir, "fixtures", kind+".html"), raw, 0644); err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(b) + if err := os.WriteFile(filepath.Join(dir, "fixtures", kind+".json"), data, 0644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "fixtures", "theme.css"), []byte(view.ThemeCSS()), 0644); err != nil { + t.Fatal(err) + } +} + +func TestBoardClientLogic(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node unavailable") + } + js, err := assets.ReadFile("static/flow-board.js") + if err != nil { + t.Fatal(err) + } + test, err := os.ReadFile("testdata/flow-board.js") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(node, "-e", string(js)+"\n"+string(test)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } +} + +func TestBoardLocalActionConfirmation(t *testing.T) { + s, st, id, _ := fixture(t) + calls := 0 + s.opts.SessionAction = func(context.Context, string, int64) error { calls++; return nil } + body := fmt.Sprintf(`{"action":"kill","session_id":%d,"machine":%q,"confirmed":false}`, id, st.MachineID()) + if w := postChat(s, "/chat/api/flow-action", body, "http://127.0.0.1:4321", s.csrf, s.token); w.Code == 200 || calls != 0 { + t.Fatal("unconfirmed kill dispatched") + } + body = strings.Replace(body, `"confirmed":false`, `"confirmed":true`, 1) + if w := postChat(s, "/chat/api/flow-action", body, "http://127.0.0.1:4321", s.csrf, s.token); w.Code != 200 || calls != 1 { + t.Fatal(w.Code, w.Body.String(), calls) + } + body = strings.Replace(body, st.MachineID(), "remote-only", 1) + if w := postChat(s, "/chat/api/flow-action", body, "http://127.0.0.1:4321", s.csrf, s.token); w.Code == 200 || calls != 1 { + t.Fatal("remote session dispatched locally") + } +} + +func TestFlowDOM(t *testing.T) { + runtime, err := exec.LookPath("node") + if err != nil { + t.Skip("node unavailable") + } + js, err := assets.ReadFile("static/flow-board.js") + if err != nil { + t.Fatal(err) + } + harness, err := os.ReadFile("testdata/flow-board-dom.js") + if err != nil { + t.Fatal(err) + } + cmd := nodeScript(runtime, string(harness)+"\n"+string(js)+"\nrunFlowDOMTests().catch(e=>{console.error(e);process.exitCode=1;});") + cmd.Env = []string{"PATH=" + t.TempDir(), "HOME=" + t.TempDir()} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } +} + +func boardFixtureRows(b view.BoardView) []view.AgentRow { + rows := []view.AgentRow{} + for _, g := range b.Groups { + for _, r := range g.Rows { + rows = append(rows, view.AgentRow{ID: r.Node.ID, MachineID: r.Node.MachineID, Adapter: r.Node.Adapter, Project: r.Node.Project, Branch: r.Node.Branch, Title: r.Title, Label: r.State, Age: max(time.Duration(0), b.ObservedAt.Sub(r.Node.StartedAt))}) + } + } + return rows +} diff --git a/internal/web/chat.go b/internal/web/chat.go index e7dacd19..442e3288 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -15,7 +15,7 @@ import ( ) func isChatPOST(path string) bool { - return path == "/chat/api/prompt-answer" || path == "/chat/api/send" || path == "/chat/api/stop" || path == "/chat/api/new" || path == "/chat/api/mode" || path == "/chat/api/decide" + return path == "/chat/api/flow-action" || path == "/chat/api/prompt-answer" || path == "/chat/api/send" || path == "/chat/api/stop" || path == "/chat/api/new" || path == "/chat/api/mode" || path == "/chat/api/decide" } func (s *Server) chatAPI(w http.ResponseWriter, r *http.Request) { if isChatPOST(r.URL.Path) { @@ -36,7 +36,7 @@ func (s *Server) chatAPI(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", 405) return } - if s.opts.Chat == nil { + if s.opts.Chat == nil && r.URL.Path != "/chat/api/flow-action" { http.Error(w, "Chat unavailable", 503) return } @@ -44,6 +44,40 @@ func (s *Server) chatAPI(w http.ResponseWriter, r *http.Request) { var out any var err error switch r.URL.Path { + case "/chat/api/flow-action": + var in struct { + Action string `json:"action"` + SessionID int64 `json:"session_id"` + Machine string `json:"machine"` + Confirmed bool `json:"confirmed"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&in) + if err == nil { + if s.opts.SessionAction == nil { + err = errors.New("Local actions unavailable") + } else if in.SessionID <= 0 || (in.Action != "jump" && in.Action != "kill") || in.Action == "kill" && !in.Confirmed { + err = errors.New("Invalid action or missing confirmation") + } else { + snap, e := s.cachedLoad(ctx, store.Scope{}, false) + err = e + local := false + for _, n := range snap.BoardData.Nodes { + if n.ID == in.SessionID && n.MachineID == snap.LocalMachineID && in.Machine == n.MachineID { + local = true + break + } + } + if err == nil && !local { + err = errors.New("Select a local session") + } + if err == nil { + err = s.opts.SessionAction(ctx, in.Action, in.SessionID) + } + } + } + out = map[string]bool{"ok": err == nil} case "/chat/api/prompts": if client, ok := s.opts.Chat.(chat.PromptClient); ok { out, err = client.Prompts(ctx) diff --git a/internal/web/chat_action_security_test.go b/internal/web/chat_action_security_test.go index 16bb0b1f..07308b94 100644 --- a/internal/web/chat_action_security_test.go +++ b/internal/web/chat_action_security_test.go @@ -10,7 +10,7 @@ import ( func TestChatActionPOSTSecurity(t *testing.T) { s, b, id := chatFixture(t) - for _, route := range []string{"mode", "decide"} { + for _, route := range []string{"mode", "decide", "flow-action"} { body := fmt.Sprintf(`{"id":%q,"mode":"full auto","action":"missing","allow":true}`, id) for _, tc := range []struct { origin, csrf, token string diff --git a/internal/web/console_test.go b/internal/web/console_test.go index e0db8222..eb642020 100644 --- a/internal/web/console_test.go +++ b/internal/web/console_test.go @@ -41,7 +41,7 @@ func TestConsoleStructureAndCSP(t *testing.T) { } } body := request(s, "GET", "/", s.token).Body.String() - for _, want := range []string{`tail -f events`, `aria-live="polite"`, `data-tail`, `data-window="6"`, `value="mocha"`, `value="latte"`} { + for _, want := range []string{`tail -f events`, `aria-live="polite"`, `data-tail`, `data-window="2"`, `value="mocha"`, `value="latte"`} { if !strings.Contains(body, want) { t.Errorf("missing %s", want) } diff --git a/internal/web/design_test.go b/internal/web/design_test.go index adb2c2ef..0ad90fb8 100644 --- a/internal/web/design_test.go +++ b/internal/web/design_test.go @@ -50,7 +50,7 @@ func TestRailCountsRefresh(t *testing.T) { if err := s.templates.ExecuteTemplate(&body, name, p); err != nil { t.Fatal(err) } - for _, want := range []string{`nav-count-agents`, `nav-count-inbox`, `nav-count-cleanup`} { + for _, want := range []string{`data-window="0"`, `data-window="1"`, `data-window="2"`} { if !strings.Contains(body.String(), want) { t.Errorf("fragment=%v missing %s", fragment, want) } @@ -62,7 +62,7 @@ func TestRailCountsOnEveryPage(t *testing.T) { s, _, _, _ := fixture(t) for _, path := range []string{"/", "/agents", "/projects", "/inbox", "/orphans", "/machines", "/cleanup", "/agents/1", "/projects/1"} { body := request(s, "GET", path, s.token).Body.String() - for _, want := range []string{`id="nav-count-agents" class="count ">1`, `id="nav-count-inbox" class="count hot">1`, `id="nav-count-cleanup" class="count ">1`} { + for _, want := range []string{`data-window="0"`, `data-window="1"`, `data-window="2"`} { if !strings.Contains(body, want) { t.Errorf("%s missing count %s", path, want) } diff --git a/internal/web/flow_attention_hints_test.go b/internal/web/flow_attention_hints_test.go index f1e3946a..b43a6b35 100644 --- a/internal/web/flow_attention_hints_test.go +++ b/internal/web/flow_attention_hints_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" "github.com/labstack/driga/internal/flowtest" "github.com/labstack/driga/internal/view" @@ -19,16 +20,24 @@ func TestFlowAttentionHTMLHasSessionHints(t *testing.T) { if err != nil { t.Fatal(err) } + snapshot, err := view.LoadBoardSnapshot(st, view.Filter{CaptureText: policy}, flowtest.Now) + if err != nil { + t.Fatal(err) + } var out strings.Builder - if err := s.templates.ExecuteTemplate(&out, "flow", pageData{Flow: f}); err != nil { + if err := s.templates.ExecuteTemplate(&out, "flow", pageData{Board: view.Board(snapshot, time.Hour, flowtest.Now)}); err != nil { t.Fatal(err) } - want := []string{"sample · Fix cache", "sample · Fix parser", "sample · #ccc333", "sample · #ddd444"} - if policy == "none" { - want[0], want[1] = "sample · work/cache", "sample · work/parser" + want := []string{} + for _, r := range f.Attention.Waits { + if r.Title != "" { + want = append(want, r.Title) + } } + for _, hint := range want { - if !strings.Contains(out.String(), hint) { + parts := strings.Split(hint, " · ") + if !strings.Contains(out.String(), parts[len(parts)-1]) { t.Errorf("%s missing %q: %s", policy, hint, out.String()) } } diff --git a/internal/web/flow_detail_duration_test.go b/internal/web/flow_detail_duration_test.go deleted file mode 100644 index fbbf418e..00000000 --- a/internal/web/flow_detail_duration_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package web - -import ( - "os" - "os/exec" - "strings" - "testing" -) - -func TestFlowDetailJavaScriptUsesSharedDuration(t *testing.T) { - runtime, err := exec.LookPath("node") - if err != nil { - t.Skip("node unavailable") - } - js, err := assets.ReadFile("static/flow-map.js") - if err != nil { - t.Fatal(err) - } - harness, err := os.ReadFile("testdata/flow-review.js") - if err != nil { - t.Fatal(err) - } - cmd := exec.Command(runtime, "-") - cmd.Stdin = strings.NewReader(string(harness) + "\n" + string(js)) - cmd.Env = []string{"PATH=" + t.TempDir(), "HOME=" + t.TempDir()} - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("detail: %v: %s", err, out) - } -} diff --git a/internal/web/flow_map_test.go b/internal/web/flow_map_test.go index df2b0b6c..a87ae5d3 100644 --- a/internal/web/flow_map_test.go +++ b/internal/web/flow_map_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "github.com/labstack/driga/internal/view" - "os" - "os/exec" "testing" ) @@ -36,22 +34,3 @@ func TestFlowJSONAuthAndShape(t *testing.T) { } } } -func TestFlowMapGeometryJavaScript(t *testing.T) { - runtime, err := exec.LookPath("node") - if err != nil { - t.Skip("node unavailable") - } - js, err := assets.ReadFile("static/flow-map.js") - if err != nil { - t.Fatal(err) - } - test, err := os.ReadFile("testdata/flow-map.js") - if err != nil { - t.Fatal(err) - } - cmd := nodeScript(runtime, string(js)+"\n"+string(test)) - cmd.Env = []string{"PATH=" + t.TempDir(), "HOME=" + t.TempDir()} - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("geometry: %v: %s", err, out) - } -} diff --git a/internal/web/flow_test.go b/internal/web/flow_test.go index e8006c40..43afe3c2 100644 --- a/internal/web/flow_test.go +++ b/internal/web/flow_test.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" "testing" + "time" ) func TestFlowSharedDefault(t *testing.T) { @@ -19,7 +20,7 @@ func TestFlowSharedDefault(t *testing.T) { if err := st.Tx(context.Background(), func(tx *store.Tx) (err error) { want, err = flowtest.SeedDefault(tx); return err }); err != nil { t.Fatal(err) } - for _, scope := range []string{"", "?machine=all", "?machine=local"} { + for _, scope := range []string{"?view=family", "?view=family&machine=all", "?view=family&machine=local"} { p, code, err := s.loadPage(httptest.NewRequest("GET", "/flow"+scope, nil)) if err != nil || code != 200 || p.Flow.Lead == nil || p.Flow.Lead.ID != want { t.Errorf("scope %q: lead = %+v, code %d, %v; want %d", scope, p.Flow.Lead, code, err, want) @@ -30,7 +31,7 @@ func TestFlowSharedDefault(t *testing.T) { func TestFlowMapLayoutParts(t *testing.T) { s, _, id, _ := fixture(t) body := request(s, "GET", fmt.Sprint("/flow?id=", id), s.token).Body.String() - for _, part := range []string{"Needs you", "Session map", "Session detail", "data-flow-svg", "data-flow-zoom", "metro-key", "/static/flow-map.js", "/static/flow-map.css"} { + for _, part := range []string{"Needs you", "Selected family", "data-flow-surface=\"map\"", "data-flow-surface=\"timeline\"", "fb-legend", "/static/flow-board.js", "/static/flow-board.css"} { if !strings.Contains(body, part) { t.Error("missing", part) } @@ -40,17 +41,14 @@ func TestFlowMapLayoutParts(t *testing.T) { } } -func TestFlowExpansionSurvivesRefresh(t *testing.T) { +func TestFlowViewAndSelectionSurviveRefreshURL(t *testing.T) { s, _, id, _ := fixture(t) - p, code, err := s.loadPage(httptest.NewRequest("GET", fmt.Sprintf("/flow?id=%d&quiet=1&machine=all", id), nil)) + p, code, err := s.loadPage(httptest.NewRequest("GET", fmt.Sprintf("/flow?id=%d&view=timeline&machine=all", id), nil)) if err != nil || code != 200 { t.Fatal(code, err) } - if !p.FlowExpanded || !strings.Contains(p.RefreshURL, "quiet=1") { - t.Fatal("refresh collapses expanded threads", p.RefreshURL) - } - if strings.Contains(p.FlowToggle, "quiet=1") || !strings.Contains(p.FlowToggle, fmt.Sprint("id=", id)) { - t.Fatal("collapse link loses session", p.FlowToggle) + if !strings.Contains(p.RefreshURL, "view=timeline") || !strings.Contains(p.RefreshURL, fmt.Sprint("id=", id)) { + t.Fatal(p.RefreshURL) } } @@ -61,7 +59,7 @@ func TestFlowPageCSP(t *testing.T) { t.Fatalf("%d %s", w.Code, w.Body.String()) } body := w.Body.String() - for _, want := range []string{"signal mixer", "Session map", "data-flow-map", "data-window=\"7\"", "<script>alert(1)</script>"} { + for _, want := range []string{"Selected family", "fb-map", "data-flow-board", "data-window=\"0\"", "<script>alert(1)</script>"} { if !strings.Contains(body, want) { t.Errorf("missing %s", want) } @@ -77,7 +75,7 @@ func TestFlowPageCSP(t *testing.T) { func TestFlowEmptyAndBadID(t *testing.T) { s, _, _, _ := fixture(t) body := request(s, "GET", "/flow", s.token).Body.String() - if !strings.Contains(body, "No live interactive session") { + if !strings.Contains(body, "data-flow-board") { t.Fatal(body) } for _, id := range []string{"0", "-1", "wrong"} { @@ -89,13 +87,13 @@ func TestFlowEmptyAndBadID(t *testing.T) { func TestFlowRenderingPolish(t *testing.T) { s, _, _, _ := fixture(t) - p := pageData{Flow: view.FlowView{Lead: &view.FlowNode{Title: "", Adapter: "claude", Mixer: model.StateMixer{Verdict: "finished", Evidence: "stored", Rows: []model.StateSignal{{Name: "hooks", Reading: "Notification"}}}}}} + p := pageData{Board: view.Board(view.BoardSnapshot{Nodes: []view.FlowNode{{ID: 1, Title: "", Adapter: "claude", Label: "finished", Mixer: model.StateMixer{Evidence: "stored"}}}}, time.Hour, time.Now())} var out strings.Builder if err := s.templates.ExecuteTemplate(&out, "flow", p); err != nil { t.Fatal(err) } body := out.String() - if !strings.Contains(body, `title="Notification"`) || strings.Contains(body, " - -
{{template "content" .}}
+{{.Title}} · driga console + +
{{template "content" .}}
{{template "chat" .}} - -

driga console · keys

0–8
switch windows
[ / ]
previous / next Flow session
c · 8
Chat · understands this page
7
Flow · lead, signal mixer and workers; pulses mark real family events
j / k · ↑ / ↓
move selection
Enter
open selected row
Tab / Shift Tab
cycle panes
/
filter focused pane
:
command line
p
pause / resume events
?
toggle help
Esc
close / clear filter

Chat offers local actions through approval cards. Use the CLI for hooks install.

+ +

driga console · keys

0–2
Flow · Projects · Machines
c
Chat · understands this page
m / t / s
Flow Map / Timeline / Sessions
j / k · ↑ / ↓
move selection
Enter
open selected row
Tab / Shift Tab
cycle panes
/
filter focused pane
:
command line
p
pause / resume events
?
toggle help
Esc
close / clear filter

Chat offers local actions through approval cards. Use the CLI for hooks install.

{{end}} {{define "content"}}
driga{{if .Hostname}}{{.Hostname}}{{else}}{{.Scope}}{{end}}● live {{.Mode}}{{if eq .Mode "snapshot"}}Collecting a snapshot (no collector running)…{{end}} -{{range .Machine.Machines}}{{if not .Sample.TS.IsZero}}mem{{printf "%.0f" (percent .Sample.MemUsed .Sample.MemTotal)}}%{{if .Sample.SwapTotal}}swap{{printf "%.0f" (percent .Sample.SwapUsed .Sample.SwapTotal)}}%{{end}}load{{if lt .Sample.Load1 0.0}}unknown{{else}}{{printf "%.1f" .Sample.Load1}}{{end}}/{{.Sample.Cores}}disk{{bytes .Sample.DiskFree}}{{end}}{{end}}{{if or .LastEvents .PendingSources .StaleSources}}intake{{if .StaleSources}} · attention{{else if .PendingSources}} · waiting{{else}} · receiving{{end}}{{end}}{{if or (eq .Page "/") (eq .Page "/flow")}}{{waitingHeader .Inbox.Rows .Agents.Rows .ObservedAt}}{{end}}{{clock .ObservedAt}}
- +{{range .Machine.Machines}}{{if not .Sample.TS.IsZero}}mem{{printf "%.0f" (percent .Sample.MemUsed .Sample.MemTotal)}}%{{if .Sample.SwapTotal}}swap{{printf "%.0f" (percent .Sample.SwapUsed .Sample.SwapTotal)}}%{{end}}load{{if lt .Sample.Load1 0.0}}unknown{{else}}{{printf "%.1f" .Sample.Load1}}{{end}}/{{.Sample.Cores}}disk{{bytes .Sample.DiskFree}}{{end}}{{end}}{{if or .LastEvents .PendingSources .StaleSources}}intake{{if .StaleSources}} · attention{{else if .PendingSources}} · waiting{{else}} · receiving{{end}}{{end}}{{if or (eq .Page "/") (eq .Page "/flow")}}{{if .IsBoard}}{{index .Board.Pulse "waiting"}} waiting{{else}}{{waitingHeader .Inbox.Rows .Agents.Rows .ObservedAt}}{{end}}{{end}}{{clock .ObservedAt}} +
{{if gt (len .Machines.Rows) 1}}
{{if .All}}{{end}}
{{end}} {{if or (eq .Page "/") (eq .Page "/agents") (eq .Page "/projects")}}

{{if .All}}Showing all sessions · Show default sessions{{else}}Showing default sessions · Show all sessions (including ended and hosted idle up to 24h){{end}}

{{end}}
-
{{if eq .Page "/"}}{{template "home" .}}{{else if eq .Page "/activity"}}
activity · 60mevents/min · load · cores
{{template "activity" .}}
{{else if eq .Page "/flow"}}{{template "flow" .}}{{else if eq .Page "/agents"}}{{template "agents" .}}{{else if eq .Page "/projects"}}{{template "projects" .}}{{else if eq .Page "/inbox"}}{{template "inbox" .}}{{else if eq .Page "/orphans"}}{{template "orphans" .}}{{else if eq .Page "/machines"}}{{template "machines" .}}{{else if eq .Page "/cleanup"}}{{template "cleanup" .}}{{else if eq .Title "Agent detail"}}{{template "agent" .}}{{else if eq .Title "Project detail"}}{{template "project" .}}{{end}}
+
{{if eq .Page "/activity"}}
activity · 60mevents/min · load · cores
{{template "activity" .}}
{{else if eq .Page "/flow"}}{{template "flow" .}}{{else if eq .Page "/agents"}}{{template "agents" .}}{{else if eq .Page "/projects"}}{{template "projects" .}}{{else if eq .Page "/inbox"}}{{template "inbox" .}}{{else if eq .Page "/orphans"}}{{template "orphans" .}}{{else if eq .Page "/machines"}}{{template "machineDestination" .}}{{else if eq .Page "/cleanup"}}{{template "cleanup" .}}{{else if eq .Title "Agent detail"}}{{template "agent" .}}{{else if eq .Title "Project detail"}}{{template "project" .}}{{end}}
driga console · Local actions in Chat · alpha
{{end}} {{define "chip"}}{{friendly .}}{{end}} {{define "rowHeader"}}{{end}} -{{define "home"}} -
needs-you · {{add (len .Needs) .NeedsMore}}j/k move · enter open
{{template "rowHeader" .}}{{$p := .}}{{range .Needs}}▌{{template "chip" .State}}{{.Adapter}}{{.Title}}{{.Project}}{{if .Branch}} · {{.Branch}}{{end}}{{relative .At $p.ObservedAt}}{{else}}

Nothing needs you.

{{end}}
{{if .NeedsMore}}

… {{.NeedsMore}} more needing you · :inbox

{{end}}
-
machine · {{.Hostname}}{{range .Machine.Machines}}{{if not .Sample.TS.IsZero}}{{.Sample.Cores}} cores{{end}}{{end}}
{{template "gauges" .}}

{{.Summary.Orphans}} orphans · {{bytes .Summary.Reclaim}} reclaimable{{if ne .Cleanup nil}} · {{.Ready}} ready / {{.Refused}} refused worktrees{{end}}{{if .History}} · {{bytes .History}} history{{end}}

{{if .CleanupError}}

Cleanup unavailable: {{.CleanupError}}

{{end}}
-
activity · 60m▮ events/min ─ load ┄ cores
{{template "activity" .}}
-
tail -f eventslive · p pause
{{range .Tail}}
{{clock .TS}} {{.Adapter}} {{.Kind}} {{.Summary}}
{{else}}

No stored events.

{{end}}
-
projects · {{len .Projects.Rows}}sorted by activity · :projects
{{template "projectCards" .}}
{{end}} - {{define "gauges"}}{{range .Machine.Machines}}
{{if gt (len $.Machine.Machines) 1}}

{{.MachineName}}

{{template "chip" .State}}
{{end}}{{if .Stale}}stale{{end}}{{if .Sample.TS.IsZero}}

Health unavailable

{{else}} {{if .Sample.MemTotal}}
memory{{printf "%.0f" (percent .Sample.MemUsed .Sample.MemTotal)}}%

{{bytes .Sample.MemUsed}} / {{bytes .Sample.MemTotal}}

{{end}} {{if .Sample.SwapTotal}}
swap{{printf "%.0f" (percent .Sample.SwapUsed .Sample.SwapTotal)}}%

{{bytes .Sample.SwapUsed}} / {{bytes .Sample.SwapTotal}}

{{else}}

No swap capacity reported

{{end}} @@ -336,3 +329,5 @@

{{.Kind}} {{end}} {{define "machine-process"}}

PID {{.PID}} · {{.Exe}} · {{bytes .Memory}} ({{.MemoryMeasure}}) · {{printf "%.1f" .CPUPercent}}% CPU

{{attributionText .Attribution}}{{with .Attribution}} · proof: {{.Proof}}{{end}}

{{end}} + +{{define "machineDestination"}}{{if eq .Reclaim "processes"}}{{template "orphans" .}}{{else if eq .Reclaim "disk"}}{{template "cleanup" .}}{{else}}{{template "machines" .}}{{end}}{{end}} diff --git a/internal/web/templates/flow.html b/internal/web/templates/flow.html index 7f37350b..02c76f48 100644 --- a/internal/web/templates/flow.html +++ b/internal/web/templates/flow.html @@ -1,12 +1 @@ -{{define "flow"}} -
-

driga/flow/{{with .Flow.Lead}}{{.DisplayTitle}}{{end}}

[ prev familynext family ]−+
-

Needs you {{len .Flow.Attention.Waits}}· machine-wide · longest wait first

{{range .Flow.Attention.Waits}}
{{.Title}}{{duration .Waited}}{{flowJoin .Adapter .Project .Branch}}

{{if eq .Reason "permission"}}◆ Permission{{with .Tool}} · {{.}}{{end}}{{with .Detail}}: {{.}}{{end}}{{else}}◆ Asked for input · what it asked wasn't captured{{end}}

{{else}}

Nothing needs you · {{.Flow.Attention.Working}} working

{{end}}
-

-
Session map · measured activity -transcript / cpuwaiting on youno stored data│tool○prompt●turn endhead▶ working◆ needs you○ idle‖ stalled?? unknown -

No live interactive session.

-
- -
jk sessionenter jump[] family−+ zoomrail = when the session existed · bars = measured growth · head = last stored verdict · hatching = no stored data
-
{{end}} +{{define "flow"}}{{template "board" .}}{{end}} diff --git a/internal/web/testdata/console.js b/internal/web/testdata/console.js index ccb50285..1f1a5046 100644 --- a/internal/web/testdata/console.js +++ b/internal/web/testdata/console.js @@ -30,15 +30,6 @@ if (DrigaConsole.parseCommand("flow 42").url !== "/flow?id=42&machine=all") throw Error("flow command"); if (DrigaConsole.parseCommand("flow").url !== "/flow?machine=all") throw Error("default flow command"); -var flowEvent={machine_id:"local",adapter:"claude",external_id:"lead",key:"event-1"}; -if (!DrigaConsole.familyEvent(flowEvent,"local",["claude:lead"],new Set())) throw Error("family pulse missing"); -if (DrigaConsole.familyEvent(flowEvent,"peer",["claude:lead"],new Set()) || DrigaConsole.familyEvent(flowEvent,"local",["claude:other"],new Set()) || DrigaConsole.familyEvent(flowEvent,"local",["claude:lead"],new Set(["event-1"]))) throw Error("spurious family pulse"); - -var geometry=DrigaConsole.flowWireGeometry({right:100,y:30},{left:140,right:240,y:30},[{key:"one",left:300,y:20},{key:"two",left:300,y:80}]); -if (geometry.trunk !== "M 240 30 H 270 M 270 20 V 80") throw Error("shared trunk geometry: "+geometry.trunk); -if (geometry.branches[0].d !== "M 270 20 H 300" || geometry.branches[1].d !== "M 270 80 H 300") throw Error("short horizontal branches"); -if (geometry.routes["two"] !== "M 240 30 H 270 V 80 H 300") throw Error("pulse must travel trunk then matching branch"); - (() => { const text=globalThis.DrigaConsole.connectionText; if(text(false,false)!=="○ reconnecting…")throw new Error("retry status"); diff --git a/internal/web/testdata/flow-board-dom.js b/internal/web/testdata/flow-board-dom.js new file mode 100644 index 00000000..349dd0ad --- /dev/null +++ b/internal/web/testdata/flow-board-dom.js @@ -0,0 +1,33 @@ +const assert=require('node:assert/strict'); +const handlers={};let now=1000,timer,requests=[]; +class Element { + constructor(tag,dataset={},attrs={}){this.tagName=tag;this.dataset=dataset;this.attrs=attrs;this.children=[];this.hidden=false;this.scrollTop=0;this.scrollLeft=0;this.value='';this.id=attrs.id||'';this.className=attrs.class||'';this.classList={values:new Set(),toggle:(c,on)=>on?this.classList.values.add(c):this.classList.values.delete(c),contains:c=>this.classList.values.has(c)};} + append(...nodes){for(const n of nodes){if(n.parentElement)n.parentElement.children=n.parentElement.children.filter(c=>c!==n);n.parentElement=this;this.children.push(n);}} + matches(s){if(s.includes(','))return s.split(',').some(p=>this.matches(p.trim()));if(s==='input')return this.tagName==='input';if(s.startsWith('.'))return this.className.split(' ').includes(s.slice(1));if(s.startsWith('#'))return this.id===s.slice(1);if(s.startsWith('[')){const m=s.match(/^\[([^=\]]+)(?:="?([^"\]]+)"?)?\]$/);if(!m)return false;const key=m[1].startsWith('data-')?m[1].slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()):null;const v=key?this.dataset[key]:this.attrs[m[1]];return v!==undefined&&(m[2]===undefined||v===m[2]);}return this.tagName===s;} + closest(s){return this.matches(s)?this:this.parentElement?.closest(s)||null;} + contains(n){return n===this||this.children.some(c=>c.contains(n));} + querySelectorAll(s){const descendants=this.children.flatMap(n=>[n,...n.querySelectorAll('*')]);if(s==='*')return descendants;const split=s.lastIndexOf(' ');if(split>0){const parent=s.slice(0,split),child=s.slice(split+1);return descendants.filter(n=>n.matches(child)&&n.parentElement?.closest(parent));}return descendants.filter(n=>n.matches(s));} + querySelector(s){return this.querySelectorAll(s)[0]||null;} + setAttribute(k,v){this.attrs[k]=String(v);}getAttribute(k){return this.attrs[k]??null;} + focus(){document.activeElement=this;}setSelectionRange(a,b){this.selectionStart=a;this.selectionEnd=b;} +} +const body=new Element('body'),main=new Element('main',{}, {id:'main'}),host=new Element('div',{flowBoard:'',observed:new Date(now).toISOString()});body.append(main);main.append(host); +const search=new Element('input',{boardSearch:''},{id:'flow-find'}),updated=new Element('span',{boardUpdated:''}),status=new Element('p',{boardStatus:''});host.append(search,updated,status); +const surfaces={},rows={},details={}; +for(const view of ['map','timeline','sessions']){host.append(new Element('button',{flowView:view}));const surface=new Element('section',{flowSurface:view},{class:'fb-'+(view==='map'?'canvas':view)});surfaces[view]=surface;host.append(surface);rows[view]=[];for(let i=1;i<=3;i++){const row=new Element(view==='map'?'g':'div',{boardKey:'local/'+i,root:'local/1',session:String(i),machine:'local',title:'Session '+i,project:'project',parent:i>1?'local/1':''},{id:view+'-'+i,role:view==='timeline'?'treeitem':'button'});surface.append(row);rows[view].push(row);}} +for(let i=1;i<=3;i++){const detail=new Element('div',{detail:'local/'+i});host.append(detail);details[i]=detail;const family=new Element('div',{familyRow:'local/'+i,familyRoot:'local/1'});family.append(new Element('button'));host.append(family);} +const panel=new Element('aside',{}, {class:'fb-panel'});host.append(panel); +const prompt=new Element('div',{prompt:'p1'}),allow=new Element('button',{answer:'allow'},{id:'prompt-p1-allow'});prompt.append(allow);host.append(prompt); +global.document={activeElement:body,body,querySelector:s=>body.querySelector(s),getElementById:id=>body.querySelector('#'+id),addEventListener:(name,fn)=>(handlers[name]??=[]).push(fn),dispatchEvent:()=>{}}; +global.location={search:'',pathname:'/flow'};global.window={htmx:null};global.CSS={escape:s=>s};global.CustomEvent=class{constructor(type,o){this.type=type;this.detail=o.detail;}};global.setInterval=fn=>(timer=fn,1);Date.now=()=>now; +global.fetch=async(path,options)=>(requests.push({path,body:JSON.parse(options.body)}),{ok:true}); +const event=(type,target,key)=>{const e={target,key,preventDefault(){this.defaultPrevented=true;}};return Promise.all((handlers[type]||[]).map(fn=>fn(e)));}; +async function runFlowDOMTests(){ + assert(rows.map[0].classList.contains('selected'));assert.equal(document.activeElement,body,'initialization stole focus');assert(allow.disabled,'new permission was armed immediately'); + await event('click',host.querySelector('[data-flow-view="timeline"]'));rows.timeline[0].focus();await event('keydown',rows.timeline[0],'ArrowDown');assert.equal(document.activeElement,rows.timeline[1]);assert.equal(rows.timeline[1].tabIndex,0);assert.equal(rows.timeline[0].tabIndex,-1); + surfaces.timeline.scrollTop=170;panel.scrollTop=60;await event('htmx:beforeSwap',main);document.activeElement=body;surfaces.timeline.hidden=true;await event('htmx:afterSwap',main); + assert.equal(document.activeElement,rows.timeline[1],'refresh lost focus');assert(rows.timeline[1].classList.contains('selected'),'refresh lost identity');assert.equal(surfaces.timeline.hidden,false);assert.equal(surfaces.timeline.scrollTop,170);assert.equal(panel.scrollTop,60); + search.focus();search.value='session 2';search.selectionStart=2;search.selectionEnd=7;await event('input',search);await event('htmx:beforeSwap',main);search.value='';document.activeElement=body;await event('htmx:afterSwap',main);assert.equal(search.value,'session 2');assert.equal(document.activeElement,search);assert.deepEqual([search.selectionStart,search.selectionEnd],[2,7]);assert(rows.timeline[0].hidden);assert(!rows.timeline[1].hidden); + document.querySelector=s=>s.startsWith('meta')?{content:'csrf'}:body.querySelector(s);await event('click',allow);assert.equal(requests.length,0,'unarmed prompt submitted');now+=700;timer();await event('click',allow);assert.equal(requests.length,1);assert.deepEqual(requests[0].body,{id:'p1',allow:true});assert.equal(document.activeElement,search,'answer stole focus'); + console.log('Flow DOM focus, selection, scroll, filtering and approval regressions passed'); +} diff --git a/internal/web/testdata/flow-board.js b/internal/web/testdata/flow-board.js new file mode 100644 index 00000000..db7a3e5b --- /dev/null +++ b/internal/web/testdata/flow-board.js @@ -0,0 +1,5 @@ +const assert=require("node:assert/strict"); +const {duration,nextIndex}=globalThis.DrigaBoard; +assert.equal(duration(-1000),"0s");assert.equal(duration(3660000),"1h 1m"); +assert.equal(nextIndex(2,"ArrowUp",4),1);assert.equal(nextIndex(0,"ArrowUp",4),0);assert.equal(nextIndex(0,"End",4),3);assert.equal(nextIndex(3,"ArrowDown",4),3); +console.log("Flow keyboard invariants passed"); diff --git a/internal/web/testdata/flow-map.js b/internal/web/testdata/flow-map.js deleted file mode 100644 index 6ec9a226..00000000 --- a/internal/web/testdata/flow-map.js +++ /dev/null @@ -1,56 +0,0 @@ -const assert = require("node:assert/strict"); -const {geometry,eventMark}=globalThis.DrigaFlow; -const now=Date.parse("2026-10-20T12:00:00Z"),at=m=>new Date(now+m*60000).toISOString(); -const node=(id,start)=>({id,adapter:"claude",started_at:at(start),retained_from_at:at(-59),events:[],waits:[],handoffs:[],mixer:{verdict:"working"}}); -const lead=node(1,-480),a=node(3,-100),b=node(2,-100); -for(let i=0;i<100;i++)lead.events.push({kind:"otlp:tool_result",occurred_at:at(-2+i/100)}); -lead.events.push({kind:"Notification",occurred_at:at(-4)}); -lead.waits=[{started_at:at(-5),ended_at:at(-4.99),reason:"permission"}]; -const quiet=Array.from({length:12},(_,i)=>node(i+10,-400+i)); -const data={lead,children:[a,b,...quiet],window_minutes:240,observed_at:at(0),gaps:[{started_at:at(-20),ended_at:at(-18)}]}; -let g=geometry(data,1400,true); -assert.deepEqual(g.rows.slice(1).map(r=>r.node.id),[...quiet.map(n=>n.id),2,3]); -assert.equal(g.rows[0].tools.reduce((s,b)=>s+b.count,0),100); -assert.equal(g.rows[0].waits.length,1,"Notification cannot create wait spans"); -assert(g.rows[0].waits[0].to>g.rows[0].waits[0].from,"short waits remain visible"); -assert(g.rows[0].hatches[0].to/g.plotWidth>.7,"snapshot fixture mostly hatches"); -assert.equal(g.rows[0].hatches.length,2); -assert.equal(geometry(data,1400,false).quiet,14); -a.mixer.verdict="waiting";a.last_activity_at=at(0);data.children.reverse(); -assert.deepEqual(geometry(data,1400,true).rows.map(r=>r.node.id),g.rows.map(r=>r.node.id)); -assert.equal(eventMark("codex","otlp:tool_result"),""); -assert.equal(eventMark("gemini","Stop"),""); -lead.waits=[];assert.equal(geometry(data,1400,true).rows[0].waits.length,0); -lead.observed={from_at:at(-2),log_bytes_per_min:[100,0],cpu_ms_per_min:[0,300]}; -g=geometry(data,500,true);assert(g.minutes<=g.plotWidth/4);assert(g.earlierMinutes>0);assert.equal(g.rows[0].activity.length,2); -console.log("flow map geometry passed"); - -// The rail is existence only; verdict patterns belong exclusively to its head. -const f=globalThis.DrigaFlow; -lead.ended_at=at(-1);lead.retained_from_at=at(-59); -g=f.geometry(data,1400,true); -assert.equal(g.rows[0].rail.from,g.rows[0].start); -assert(g.rows[0].rail.toh.from>=g.rows[0].rail.from&&h.to<=g.rows[0].rail.to)); -assert.deepEqual(f.sortAttention([{id:"new",occurred_at:at(-1)},{id:"old",waiting_since_at:at(-30),occurred_at:at(-2)}]).map(w=>w.id),["old","new"]); -assert.equal(f.waitReason({reason:"unknown",detail:"must not be invented"}),"◆ Asked for input · what it asked wasn't captured"); -assert.equal(f.waitReason({reason:"permission",tool:"Bash",detail:"fixture command"}),"◆ Permission · Bash: fixture command"); -assert.equal(f.waitReason({reason:"permission",tool:"Bash"}),"◆ Permission · Bash"); -assert.equal(f.signalGlyph({strength:0,supports:false}),"–"); -assert.equal(f.signalGlyph({strength:1,supports:true}),"✓"); -assert.equal(f.signalGlyph({strength:1,supports:false}),"✗"); -const runs=f.recentEvents(Array.from({length:7},(_,i)=>({kind:"Stop",occurred_at:at(-10+i)}))); -assert.equal(runs.length,4);assert.equal(runs[3].more,4); -assert.equal(runs[3].kind,"Stop");assert.equal(runs[3].from,at(-10));assert.equal(runs[3].to,at(-7)); - -assert.equal(f.sessionTitle({title:"Task",adapter:"claude",started_from:"Editor"}),"Task · from Editor"); -assert.equal(f.sessionTitle({title:"Task",adapter:"claude"}),"Task"); diff --git a/internal/web/testdata/flow-review.js b/internal/web/testdata/flow-review.js deleted file mode 100644 index 83542e10..00000000 --- a/internal/web/testdata/flow-review.js +++ /dev/null @@ -1,79 +0,0 @@ -const assert = require("node:assert/strict"); -class Element { - constructor(tag="div") { this.listeners={};this.style={};this.tag=tag;this.children=[];this.dataset={};this.attrs={};this.textContent="";this.parentElement={clientWidth:1200};this.classList={toggle(){}}; } - append(...items) { this.children.push(...items); } - replaceChildren(...items) { this.children=items; } - setAttribute(k,v) { this.attrs[k]=v; } - addEventListener(key,fn) { this.listeners[key]=fn; } - querySelectorAll() { return []; } - get childElementCount() { return this.children.length; } -} -const panels=new Map(),root=new Element(); -root.querySelector=key=>{if(!panels.has(key))panels.set(key,new Element());return panels.get(key);}; -root.querySelector("[data-flow-zoom]").value="60"; -const documentHandlers={}; -globalThis.document={querySelector:key=>key==="[data-flow-map]"?root:null,createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),addEventListener(key,fn){documentHandlers[key]=fn;}}; -globalThis.window={addEventListener(){}}; -globalThis.location={href:"http://fixture/flow?id=1",origin:"http://fixture"}; -let reduceMotion=true;globalThis.matchMedia=()=>({matches:reduceMotion}); -globalThis.setInterval=()=>0; -let hasLead=true; -const now=Date.now();let wall=now;Date.now=()=>wall;const at=ms=>new Date(now+ms).toISOString(); -const lead={id:1,machine_id:"local",adapter:"claude",title:"Long session",started_from:"Fixture Editor",started_at:at(-2765*60000),ended_at:at(-60*60000),elapsed_text:"45h5m0s",jump:{kind:"resume",cwd:"/fixture/it's a project",command:"claude --resume 'fixture'"},events:[],waits:[],mixer:{verdict:"finished",observed_at:at(0),agreement:1,rows:[{name:"registry",reading:"idle",strength:1,supports:true},{name:"hooks",reading:"Stop",strength:1,supports:false},{name:"telemetry",reading:"unavailable",strength:0}]}}; -const waits=["sample · Fix cache","sample · Fix parser","sample · #ccc333","sample · #ddd444"].map((title,i)=>({title,session_id:i+1,machine_id:"local",reason:"unknown",occurred_at:at(-60000)})); -globalThis.fetch=async()=>({ok:true,json:async()=>({schema:2,data:{lead:hasLead?lead:null,children:[child,{id:3,started_at:at(-600000),events:[],waits:[]}],attention:{waits},observed_at:at(0),window_minutes:60,gaps:[]}})}); -const child={id:2,parent_id:1,adapter:"codex",title:"Worker",started_at:at(-10*60000),retained_from_at:at(-2*60000),events:[],waits:[{id:"open",started_at:at(-60000)}],mixer:{verdict:"waiting",observed_at:at(0)}}; -const allText=el=>[el.textContent,...el.children.map(allText)].join(" "); -let copied=""; -Object.defineProperty(globalThis,"navigator",{value:{clipboard:{writeText:async text=>{copied=text;}}},configurable:true}); -setImmediate(async()=>{ - assert.equal(root.querySelector("[data-flow-status]").textContent, "", "render failed"); - const empty=root.querySelector("[data-flow-empty]"); - assert.equal(empty.hidden,true,'populated Flow retained empty state'); - hasLead=false;await root.querySelector("[data-flow-zoom]").listeners.change(); - assert.equal(empty.hidden,false,'empty Flow lost empty state'); - hasLead=true;await root.querySelector("[data-flow-zoom]").listeners.change(); - assert.equal(empty.hidden,true,'arriving live session retained empty state'); - for(const tag of ['button','a','summary','input','textarea','select']) { - const event={key:'Enter',target:{closest(selector){return selector.split(',').includes(tag)?this:null;}},preventDefault(){this.defaultPrevented=true;},stopImmediatePropagation(){this.stopped=true;}}; - documentHandlers.keydown(event); - assert(!event.defaultPrevented&&!event.stopped,tag+' lost native Enter'); - } - const dl=root.querySelector("[data-flow-detail]").children.find(el=>el.tag==="dl"); - assert(dl,"missing session details"); - assert.equal(dl.children[1].textContent,"45h5m0s","running duration must use shared server formatter and respect ended_at"); - const cards=root.querySelector("[data-flow-attention]"); - for(const wait of waits)assert(allText(cards).includes(wait.title),"live card lost session hint "+wait.title); - const detail=root.querySelector("[data-flow-detail]"); - const emptyLog=detail.children.find(el=>el.className==="metro-log").children[0]; - assert(emptyLog.className.includes("metro-log-empty"),"empty event message must span the log rather than the timestamp column"); - for(const copy of ["verdict as of","telemetry unavailable","Why finished","1 of 4 signals agree","✓","✗","–"])assert(allText(detail).includes(copy),"missing detail copy "+copy); - assert(allText(cards).includes("what it asked wasn't captured")); - const walk=el=>[el,...el.children.flatMap(walk)]; - const svg=walk(root.querySelector("[data-flow-svg]")); - assert(allText(detail).includes("Long session · from Fixture Editor"),"detail lost launch surface"); - const mapTitle=svg.find(el=>el.attrs.class==="metro-title"); - assert(mapTitle.textContent.includes("from"),"map label lost launch surface"); - assert(mapTitle.children.some(el=>el.tag==="title"&&el.textContent==="Long session · from Fixture Editor"),"tooltip lost full launch surface"); - assert(svg.some(el=>el.tag==="circle"&&Number(el.attrs.r)===7),"14px verdict disc missing"); - assert(!svg.some(el=>(el.attrs.class||"").includes("metro-head-pattern")),"legacy dashed stub"); - assert(svg.some(el=>el.textContent.startsWith("now · ")),"now clock missing"); - const actions=detail.children.findIndex(el=>el.className==="metro-actions"); - assert(actions>=0&&actionsel.className.includes("fresh")),"reduced motion disables fresh-card pulse"); - assert(svg.some(el=>el.attrs.class==="metro-hatch"&&Number(el.attrs.height)===22),"22px no-data band"); - assert(svg.some(el=>el.attrs.class==="metro-quiet"),"quiet fold must live inside SVG"); - assert(svg.some(el=>el.textContent==="show ▾")); - reduceMotion=false; - await root.querySelector("[data-flow-zoom]").listeners.change(); - assert(cards.children.some(el=>el.className.includes("fresh")),"new card pulse missing"); - wall+=8000; - await root.querySelector("[data-flow-zoom]").listeners.change(); - assert(!cards.children.some(el=>el.className.includes("fresh")),"refresh restarted a finished card pulse"); - assert(!walk(root.querySelector("[data-flow-svg]")).some(el=>(el.attrs.class||"")==="metro-wait open"),"refresh restarted a finished wait pulse"); -}); diff --git a/internal/web/testdata/observations.js b/internal/web/testdata/observations.js deleted file mode 100644 index a6c9046d..00000000 --- a/internal/web/testdata/observations.js +++ /dev/null @@ -1,15 +0,0 @@ -var seen = new Map(); -var node = (at, bytes, key="local/codex:one") => ({key, last_growth_at:at, last_growth_bytes:bytes}); -var t0="2026-10-19T19:00:00Z", t1="2026-10-19T19:00:05Z", t2="2026-10-19T19:00:10Z"; -var update = nodes => DrigaConsole.observationPulses(seen,nodes); -if(update([node(t0,100)]).length) throw Error("initial history pulsed"); -if(update([node(t0,100)]).length) throw Error("unchanged refresh pulsed"); -var pulses=update([node(t1,12288)]); -if(pulses.length!==1 || pulses[0].label!=="+12 KB transcript") throw Error("new growth did not pulse with byte label"); -if(update([node(t1,12288),node(t1,12288)]).length) throw Error("duplicate refresh pulsed"); -if(update([node(t0,100)]).length || update([node(t1,12288)]).length) throw Error("out-of-order refresh replayed growth"); -if(update([node(t2,2,"peer/codex:one")]).length) throw Error("new machine inherited baseline"); -if(update([node(null,0,"local/codex:cpu")]).length) throw Error("CPU-only observation pulsed"); -if(update([node(t2,2,"local/codex:cpu")]).length!==1) throw Error("first growth after CPU-only observation missing"); -if(update([node(t2,500)]).length!==1) throw Error("second advance missing"); -if(DrigaConsole.transcriptSize(0)!=="0 B" || DrigaConsole.transcriptSize(512)!=="512 B") throw Error("small byte labels"); diff --git a/scripts/flow-board-browser.cjs b/scripts/flow-board-browser.cjs new file mode 100644 index 00000000..7aebb710 --- /dev/null +++ b/scripts/flow-board-browser.cjs @@ -0,0 +1,37 @@ +// Browser QA uses only synthetic fixtures and a loopback server. +// Run fixture export first: FLOW_SHOTS_DIR=... scripts/isolated.sh go test +// ./internal/web -run TestBoardFixturePages. Set PLAYWRIGHT_MODULE when needed. +const fs=require("node:fs"),path=require("node:path"),http=require("node:http"),assert=require("node:assert/strict"); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||"playwright"); +const repo=path.resolve(__dirname,".."),out=process.env.FLOW_SHOTS_DIR; +if(!out)throw Error("FLOW_SHOTS_DIR must point to synthetic fixture exports"); +let kind="busy",revision=0;const streams=new Set(),requests=[]; +const server=http.createServer((req,res)=>{ + const u=new URL(req.url,"http://localhost"); + if(req.method==="POST"){let body="";req.on("data",d=>body+=d);req.on("end",()=>{requests.push({path:u.pathname,body:JSON.parse(body)});res.setHeader("Content-Type","application/json");res.end("{\"ok\":true}");});return;} + if(u.pathname==="/events"){res.writeHead(200,{"Content-Type":"text/event-stream"});res.write(": fixture\n\n");streams.add(res);req.on("close",()=>streams.delete(res));return;} + const staticPath=u.pathname==="/static/theme.css"?path.join(out,"fixtures/theme.css"):u.pathname.startsWith("/static/")?path.join(repo,"internal/web",u.pathname):null; + if(staticPath){if(!fs.existsSync(staticPath)){res.writeHead(404);res.end();return;}res.setHeader("Content-Type",({".css":"text/css",".js":"application/javascript",".woff2":"font/woff2",".svg":"image/svg+xml",".png":"image/png"})[path.extname(staticPath)]||"application/octet-stream");res.end(fs.readFileSync(staticPath));return;} + let html=fs.readFileSync(path.join(out,"fixtures",kind+".html"),"utf8").replace("data-flow-board",`data-revision="${revision}" data-flow-board`); + if(revision)html=html.replaceAll("Add board view","Add board view refreshed"); + if(req.headers["hx-request"])html=html.match(/]*>([\s\S]*)<\/main>/)[1]; + res.setHeader("Content-Type","text/html");res.setHeader("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");res.end(html); +}); +(async()=>{let browser;try{ + await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve)); + browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH}:{}),args:["--disable-background-networking","--disable-component-update","--disable-sync","--no-first-run"]}); + const page=await browser.newPage({viewport:{width:1600,height:1000},serviceWorkers:"block"});const errors=[];page.on("pageerror",e=>errors.push(e.message));const base="http://127.0.0.1:"+server.address().port; + await page.goto(base+"/flow");await page.locator(".fb-node.selected").waitFor(); + await page.locator('[data-flow-view="timeline"]').click();const rows=page.locator('[data-flow-surface="timeline"] [role="treeitem"]');await rows.nth(7).focus();await page.keyboard.press("Enter");await page.keyboard.press("ArrowDown"); + const key=await page.evaluate(()=>document.activeElement.dataset.boardKey);assert(key); + await page.evaluate(()=>{window.focusedBefore=document.activeElement;document.querySelector(".fb-timeline").scrollTop=160;}); + revision++;for(const res of streams)res.write("event: change\ndata: refresh\n\n");await page.waitForFunction(()=>document.querySelector("[data-flow-board]").dataset.revision==="1"); + assert.equal(await page.locator("[data-flow-board]").count(),1,"refresh duplicated Flow");assert.equal(await page.evaluate(()=>document.activeElement.dataset.boardKey),key,"refresh lost keyboard focus");assert(await page.evaluate(()=>document.activeElement===window.focusedBefore),"morph replaced focused row");assert.equal(await page.locator('[data-flow-surface="timeline"] .selected').getAttribute("data-board-key"),key);assert.equal(await page.locator(".fb-timeline").evaluate(n=>n.scrollTop),160); + await page.keyboard.press("/");await page.keyboard.type("retry");revision++;for(const res of streams)res.write("event: change\ndata: refresh\n\n");await page.waitForFunction(()=>document.querySelector("[data-flow-board]").dataset.revision==="2");assert.equal(await page.locator("[data-board-search]").inputValue(),"retry");assert(await page.locator("[data-board-search]").evaluate(n=>n===document.activeElement)); + await page.locator("[data-board-search]").fill("");await page.locator('[data-flow-view="map"]').click(); + await page.locator("[data-answer]").first().waitFor();await page.waitForTimeout(1100);await page.locator('[data-answer="deny"]').first().click();assert(requests.some(r=>r.path==="/chat/api/prompt-answer"&&!r.body.allow));await page.waitForTimeout(200);assert.equal(await page.locator("[data-flow-board]").count(),1,"prompt refresh duplicated Flow"); + for(const width of [375,768,1280,1600])for(const theme of ["mocha","latte"]){await page.setViewportSize({width,height:1000});await page.evaluate(t=>{localStorage.setItem("driga-theme",t);document.documentElement.dataset.theme=t;},theme);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),"horizontal page overflow");assert.equal(await page.locator("[data-flow-board]").count(),1,"duplicate board before screenshot");assert.equal(await page.locator("nav.wins").count(),1,"duplicate navigation before screenshot");await page.screenshot({path:path.join(out,`map-${width}-${theme}.png`),fullPage:false});} + await page.setViewportSize({width:1600,height:1000});await page.locator('[data-flow-view="timeline"]').click();await page.screenshot({path:path.join(out,"timeline-1600.png"),fullPage:false}); + for(const state of ["quiet","empty"]){kind=state;await page.goto(base+"/flow");await page.screenshot({path:path.join(out,state+"-1600.png"),fullPage:false});} + await page.emulateMedia({reducedMotion:"reduce"});assert.equal(await page.locator(".flow-board").evaluate(n=>getComputedStyle(n).animationName),"none");assert.deepEqual(errors,[]);fs.writeFileSync(path.join(out,"browser-results.json"),JSON.stringify({focus:true,selection:true,scroll:true,search:true,prompt:true,errors},null,2));console.log("Flow browser QA passed"); +}finally{if(browser)await browser.close();for(const res of streams)res.end();await new Promise(resolve=>server.close(resolve));}})().catch(e=>{console.error(e);process.exitCode=1;});