diff --git a/.claude/skills/tui-snapshot-review/SKILL.md b/.claude/skills/tui-snapshot-review/SKILL.md new file mode 100644 index 00000000..073687b1 --- /dev/null +++ b/.claude/skills/tui-snapshot-review/SKILL.md @@ -0,0 +1,282 @@ +--- +name: tui-snapshot-review +description: Use deterministic TUI snapshots for cmd/runecode-tui layout, wording, spacing, color, and visual polish audits. +argument-hint: "[full audit | focused area, scenario, route, or viewport]" +disable-model-invocation: true +--- + +Use this workflow when changing or reviewing `cmd/runecode-tui` UI/UX, layout, route wording, spacing, color treatment, shell chrome, overlays, or other visual polish. + +The snapshot tool now supports agent-facing audit bundles. Use bundles for audit scope selection. Use raw scenarios only when the request is narrowly scoped to one exact deterministic state. + +## Core Principles + +- Default to non-GUI review. Do not open desktop windows unless the human explicitly asks for GUI review. +- Snapshot runs clean their output directory before and after by default. Use `TUI_SNAPSHOT_KEEP=1` when artifacts must persist for inspection. +- Snapshot artifacts default to repo-local `.tui-snapshots/`. `TUI_SNAPSHOT_DIR` may override that to another allowed snapshot output directory. +- Snapshot tooling is dev/CI-only. Do not add snapshot-enabled binaries, flags, or helpers to release artifacts. +- In plan/read-only mode, do not claim a TUI audit unless a preserved snapshot manifest already exists. Missing preserved artifacts are a workflow blocker, not an audit finding. +- `python3 ./tools/tui_snapshot_review.py ...` is read-only. `just tui-snapshot-review*` recipes rebuild and regenerate snapshots first, so they are not read-only inspection commands. +- Use `justfile` recipes as the command source of truth. + +## Available Commands + +- Full local snapshot set: `just tui-snapshot-all` +- Full audit bundle: `just tui-snapshot-audit-full` +- Dashboard-focused snapshots: `just tui-snapshot-dashboard` +- Dashboard desktop and mobile snapshots: `just tui-snapshot-dashboard-multi` +- Action Center-focused snapshots: `just tui-snapshot-action-center` +- Focused audit bundles: + - `just tui-snapshot-audit-dashboard` + - `just tui-snapshot-audit-action-center` + - `just tui-snapshot-audit-runs` + - `just tui-snapshot-audit-approvals` + - `just tui-snapshot-audit-audit` + - `just tui-snapshot-audit-status` + - `just tui-snapshot-audit-setup` + - `just tui-snapshot-audit-chat` +- Fresh full-audit plus non-GUI summary: `just tui-snapshot-review` +- Fresh full-audit plus non-GUI artifact path listing: `just tui-snapshot-review-list` +- Explicit GUI review: `just tui-snapshot-review-open` +- Deterministic CI/dev validation: `just tui-snapshot-ci` +- Release safety check: `just tui-release-safety` +- Read-only preserved-artifact summary: `python3 ./tools/tui_snapshot_review.py --mode summary ./.tui-snapshots` +- Read-only preserved-artifact path listing: `python3 ./tools/tui_snapshot_review.py --mode list ./.tui-snapshots` + +## Environment Controls + +- Preserve artifacts: `TUI_SNAPSHOT_KEEP=1` +- Override output directory: `TUI_SNAPSHOT_DIR=/tmp/my-snapshots` +- Combine controls when an agent must inspect files directly: `TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR=/tmp/runecode-tui-agent-review just tui-snapshot-audit-full` + +## Mode Detection Preflight + +1. Check the current OpenCode/system context before running any snapshot command. +2. If the context says plan/read-only mode is active, treat all write-producing snapshot commands as forbidden: + - `just tui-dev-snapshot-build` + - `just tui-snapshot-audit-*` + - `just tui-snapshot-review*` +3. Do not perform a write probe to discover mode. If the context says read-only, believe it. +4. In plan/read-only mode, only inspect an already-preserved temp directory with explicit scope validation: + +```sh +python3 ./tools/tui_snapshot_review.py \ + --mode summary \ + --require-bundle full-audit \ + --require-viewport desktop \ + ./.tui-snapshots + +python3 ./tools/tui_snapshot_review.py \ + --mode list \ + --require-bundle full-audit \ + --require-viewport desktop \ + ./.tui-snapshots +``` + +5. If the preserved bundle is missing or incomplete, stop and tell the user exactly how to continue: + +```text +I’m in OpenCode plan/read-only mode, so I can’t generate the TUI snapshot bundle because that writes a snapshot binary and snapshot artifacts. + +To continue, either switch me to build mode, or run this command yourself: + +TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR=./.tui-snapshots just tui-snapshot-audit-full + +Then tell me to continue, and I’ll audit the preserved bundle with: + +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle full-audit --require-viewport desktop ./.tui-snapshots +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit --require-viewport desktop ./.tui-snapshots +``` + +6. In write-capable mode, proceed with bundle generation and preserved-artifact review normally. + +## Read-Only Or Plan Mode + +1. Do not run `just tui-dev-snapshot-build`, `just tui-snapshot-audit-*`, or `just tui-snapshot-review*` in read-only/plan mode. Those commands rebuild binaries or write snapshot artifacts. +2. Only inspect an already-preserved temp directory: + +```sh +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle full-audit ./.tui-snapshots +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit ./.tui-snapshots +``` + +3. If `manifest.json` is missing or the listed PNG/SVG artifacts are absent, report a workflow blocker. Do not convert missing artifacts into a TUI audit finding. +4. State clearly that no audit coverage can be claimed until either: + - a preserved bundle exists under an allowed snapshot dir, or + - write-producing snapshot commands are allowed again. + +## Audit Bundles And Scenarios + +Use public audit-bundle recipes first. When a focused audit needs a specific deterministic state, build the snapshot binary and invoke the helper directly. + +Agent-facing audit bundles: + +- `full-audit`: representative desktop audit coverage across Dashboard, Chat, Runs, Approvals, Action Center, Audit, Status, Model Providers, Git Setup, and Git Remote. Agents must confirm this bundle coverage in the non-GUI summary before claiming a full audit was completed. +- `dashboard-audit` +- `action-center-audit` +- `runs-audit` +- `approvals-audit` +- `audit-route-audit` +- `status-audit` +- `setup-audit` +- `chat-audit` + +Public scenario groups: + +- `all`: all deterministic scenarios +- `dashboard`: dashboard scenarios +- `action-center`: Action Center triage scenario + +Specific deterministic scenarios: + +- `dashboard-healthy-empty` +- `dashboard-approval-waiting` +- `dashboard-blocked` +- `dashboard-degraded` +- `action-center-triage` + +Viewport presets: + +- `desktop`: 160x48 +- `compact`: 120x36 +- `mobile`: 80x32 + +Focused direct helper pattern: + +```sh +just tui-dev-snapshot-build +TUI_SNAPSHOT_KEEP=1 sh ./tools/tui_snapshot_local.sh \ + --binary /tmp/runecode-current/bin/runecode-tui \ + --scenario dashboard-degraded \ + --viewport desktop \ + --review \ + --review-mode summary +``` + +## Full TUI Audit Workflow + +1. Confirm the request is a full visual audit or broad polish review for `cmd/runecode-tui`. +2. Use the full audit bundle, not the raw `all` scenario group. +3. Generate and preserve the full audit bundle for inspection: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-full +``` + +4. Produce a non-GUI summary from the preserved artifact directory: + +```sh +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle full-audit --require-viewport desktop ./.tui-snapshots +``` + +5. Print artifact paths for direct file inspection from the same preserved directory: + +```sh +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit --require-viewport desktop ./.tui-snapshots +``` + +6. Inspect the selected `.png` or `.svg` files directly with file-reading tools. Do not open GUI windows unless asked. +7. Confirm the review summary reports the expected bundle, route coverage, viewport coverage, and scenario coverage before claiming the audit is complete. +Route coverage expected today: Dashboard, Chat, Runs, Approvals, Action Center, Audit, Status, Model Providers, Git Setup, and Git Remote. +8. Review each captured route/state for route hierarchy, text density, sidebar behavior, footer/chrome weight, inspector layout, modal/overlay polish, color contrast, and raw debug-token leakage. +9. Compare desktop and mobile/compact behavior when the audit involves layout responsiveness: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-dashboard-multi +``` + +10. Record findings with bundle name, scenario name, viewport, artifact path, and concrete issue. +11. If code changes are made, regenerate the relevant bundle or focused route snapshots and compare the updated artifacts. +12. Run validation before handoff: + +```sh +just tui-snapshot-ci +``` + +13. If snapshot plumbing, build tags, release safety, or dev/CI boundaries changed, also run: + +```sh +just tui-release-safety +``` + +## Focused TUI Audit Workflow + +Use this workflow when the human asks to audit one route, state, viewport, or a small polish change. + +1. Identify the target surface. +2. Map the request to the smallest matching audit bundle first: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-dashboard +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-action-center +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-runs +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-approvals +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-audit +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-status +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-setup +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-chat +``` + +3. Only fall back to a raw scenario when the request is truly about one exact deterministic state. +4. Use non-GUI review first against the preserved output directory: + +```sh +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle dashboard-audit ./.tui-snapshots +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle dashboard-audit ./.tui-snapshots +``` + +5. Confirm the summary/list output matches the requested focused bundle or focused scenario before analyzing it. +If the user asks for a route or area without a matching bundle yet, state exactly which existing bundle or explicit scenario you are using as the nearest proxy and what route coverage it actually provides. +6. Inspect only the relevant artifact paths. Ignore unrelated scenarios. +7. Analyze the focused area for the requested concern, such as wording, spacing, overflow, truncation, color, responsive layout, or debug-token exposure. +8. If a fix is implemented, regenerate only the focused bundle or focused scenario where possible. +9. Run at least the relevant targeted verification: + +```sh +go test ./cmd/runecode-tui +just tui-snapshot-ci +``` + +## GUI Review + +Only open GUI windows when the human explicitly asks for visual desktop review. + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-open +``` + +If GUI opening fails, report the selected artifact paths from `just tui-snapshot-review-list` instead of retrying with different openers. + +## Review Checklist + +- Does the primary route communicate product state before implementation detail? +- Are degraded, blocked, or approval-required states clear without raw token spam? +- Are raw proof and debug details routed to inspectors, Status, Runs, Audit, or Action Center detail surfaces? +- Does the sidebar stay readable at the selected viewport? +- Does the footer stay calm and avoid consuming too much vertical space? +- Are modals, overlays, palette rows, and inspectors aligned and clipped correctly? +- Are colors, emphasis, and accents useful without becoming noisy? +- Does mobile or compact layout preserve the core task path? +- Do generated artifacts match the manifest and remain deterministic across reruns? + +## Reporting + +When reporting an audit, include: + +- Command(s) run +- Bundle or scenario requested +- Scenario(s) and viewport(s) reviewed +- Route coverage confirmed from manifest summary +- Artifact paths inspected +- Findings ordered by severity +- Any fixes made +- Verification results + +## Guardrails + +- Do not use `tui-snapshot-review-open` unless the human explicitly requests GUI-opened artifacts. +- Do not preserve artifacts unless inspection is needed. +- Do not commit generated snapshot artifacts. +- Do not add snapshot tooling or snapshot-enabled binaries to release artifacts. +- Do not weaken temp-root, manifest path, or release-safety checks. +- If snapshot workflow behavior changes, run both `just tui-snapshot-ci` and `just tui-release-safety`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08cffb92..c91980e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: ci-target: ci-required-shared-linux - os: macos-latest label: macos portability - ci-target: ci-portability + ci-target: ci-portability-unix env: NIX_CONFIG: | substituters = https://cache.nixos.org diff --git a/.gitignore b/.gitignore index af8f8051..04129dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,14 @@ Thumbs.db *.swp *.swo +# Python cache +__pycache__/ +*.pyc + # Local secretsd runtime state cmd/runecode-broker/.runecode-secretsd/ audit-anchor-ed25519.private audit-anchor-presence-hmac.key + +# TUI snapshot tool +.tui-snapshots/ diff --git a/AGENTS.md b/AGENTS.md index d81cfa58..165d1951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Repo bootstrap for coding agents. Read this first, then follow the linked standa - Lint: `just lint` - Test: `just test` - CI parity gate: `just ci` +- TUI visual review: use the `tui-snapshot-review` skill when changing or auditing `cmd/runecode-tui` layout, wording, spacing, color, or other visual polish. - Protocol-focused checks: - `go test ./internal/protocolschema` - `cd runner && node --test scripts/protocol-fixtures.test.js` diff --git a/README.md b/README.md index 7928c894..199af14c 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,9 @@ just model-check just model-check-core just model-check-replay just test +just tui-perf +just tui-snapshot-ci +just tui-release-safety just ci-fast just ci just ci-required-shared-linux @@ -260,7 +263,7 @@ cd runner && npm test cd runner && npm run boundary-check ``` -These checks are covered by `just ci`, while the required shared-Linux performance-contract subset runs in the dedicated `just ci-required-shared-linux` lane rather than every local `just ci` run. +`just ci-fast` includes focused TUI interaction benchmarks through `just tui-perf`. `just ci` also runs deterministic TUI snapshot validation and release-safety checks through `just tui-snapshot-ci` and `just tui-release-safety`. The required shared-Linux performance-contract subset still runs in the dedicated `just ci-required-shared-linux` lane rather than every local `just ci` run. Formal model checking entrypoint: @@ -325,6 +328,33 @@ go run ./cmd/runecode stop Bare `runecode` is the canonical `attach` path: it resolves the authoritative repository root, ensures the repo-scoped local broker lifecycle exists, and opens the TUI against that broker-owned product instance. `runecode status` is intentionally non-starting and reports either broker-owned lifecycle plus project-substrate posture or only the bootstrap-local fact that no live product instance is reachable. +When developing inside this repository, prefer the repo-local dev recipes so the product path uses the in-progress source binaries instead of any system-wide `runecode*` install on your `PATH`: + +```sh +just tui-dev +just tui-dev-restart +just tui-dev-status +just tui-dev-stop +just tui-perf +just tui-snapshot-ci +just tui-snapshot-dashboard +just tui-snapshot-action-center +just tui-snapshot-all +just tui-snapshot-audit-full +just tui-snapshot-audit-dashboard +just tui-snapshot-audit-action-center +just tui-snapshot-review +just tui-snapshot-review-shell +just tui-snapshot-review-shell-narrow-mobile +just tui-snapshot-review-open +``` + +These recipes build `runecode`, `runecode-broker`, and `runecode-tui` from the current working tree into `/tmp/runecode-current/bin`, keep Go build/temp artifacts under `/tmp/runecode-ci-cache`, and then run the canonical `runecode` lifecycle flow with that repo-local bin directory first on `PATH`. Outside the repo, installed `runecode` behavior stays unchanged. + +The `tui-snapshot-*` recipes generate deterministic TUI review artifacts under repo-local `.tui-snapshots/` by default, or under `TUI_SNAPSHOT_DIR` when you want a different allowed local output directory. Each run writes a `manifest.json` plus per-scenario `.ansi`, `.txt`, and `.svg` files, and also derives `.png` files when ImageMagick `magick`/`convert` is available locally. The audit recipes use explicit snapshot bundles such as `full-audit`, `dashboard-audit`, `action-center-audit`, route-focused audit bundles, shell-overlay bundles, and compact/mobile shell bundles so review scope is captured in the manifest rather than inferred from filenames. Use `just --list` to see the full `tui-snapshot-*` recipe set. + +The local snapshot helper cleans the target snapshot directory before it runs and, by default, cleans it again after review so repeated runs start from a known-empty directory and do not leave artifacts behind. Set `TUI_SNAPSHOT_KEEP=1` to preserve the generated files for follow-up inspection. `just tui-snapshot-review` is intentionally non-GUI by default and prints a manifest/scenario summary; use `just tui-snapshot-review-open` for the explicit GUI review path that opens generated PNGs when present, otherwise the SVGs. This remains a repo-local developer review loop and is structured so future visual-regression baselines can key off the stable scenario names in the manifest. + `runecode-tui` remains a low-level/dev entrypoint for attaching to an already running broker listener and still supports `--runtime-dir` / `--socket-name` for isolated local-dev IPC overrides. `runecode-broker` now also accepts those as broker-global options for live-IPC command surfaces such as session, approval, and external-anchor mutation commands. Low-level broker help still covers plumbing/admin surfaces such as: diff --git a/cmd/runecode-tui/command_surface.go b/cmd/runecode-tui/command_surface.go index 0de72663..b9a2fc3e 100644 --- a/cmd/runecode-tui/command_surface.go +++ b/cmd/runecode-tui/command_surface.go @@ -40,18 +40,55 @@ type paletteEntry struct { } func (m shellModel) buildPaletteEntries() []paletteEntry { + entries := make([]paletteEntry, 0, 64) + entries = append(entries, m.buildPaletteCommandEntries()...) + entries = append(entries, m.buildActionCenterPaletteEntries()...) + entries = append(entries, m.buildActiveSurfacePaletteEntries()...) + entries = append(entries, buildPaletteDiscoverabilityEntries(m.objectIndex.clone(), len(entries)+1)...) + return entries +} + +func (m shellModel) buildPaletteCommandEntries() []paletteEntry { entries := make([]paletteEntry, 0, 64) idx := 1 add := func(label, description, search string, action paletteActionMsg) { entries = append(entries, paletteEntry{Index: idx, Label: label, Description: description, Search: search, Action: action}) idx++ } - m.actions.appendPaletteEntries(add, m) - m.objectIndex.appendPaletteEntries(add) - m.appendActionCenterPaletteEntries(add) + return entries +} + +func buildPaletteDiscoverabilityEntries(idx shellDiscoverabilityIndex, startIndex int) []paletteEntry { + entries := make([]paletteEntry, 0, 64) + index := startIndex + add := func(label, description, search string, action paletteActionMsg) { + entries = append(entries, paletteEntry{Index: index, Label: label, Description: description, Search: search, Action: action}) + index++ + } + idx.appendPaletteEntries(add) + return entries +} + +func (m shellModel) buildActiveSurfacePaletteEntries() []paletteEntry { + entries := make([]paletteEntry, 0, 16) + index := 1 + add := func(label, description, search string, action paletteActionMsg) { + entries = append(entries, paletteEntry{Index: index, Label: label, Description: description, Search: search, Action: action}) + index++ + } m.appendActiveSurfaceActionEntries(add) + return entries +} +func (m shellModel) buildActionCenterPaletteEntries() []paletteEntry { + entries := make([]paletteEntry, 0, 16) + index := 1 + add := func(label, description, search string, action paletteActionMsg) { + entries = append(entries, paletteEntry{Index: index, Label: label, Description: description, Search: search, Action: action}) + index++ + } + m.appendActionCenterPaletteEntries(add) return entries } @@ -88,9 +125,9 @@ func (m shellModel) appendActionCenterPaletteEntries(add func(string, string, st if !ok { return } - for family, items := range actionModel.familyBuckets() { + for family, items := range actionModel.snapshot().Families { for _, item := range items { - if strings.TrimSpace(item.Title) == "" || strings.TrimSpace(item.Detail) == "" { + if strings.TrimSpace(item.Title) == "" || strings.TrimSpace(item.Reason) == "" { continue } target := item.Target @@ -100,7 +137,7 @@ func (m shellModel) appendActionCenterPaletteEntries(add func(string, string, st add( fmt.Sprintf("triage %s %s", family, item.Title), fmt.Sprintf("urgency=%s impact=%s", valueOrNA(item.Urgency), valueOrNA(item.Impact)), - fmt.Sprintf("triage action center %s %s %s %s %s", family, item.Title, item.Detail, item.Impact, item.ExpiryCue), + fmt.Sprintf("triage action center %s %s %s %s %s", family, item.Title, item.Reason, item.Impact, item.EvidenceCue), paletteActionMsg{Verb: verbJump, Target: target}, ) } diff --git a/cmd/runecode-tui/help.go b/cmd/runecode-tui/help.go index 3aa8e71e..a298d90f 100644 --- a/cmd/runecode-tui/help.go +++ b/cmd/runecode-tui/help.go @@ -1,11 +1,6 @@ package main -import ( - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" -) +import "github.com/charmbracelet/bubbles/key" type shellHelpKeys []key.Binding @@ -13,16 +8,7 @@ func (k shellHelpKeys) ShortHelp() []key.Binding { return []key.Binding(k) } func (k shellHelpKeys) FullHelp() [][]key.Binding { return [][]key.Binding{[]key.Binding(k)} } func renderHelp(keys shellKeyMap, paletteOpen bool, actions shellActionGraph) string { - bindings := keys.helpBindings(paletteOpen) - bubbleBindings := make([]key.Binding, 0, len(bindings)) - for _, b := range bindings { - bubbleBindings = append(bubbleBindings, key.NewBinding(key.WithKeys(b.Keys...), key.WithHelp(b.label(), b.Description))) - } - h := help.New() - h.ShowAll = false - view := "Help: " + h.View(shellHelpKeys(bubbleBindings)) - if entries := actions.helpEntries(6); len(entries) > 0 { - view += " | Actions: " + strings.Join(entries, " · ") - } - return view + _ = paletteOpen + _ = actions + return keyHint(keys.LeaderStart.label() + " leader • ctrl+p commands • ctrl+j sessions • tab focus") } diff --git a/cmd/runecode-tui/help_test.go b/cmd/runecode-tui/help_test.go index a0fa92d1..15c1c1ec 100644 --- a/cmd/runecode-tui/help_test.go +++ b/cmd/runecode-tui/help_test.go @@ -9,12 +9,28 @@ import ( func TestHelpUsesBaseBindingsForLeaderOverlay(t *testing.T) { m := newShellModel() + m.width = 150 + m.height = 40 help := renderHelp(m.keys, false, m.actions) + if !strings.Contains(help, "ctrl+p commands") || !strings.Contains(help, "ctrl+j sessions") { + t.Fatalf("expected concise footer discovery help, got %q", help) + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + v := updated.(shellModel).View() + for _, want := range []string{"Leader Mode", "Help:", "ctrl+p opens quick jump palette", "ctrl+j opens session quick switcher"} { + if !strings.Contains(v, want) { + t.Fatalf("expected leader overlay help cue %q, got %q", want, v) + } + } + if strings.Contains(v, "ctrl+n") || strings.Contains(v, "Open selected match") { + t.Fatalf("expected leader overlay help not to advertise palette/session overlay bindings, got %q", v) + } if strings.Contains(help, "ctrl+n") || strings.Contains(help, "Open selected match") { t.Fatalf("expected leader/help footer not to advertise palette/session overlay bindings, got %q", help) } - if !strings.Contains(help, "ctrl+p") || !strings.Contains(help, "ctrl+j") { - t.Fatalf("expected base shell help bindings to remain visible, got %q", help) + if !strings.Contains(v, "tab next focus area") || !strings.Contains(v, "shift+tab previous focus area") { + t.Fatalf("expected base shell focus bindings to move into leader root help, got %q", v) } } @@ -28,7 +44,19 @@ func TestShellViewLeaderOverlayKeepsBaseFooterHelp(t *testing.T) { if !strings.Contains(v, "Leader Mode") { t.Fatalf("expected leader overlay in view, got %q", v) } + if !strings.Contains(v, "Help:") { + t.Fatalf("expected leader overlay to carry relocated help copy, got %q", v) + } if strings.Contains(v, "ctrl+n") || strings.Contains(v, "Open selected match") { t.Fatalf("expected footer help under leader overlay not to switch into palette/session bindings, got %q", v) } } + +func TestHelpFooterStaysCompactWhilePreservingDiscovery(t *testing.T) { + m := newShellModel() + help := renderHelp(m.keys, false, m.actions) + mustContainAll(t, help, "leader", "ctrl+p commands", "ctrl+j sessions", "tab focus") + if strings.Contains(help, "ctrl+n") || strings.Contains(help, "Open selected match") { + t.Fatalf("expected compact footer help without exhaustive overlay bindings, got %q", help) + } +} diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 475e63bf..85624299 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -216,43 +216,58 @@ func mustLatestSealDigestForTUILocalRPCProbe(t *testing.T, service *brokerapi.Se func assertTUIBrokerBackedRoutes(t *testing.T) { t.Helper() recording := newRecordingBrokerClient(&rpcBrokerClient{}) + assertDashboardAndChatBackedRoutes(t, recording) + assertWorkAndSetupBackedRoutes(t, recording) + if !containsCall(recording.Calls(), "ArtifactRead") || !containsCall(recording.Calls(), "AuditVerificationGet") || !containsCall(recording.Calls(), "AuditRecordGet") { + t.Fatalf("expected broker-backed route calls, got %v", recording.Calls()) + } +} + +func assertDashboardAndChatBackedRoutes(t *testing.T, recording localBrokerClient) { + t.Helper() dashboard := newDashboardRouteModel(routeDefinition{ID: routeDashboard, Label: "Dashboard"}, recording) assertRouteOutputContainsAll(t, dashboard, routeDashboard, - "Now", - "Safety strip", - "backend_kind=unknown", - "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", - "feed: waiting for shell watch manager", + "Current work", + "At a glance", + "Approvals:", + "Next action", + "Evidence: Runs, Audit, and Status keep proof details.", ) chat := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, recording) assertRouteOutputContainsAll(t, chat, routeChat, - "Sessions: 1 active=session-tui", - "Composer: idle", + "Active session", + "Canonical session session-tui in workspace workspace-local is active.", + "Composer is idle.", ) assertRouteInspectorContainsAll(t, chat, routeChat, "Inspector", "Linked runs: run-tui", ) +} + +func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { + t.Helper() runs := newRunsRouteModel(routeDefinition{ID: routeRuns, Label: "Runs"}, recording) assertRouteOutputContainsAll(t, runs, routeRuns, - "backend_kind=unknown", + "Run overview", + "Safety and evidence", ) assertRouteInspectorContainsAll(t, runs, routeRuns, - "Authoritative broker state (control-plane truth):", - "Coordination summary:", + "Workflow operation:", + "Evidence links:", ) approvals := newApprovalsRouteModel(routeDefinition{ID: routeApprovals, Label: "Approvals"}, recording) assertRouteOutputContainsAll(t, approvals, routeApprovals, - "Approval safety strip", + "Approval review", + "Decision workbench", ) assertRouteInspectorContainsAll(t, approvals, routeApprovals, - "Approval trigger code:", - "Canonical bound identity:", + "Why this approval exists:", + "What is gated:", ) artifacts := newArtifactsRouteModel(routeDefinition{ID: routeArtifacts, Label: "Artifacts"}, recording) @@ -263,15 +278,11 @@ func assertTUIBrokerBackedRoutes(t *testing.T) { status := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, recording) assertRouteOutputContainsAll(t, status, routeStatus, - "Runtime/audit readiness strip", - "Broker ready=true local_only=true", - "Protocol posture:", - "Project substrate posture:", + "System health", + "Overview:", + "Version:", + "Project setup", ) - - if !containsCall(recording.Calls(), "ArtifactRead") || !containsCall(recording.Calls(), "AuditVerificationGet") || !containsCall(recording.Calls(), "AuditRecordGet") { - t.Fatalf("expected broker-backed route calls, got %v", recording.Calls()) - } } func assertRouteInspectorContainsAll(t *testing.T, model routeModel, id routeID, want ...string) { @@ -320,7 +331,7 @@ func assertArtifactsRouteRedactsDiffContent(t *testing.T, model routeModel) { } updated, _ = updated.Update(cmd()) view := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body - for _, needle := range []string{"Typed detail mode:", "diff content unavailable: broker_limit_policy_rejected"} { + for _, needle := range []string{"Detail mode:", "diff content unavailable: broker_limit_policy_rejected"} { if !strings.Contains(view, needle) { t.Fatalf("artifacts view missing %q: %s", needle, view) } @@ -335,13 +346,13 @@ func assertAuditRouteSupportsDrillDown(t *testing.T, model routeModel) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, needle := range []string{"Audit safety strip", "Timeline paging: page=1 entries=1 has_next=no", "Verification posture:"} { + for _, needle := range []string{"Audit health", "Timeline: page 1 • 1 records • no more pages", "Audit summary:"} { if !strings.Contains(view, needle) { t.Fatalf("audit view missing %q: %s", needle, view) } } - if !strings.Contains(view, "Verification findings:") && !strings.Contains(view, "Verification findings (machine-readable):") { - t.Fatalf("audit view missing verification findings section: %s", view) + if !strings.Contains(view, "Findings to review:") { + t.Fatalf("audit view missing findings section: %s", view) } updated, cmd = updated.Update(teaKey("enter")) if cmd == nil { @@ -349,7 +360,7 @@ func assertAuditRouteSupportsDrillDown(t *testing.T, model routeModel) { } updated, _ = updated.Update(cmd()) view = updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body - for _, needle := range []string{"Record family:", "Verification posture:", "Linked references:"} { + for _, needle := range []string{"Record family:", "Audit health:", "Linked references:"} { if !strings.Contains(view, needle) { t.Fatalf("audit drill-down view missing %q: %s", needle, view) } diff --git a/cmd/runecode-tui/main.go b/cmd/runecode-tui/main.go index f74e6e66..79c86fca 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -2,10 +2,12 @@ package main import ( + "errors" "flag" "fmt" "io" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" @@ -15,39 +17,87 @@ type usageError struct{ message string } func (e *usageError) Error() string { return e.message } +func usageErrorf(format string, args ...any) error { + return &usageError{message: fmt.Sprintf(format, args...)} +} + +func isUsageError(err error) bool { + var usageErr *usageError + return errors.As(err, &usageErr) +} + +var ( + newShellModelFunc = newShellModel + isTerminalFunc = term.IsTerminal + runShellProgram = func(model shellModel) (shellModel, error) { + p := tea.NewProgram(model, tea.WithAltScreen()) + finalModel, err := p.Run() + if finalShell, ok := finalModel.(shellModel); ok { + return finalShell, err + } + return model, err + } +) + func main() { - args := os.Args[1:] + os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func runMain(args []string, stdin *os.File, stdout *os.File, stderr io.Writer) int { cfg, err := parseCLIConfig(args) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) + fmt.Fprintln(stderr, err) + return 2 } if cfg.showHelp { - if err := writeHelp(os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, "runecode-tui failed to write help: %v\n", err) - os.Exit(1) + if err := writeHelp(stdout); err != nil { + fmt.Fprintf(stderr, "runecode-tui failed to write help: %v\n", err) + return 1 } + return 0 + } - return + if cfg.snapshot.enabled { + return runSnapshotMode(cfg.snapshot, stderr) } - if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { - if err := writeNonInteractiveMessage(os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, "runecode-tui failed to write output: %v\n", err) - os.Exit(1) + if !isTerminalFunc(int(stdin.Fd())) || !isTerminalFunc(int(stdout.Fd())) { + if err := writeNonInteractiveMessage(stdout); err != nil { + fmt.Fprintf(stderr, "runecode-tui failed to write output: %v\n", err) + return 1 } - - return + return 0 } setCLIIPCConfigOverrides(cfg) + model := newShellModelFunc() + + finalModel, err := runShellProgram(model) + finalModel.flushWorkbenchState() + if err == nil { + if flushErr := finalModel.workbenchFlushError(); flushErr != nil { + fmt.Fprintf(stderr, "runecode-tui failed: %s\n", safeWorkbenchPersistenceErrorText(flushErr)) + return 1 + } + } + if err != nil { + fmt.Fprintf(stderr, "runecode-tui failed: %v\n", err) + return 1 + } + return 0 +} - p := tea.NewProgram(newShellModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "runecode-tui failed: %v\n", err) - os.Exit(1) +func runSnapshotMode(cfg tuiSnapshotConfig, stderr io.Writer) int { + if err := writeSnapshotArtifacts(cfg); err != nil { + if isUsageError(err) { + fmt.Fprintln(stderr, err) + return 2 + } + fmt.Fprintf(stderr, "runecode-tui snapshot failed: %v\n", err) + return 1 } + return 0 } func isHelpArg(arg string) bool { @@ -63,6 +113,7 @@ type tuiCLIConfig struct { showHelp bool runtimeDir string socketName string + snapshot tuiSnapshotConfig } func parseCLIConfig(args []string) (tuiCLIConfig, error) { @@ -71,15 +122,73 @@ func parseCLIConfig(args []string) (tuiCLIConfig, error) { } fs := flag.NewFlagSet("runecode-tui", flag.ContinueOnError) fs.SetOutput(io.Discard) + showHelp := fs.Bool("help", false, "show help") + showHelpShort := fs.Bool("h", false, "show help") runtimeDir := fs.String("runtime-dir", "", "broker local IPC runtime directory override") socketName := fs.String("socket-name", "", "broker local IPC socket filename override") + snapshotFlags := registerSnapshotFlags(fs) + if containsStandaloneHelpFlag(args, fs) { + return tuiCLIConfig{showHelp: true}, nil + } if err := fs.Parse(args); err != nil { return tuiCLIConfig{}, &usageError{message: "runecode-tui usage: runecode-tui [--runtime-dir dir] [--socket-name broker.sock] [--help]"} } + if *showHelp || *showHelpShort { + return tuiCLIConfig{showHelp: true}, nil + } if len(fs.Args()) > 0 { return tuiCLIConfig{}, &usageError{message: "runecode-tui accepts no positional arguments; use --help for usage"} } - return tuiCLIConfig{runtimeDir: *runtimeDir, socketName: *socketName}, nil + cfg := tuiCLIConfig{runtimeDir: *runtimeDir, socketName: *socketName} + applySnapshotCLIConfig(&cfg, snapshotFlags) + return cfg, nil +} + +func containsStandaloneHelpFlag(args []string, fs *flag.FlagSet) bool { + for i := 0; i < len(args); i++ { + switch args[i] { + case "-h", "-help", "--help": + return true + case "--": + return false + } + + name, hasValue, ok := parseFlagToken(args[i]) + if !ok || hasValue { + continue + } + + f := fs.Lookup(name) + if f == nil || !flagConsumesValue(f) { + continue + } + + i++ + } + + return false +} + +func parseFlagToken(arg string) (name string, hasValue bool, ok bool) { + if arg == "" || arg == "-" || arg == "--" || !strings.HasPrefix(arg, "-") { + return "", false, false + } + + trimmed := strings.TrimLeft(arg, "-") + if trimmed == "" { + return "", false, false + } + + if idx := strings.Index(trimmed, "="); idx >= 0 { + return trimmed[:idx], true, true + } + + return trimmed, false, true +} + +func flagConsumesValue(f *flag.Flag) bool { + boolValue, ok := f.Value.(interface{ IsBoolFlag() bool }) + return !ok || !boolValue.IsBoolFlag() } func setCLIIPCConfigOverrides(cfg tuiCLIConfig) { diff --git a/cmd/runecode-tui/main_test.go b/cmd/runecode-tui/main_test.go index 1e463974..3e1d0b56 100644 --- a/cmd/runecode-tui/main_test.go +++ b/cmd/runecode-tui/main_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "fmt" + "os" "strings" "testing" ) @@ -47,6 +49,22 @@ func TestParseCLIConfigParsesIPCOverrides(t *testing.T) { } } +func TestParseCLIConfigShowsHelpWhenCombinedWithOtherFlags(t *testing.T) { + for _, args := range [][]string{ + {"--help", "--runtime-dir", "/tmp/runtime"}, + {"--runtime-dir", "/tmp/runtime", "--help"}, + {"--help", "--verbose"}, + } { + cfg, err := parseCLIConfig(args) + if err != nil { + t.Fatalf("parseCLIConfig(%v) returned error: %v", args, err) + } + if !cfg.showHelp { + t.Fatalf("parseCLIConfig(%v) cfg = %+v, want showHelp", args, cfg) + } + } +} + func TestWriteNonInteractiveMessageIncludesBrokerRemediation(t *testing.T) { var out bytes.Buffer if err := writeNonInteractiveMessage(&out); err != nil { @@ -63,3 +81,68 @@ func TestWriteNonInteractiveMessageIncludesBrokerRemediation(t *testing.T) { } } } + +type flushTrackingWorkbenchStore struct { + memoryWorkbenchStateStore + flushed bool + err error +} + +func (s *flushTrackingWorkbenchStore) Flush() { + s.flushed = true +} + +func (s *flushTrackingWorkbenchStore) LastError() error { + return s.err +} + +func TestRunMainFlushesWorkbenchStateBeforeExit(t *testing.T) { + store := &flushTrackingWorkbenchStore{} + origModel := newShellModelFunc + origTerm := isTerminalFunc + origRunner := runShellProgram + newShellModelFunc = func() shellModel { return newShellModelWithWorkbenchStore(store) } + isTerminalFunc = func(int) bool { return true } + runShellProgram = func(model shellModel) (shellModel, error) { return model, nil } + defer func() { + newShellModelFunc = origModel + isTerminalFunc = origTerm + runShellProgram = origRunner + }() + + var stderr bytes.Buffer + code := runMain(nil, os.Stdin, os.Stdout, &stderr) + if code != 0 { + t.Fatalf("runMain() exit code = %d stderr=%q", code, stderr.String()) + } + if !store.flushed { + t.Fatal("expected runMain to flush workbench state before exit") + } +} + +func TestRunMainReturnsFailureWhenWorkbenchFlushFails(t *testing.T) { + store := &flushTrackingWorkbenchStore{err: fmt.Errorf("open /home/user/.config/runecode/workbench.json: permission denied")} + origModel := newShellModelFunc + origTerm := isTerminalFunc + origRunner := runShellProgram + newShellModelFunc = func() shellModel { return newShellModelWithWorkbenchStore(store) } + isTerminalFunc = func(int) bool { return true } + runShellProgram = func(model shellModel) (shellModel, error) { return model, nil } + defer func() { + newShellModelFunc = origModel + isTerminalFunc = origTerm + runShellProgram = origRunner + }() + + var stderr bytes.Buffer + code := runMain(nil, os.Stdin, os.Stdout, &stderr) + if code != 1 { + t.Fatalf("runMain() exit code = %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "runecode-tui failed") { + t.Fatalf("expected flush failure on stderr, got %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "local path redacted") || strings.Contains(stderr.String(), "/home/user") { + t.Fatalf("expected path-sanitized flush failure on stderr, got %q", stderr.String()) + } +} diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index 8cd311aa..916e9064 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -8,36 +8,63 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +type shellPaletteEntriesLoadedMsg struct { + request uint64 + entries []paletteEntry +} + +type shellPaletteFilterLoadedMsg struct { + request uint64 + entriesVersion uint64 + needle string + matches []int +} + type paletteModel struct { - open bool - query string - selectedIndex int - matches []paletteEntry - entries []paletteEntry + open bool + query string + appliedNeedle string + selectedIndex int + matchIndexes []int + entries []paletteEntry + normalizedEntries []string + entriesVersion uint64 + entriesRequest uint64 + filterRequest uint64 + entriesLoading bool + filterLoading bool } func newPaletteModel(entries []paletteEntry) paletteModel { - m := paletteModel{entries: entries} - m.rebuildMatches() - return m + return paletteModel{}.UpdateEntries(entries) } func (m paletteModel) UpdateEntries(entries []paletteEntry) paletteModel { m.entries = append([]paletteEntry(nil), entries...) - m.rebuildMatches() + m.normalizedEntries = buildPaletteNormalizedEntries(m.entries) + m.entriesVersion++ + m.entriesLoading = false + m.filterLoading = false + m.appliedNeedle = "" + m.selectedIndex = 0 + m.matchIndexes = buildPaletteFullIndexes(m.matchIndexes[:0], len(m.entries)) return m } func (m paletteModel) Open() paletteModel { m.open = true m.query = "" + m.appliedNeedle = "" m.selectedIndex = 0 - m.rebuildMatches() + m.filterLoading = false + m.matchIndexes = buildPaletteFullIndexes(m.matchIndexes[:0], len(m.entries)) return m } func (m paletteModel) Close() paletteModel { m.open = false + m.entriesLoading = false + m.filterLoading = false return m } @@ -45,17 +72,97 @@ func (m paletteModel) IsOpen() bool { return m.open } +func (m paletteModel) MatchCount() int { + return len(m.matchIndexes) +} + +func (m paletteModel) MatchEntry(index int) (paletteEntry, bool) { + if index < 0 || index >= len(m.matchIndexes) { + return paletteEntry{}, false + } + entryIndex := m.matchIndexes[index] + if entryIndex < 0 || entryIndex >= len(m.entries) { + return paletteEntry{}, false + } + return m.entries[entryIndex], true +} + func (m paletteModel) SelectedEntry() (paletteEntry, bool) { - if len(m.matches) == 0 { + if len(m.matchIndexes) == 0 { return paletteEntry{}, false } - if m.selectedIndex < 0 { - m.selectedIndex = 0 + selected := m.selectedIndex + if selected < 0 { + selected = 0 } - if m.selectedIndex >= len(m.matches) { - m.selectedIndex = len(m.matches) - 1 + if selected >= len(m.matchIndexes) { + selected = len(m.matchIndexes) - 1 } - return m.matches[m.selectedIndex], true + return m.MatchEntry(selected) +} + +func (m paletteModel) BeginEntriesRefresh() (paletteModel, uint64) { + m.entriesRequest++ + m.entriesLoading = true + return m, m.entriesRequest +} + +func (m paletteModel) ApplyEntriesRefresh(msg shellPaletteEntriesLoadedMsg) (paletteModel, bool) { + if msg.request != m.entriesRequest { + return m, false + } + m = m.UpdateEntries(msg.entries) + return m, true +} + +func (m paletteModel) BeginFilterRefresh() (paletteModel, uint64, bool) { + needle := normalizePaletteQuery(m.query) + if needle == "" { + m.filterRequest++ + m.filterLoading = false + m.appliedNeedle = "" + m.matchIndexes = buildPaletteFullIndexes(m.matchIndexes[:0], len(m.entries)) + return m, 0, false + } + m.filterRequest++ + m.filterLoading = true + return m, m.filterRequest, true +} + +func (m paletteModel) ApplyFilterRefresh(msg shellPaletteFilterLoadedMsg) (paletteModel, bool) { + if msg.request != m.filterRequest || msg.entriesVersion != m.entriesVersion { + return m, false + } + if msg.needle != normalizePaletteQuery(m.query) { + return m, false + } + m.filterLoading = false + m.appliedNeedle = msg.needle + m.matchIndexes = append(m.matchIndexes[:0], msg.matches...) + m.clampSelectedIndex() + return m, true +} + +func (m paletteModel) AppendQuery(value string) paletteModel { + if strings.TrimSpace(value) == "" { + return m + } + m.query += value + m.selectedIndex = 0 + return m +} + +func (m paletteModel) DeleteQueryRune() paletteModel { + if len(m.query) == 0 { + return m + } + _, size := utf8.DecodeLastRuneInString(m.query) + if size <= 0 { + return m + } + m.query = m.query[:len(m.query)-size] + m.selectedIndex = 0 + return m } func (m paletteModel) Update(msg tea.Msg, keys shellKeyMap) (paletteModel, paletteActionMsg, bool) { @@ -73,10 +180,7 @@ func (m paletteModel) UpdateMouse(msg tea.MouseMsg, paletteStartY int, layoutWid if !m.open { return m, paletteActionMsg{}, false } - if msg.Button != tea.MouseButtonLeft { - return m, paletteActionMsg{}, false - } - if msg.Action != tea.MouseActionRelease { + if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionRelease { return m, paletteActionMsg{}, false } index, ok := m.matchIndexAtPosition(msg.X, msg.Y, paletteStartY, layoutWidth) @@ -98,11 +202,9 @@ func (m paletteModel) updateKey(key tea.KeyMsg, keys shellKeyMap) (paletteModel, case keys.PalettePrev.matches(key): return m.stepSelection(-1), paletteActionMsg{}, false case key.Type == tea.KeyBackspace || key.Type == tea.KeyDelete: - return m.deleteQueryRune(), paletteActionMsg{}, false + return m.DeleteQueryRune(), paletteActionMsg{}, false case isTypingKey(key): - m.query += key.String() - m.rebuildMatches() - return m, paletteActionMsg{}, false + return m.AppendQuery(key.String()), paletteActionMsg{}, false default: return m, paletteActionMsg{}, false } @@ -117,56 +219,103 @@ func (m paletteModel) pickRoute() (paletteModel, paletteActionMsg, bool) { } func (m paletteModel) stepSelection(delta int) paletteModel { - if len(m.matches) == 0 { + if len(m.matchIndexes) == 0 { return m } if delta > 0 { - m.selectedIndex = (m.selectedIndex + 1) % len(m.matches) + m.selectedIndex = (m.selectedIndex + 1) % len(m.matchIndexes) return m } m.selectedIndex-- if m.selectedIndex < 0 { - m.selectedIndex = len(m.matches) - 1 + m.selectedIndex = len(m.matchIndexes) - 1 } return m } -func (m paletteModel) deleteQueryRune() paletteModel { - if len(m.query) == 0 { - return m +func (m *paletteModel) clampSelectedIndex() { + if m.selectedIndex >= len(m.matchIndexes) { + if len(m.matchIndexes) == 0 { + m.selectedIndex = 0 + } else { + m.selectedIndex = len(m.matchIndexes) - 1 + } } - _, size := utf8.DecodeLastRuneInString(m.query) - if size <= 0 { - return m + if m.selectedIndex < 0 { + m.selectedIndex = 0 } - m.query = m.query[:len(m.query)-size] - m.rebuildMatches() - return m } -func (m *paletteModel) rebuildMatches() { - needle := strings.ToLower(strings.TrimSpace(m.query)) - m.matches = m.matches[:0:0] +func buildPaletteFilterResult(request uint64, entriesVersion uint64, query string, normalizedEntries []string, priorNeedle string, priorMatches []int) shellPaletteFilterLoadedMsg { + needle := normalizePaletteQuery(query) + searchSpace := paletteFilterSearchSpace(needle, priorNeedle, priorMatches, len(normalizedEntries)) + return shellPaletteFilterLoadedMsg{ + request: request, + entriesVersion: entriesVersion, + needle: needle, + matches: filterPaletteMatchIndexes(needle, normalizedEntries, searchSpace), + } +} + +func paletteFilterSearchSpace(needle string, priorNeedle string, priorMatches []int, total int) []int { + if priorNeedle != "" && strings.HasPrefix(needle, priorNeedle) && len(priorMatches) > 0 { + return append([]int(nil), priorMatches...) + } + return buildPaletteFullIndexes(make([]int, 0, total), total) +} + +func filterPaletteMatchIndexes(needle string, normalizedEntries []string, searchSpace []int) []int { if needle == "" { - m.matches = append(m.matches, m.entries...) - } else { - for _, r := range m.entries { - if strings.Contains(strings.ToLower(r.Label), needle) || strings.Contains(strings.ToLower(r.Description), needle) || strings.Contains(strings.ToLower(r.Search), needle) { - m.matches = append(m.matches, r) - } - } + return append([]int(nil), searchSpace...) } - if m.selectedIndex >= len(m.matches) { - if len(m.matches) == 0 { - m.selectedIndex = 0 - } else { - m.selectedIndex = len(m.matches) - 1 + matches := make([]int, 0, len(searchSpace)) + for _, i := range searchSpace { + if i < 0 || i >= len(normalizedEntries) { + continue + } + if strings.Contains(normalizedEntries[i], needle) { + matches = append(matches, i) } } + return matches +} + +func buildPaletteFullIndexes(dst []int, count int) []int { + dst = dst[:0] + for i := 0; i < count; i++ { + dst = append(dst, i) + } + return dst +} + +func normalizePaletteQuery(query string) string { + return strings.ToLower(strings.TrimSpace(query)) +} + +func buildPaletteNormalizedEntries(entries []paletteEntry) []string { + if len(entries) == 0 { + return nil + } + normalized := make([]string, len(entries)) + for i, entry := range entries { + normalized[i] = normalizePaletteEntrySearch(entry) + } + return normalized +} + +func normalizePaletteEntrySearch(entry paletteEntry) string { + var b strings.Builder + b.Grow(len(entry.Label) + len(entry.Description) + len(entry.Search) + 2) + b.WriteString(strings.TrimSpace(entry.Label)) + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(entry.Description)) + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(entry.Search)) + return strings.ToLower(b.String()) } func (m paletteModel) matchIndexAtPosition(x int, y int, paletteStartY int, layoutWidth int) (int, bool) { - if len(m.matches) == 0 { + if len(m.matchIndexes) == 0 { return 0, false } startX, endX := centeredOverlayContentBounds(layoutWidth) @@ -176,7 +325,7 @@ func (m paletteModel) matchIndexAtPosition(x int, y int, paletteStartY int, layo startY := paletteStartY + 5 for _, candidateY := range []int{y, y - 1} { index := candidateY - startY - if index >= 0 && index < len(m.matches) { + if index >= 0 && index < len(m.matchIndexes) { return index, true } } @@ -196,6 +345,59 @@ func paletteMatchLine(entry paletteEntry, selected bool) string { return line } +func paletteMatchLineBounded(entry paletteEntry, width int) string { + label := strings.TrimSpace(entry.Label) + description := strings.TrimSpace(entry.Description) + kind := paletteEntryShortcut(entry) + if width <= 0 { + if description == "" { + return label + } + return label + " " + muted(description) + } + parts := []string{label} + if description != "" { + parts = append(parts, description) + } + if kind != "" { + parts = append(parts, "["+kind+"]") + } + return clipDisplayText(strings.Join(parts, " "), width) +} + +func paletteEntryShortcut(entry paletteEntry) string { + if strings.TrimSpace(entry.Action.Target.CommandID) != "" { + return "Cmd" + } + if route := strings.TrimSpace(string(entry.Action.Target.RouteID)); route != "" { + return "Route" + } + if strings.TrimSpace(entry.Action.Target.SessionID) != "" { + return "Session" + } + if strings.TrimSpace(entry.Action.Target.RunID) != "" { + return "Run" + } + if strings.TrimSpace(entry.Action.Target.ApprovalID) != "" { + return "Approval" + } + if strings.TrimSpace(entry.Action.Target.Digest) != "" { + return "Evidence" + } + if kind := strings.TrimSpace(entry.Action.Target.Kind); kind != "" { + return kind + } + return string(entry.Action.Verb) +} + +func paletteSearchText(query string) string { + query = strings.TrimSpace(query) + if query == "" { + return muted("type to filter commands, routes, sessions, and evidence") + } + return query +} + func isTypingKey(msg tea.KeyMsg) bool { if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { r := msg.Runes[0] diff --git a/cmd/runecode-tui/palette_test.go b/cmd/runecode-tui/palette_test.go index e5773c5d..9a951125 100644 --- a/cmd/runecode-tui/palette_test.go +++ b/cmd/runecode-tui/palette_test.go @@ -12,12 +12,12 @@ func TestPaletteDeleteQueryRunePreservesUTF8(t *testing.T) { m.open = true m.query = "Goλ" - m = m.deleteQueryRune() + m = m.DeleteQueryRune() if m.query != "Go" { t.Fatalf("expected UTF-8-safe delete to keep valid string, got %q", m.query) } - m = m.deleteQueryRune() + m = m.DeleteQueryRune() if m.query != "G" { t.Fatalf("expected second delete to remove one rune, got %q", m.query) } @@ -103,3 +103,108 @@ func keyMsg(key string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} } } + +func TestPaletteFilteringMatchesAcrossCachedNormalizedFields(t *testing.T) { + entries := []paletteEntry{ + {Index: 1, Label: "jump route chat", Description: "open workspace home", Search: "session-alpha ws-home"}, + {Index: 2, Label: "inspect session beta", Description: "follow-up needed", Search: "workspace-2 run-2"}, + } + m := newPaletteModel(entries).Open() + if got := len(m.normalizedEntries); got != len(entries) { + t.Fatalf("expected %d cached normalized entries, got %d", len(entries), got) + } + + m.query = "WORKSPACE-2" + updated, request, ok := m.BeginFilterRefresh() + if !ok { + t.Fatal("expected filter refresh request for workspace query") + } + m, _ = updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)) + if m.MatchCount() != 1 { + t.Fatalf("expected one workspace match, got %d", m.MatchCount()) + } + matched, ok := m.MatchEntry(0) + if !ok || matched.Index != 2 { + t.Fatalf("expected search text match for second entry, got %+v ok=%v", matched, ok) + } + + m.query = "FOLLOW-UP" + updated, request, ok = m.BeginFilterRefresh() + if !ok { + t.Fatal("expected filter refresh request for description query") + } + m, _ = updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)) + matched, ok = m.MatchEntry(0) + if m.MatchCount() != 1 || !ok || matched.Index != 2 { + t.Fatalf("expected description match for second entry, got %+v count=%d ok=%v", matched, m.MatchCount(), ok) + } +} + +func TestPaletteIncrementalFilteringNarrowsFromPreviousMatches(t *testing.T) { + entries := []paletteEntry{ + {Index: 1, Label: "copy current identity", Description: "copy route identity", Search: "copy identity route"}, + {Index: 2, Label: "copy next route action", Description: "copy route action", Search: "copy next action"}, + {Index: 3, Label: "open runs", Description: "jump to runs", Search: "open runs"}, + } + m := newPaletteModel(entries).Open() + m = m.AppendQuery("c") + updated, request, ok := m.BeginFilterRefresh() + if !ok { + t.Fatal("expected initial filter refresh request") + } + m, _ = updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)) + if m.MatchCount() != 2 { + t.Fatalf("expected broad initial copy matches, got %d", m.MatchCount()) + } + m = m.AppendQuery("o") + updated, request, ok = m.BeginFilterRefresh() + if !ok { + t.Fatal("expected second filter refresh request") + } + m, _ = updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)) + if m.MatchCount() != 2 { + t.Fatalf("expected incremental filter to retain two copy matches, got %d", m.MatchCount()) + } + m = m.AppendQuery("p") + updated, request, ok = m.BeginFilterRefresh() + if !ok { + t.Fatal("expected third filter refresh request") + } + m, _ = updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)) + if m.MatchCount() != 2 { + t.Fatalf("expected copy matches after third rune, got %d", m.MatchCount()) + } +} + +func TestPaletteFilterRefreshRejectsStaleResultAfterQueryClears(t *testing.T) { + entries := []paletteEntry{ + {Index: 1, Label: "open chat", Search: "chat"}, + {Index: 2, Label: "open runs", Search: "runs"}, + } + m := newPaletteModel(entries).Open().AppendQuery("runs") + filtering, request, ok := m.BeginFilterRefresh() + if !ok { + t.Fatal("expected non-empty query to request async filtering") + } + delayed := buildPaletteFilterResult(request, filtering.entriesVersion, filtering.query, filtering.normalizedEntries, filtering.appliedNeedle, filtering.matchIndexes) + + cleared := filtering + for cleared.query != "" { + cleared = cleared.DeleteQueryRune() + } + cleared, _, ok = cleared.BeginFilterRefresh() + if ok { + t.Fatal("expected empty query to apply full match set synchronously") + } + if cleared.MatchCount() != len(entries) { + t.Fatalf("expected full match set after clearing query, got %d", cleared.MatchCount()) + } + + updated, applied := cleared.ApplyFilterRefresh(delayed) + if applied { + t.Fatal("expected stale delayed filter result to be rejected") + } + if updated.MatchCount() != len(entries) { + t.Fatalf("expected stale filter result not to change matches, got %d", updated.MatchCount()) + } +} diff --git a/cmd/runecode-tui/route_action_center.go b/cmd/runecode-tui/route_action_center.go index eafe13a5..be2e0fb5 100644 --- a/cmd/runecode-tui/route_action_center.go +++ b/cmd/runecode-tui/route_action_center.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "strings" "time" @@ -12,7 +11,9 @@ import ( type actionCenterLoadedMsg struct { approvals []brokerapi.ApprovalSummary runs []brokerapi.RunSummary + project brokerapi.ProjectSubstratePostureGetResponse audit brokerapi.AuditVerificationGetResponse + auditErr string err error seq uint64 } @@ -27,24 +28,35 @@ const ( ) type actionCenterItem struct { - Title string - Detail string - Urgency string - ExpiryCue string - StaleCue string - Impact string - Target paletteTarget + Title string + State routeLoadState + Urgency string + Reason string + Impact string + Owner string + RequiredAction string + TargetLabel string + EvidenceCue string + Target paletteTarget +} + +type actionCenterViewModel struct { + Families map[actionCenterFamily][]actionCenterItem + Summary actionCenterSummary } type actionCenterRouteModel struct { def routeDefinition client localBrokerClient + now func() time.Time loading bool errText string statusText string approvals []brokerapi.ApprovalSummary runs []brokerapi.RunSummary + project brokerapi.ProjectSubstratePostureGetResponse audit *brokerapi.AuditVerificationGetResponse + auditErr string watch dashboardLiveActivity watchHealth shellSyncHealth inspectorOn bool @@ -57,6 +69,7 @@ func newActionCenterRouteModel(def routeDefinition, client localBrokerClient) ro return actionCenterRouteModel{ def: def, client: client, + now: time.Now, inspectorOn: true, family: actionCenterFamilyApprovals, selected: map[actionCenterFamily]int{ @@ -80,25 +93,11 @@ func (m actionCenterRouteModel) Title() string { return m.def.Label } func (m actionCenterRouteModel) Update(msg tea.Msg) (routeModel, tea.Cmd) { switch typed := msg.(type) { case routeActivatedMsg: - if typed.RouteID != m.def.ID { - return m, nil - } - if typed.InspectorSet { - m.inspectorOn = typed.InspectorVisible - } - m.loading = true - m.errText = "" - m.statusText = "" - m.loadSeq++ - return m, m.loadCmd(m.loadSeq) + return m.handleRouteActivated(typed) case tea.KeyMsg: return m.handleKey(typed) case routeShellPreferencesMsg: - if typed.RouteID != m.def.ID { - return m, nil - } - m.inspectorOn = typed.InspectorVisible - return m, nil + return m.handleShellPreferences(typed) case actionCenterLoadedMsg: if typed.seq != m.loadSeq { return m, nil @@ -111,7 +110,9 @@ func (m actionCenterRouteModel) Update(msg tea.Msg) (routeModel, tea.Cmd) { m.errText = "" m.approvals = typed.approvals m.runs = typed.runs + m.project = typed.project m.audit = &typed.audit + m.auditErr = typed.auditErr m.normalizeSelection() return m, nil case shellLiveActivityUpdatedMsg: @@ -125,25 +126,66 @@ func (m actionCenterRouteModel) Update(msg tea.Msg) (routeModel, tea.Cmd) { } } +func (m actionCenterRouteModel) handleRouteActivated(msg routeActivatedMsg) (routeModel, tea.Cmd) { + if msg.RouteID != m.def.ID { + return m, nil + } + if msg.InspectorSet { + m.inspectorOn = msg.InspectorVisible + } + m.loading = true + m.errText = "" + m.statusText = "" + m.loadSeq++ + return m, m.loadCmd(m.loadSeq) +} + +func (m actionCenterRouteModel) handleShellPreferences(msg routeShellPreferencesMsg) (routeModel, tea.Cmd) { + if msg.RouteID != m.def.ID { + return m, nil + } + m.inspectorOn = msg.InspectorVisible + return m, nil +} + func (m actionCenterRouteModel) View(width, height int, focus focusArea) string { - _ = width _ = height if m.loading { - return renderStateCard(routeLoadStateLoading, "Action Center", "Loading approvals, run posture, audit posture, and shell watch health...") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Action Center", + Message: "Loading broker-known approvals, blocked work, setup posture, degraded cues, and shell watch health.", + Reason: "Action Center waits for broker-owned follow-up surfaces before presenting operator triage.", + NextAction: "Wait for the route to settle, or press r to retry if loading stalls.", + ShortcutCue: "r reload", + }) } if strings.TrimSpace(m.errText) != "" { - return renderStateCard(routeLoadStateError, "Action Center", "Load failed: "+m.errText+" (press r to retry)") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateError, + Title: "Action Center", + Message: "Action Center is temporarily unavailable.", + Reason: m.errText, + NextAction: "Press r to retry. If the error continues, use Status for broker posture and Dashboard for a lighter overview.", + ShortcutCue: "r reload", + RouteCue: "Status or Dashboard", + }) } - families := m.familyBuckets() + vm := m.snapshot() return compactLines( sectionTitle("Action Center")+" "+focusBadge(focus), - fmt.Sprintf("Queue families: %s=%d %s=%d %s=%d", infoBadge("approvals"), len(families[actionCenterFamilyApprovals]), warnBadge("operational_attention"), len(families[actionCenterFamilyOps]), dangerBadge("blocked_work_impact"), len(families[actionCenterFamilyBlocked])), - fmt.Sprintf("Active triage family: %s", stateBadgeWithLabel("family", string(m.family))), - "Question/answer queues are reserved for future canonical broker models and are intentionally not implemented locally.", - renderDirectory("Approvals queue (canonical)", renderActionCenterItems(families[actionCenterFamilyApprovals]), m.selectedIndex(actionCenterFamilyApprovals, len(families[actionCenterFamilyApprovals]))), - renderDirectory("Operational attention", renderActionCenterItems(families[actionCenterFamilyOps]), m.selectedIndex(actionCenterFamilyOps, len(families[actionCenterFamilyOps]))), - renderDirectory("Blocked-work impact", renderActionCenterItems(families[actionCenterFamilyBlocked]), m.selectedIndex(actionCenterFamilyBlocked, len(families[actionCenterFamilyBlocked]))), - muted("If every bucket is empty, the control plane is currently waiting on new canonical work or operator intervention."), + renderStateCardSpec(stateCardSpec{ + State: vm.Summary.State, + Title: vm.Summary.Title, + Message: vm.Summary.Message, + Reason: vm.Summary.Reason, + NextAction: vm.Summary.NextAction, + RouteCue: "Approvals, Runs, Audit, Status", + }), + renderActionCenterQueueStrip(vm, m.family), + renderActionCenterDirectory("Approvals", vm.Families[actionCenterFamilyApprovals], m.selectedIndex(actionCenterFamilyApprovals, len(vm.Families[actionCenterFamilyApprovals])), width), + renderActionCenterDirectory("Operational Attention", vm.Families[actionCenterFamilyOps], m.selectedIndex(actionCenterFamilyOps, len(vm.Families[actionCenterFamilyOps])), width), + renderActionCenterDirectory("Blocked Work", vm.Families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(vm.Families[actionCenterFamilyBlocked])), width), keyHint("Route keys: [/] change family, j/k move, enter drill-down, i toggle inspector, r reload"), ) } @@ -155,8 +197,8 @@ func (m actionCenterRouteModel) ShellSurface(ctx routeShellContext) routeSurface if status == "" && strings.TrimSpace(m.errText) != "" { status = "Load failed: " + strings.TrimSpace(m.errText) } - families := m.familyBuckets() - activeItems := families[m.family] + vm := m.snapshot() + activeItems := vm.Families[m.family] selected := m.selectedIndex(m.family, len(activeItems)) inspector := "" if m.inspectorOn { @@ -174,6 +216,16 @@ func (m actionCenterRouteModel) ShellSurface(ctx routeShellContext) routeSurface } } +func (m actionCenterRouteModel) snapshot() actionCenterViewModel { + now := time.Now + if m.now != nil { + now = m.now + } + current := now().UTC() + families := m.familyBucketsAt(current) + return actionCenterViewModel{Families: families, Summary: buildActionCenterSummary(families)} +} + func (m actionCenterRouteModel) handleKey(key tea.KeyMsg) (routeModel, tea.Cmd) { switch key.String() { case "r": @@ -226,10 +278,17 @@ func (m actionCenterRouteModel) loadCmd(seq uint64) tea.Cmd { if err != nil { return actionCenterLoadedMsg{err: err, seq: seq} } - auditResp, err := m.client.AuditVerificationGet(ctx, 40) + projectResp, err := m.client.ProjectSubstratePostureGet(ctx) if err != nil { return actionCenterLoadedMsg{err: err, seq: seq} } - return actionCenterLoadedMsg{approvals: approvalResp.Approvals, runs: runResp.Runs, audit: auditResp, seq: seq} + auditResp := degradedDashboardAuditFallback() + auditErr := "" + if loadedAudit, err := m.client.AuditVerificationGet(ctx, 40); err == nil { + auditResp = loadedAudit + } else { + auditErr = safeUIErrorText(err) + } + return actionCenterLoadedMsg{approvals: approvalResp.Approvals, runs: runResp.Runs, project: projectResp, audit: auditResp, auditErr: auditErr, seq: seq} } } diff --git a/cmd/runecode-tui/route_action_center_blocked_helpers.go b/cmd/runecode-tui/route_action_center_blocked_helpers.go new file mode 100644 index 00000000..ca914ac8 --- /dev/null +++ b/cmd/runecode-tui/route_action_center_blocked_helpers.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func approvalCountsByRun(approvals []brokerapi.ApprovalSummary) map[string]int { + byRun := map[string]int{} + for _, ap := range approvals { + runID := strings.TrimSpace(ap.BoundScope.RunID) + if runID != "" { + byRun[runID]++ + } + } + return byRun +} + +func runHasBlockedImpact(run brokerapi.RunSummary) bool { + state := strings.ToLower(strings.TrimSpace(run.LifecycleState)) + return strings.Contains(state, "block") || strings.Contains(state, "wait") || run.PendingApprovalCount > 0 || strings.TrimSpace(run.BlockingReasonCode) != "" +} + +func linkedApprovalCount(run brokerapi.RunSummary, byRun map[string]int) int { + if run.PendingApprovalCount > 0 { + return run.PendingApprovalCount + } + return byRun[run.RunID] +} + +func blockedImpactItem(run brokerapi.RunSummary, blockedCount int) actionCenterItem { + urgency, stateCard, owner, requiredAction := blockedImpactDisposition(run) + return actionCenterItem{ + Title: fmt.Sprintf("run %s blocked impact", valueOrNA(run.RunID)), + State: stateCard, + Urgency: urgency, + Reason: blockedImpactReason(run, blockedCount), + Impact: fmt.Sprintf("Workflow progress is waiting for run %s; %d pending approval(s) and %d linked queue item(s) are visible.", valueOrNA(run.RunID), run.PendingApprovalCount, blockedCount), + Owner: owner, + RequiredAction: requiredAction, + TargetLabel: fmt.Sprintf("Runs › %s", valueOrNA(run.RunID)), + EvidenceCue: fmt.Sprintf("source=run_summary run_id=%s", valueOrNA(run.RunID)), + Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: run.RunID}, + } +} + +func blockedImpactDisposition(run brokerapi.RunSummary) (string, routeLoadState, string, string) { + if run.PendingApprovalCount > 0 { + return "high", routeLoadStateApprovalRequired, "owner: operator makes approval decision", "Open Approvals or Runs, review the exact gate, then decide so workflow progress can resume." + } + return "medium", routeLoadStateBlocked, "owner: operator clears workflow blocker", "Open Runs to inspect the blocked reason and linked approvals, then continue from the target route." +} + +func blockedImpactReason(run brokerapi.RunSummary, blockedCount int) string { + reasonParts := []string{fmt.Sprintf("Lifecycle is %s", humanizeExecutionToken(run.LifecycleState))} + if strings.TrimSpace(run.BlockingReasonCode) != "" { + reasonParts = append(reasonParts, humanizeExecutionToken(run.BlockingReasonCode)) + } + if blockedCount > 0 { + reasonParts = append(reasonParts, fmt.Sprintf("%d approval queue item(s) are linked", blockedCount)) + } + return strings.Join(reasonParts, "; ") + "." +} diff --git a/cmd/runecode-tui/route_action_center_helpers.go b/cmd/runecode-tui/route_action_center_helpers.go index 1c13f355..afdf2890 100644 --- a/cmd/runecode-tui/route_action_center_helpers.go +++ b/cmd/runecode-tui/route_action_center_helpers.go @@ -9,10 +9,22 @@ import ( "github.com/runecode-ai/runecode/internal/brokerapi" ) +type actionCenterSummary struct { + State routeLoadState + Title string + Message string + Reason string + NextAction string +} + func (m actionCenterRouteModel) familyBuckets() map[actionCenterFamily][]actionCenterItem { + return m.familyBucketsAt(time.Now().UTC()) +} + +func (m actionCenterRouteModel) familyBucketsAt(now time.Time) map[actionCenterFamily][]actionCenterItem { buckets := map[actionCenterFamily][]actionCenterItem{ - actionCenterFamilyApprovals: buildApprovalActionItems(m.approvals), - actionCenterFamilyOps: buildOperationalAttentionItems(m.audit, m.watch, m.watchHealth, m.runs), + actionCenterFamilyApprovals: buildApprovalActionItems(m.approvals, now), + actionCenterFamilyOps: buildOperationalAttentionItems(m.audit, m.auditErr, m.watch, m.watchHealth, m.runs, m.project), actionCenterFamilyBlocked: buildBlockedImpactItems(m.runs, m.approvals), } for family, items := range buckets { @@ -24,8 +36,8 @@ func (m actionCenterRouteModel) familyBuckets() map[actionCenterFamily][]actionC return buckets } -func buildApprovalActionItems(items []brokerapi.ApprovalSummary) []actionCenterItem { - now := time.Now().UTC() +func buildApprovalActionItems(items []brokerapi.ApprovalSummary, now time.Time) []actionCenterItem { + now = now.UTC() out := make([]actionCenterItem, 0, len(items)) for _, ap := range items { approvalID := strings.TrimSpace(ap.ApprovalID) @@ -35,19 +47,38 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary) []actionCenterI expiryCue, urgency := approvalExpiryUrgency(ap.ExpiresAt, now) staleCue, urgency := approvalStalenessUrgency(ap, urgency) urgency = pendingApprovalUrgency(ap.Status, urgency) - impact := fmt.Sprintf("run=%s stage=%s action=%s", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) + state := routeLoadStateApprovalRequired + if urgency == "critical" { + state = routeLoadStateBlocked + } + reasons := []string{fmt.Sprintf("Approval is %s", humanizeExecutionToken(ap.Status))} + if trigger := strings.TrimSpace(ap.ApprovalTriggerCode); trigger != "" { + reasons = append(reasons, fmt.Sprintf("triggered by %s", humanizeExecutionToken(trigger))) + } + if staleCue != "fresh" { + reasons = append(reasons, humanizeExecutionToken(staleCue)) + } + if expiryCue != "no_expiry" { + reasons = append(reasons, humanizeExecutionToken(expiryCue)) + } + impact := fmt.Sprintf("Workflow progress stays gated for run %s stage %s until this decision is resolved.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID)) + targetLabel := fmt.Sprintf("Approvals › %s", approvalID) + evidence := fmt.Sprintf("source=approval_summary approval_id=%s run=%s", approvalID, valueOrNA(ap.BoundScope.RunID)) out = append(out, actionCenterItem{ - Title: fmt.Sprintf("approval %s", approvalID), - Detail: fmt.Sprintf("status=%s trigger=%s", valueOrNA(ap.Status), valueOrNA(ap.ApprovalTriggerCode)), - Urgency: urgency, - ExpiryCue: expiryCue, - StaleCue: staleCue, - Impact: impact, - Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: approvalID}, + Title: fmt.Sprintf("approval %s", approvalID), + State: state, + Urgency: urgency, + Reason: strings.Join(reasons, "; "), + Impact: impact, + Owner: "owner: operator decision", + RequiredAction: "Open Approvals, review the exact gated action and evidence, then approve or reject.", + TargetLabel: targetLabel, + EvidenceCue: evidence, + Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: approvalID}, }) } if len(out) == 0 { - return []actionCenterItem{{Title: "no pending approvals", Detail: "canonical approval queue is currently empty", Urgency: "low", ExpiryCue: "n/a", StaleCue: "n/a", Impact: "none"}} + return []actionCenterItem{{Title: "no pending approvals", State: routeLoadStateEmpty, Urgency: "low", Reason: "The broker approval queue is currently empty.", Impact: "No workflow work is waiting on approval decisions from the current broker surfaces.", Owner: "owner: none", RequiredAction: "No approval follow-up is needed right now.", TargetLabel: "Approvals", EvidenceCue: "source=approval_list empty"}} } return out } @@ -97,14 +128,15 @@ func lowerUrgencyUnlessCritical(current string, next string) string { return next } -func buildOperationalAttentionItems(audit *brokerapi.AuditVerificationGetResponse, watch dashboardLiveActivity, health shellSyncHealth, runs []brokerapi.RunSummary) []actionCenterItem { +func buildOperationalAttentionItems(audit *brokerapi.AuditVerificationGetResponse, auditErr string, watch dashboardLiveActivity, health shellSyncHealth, runs []brokerapi.RunSummary, project brokerapi.ProjectSubstratePostureGetResponse) []actionCenterItem { items := []actionCenterItem{} items = append(items, operationalSyncHealthItem(health)...) items = append(items, operationalWatchFamilyItems(watch)...) - items = append(items, operationalAuditItems(audit)...) + items = append(items, operationalAuditItems(audit, auditErr)...) items = append(items, operationalRunItems(runs)...) + items = append(items, operationalProjectSetupItems(project)...) if len(items) == 0 { - return []actionCenterItem{{Title: "no active operational attention", Detail: "audit anchoring, watch sync, and run posture all nominal", Urgency: "low", ExpiryCue: "n/a", StaleCue: "n/a", Impact: "none"}} + return []actionCenterItem{{Title: "no active operational attention", State: routeLoadStateReady, Urgency: "low", Reason: "Audit evidence, watch sync, runtime posture, and setup posture are nominal on current broker surfaces.", Impact: "No degraded operational follow-up is visible here.", Owner: "owner: none", RequiredAction: "No operational remediation is needed right now.", TargetLabel: "Action Center", EvidenceCue: "source=operational_attention empty"}} } return items } @@ -118,13 +150,16 @@ func operationalSyncHealthItem(health shellSyncHealth) []actionCenterItem { urgency = "critical" } return []actionCenterItem{{ - Title: "shell watch sync health", - Detail: fmt.Sprintf("state=%s error=%s", health.State, defaultPlaceholder(health.ErrorText, "n/a")), - Urgency: urgency, - ExpiryCue: "n/a", - StaleCue: "n/a", - Impact: "live activity coverage degraded", - Target: paletteTarget{Kind: "route", RouteID: routeStatus}, + Title: "shell watch sync health", + State: routeLoadStateDegraded, + Urgency: urgency, + Reason: fmt.Sprintf("Watch sync is %s.", humanizeExecutionToken(string(health.State))), + Impact: "Live operator follow-up coverage is degraded until shell watch sync recovers.", + Owner: "owner: operator checks broker connectivity", + RequiredAction: "Open Status to confirm broker connectivity and sync posture before relying on live updates.", + TargetLabel: "Status", + EvidenceCue: fmt.Sprintf("source=shell_sync_health state=%s", health.State), + Target: paletteTarget{Kind: "route", RouteID: routeStatus}, }} } @@ -139,19 +174,36 @@ func operationalWatchFamilyItems(watch dashboardLiveActivity) []actionCenterItem urgency = "high" } items = append(items, actionCenterItem{ - Title: fmt.Sprintf("watch family %s", valueOrNA(family.family)), - Detail: fmt.Sprintf("errors=%d last_status=%s last_subject=%s", family.errorCount, valueOrNA(family.lastStatus), valueOrNA(family.lastSubject)), - Urgency: urgency, - ExpiryCue: "n/a", - StaleCue: "n/a", - Impact: "operator follow stream may be degraded", - Target: paletteTarget{Kind: "route", RouteID: routeDashboard}, + Title: fmt.Sprintf("watch family %s", valueOrNA(family.family)), + State: routeLoadStateDegraded, + Urgency: urgency, + Reason: fmt.Sprintf("%s reported %d error(s); last status %s.", humanizeExecutionToken(family.family), family.errorCount, humanizeExecutionToken(family.lastStatus)), + Impact: "Operator follow-up cues from this watch family may be incomplete or stale.", + Owner: "owner: operator verifies watch health", + RequiredAction: "Use Dashboard live activity and Status to confirm whether watch degradation is transient or ongoing.", + TargetLabel: "Dashboard", + EvidenceCue: fmt.Sprintf("source=watch_family family=%s", valueOrNA(family.family)), + Target: paletteTarget{Kind: "route", RouteID: routeDashboard}, }) } return items } -func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse) []actionCenterItem { +func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditErr string) []actionCenterItem { + if strings.TrimSpace(auditErr) != "" { + return []actionCenterItem{{ + Title: "audit verification unavailable", + State: routeLoadStateDegraded, + Urgency: "high", + Reason: fmt.Sprintf("Audit verification could not be loaded: %s", auditErr), + Impact: "Evidence posture is shown with degraded fallback until broker verification becomes available again.", + Owner: "owner: operator verifies evidence health", + RequiredAction: "Open Audit to confirm the fallback posture and verify whether the broker audit surface has recovered.", + TargetLabel: "Audit", + EvidenceCue: "source=audit_verification fallback", + Target: paletteTarget{Kind: "route", RouteID: routeAudit}, + }} + } if audit == nil { return nil } @@ -164,101 +216,96 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse) []acti urgency = "critical" } return []actionCenterItem{{ - Title: "audit verification posture", - Detail: fmt.Sprintf("integrity=%s anchoring=%s degraded=%t", valueOrNA(s.IntegrityStatus), valueOrNA(s.AnchoringStatus), s.CurrentlyDegraded), - Urgency: urgency, - ExpiryCue: "n/a", - StaleCue: "n/a", - Impact: fmt.Sprintf("hard_failures=%d degraded_reasons=%d", len(s.HardFailures), len(s.DegradedReasons)), - Target: paletteTarget{Kind: "route", RouteID: routeAudit}, + Title: "audit verification posture", + State: routeLoadStateDegraded, + Urgency: urgency, + Reason: fmt.Sprintf("Integrity is %s, receipts are %s, and evidence needs review.", humanizeExecutionToken(s.IntegrityStatus), humanizeExecutionToken(s.AnchoringStatus)), + Impact: fmt.Sprintf("Evidence confidence is reduced; %d hard failure(s) and %d degraded reason(s) are available in Audit.", len(s.HardFailures), len(s.DegradedReasons)), + Owner: "owner: operator reviews evidence posture", + RequiredAction: "Open Audit to inspect the degraded or failed evidence details before treating the trail as healthy.", + TargetLabel: "Audit", + EvidenceCue: fmt.Sprintf("source=audit_verification summary findings=%d", s.FindingCount), + Target: paletteTarget{Kind: "route", RouteID: routeAudit}, }} } func operationalRunItems(runs []brokerapi.RunSummary) []actionCenterItem { items := make([]actionCenterItem, 0, len(runs)) for _, run := range runs { - if !run.RuntimePostureDegraded && !run.AuditCurrentlyDegraded { + state := strings.ToLower(strings.TrimSpace(run.LifecycleState)) + failedLifecycle := strings.Contains(state, "fail") || strings.Contains(state, "error") + degradedLifecycle := strings.Contains(state, "degrad") + if !run.RuntimePostureDegraded && !run.AuditCurrentlyDegraded && !failedLifecycle && !degradedLifecycle { continue } + urgency := "high" + if failedLifecycle { + urgency = "critical" + } + reasonParts := []string{fmt.Sprintf("Lifecycle is %s", humanizeExecutionToken(run.LifecycleState))} + if run.RuntimePostureDegraded { + reasonParts = append(reasonParts, "runtime posture needs review") + } + if run.AuditCurrentlyDegraded { + reasonParts = append(reasonParts, "audit posture needs review") + } items = append(items, actionCenterItem{ - Title: fmt.Sprintf("run %s operational posture", valueOrNA(run.RunID)), - Detail: fmt.Sprintf("runtime_degraded=%t audit_degraded=%t", run.RuntimePostureDegraded, run.AuditCurrentlyDegraded), - Urgency: "high", - ExpiryCue: "n/a", - StaleCue: "n/a", - Impact: fmt.Sprintf("backend=%s isolation=%s", valueOrNA(run.BackendKind), valueOrNA(run.IsolationAssuranceLevel)), - Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: run.RunID}, + Title: fmt.Sprintf("run %s operational posture", valueOrNA(run.RunID)), + State: routeLoadStateDegraded, + Urgency: urgency, + Reason: strings.Join(reasonParts, "; ") + ".", + Impact: fmt.Sprintf("Execution confidence is reduced for run %s. Runtime proof remains available in Runs.", valueOrNA(run.RunID)), + Owner: "owner: operator reviews run posture", + RequiredAction: "Open Runs to inspect the degraded run posture and confirm linked evidence before continuing.", + TargetLabel: fmt.Sprintf("Runs › %s", valueOrNA(run.RunID)), + EvidenceCue: fmt.Sprintf("source=run_summary run_id=%s", valueOrNA(run.RunID)), + Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: run.RunID}, }) } return items } +func operationalProjectSetupItems(project brokerapi.ProjectSubstratePostureGetResponse) []actionCenterItem { + if strings.TrimSpace(project.PostureSummary.SchemaID) == "" || !dashboardProjectSubstrateNeedsAttention(project) { + return nil + } + state := routeLoadStateApprovalRequired + urgency := "medium" + if dashboardProjectSubstrateBlocked(project) { + state = routeLoadStateBlocked + urgency = "high" + } + return []actionCenterItem{{ + Title: "project setup remediation", + State: state, + Urgency: urgency, + Reason: dashboardProjectSubstrateReason(project), + Impact: "Normal product work may stay blocked or require operator remediation until setup posture is brought back into supported shape.", + Owner: "owner: operator remediates project setup", + RequiredAction: "Open Status for broker-owned setup guidance, preview/apply availability, and post-remediation validation.", + TargetLabel: "Status", + EvidenceCue: fmt.Sprintf("source=project_substrate_posture compatibility=%s", valueOrNA(project.PostureSummary.CompatibilityPosture)), + Target: paletteTarget{Kind: "route", RouteID: routeStatus}, + }} +} + func buildBlockedImpactItems(runs []brokerapi.RunSummary, approvals []brokerapi.ApprovalSummary) []actionCenterItem { items := make([]actionCenterItem, 0, len(runs)) - byRun := map[string]int{} - for _, ap := range approvals { - runID := strings.TrimSpace(ap.BoundScope.RunID) - if runID != "" { - byRun[runID]++ - } - } + byRun := approvalCountsByRun(approvals) for _, run := range runs { - state := strings.ToLower(strings.TrimSpace(run.LifecycleState)) - isBlocked := strings.Contains(state, "block") || strings.Contains(state, "wait") || run.PendingApprovalCount > 0 || strings.TrimSpace(run.BlockingReasonCode) != "" - if !isBlocked { + if !runHasBlockedImpact(run) { continue } - urgency := "medium" - if run.PendingApprovalCount > 0 { - urgency = "high" - } - blockedCount := run.PendingApprovalCount - if blockedCount == 0 { - blockedCount = byRun[run.RunID] - } - items = append(items, actionCenterItem{ - Title: fmt.Sprintf("run %s blocked impact", valueOrNA(run.RunID)), - Detail: fmt.Sprintf("lifecycle=%s reason=%s", valueOrNA(run.LifecycleState), valueOrNA(run.BlockingReasonCode)), - Urgency: urgency, - ExpiryCue: "n/a", - StaleCue: "n/a", - Impact: fmt.Sprintf("pending_approvals=%d linked_queue_items=%d", run.PendingApprovalCount, blockedCount), - Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: run.RunID}, - }) + items = append(items, blockedImpactItem(run, linkedApprovalCount(run, byRun))) } if len(items) == 0 { - return []actionCenterItem{{Title: "no blocked work impact", Detail: "no run currently reports blocking posture", Urgency: "low", ExpiryCue: "n/a", StaleCue: "n/a", Impact: "none"}} + return []actionCenterItem{{Title: "no blocked work impact", State: routeLoadStateReady, Urgency: "low", Reason: "No run currently reports blocking posture.", Impact: "No workflow work is blocked on the current broker surfaces.", Owner: "owner: none", RequiredAction: "No blocked-work follow-up is needed right now.", TargetLabel: "Runs", EvidenceCue: "source=run_list no_blocked_items"}} } return items } -func renderActionCenterItems(items []actionCenterItem) []string { - out := make([]string, 0, len(items)) - for _, item := range items { - out = append(out, fmt.Sprintf("%s | %s | urgency=%s expiry=%s stale/superseded=%s | impact=%s", item.Title, item.Detail, valueOrNA(item.Urgency), valueOrNA(item.ExpiryCue), valueOrNA(item.StaleCue), valueOrNA(item.Impact))) - } - return out -} - -func renderActionCenterInspector(family actionCenterFamily, items []actionCenterItem, selected int) string { - if len(items) == 0 || selected < 0 || selected >= len(items) { - return renderInspectorHeader("Action Center inspector", appTheme.InspectorHint.Render("triage detail")) + "\n No item selected." - } - item := items[selected] - return compactLines( - renderInspectorHeader("Action Center inspector", appTheme.InspectorHint.Render("triage detail")), - fmt.Sprintf(" family=%s", family), - fmt.Sprintf(" title=%s", item.Title), - fmt.Sprintf(" urgency=%s", valueOrNA(item.Urgency)), - fmt.Sprintf(" expiry=%s", valueOrNA(item.ExpiryCue)), - fmt.Sprintf(" stale_or_superseded=%s", valueOrNA(item.StaleCue)), - fmt.Sprintf(" impact=%s", valueOrNA(item.Impact)), - fmt.Sprintf(" drill_down_target=%s/%s", valueOrNA(item.Target.Kind), valueOrNA(string(item.Target.RouteID))), - ) -} - func (m *actionCenterRouteModel) normalizeSelection() { - buckets := m.familyBuckets() + buckets := m.snapshot().Families for _, family := range []actionCenterFamily{actionCenterFamilyApprovals, actionCenterFamilyOps, actionCenterFamilyBlocked} { max := len(buckets[family]) if max <= 0 { @@ -275,7 +322,7 @@ func (m *actionCenterRouteModel) normalizeSelection() { } func (m actionCenterRouteModel) moveSelection(delta int) { - buckets := m.familyBuckets() + buckets := m.snapshot().Families items := buckets[m.family] if len(items) == 0 { m.selected[m.family] = 0 @@ -292,7 +339,7 @@ func (m actionCenterRouteModel) moveSelection(delta int) { } func (m actionCenterRouteModel) selectedItem() (actionCenterItem, bool) { - buckets := m.familyBuckets() + buckets := m.snapshot().Families items := buckets[m.family] if len(items) == 0 { return actionCenterItem{}, false diff --git a/cmd/runecode-tui/route_action_center_render.go b/cmd/runecode-tui/route_action_center_render.go new file mode 100644 index 00000000..8c08e077 --- /dev/null +++ b/cmd/runecode-tui/route_action_center_render.go @@ -0,0 +1,212 @@ +package main + +import ( + "fmt" + "strings" +) + +func renderActionCenterQueueStrip(vm actionCenterViewModel, active actionCenterFamily) string { + parts := []string{ + fmt.Sprintf("Approvals %d", len(vm.Families[actionCenterFamilyApprovals])), + fmt.Sprintf("Operational Attention %d", len(vm.Families[actionCenterFamilyOps])), + fmt.Sprintf("Blocked Work %d", len(vm.Families[actionCenterFamilyBlocked])), + fmt.Sprintf("Focus %s", actionCenterFamilyLabel(active)), + } + return strings.Join(parts, " • ") +} + +func renderActionCenterDirectory(title string, items []actionCenterItem, selected int, width int) string { + if len(items) == 0 { + return renderStateCardSpec(stateCardSpec{State: routeLoadStateEmpty, Title: title, Message: "No broker-known follow-up items are in this queue.", Reason: "The current canonical broker surfaces did not return triage items for this family."}) + } + rows := make([]boundedListRow, 0, len(items)) + for i, item := range items { + marker := " " + if i == selected { + marker = ">" + } + rows = append(rows, boundedListRow{Text: fmt.Sprintf(" %s %s", marker, renderActionCenterItem(item, width-4)), Selectable: true}) + } + return compactLines(tableHeader(title), renderBoundedList(boundedListSpec{Rows: rows, Selected: selected, Width: 0, ApplySelected: true, PreserveGaps: true})) +} + +func renderActionCenterItems(items []actionCenterItem) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, renderActionCenterItem(item, 0)) + } + return out +} + +func renderActionCenterItem(item actionCenterItem, width int) string { + title := fmt.Sprintf("%s %s", tableHeader(actionCenterStateLabel(item.State)), item.Title) + summary := " " + actionCenterMainListSummary(item) + if width <= 0 { + return strings.Join([]string{title, summary}, "\n") + } + return strings.Join([]string{clipDisplayText(title, width), clipDisplayText(summary, width)}, "\n") +} + +func actionCenterStateLabel(state routeLoadState) string { + switch state { + case routeLoadStateApprovalRequired: + return "Approval required" + case routeLoadStateBlocked: + return "Blocked" + case routeLoadStateDegraded: + return "Needs review" + case routeLoadStateReady: + return "Ready" + case routeLoadStateEmpty: + return "Clear" + default: + return strings.ToUpper(string(state)) + } +} + +func actionCenterFamilyLabel(family actionCenterFamily) string { + switch family { + case actionCenterFamilyApprovals: + return "Approvals" + case actionCenterFamilyOps: + return "Operational Attention" + case actionCenterFamilyBlocked: + return "Blocked Work" + default: + return strings.TrimSpace(string(family)) + } +} + +func actionCenterMainListSummary(item actionCenterItem) string { + reason := actionCenterShortReason(item.Reason) + continueIn := actionCenterContinueCue(item.TargetLabel) + if continueIn == "" { + return valueOrNA(reason) + } + if reason == "" { + return continueIn + } + return reason + " • " + continueIn +} + +func actionCenterShortReason(reason string) string { + reason = strings.TrimSpace(reason) + if reason == "" || strings.EqualFold(reason, "n/a") { + return "" + } + parts := strings.Split(reason, ";") + if len(parts) == 0 { + return strings.TrimSpace(strings.TrimSuffix(reason, ".")) + } + base := strings.TrimSpace(strings.TrimSuffix(parts[0], ".")) + for _, part := range parts[1:] { + cue := strings.TrimSpace(strings.TrimSuffix(part, ".")) + if cue == "" { + continue + } + cueLower := strings.ToLower(cue) + if strings.Contains(cueLower, "expired") || strings.Contains(cueLower, "expiring") || strings.Contains(cueLower, "superseded") || strings.Contains(cueLower, "stale") { + if base == "" { + return cue + } + return base + " • " + cue + } + } + return base +} + +func actionCenterContinueCue(target string) string { + target = strings.TrimSpace(target) + if target == "" || strings.EqualFold(target, "n/a") { + return "" + } + return "Continue in " + target +} + +func renderActionCenterInspector(family actionCenterFamily, items []actionCenterItem, selected int) string { + if len(items) == 0 || selected < 0 || selected >= len(items) { + return renderInspectorHeader("Action Center inspector", appTheme.InspectorHint.Render("triage detail")) + "\n No item selected." + } + item := items[selected] + drillDown := "Unavailable" + if strings.TrimSpace(item.Target.Kind) != "" || strings.TrimSpace(string(item.Target.RouteID)) != "" { + drillDown = strings.TrimSpace(strings.Join([]string{valueOrNA(item.TargetLabel), fmt.Sprintf("(%s/%s)", valueOrNA(item.Target.Kind), valueOrNA(string(item.Target.RouteID)))}, " ")) + } + return compactLines( + renderInspectorHeader("Action Center inspector", appTheme.InspectorHint.Render("triage detail")), + fmt.Sprintf(" %s • %s • %s", actionCenterFamilyLabel(family), actionCenterStateLabel(item.State), humanizeExecutionToken(item.Urgency)), + " "+item.Title, + "", + " Why this needs attention", + " "+valueOrNA(item.Reason), + " What it affects", + " "+valueOrNA(item.Impact), + " What to do now", + " "+valueOrNA(strings.TrimSpace(item.RequiredAction)), + " Continue in", + " "+drillDown, + " Evidence", + " "+actionCenterEvidenceSummary(item.EvidenceCue), + ) +} + +func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem) actionCenterSummary { + approvals := countActiveActionCenterItems(families[actionCenterFamilyApprovals]) + ops := countActiveActionCenterItems(families[actionCenterFamilyOps]) + blocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateBlocked) + approvalBlocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateApprovalRequired) + if blocked > 0 { + return actionCenterSummary{State: routeLoadStateBlocked, Title: "Action Center", Message: "Some work is blocked and needs operator follow-up.", Reason: fmt.Sprintf("%d blocked item(s), %d approval-gated item(s), %d operational item(s), and %d approval decision(s) are visible.", blocked, approvalBlocked, ops, approvals), NextAction: "Start with Blocked Work, then review linked approvals or setup guidance."} + } + if approvals > 0 || approvalBlocked > 0 { + return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Action Center", Message: "Approval decisions are waiting on the operator.", Reason: fmt.Sprintf("%d approval decision(s), %d approval-gated workflow item(s), and %d operational item(s) are visible.", approvals, approvalBlocked, ops), NextAction: "Open Approvals and review the exact gated action before deciding."} + } + if ops > 0 { + return actionCenterSummary{State: routeLoadStateDegraded, Title: "Action Center", Message: "Runtime, evidence, setup, or sync posture needs review.", Reason: fmt.Sprintf("%d operational attention item(s) are visible.", ops), NextAction: "Review Operational Attention first, then confirm detail in Audit, Runs, or Status."} + } + return actionCenterSummary{State: routeLoadStateReady, Title: "Action Center", Message: "No broker-known attention items are currently waiting in Action Center.", Reason: "Approvals, Blocked Work, and Operational Attention are clear on the current broker surfaces.", NextAction: "Return to Dashboard or Chat and continue work until new follow-up appears."} +} + +func actionCenterEvidenceSummary(cue string) string { + parts := []string{} + for _, field := range strings.Fields(strings.TrimSpace(cue)) { + field = strings.TrimSpace(field) + if field == "" { + continue + } + if strings.HasPrefix(field, "source=") { + parts = append(parts, "source "+humanizeExecutionToken(strings.TrimPrefix(field, "source="))) + continue + } + kv := strings.SplitN(field, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[1]) == "" { + parts = append(parts, humanizeExecutionToken(field)) + continue + } + parts = append(parts, humanizeExecutionToken(kv[0])+" "+kv[1]) + } + if len(parts) == 0 { + return valueOrNA(cue) + } + return strings.Join(parts, " • ") +} + +func countActionCenterItemsByState(items []actionCenterItem, state routeLoadState) int { + total := 0 + for _, item := range items { + if item.State == state { + total++ + } + } + return total +} + +func countActiveActionCenterItems(items []actionCenterItem) int { + total := 0 + for _, item := range items { + if item.State != routeLoadStateReady && item.State != routeLoadStateEmpty { + total++ + } + } + return total +} diff --git a/cmd/runecode-tui/route_action_center_test.go b/cmd/runecode-tui/route_action_center_test.go index 762edbf3..4fa25f2c 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -29,23 +29,31 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) view := updated.View(140, 40, focusContent) mustContainAll(t, view, "Action Center", - "Queue families:", - "approvals", - "operational_attention", - "blocked_work_impact", - "Approvals queue (canonical)", - "Operational attention", + "Blocked", + "Approvals", + "Operational Attention", + "Blocked Work", + "Focus Approvals", + "Continue in", + ) + mustNotContainAny(t, view, "Blocked-work impact", - "Question/answer queues are reserved for future canonical broker models", + "owner/action:", + "Focused queue:", ) surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "family=", - "urgency=", - "expiry=", - "stale_or_superseded=", - "impact=", + "Why this needs attention", + "What it affects", + "What to do", + "Continue in", + "Evidence", + ) + mustNotContainAny(t, inspector, + "queue=", + "state=", + "required_action=", ) } @@ -57,6 +65,7 @@ func TestActionCenterKeyboardTriageAndDrillDown(t *testing.T) { } updated, _ = updated.Update(cmd()) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{']'}}) updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{']'}}) updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -76,11 +85,11 @@ func TestActionCenterKeyboardTriageAndDrillDown(t *testing.T) { } view := updated.View(140, 40, focusContent) - if !strings.Contains(view, "Active triage family") { + if !strings.Contains(view, "Focus Blocked Work") { t.Fatalf("expected family indicator in view, got %q", view) } surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) - if !strings.Contains(surface.Regions.Inspector.Body, "drill_down_target=") { + if !strings.Contains(surface.Regions.Inspector.Body, "Continue in") { t.Fatalf("expected action center drill-down details in inspector, got %q", surface.Regions.Inspector.Body) } } @@ -91,17 +100,19 @@ func TestBuildApprovalActionItemsIncludesExpiryAndSupersededCues(t *testing.T) { {ApprovalID: "ap-expired", Status: "pending", ExpiresAt: now.Add(-1 * time.Minute).Format(time.RFC3339), BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-1", ActionKind: "promotion"}}, {ApprovalID: "ap-soon", Status: "pending", ExpiresAt: now.Add(30 * time.Minute).Format(time.RFC3339), BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-2", ActionKind: "promotion"}}, {ApprovalID: "ap-super", Status: "superseded", SupersededByApprovalID: "ap-new", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-3", ActionKind: "promotion"}}, - }) + }, now) joined := renderActionCenterItems(items) text := strings.Join(joined, "\n") mustContainAll(t, text, "approval ap-expired", - "expiry=expired", + "expired", "approval ap-soon", - "expiry=expiring_soon", + "expiring soon", "approval ap-super", - "stale/superseded=superseded", + "superseded", + "Continue in Approvals", ) + mustNotContainAny(t, text, "owner/action:", "Next:") } func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testing.T) { @@ -111,17 +122,56 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin approvalWatch: watchFamilySummary{family: "approval_watch", errorCount: 0, lastStatus: "ok"}, sessionWatch: watchFamilySummary{family: "session_watch", errorCount: 0, lastStatus: "ok"}, } - items := buildOperationalAttentionItems(audit, watch, shellSyncHealth{State: shellSyncStateDisconnected, ErrorText: "local_ipc_dial_error"}, []brokerapi.RunSummary{{RunID: "run-1", RuntimePostureDegraded: true}}) + items := buildOperationalAttentionItems(audit, "", watch, shellSyncHealth{State: shellSyncStateDisconnected, ErrorText: "local_ipc_dial_error"}, []brokerapi.RunSummary{{RunID: "run-1", RuntimePostureDegraded: true}}, brokerapi.ProjectSubstratePostureGetResponse{}) text := strings.Join(renderActionCenterItems(items), "\n") mustContainAll(t, text, "shell watch sync health", - "state=disconnected", + "Watch sync is disconnected", "audit verification posture", - "anchoring=degraded", + "receipts are degraded", "run run-1 operational posture", + "Continue in", + ) + mustNotContainAny(t, text, "owner/action:", "Next:") +} + +func TestActionCenterItemsStateReasonImpactOwnerTargetEvidence(t *testing.T) { + model := newActionCenterRouteModel(routeDefinition{ID: routeAction, Label: "Action Center"}, &fakeBrokerClient{}).(actionCenterRouteModel) + model.now = func() time.Time { return time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) } + model.runs = []brokerapi.RunSummary{{RunID: "run-1", LifecycleState: "waiting", PendingApprovalCount: 1}} + model.approvals = []brokerapi.ApprovalSummary{{ApprovalID: "ap-1", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-1", ActionKind: "promotion"}}} + vm := model.snapshot() + blockedItems := vm.Families[actionCenterFamilyBlocked] + if len(blockedItems) == 0 { + t.Fatal("expected blocked items") + } + item := blockedItems[0] + if strings.TrimSpace(item.Reason) == "" || strings.TrimSpace(item.Impact) == "" || strings.TrimSpace(item.Owner) == "" || strings.TrimSpace(item.RequiredAction) == "" || strings.TrimSpace(item.TargetLabel) == "" || strings.TrimSpace(item.EvidenceCue) == "" { + t.Fatalf("expected full item content, got %+v", item) + } + text := renderActionCenterItem(item, 0) + mustContainAll(t, text, + item.Title, + actionCenterShortReason(item.Reason), + "Continue in", + ) + mustNotContainAny(t, text, + "owner/action:", + "target:", + "impact:", + "Next:", ) } func timeNowUTCForTest() time.Time { return time.Now().UTC() } + +func mustNotContainAny(t *testing.T, body string, values ...string) { + t.Helper() + for _, value := range values { + if strings.Contains(body, value) { + t.Fatalf("expected %q to be absent from %q", value, body) + } + } +} diff --git a/cmd/runecode-tui/route_approval_fixture_test.go b/cmd/runecode-tui/route_approval_fixture_test.go new file mode 100644 index 00000000..abbaa864 --- /dev/null +++ b/cmd/runecode-tui/route_approval_fixture_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +var testSignedApprovalRequest = &trustpolicy.SignedObjectEnvelope{ + SchemaID: trustpolicy.EnvelopeSchemaID, + SchemaVersion: trustpolicy.EnvelopeSchemaVersion, + PayloadSchemaID: trustpolicy.ApprovalRequestSchemaID, + PayloadSchemaVersion: trustpolicy.ApprovalRequestSchemaVersion, + Payload: []byte(`{"schema_id":"runecode.protocol.v0.ApprovalRequest","schema_version":"0.3.0"}`), + SignatureInput: trustpolicy.SignatureInputProfile, + Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: strings.Repeat("a", 64), Signature: "c2ln"}, +} + +var testSignedApprovalDecision = testApprovalDecisionEnvelopeForRequest(testSignedApprovalRequest) + +func testApprovalDecisionEnvelopeForRequest(request *trustpolicy.SignedObjectEnvelope) *trustpolicy.SignedObjectEnvelope { + identity, err := approvalRequestDigestIdentity(*request) + if err != nil { + panic(err) + } + payload := fmt.Sprintf(`{"schema_id":"%s","schema_version":"%s","approval_request_hash":{"hash_alg":"sha256","hash":"%s"}}`, trustpolicy.ApprovalDecisionSchemaID, trustpolicy.ApprovalDecisionSchemaVersion, strings.TrimPrefix(identity, "sha256:")) + return &trustpolicy.SignedObjectEnvelope{ + SchemaID: trustpolicy.EnvelopeSchemaID, + SchemaVersion: trustpolicy.EnvelopeSchemaVersion, + PayloadSchemaID: trustpolicy.ApprovalDecisionSchemaID, + PayloadSchemaVersion: trustpolicy.ApprovalDecisionSchemaVersion, + Payload: []byte(payload), + SignatureInput: trustpolicy.SignatureInputProfile, + Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: strings.Repeat("b", 64), Signature: "c2ln"}, + } +} diff --git a/cmd/runecode-tui/route_approvals.go b/cmd/runecode-tui/route_approvals.go index e9422044..8ebbfe6c 100644 --- a/cmd/runecode-tui/route_approvals.go +++ b/cmd/runecode-tui/route_approvals.go @@ -133,7 +133,7 @@ func (m approvalsRouteModel) handleApprovalResolved(msg approvalResolvedMsg) (ro return m, nil } m.errText = "" - m.statusText = fmt.Sprintf("Approval %s resolved via typed ApprovalResolve (%s).", msg.approvalID, valueOrNA(msg.result)) + m.statusText = fmt.Sprintf("Approval %s resolved; broker result=%s and the blocked workflow can continue when broker state advances.", approvalUIValue(msg.approvalID), approvalUIValue(msg.result)) m.loading = true m.loadSeq++ return m, m.loadCmd("", m.loadSeq) @@ -153,9 +153,8 @@ func (m approvalsRouteModel) View(width, height int, focus focusArea) string { } body := []string{ sectionTitle("Approvals") + " " + focusBadge(focus), - renderApprovalSafetyStrip(m.active), - renderApprovalFlowPath(m.active), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderApprovalOverviewCard(m.active), + renderApprovalDecisionWorkbench(m.active), renderDirectory("Approval queue", renderApprovalDirectoryItems(m.items), m.selected), } if len(m.items) == 0 { @@ -164,7 +163,7 @@ func (m approvalsRouteModel) View(width, height int, focus focusArea) string { if m.statusText != "" { body = append(body, "Status: "+m.statusText) } - body = append(body, keyHint("Route keys: j/k move, enter load detail, a resolve current approval where supported, v cycle rendered/raw/structured, i toggle inspector, r reload")) + body = append(body, keyHint("Keys: j/k move, enter detail, a resolve, v mode, r reload")) return compactLines(body...) } @@ -187,7 +186,7 @@ func (m approvalsRouteModel) ShellSurface(ctx routeShellContext) routeSurface { Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Title: "Approval workspace", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, Inspector: routeSurfaceRegion{Title: "Approval inspector", Body: inspector}, - Bottom: routeSurfaceRegion{Body: keyHint("Route keys: j/k move, enter load detail, a resolve current approval where supported, v cycle rendered/raw/structured, i toggle inspector, r reload")}, + Bottom: routeSurfaceRegion{Body: keyHint("Keys: j/k move, enter detail, a resolve, v mode, r reload")}, Status: routeSurfaceRegion{Body: status}, }, Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: m.inspectorOn}}, @@ -252,7 +251,7 @@ func (m approvalsRouteModel) handleResolveKey() (routeModel, tea.Cmd) { if err := validateApprovalResolveInput(*m.active); err != nil { m.resolving = false m.errText = "" - m.statusText = safeUIErrorText(err) + m.statusText = approvalResolveUnavailableReason(err) return m, nil } m.resolving = true @@ -277,13 +276,13 @@ func (m approvalsRouteModel) resolveCmd(resp brokerapi.ApprovalGetResponse) tea. return func() tea.Msg { resolveReq, err := approvalResolveRequestFromDetail(resp) if err != nil { - return approvalResolvedMsg{approvalID: strings.TrimSpace(resp.Approval.ApprovalID), err: err} + return approvalResolvedMsg{approvalID: approvalUIValue(resp.Approval.ApprovalID), err: err} } ctx, cancel := withLoadTimeout() defer cancel() resolveResp, err := m.client.ApprovalResolve(ctx, resolveReq) if err != nil { - return approvalResolvedMsg{approvalID: strings.TrimSpace(resp.Approval.ApprovalID), err: err} + return approvalResolvedMsg{approvalID: approvalUIValue(resp.Approval.ApprovalID), err: err} } result := strings.TrimSpace(resolveResp.ResolutionReasonCode) if result == "" { @@ -292,7 +291,7 @@ func (m approvalsRouteModel) resolveCmd(resp brokerapi.ApprovalGetResponse) tea. if result == "" { result = "resolved" } - return approvalResolvedMsg{approvalID: strings.TrimSpace(resp.Approval.ApprovalID), result: result} + return approvalResolvedMsg{approvalID: approvalUIValue(resp.Approval.ApprovalID), result: approvalUIValue(result)} } } diff --git a/cmd/runecode-tui/route_approvals_followup_helpers.go b/cmd/runecode-tui/route_approvals_followup_helpers.go new file mode 100644 index 00000000..85d60afe --- /dev/null +++ b/cmd/runecode-tui/route_approvals_followup_helpers.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func approvalEffectSummary(detail brokerapi.ApprovalDetail) string { + summary := strings.TrimSpace(detail.WhatChangesIfApproved.Summary) + effect := strings.TrimSpace(detail.WhatChangesIfApproved.EffectKind) + if summary == "" && effect == "" { + return "broker-owned next step is not described in approval detail" + } + if summary == "" { + return fmt.Sprintf("effect=%s", effect) + } + if effect == "" { + return summary + } + return fmt.Sprintf("%s (effect=%s)", summary, effect) +} + +func approvalResolveSummary(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + state := workflowApprovalState(summary, detail) + if state == "resolved" { + return "already resolved; broker recorded the decision and follow-on workflow state can continue when refreshed" + } + if state == "expired" { + return "unavailable because this approval expired; a fresh broker approval is required before the blocked action can continue" + } + if approvalResolveSupported(summary, detail) { + return "available from this route after reviewing the evidence" + } + switch strings.TrimSpace(summary.BoundScope.ActionKind) { + case "promotion": + return "continue in the promotion flow after review" + default: + return "unavailable here because this approval type does not have a supported typed resolve path in the current TUI" + } +} + +func approvalResolveSupported(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) bool { + selection := detail.BackendPostureSelection + return strings.TrimSpace(summary.BoundScope.ActionKind) == "backend_posture_change" && selection != nil && strings.TrimSpace(selection.TargetInstanceID) != "" && strings.TrimSpace(selection.TargetBackendKind) != "" +} + +func approvalResolveStatus(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + state := workflowApprovalState(summary, detail) + if state == "resolved" { + return "resolved" + } + if state == "expired" { + return "expired" + } + if state == "denied" { + return "denied" + } + if approvalLifecycleState(detail) != "pending" { + return "approval-required" + } + if approvalResolveSupported(summary, detail) { + return "supported" + } + return "unsupported" +} + +func approvalReviewFirst(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + if digest := strings.TrimSpace(detail.BoundIdentity.SummaryPreviewDigest); digest != "" { + return "summary preview artifact " + shortIdentity(digest) + " (copy raw digest from Artifacts if needed)" + } + if digest := strings.TrimSpace(detail.BoundIdentity.DiffDigest); digest != "" { + return "diff artifact " + shortIdentity(digest) + " (copy raw digest from Artifacts if needed)" + } + if digest := strings.TrimSpace(detail.BoundIdentity.ArtifactSetDigest); digest != "" { + return "artifact set " + shortIdentity(digest) + " (copy raw digest from Artifacts if needed)" + } + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + return "run evidence for " + run + } + return "the linked broker evidence" +} + +func approvalOperatorReason(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + switch workflowApprovalState(summary, detail) { + case "expired": + return "the previous approval window expired, so the blocked action needs a fresh approval before it can continue" + case "resolved": + return "the broker already recorded a decision for this approval" + case "denied": + return "the broker recorded a denied decision, so workflow progress needs a new approved gate" + default: + if approvalResolveSupported(summary, detail) { + return fmt.Sprintf("operator review is still required before %s can continue", approvalDisplayLabel(summary)) + } + return fmt.Sprintf("operator review is required before %s can continue", approvalDisplayLabel(summary)) + } +} + +func approvalWorkScopeSummary(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + scope := detail.BlockedWorkScope + if strings.TrimSpace(scope.ActionKind) == "" { + scope.WorkspaceID = summary.BoundScope.WorkspaceID + scope.RunID = summary.BoundScope.RunID + scope.StageID = summary.BoundScope.StageID + scope.StepID = summary.BoundScope.StepID + scope.RoleInstanceID = summary.BoundScope.RoleInstanceID + scope.ActionKind = summary.BoundScope.ActionKind + } + parts := []string{} + if run := strings.TrimSpace(valueOrBlank(scope.RunID)); run != "" { + parts = append(parts, "run "+run) + } + if stage := strings.TrimSpace(valueOrBlank(scope.StageID)); stage != "" { + parts = append(parts, "stage "+stage) + } + if step := strings.TrimSpace(valueOrBlank(scope.StepID)); step != "" { + parts = append(parts, "step "+step) + } + if role := strings.TrimSpace(valueOrBlank(scope.RoleInstanceID)); role != "" { + parts = append(parts, "role "+role) + } + if action := strings.TrimSpace(scope.ActionKind); action != "" { + parts = append(parts, humanizeExecutionToken(action)) + } + if len(parts) == 0 { + return "linked approval scope" + } + return strings.Join(parts, " • ") +} + +func approvalEvidenceGuidance(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + first := approvalReviewFirst(summary, detail) + if strings.TrimSpace(summary.BoundScope.RunID) != "" { + return fmt.Sprintf("Evidence trail: review %s, then open Audit for policy and verification context.", first) + } + return fmt.Sprintf("Evidence trail: review %s, then continue in Audit for the linked decision record.", first) +} + +func approvalDecisionGuidance(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + switch workflowApprovalState(summary, detail) { + case "resolved": + return "Decision path: this approval is already settled; refresh to confirm the workflow continues." + case "expired": + return "Decision path: this approval expired; request a fresh approval before continuing." + case "denied": + return "Decision path: this approval was denied; the workflow needs a new approved gate before continuing." + } + if approvalResolveSupported(summary, detail) { + return "Decision path: after review, you can resolve this approval here." + } + return fmt.Sprintf("Decision path: review here, then continue in %s for the final workflow-specific step.", approvalFollowUpRoute(summary)) +} + +func approvalAuditLinkSummary(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + return "run " + run + } + if digest := strings.TrimSpace(detail.BoundIdentity.PolicyDecisionHash); digest != "" { + return "policy decision " + shortIdentity(digest) + } + return "linked approval records" +} + +func approvalNextAction(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + if approvalResolveSupported(summary, detail) { + return "resolve it here if the evidence supports the requested change" + } + return "continue in the linked route after review" +} + +func approvalFollowUpRoute(summary brokerapi.ApprovalSummary) string { + if strings.TrimSpace(summary.BoundScope.RunID) != "" { + return "Artifacts → Audit" + } + return "Audit" +} + +func approvalDisplayLabel(summary brokerapi.ApprovalSummary) string { + action := humanizeExecutionToken(summary.BoundScope.ActionKind) + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + if action != "" { + return fmt.Sprintf("%s for %s", approvalUIValue(action), approvalUIValue(run)) + } + return "approval for " + approvalUIValue(run) + } + if action != "" { + return approvalUIValue(action) + } + return approvalUIValue(summary.ApprovalID) +} + +func approvalQueueReason(summary brokerapi.ApprovalSummary) string { + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + return "Review evidence for " + approvalUIValue(run) + } + if action := strings.TrimSpace(summary.BoundScope.ActionKind); action != "" { + return "Review the requested " + approvalUIValue(humanizeExecutionToken(action)) + } + return "Review linked evidence" +} + +func shortIdentity(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 20 { + return valueOrNA(value) + } + parts := strings.SplitN(value, ":", 2) + if len(parts) == 2 && len(parts[1]) > 12 { + return parts[0] + ":" + parts[1][:12] + } + return value[:20] +} + +func valueOrBlank(value string) string { + return strings.TrimSpace(value) +} diff --git a/cmd/runecode-tui/route_approvals_helpers.go b/cmd/runecode-tui/route_approvals_helpers.go index e717a2b0..d01d4793 100644 --- a/cmd/runecode-tui/route_approvals_helpers.go +++ b/cmd/runecode-tui/route_approvals_helpers.go @@ -1,162 +1,12 @@ package main import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" "fmt" "strings" "github.com/runecode-ai/runecode/internal/brokerapi" - "github.com/runecode-ai/runecode/internal/trustpolicy" - "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" ) -func validateApprovalResolveInput(resp brokerapi.ApprovalGetResponse) error { - approvalID := strings.TrimSpace(resp.Approval.ApprovalID) - if approvalID == "" { - return fmt.Errorf("approval detail missing approval_id") - } - if resp.SignedApprovalRequest == nil || resp.SignedApprovalDecision == nil { - return fmt.Errorf("approval resolve requires signed approval request and decision envelopes") - } - boundScope := resp.Approval.BoundScope - actionKind := strings.TrimSpace(boundScope.ActionKind) - switch actionKind { - case "backend_posture_change": - if err := validateApprovalResolveEnvelopeBinding(resp); err != nil { - return err - } - selection := resp.ApprovalDetail.BackendPostureSelection - if selection == nil { - return fmt.Errorf("approval resolve requires typed backend posture selection detail") - } - if strings.TrimSpace(selection.TargetInstanceID) == "" || strings.TrimSpace(selection.TargetBackendKind) == "" { - return fmt.Errorf("approval resolve requires backend posture target instance and backend kind") - } - case "promotion": - return fmt.Errorf("promotion approvals must be resolved via promote-excerpt to preserve exact promotion binding") - default: - return fmt.Errorf("approval resolve does not support this action kind") - } - return nil -} - -func validateApprovalResolveEnvelopeBinding(resp brokerapi.ApprovalGetResponse) error { - requestID, err := approvalRequestDigestIdentity(*resp.SignedApprovalRequest) - if err != nil { - return fmt.Errorf("approval resolve request envelope invalid: %w", err) - } - if err := approvalDecisionMatchesRequest(*resp.SignedApprovalDecision, requestID); err != nil { - return fmt.Errorf("approval resolve decision envelope invalid: %w", err) - } - return nil -} - -func approvalRequestDigestIdentity(envelope trustpolicy.SignedObjectEnvelope) (string, error) { - if envelope.PayloadSchemaID != trustpolicy.ApprovalRequestSchemaID { - return "", fmt.Errorf("unexpected request payload schema") - } - digest, err := digestIdentityFromEnvelopePayload(envelope.Payload) - if err != nil { - return "", err - } - return digest, nil -} - -func approvalDecisionMatchesRequest(envelope trustpolicy.SignedObjectEnvelope, requestID string) error { - if envelope.PayloadSchemaID != trustpolicy.ApprovalDecisionSchemaID { - return fmt.Errorf("unexpected decision payload schema") - } - if len(envelope.Payload) == 0 { - return fmt.Errorf("decision payload missing") - } - payload := map[string]json.RawMessage{} - if err := json.Unmarshal(envelope.Payload, &payload); err != nil { - return fmt.Errorf("decode decision payload: %w", err) - } - rawDigest, ok := payload["approval_request_hash"] - if !ok { - return nil - } - var digest trustpolicy.Digest - if err := json.Unmarshal(rawDigest, &digest); err != nil { - return fmt.Errorf("decode approval_request_hash: %w", err) - } - identity, err := digest.Identity() - if err != nil { - return fmt.Errorf("invalid approval_request_hash: %w", err) - } - if identity != requestID { - return fmt.Errorf("decision payload does not match approval request") - } - return nil -} - -func digestIdentityFromEnvelopePayload(payload json.RawMessage) (string, error) { - canonicalDigest := trustpolicy.Digest{} - canonical, err := canonicalJSONPayload(payload) - if err != nil { - return "", err - } - canonicalDigest = trustpolicy.Digest{HashAlg: "sha256", Hash: canonical} - return canonicalDigest.Identity() -} - -func canonicalJSONPayload(payload json.RawMessage) (string, error) { - transformed, err := jsoncanonicalizerTransform(payload) - if err != nil { - return "", fmt.Errorf("canonicalize approval request payload: %w", err) - } - sum := sha256HexDigest(transformed) - return sum, nil -} - -func jsoncanonicalizerTransform(payload json.RawMessage) ([]byte, error) { - return jsoncanonicalizer.Transform(payload) -} - -func sha256HexDigest(payload []byte) string { - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:]) -} - -func approvalResolveRequestFromDetail(resp brokerapi.ApprovalGetResponse) (brokerapi.ApprovalResolveRequest, error) { - if err := validateApprovalResolveInput(resp); err != nil { - return brokerapi.ApprovalResolveRequest{}, err - } - summary := resp.Approval - boundScope := summary.BoundScope - if strings.TrimSpace(boundScope.SchemaID) == "" { - boundScope.SchemaID = "runecode.protocol.v0.ApprovalBoundScope" - } - if strings.TrimSpace(boundScope.SchemaVersion) == "" { - boundScope.SchemaVersion = "0.1.0" - } - resolveReq := brokerapi.ApprovalResolveRequest{ - SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", - SchemaVersion: localAPISchemaVersion, - ApprovalID: strings.TrimSpace(summary.ApprovalID), - BoundScope: boundScope, - ResolutionDetails: brokerapi.ApprovalResolveDetails{ - SchemaID: "runecode.protocol.v0.ApprovalResolveDetails", - SchemaVersion: "0.1.0", - }, - SignedApprovalRequest: *resp.SignedApprovalRequest, - SignedApprovalDecision: *resp.SignedApprovalDecision, - } - if strings.TrimSpace(boundScope.ActionKind) == "backend_posture_change" && resp.ApprovalDetail.BackendPostureSelection != nil { - selection := resp.ApprovalDetail.BackendPostureSelection - resolveReq.ResolutionDetails.BackendPostureSelection = &brokerapi.ApprovalResolveBackendPostureSelectionDetail{ - SchemaID: "runecode.protocol.v0.ApprovalResolveBackendPostureSelectionDetail", - SchemaVersion: "0.1.0", - TargetInstanceID: strings.TrimSpace(selection.TargetInstanceID), - TargetBackendKind: strings.TrimSpace(selection.TargetBackendKind), - } - } - return resolveReq, nil -} - func renderApprovalList(items []brokerapi.ApprovalSummary, selected int) string { if len(items) == 0 { return " - no approvals" @@ -176,7 +26,14 @@ func renderApprovalList(items []brokerapi.ApprovalSummary, selected int) string func renderApprovalDirectoryItems(items []brokerapi.ApprovalSummary) []string { out := make([]string, 0, len(items)) for _, item := range items { - out = append(out, fmt.Sprintf("%s %s trigger=%s", item.ApprovalID, stateBadgeWithLabel("status", item.Status), item.ApprovalTriggerCode)) + row := fmt.Sprintf("%s %s %s", valueOrNA(item.ApprovalID), approvalDisplayLabel(item), approvalPrimaryStateBadge(item)) + if cue := strings.TrimSpace(approvalQueueReason(item)); cue != "" { + row += " • " + cue + } + if scope := strings.TrimSpace(approvalBoundScopeCue(item)); scope != "" { + row += " • " + scope + } + out = append(out, row) } return out } @@ -203,17 +60,15 @@ func renderApprovalInspector(resp *brokerapi.ApprovalGetResponse, presentation c document.SetDocument(ref, contentKind, "approval details", content) return renderInspectorShell(inspectorShellSpec{ Title: "Approval inspector", - Summary: fmt.Sprintf("approval=%s status=%s trigger=%s", summary.ApprovalID, valueOrNA(summary.Status), valueOrNA(summary.ApprovalTriggerCode)), - Identity: fmt.Sprintf("approval=%s run=%s", summary.ApprovalID, valueOrNA(boundScope.RunID)), - Status: fmt.Sprintf("lifecycle=%s policy_reason=%s", lifecycleState, valueOrNA(detail.PolicyReasonCode)), - Badges: []string{stateBadgeWithLabel("status", summary.Status), appTheme.InspectorHint.Render("policy/trigger/system cues are distinct")}, + Summary: fmt.Sprintf("%s is %s.", approvalDisplayLabel(summary), approvalDisplayState(summary, detail)), + Identity: fmt.Sprintf("Approval %s • run %s • %s", summary.ApprovalID, valueOrNA(boundScope.RunID), humanizeExecutionToken(boundScope.ActionKind)), + Status: fmt.Sprintf("Decision path: %s", approvalResolveSummary(summary, detail)), + Badges: []string{stateBadgeWithLabel("status", summary.Status), appTheme.InspectorHint.Render("operator review details")}, References: []inspectorReference{ {Label: "run", Items: mapReferenceIDs([]string{boundScope.RunID}, func(id string) paletteActionMsg { return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: id}} })}, - {Label: "stage", Items: mapReferenceIDs([]string{boundScope.StageID}, func(id string) paletteActionMsg { - return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeApprovals}} - })}, + {Label: "approval evidence", Items: approvalEvidenceReferenceItems(resp)}, }, LocalActions: approvalInspectorLocalActions(), CopyActions: approvalRouteCopyActions(resp), @@ -227,6 +82,7 @@ func approvalInspectorLocalActions() []routeActionItem { return []routeActionItem{ {Label: "resolve:typed"}, {Label: "jump:runs", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeRuns}}}, + {Label: "jump:artifacts", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeArtifacts}}}, {Label: "jump:audit", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}}}, {Label: "copy:approval_id"}, } @@ -243,6 +99,34 @@ func approvalInspectorReferenceActions(resp *brokerapi.ApprovalGetResponse) []ro }) { items = append(items, routeActionItem{Label: "run:" + ref.Label, Action: ref.Action}) } + for _, ref := range approvalEvidenceReferenceItems(resp) { + items = append(items, routeActionItem{Label: "evidence:" + ref.Label, Action: ref.Action}) + } + return items +} + +func approvalEvidenceReferenceItems(resp *brokerapi.ApprovalGetResponse) []inspectorReferenceItem { + if resp == nil { + return nil + } + detail := resp.ApprovalDetail + items := make([]inspectorReferenceItem, 0, 6) + appendArtifact := func(label, digest string) { + digest = strings.TrimSpace(digest) + if digest == "" { + return + } + items = append(items, inspectorReferenceItem{Label: label + " " + shortIdentity(digest), Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "artifact", RouteID: routeArtifacts, Digest: digest}}}) + } + appendArtifact("summary", detail.BoundIdentity.SummaryPreviewDigest) + appendArtifact("diff", detail.BoundIdentity.DiffDigest) + appendArtifact("artifact_set", detail.BoundIdentity.ArtifactSetDigest) + if policy := strings.TrimSpace(detail.BoundIdentity.PolicyDecisionHash); policy != "" { + items = append(items, inspectorReferenceItem{Label: "policy " + shortIdentity(policy), Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}}}) + } + if manifest := strings.TrimSpace(detail.BoundIdentity.ManifestHash); manifest != "" { + items = append(items, inspectorReferenceItem{Label: "manifest " + shortIdentity(manifest), Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}}}) + } return items } @@ -256,29 +140,31 @@ func approvalBindingLabel(bindingKind string) string { func approvalInspectorContent(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail, identity brokerapi.ApprovalBoundIdentity, boundScope brokerapi.ApprovalBoundScope, bindingLabel string, lifecycleState string, lifecycleFlags string, presentation contentPresentationMode) string { if presentation == presentationStructured { return compactLines( - fmt.Sprintf("structured approval: id=%s", summary.ApprovalID), - fmt.Sprintf("counts: lifecycle_flags=%s links=identity+scope", lifecycleFlags), + fmt.Sprintf("structured approval: id=%s", approvalUIValue(summary.ApprovalID)), + fmt.Sprintf("state: workflow=%s resolve=%s lifecycle_flags=%s", approvalUIValue(workflowApprovalState(summary, detail)), approvalUIValue(approvalResolveStatus(summary, detail)), approvalUIValue(lifecycleFlags)), + fmt.Sprintf("review_first=%s next_route=%s", approvalUIValue(approvalReviewFirst(summary, detail)), approvalUIValue(approvalFollowUpRoute(summary))), + fmt.Sprintf("identity: request=%s decision=%s", approvalUIValue(identity.ApprovalRequestDigest), approvalUIValue(identity.ApprovalDecisionDigest)), ) } if presentation == presentationRaw { return compactLines( - fmt.Sprintf("raw approval_id=%s status=%s trigger=%s", summary.ApprovalID, summary.Status, summary.ApprovalTriggerCode), - fmt.Sprintf("raw bound_scope workspace=%s run=%s stage=%s action=%s", valueOrNA(boundScope.WorkspaceID), valueOrNA(boundScope.RunID), valueOrNA(boundScope.StageID), valueOrNA(boundScope.ActionKind)), - fmt.Sprintf("raw lifecycle state=%s reason=%s stale=%t", detail.LifecycleDetail.LifecycleState, valueOrNA(detail.LifecycleDetail.LifecycleReasonCode), detail.LifecycleDetail.Stale), + fmt.Sprintf("raw approval_id=%s status=%s trigger=%s", approvalUIValue(summary.ApprovalID), approvalUIValue(summary.Status), approvalUIValue(summary.ApprovalTriggerCode)), + fmt.Sprintf("raw bound_scope workspace=%s run=%s stage=%s action=%s", approvalUIValue(boundScope.WorkspaceID), approvalUIValue(boundScope.RunID), approvalUIValue(boundScope.StageID), approvalUIValue(boundScope.ActionKind)), + fmt.Sprintf("raw lifecycle state=%s reason=%s stale=%t", approvalUIValue(detail.LifecycleDetail.LifecycleState), approvalUIValue(detail.LifecycleDetail.LifecycleReasonCode), detail.LifecycleDetail.Stale), ) } return compactLines( - fmt.Sprintf("Approval type: %s (binding_kind=%s) %s", bindingLabel, detail.BindingKind, infoBadge("type cue")), - fmt.Sprintf("Lifecycle state: %s (%s) %s", lifecycleState, lifecycleFlags, postureBadge(lifecycleState)), - fmt.Sprintf("Lifecycle reason code: %s", detail.LifecycleDetail.LifecycleReasonCode), - fmt.Sprintf("Policy reason code: %s %s", detail.PolicyReasonCode, warnBadge("policy cue")), - fmt.Sprintf("Approval trigger code: %s %s", summary.ApprovalTriggerCode, infoBadge("trigger cue")), - fmt.Sprintf("Distinct blocking semantics: trigger=%s cue=%s", summary.ApprovalTriggerCode, renderBlockingStateCue(true, summary.ApprovalTriggerCode)), - "Execution/system errors: shown as load failures above; not merged with policy/trigger codes. "+dangerBadge("system cue"), - fmt.Sprintf("What changes if approved: effect=%s summary=%s", detail.WhatChangesIfApproved.EffectKind, detail.WhatChangesIfApproved.Summary), - fmt.Sprintf("Blocked work scope: kind=%s action=%s run=%s stage=%s step=%s role=%s", detail.BlockedWorkScope.ScopeKind, detail.BlockedWorkScope.ActionKind, valueOrNA(detail.BlockedWorkScope.RunID), valueOrNA(detail.BlockedWorkScope.StageID), valueOrNA(detail.BlockedWorkScope.StepID), valueOrNA(detail.BlockedWorkScope.RoleInstanceID)), - fmt.Sprintf("Canonical bound identity: request=%s decision=%s manifest=%s policy_decision=%s", valueOrNA(identity.ApprovalRequestDigest), valueOrNA(identity.ApprovalDecisionDigest), valueOrNA(identity.ManifestHash), valueOrNA(identity.PolicyDecisionHash)), - fmt.Sprintf("Exact bound scope: workspace=%s run=%s stage=%s step=%s role=%s action=%s", valueOrNA(boundScope.WorkspaceID), valueOrNA(boundScope.RunID), valueOrNA(boundScope.StageID), valueOrNA(boundScope.StepID), valueOrNA(boundScope.RoleInstanceID), valueOrNA(boundScope.ActionKind)), + fmt.Sprintf("Approval state: %s %s", approvalUIValue(approvalDisplayState(summary, detail)), approvalPrimaryStateBadge(summary)), + fmt.Sprintf("Why this approval exists: %s", approvalUIValue(approvalPrimaryReason(summary, detail))), + fmt.Sprintf("What is gated: %s", approvalUIValue(approvalExactObjectAction(summary, detail))), + fmt.Sprintf("Review first: %s", approvalUIValue(approvalReviewFirst(summary, detail))), + fmt.Sprintf("If approved next: %s", approvalUIValue(approvalEffectSummary(detail))), + fmt.Sprintf("Continue after review: %s", approvalUIValue(approvalFollowUpRoute(summary))), + fmt.Sprintf("Resolve availability: %s", approvalUIValue(approvalResolveSummary(summary, detail))), + fmt.Sprintf("Resolve unavailable because: %s", approvalUIValue(approvalResolveBlockedReason(summary, detail))), + fmt.Sprintf("Workflow state: %s; lifecycle=%s (%s)", approvalUIValue(workflowApprovalState(summary, detail)), approvalUIValue(lifecycleState), approvalUIValue(lifecycleFlags)), + "Safety cue: approval policy, lifecycle, and execution/system errors remain distinct before any decision is accepted.", + muted(fmt.Sprintf("Structured/raw modes expose %s, binding kind, trigger codes, policy reason codes, bound scope, request/decision digests, manifest, and policy decision hash.", approvalUIValue(bindingLabel))), ) } @@ -296,20 +182,26 @@ func approvalRouteCopyActions(resp *brokerapi.ApprovalGetResponse) []routeCopyAc summary := resp.Approval bound := summary.BoundScope raw := compactLines( - fmt.Sprintf("approval_id=%s", summary.ApprovalID), - fmt.Sprintf("status=%s", summary.Status), - fmt.Sprintf("trigger=%s", summary.ApprovalTriggerCode), - fmt.Sprintf("run_id=%s", bound.RunID), - fmt.Sprintf("stage_id=%s", bound.StageID), - fmt.Sprintf("action_kind=%s", bound.ActionKind), + fmt.Sprintf("approval_id=%s", approvalUIValue(summary.ApprovalID)), + fmt.Sprintf("status=%s", approvalUIValue(summary.Status)), + fmt.Sprintf("trigger=%s", approvalUIValue(summary.ApprovalTriggerCode)), + fmt.Sprintf("run_id=%s", approvalUIValue(bound.RunID)), + fmt.Sprintf("stage_id=%s", approvalUIValue(bound.StageID)), + fmt.Sprintf("action_kind=%s", approvalUIValue(bound.ActionKind)), ) return compactCopyActions([]routeCopyAction{ - {ID: "approval_id", Label: "approval id", Text: summary.ApprovalID}, - {ID: "run_id", Label: "bound run id", Text: bound.RunID}, - {ID: "raw_block", Label: "raw block", Text: raw}, + {ID: "approval_id", Label: "approval id", Text: approvalUIValue(summary.ApprovalID)}, + {ID: "run_id", Label: "bound run id", Text: approvalUIValue(bound.RunID)}, + {ID: "request_digest", Label: "request digest", Text: approvalUIValue(resp.ApprovalDetail.BoundIdentity.ApprovalRequestDigest)}, + {ID: "decision_digest", Label: "decision digest", Text: approvalUIValue(resp.ApprovalDetail.BoundIdentity.ApprovalDecisionDigest)}, + {ID: "raw_block", Label: "raw block", Text: sanitizeUIText(raw)}, }) } +func approvalUIValue(value string) string { + return valueOrNA(sanitizeUIText(value)) +} + func renderApprovalLifecycleFlags(detail brokerapi.ApprovalLifecycleDetail) string { flags := []string{} if detail.Stale { @@ -361,19 +253,74 @@ func renderApprovalSafetyStrip(resp *brokerapi.ApprovalGetResponse) string { stateCue := renderBlockingStateCue(true, d.PolicyReasonCode) triggerCue := renderBlockingStateCue(true, s.ApprovalTriggerCode) return compactLines( - tableHeader("Approval safety strip")+" "+approvalRequiredBadge("APPROVAL_REQUIRED")+" profile cues remain explicit", - fmt.Sprintf("status=%s %s | policy_reason_code=%s %s | approval_trigger_code=%s %s", s.Status, postureBadge(s.Status), valueOrNA(d.PolicyReasonCode), stateCue, valueOrNA(s.ApprovalTriggerCode), triggerCue), + tableHeader("Approval posture")+" "+approvalPrimaryStateBadge(s)+" "+approvalSupportBadge(s, d), + fmt.Sprintf("Needs attention because %s", approvalPrimaryReason(s, d)), + fmt.Sprintf("Broker cues: policy %s %s | trigger %s %s", approvalUIValue(d.PolicyReasonCode), stateCue, approvalUIValue(s.ApprovalTriggerCode), triggerCue), ) } func renderApprovalFlowPath(resp *brokerapi.ApprovalGetResponse) string { if resp == nil { - return "Flow path: run -> approval -> typed resolve (shared exact-action path) -> run resumes (load an approval detail to inspect)" + return "Evidence path: run waits for approval -> review evidence -> resolve where supported -> blocked workflow continues when broker state advances" } s := resp.Approval - workspace := valueOrNA(s.BoundScope.WorkspaceID) - run := valueOrNA(s.BoundScope.RunID) - stage := valueOrNA(s.BoundScope.StageID) - action := valueOrNA(s.BoundScope.ActionKind) - return fmt.Sprintf("Flow path: workspace=%s run=%s stage=%s action=%s -> approval=%s -> typed approval_resolve -> resume signal", workspace, run, stage, action, s.ApprovalID) + return fmt.Sprintf("Evidence path: %s -> artifacts (%s) -> audit (%s) -> verification posture (Audit) -> anchor/export actions where available", approvalUIValue(approvalDisplayLabel(s)), approvalUIValue(approvalReviewFirst(s, resp.ApprovalDetail)), approvalUIValue(approvalAuditLinkSummary(s, resp.ApprovalDetail))) +} + +func renderApprovalOverviewCard(resp *brokerapi.ApprovalGetResponse) string { + if resp == nil { + return renderStateCardSpec(stateCardSpec{State: routeLoadStateWaiting, Title: "Approval review", Message: "Select an approval to see the blocked workflow, review order, and next step.", Reason: "No active approval detail is loaded yet.", NextAction: "Move through the queue and press enter to inspect the broker-owned approval detail.", ShortcutCue: "enter", RouteCue: "Approvals"}) + } + summary := resp.Approval + detail := resp.ApprovalDetail + state := routeLoadStateReady + switch workflowApprovalState(summary, detail) { + case "approval required", "pending": + state = routeLoadStateApprovalRequired + case "expired", "unsupported": + state = routeLoadStateBlocked + case "resolved": + state = routeLoadStateCompleted + } + return renderStateCardSpec(stateCardSpec{ + State: state, + Title: "Approval review", + Message: fmt.Sprintf("%s needs attention for %s.", approvalDisplayLabel(summary), approvalDisplayState(summary, detail)), + Reason: approvalOperatorReason(summary, detail), + NextAction: fmt.Sprintf("Review %s first, then %s.", approvalReviewFirst(summary, detail), approvalNextAction(summary, detail)), + ShortcutCue: "enter / a", + RouteCue: approvalFollowUpRoute(summary), + }) +} + +func renderApprovalDecisionWorkbench(resp *brokerapi.ApprovalGetResponse) string { + if resp == nil { + return compactLines( + tableHeader("Decision workbench"), + "Load an approval to review the evidence trail and decide where the workflow should continue.", + ) + } + summary := resp.Approval + detail := resp.ApprovalDetail + return compactLines( + tableHeader("Decision workbench"), + fmt.Sprintf("Start with %s. Scope: %s.", approvalReviewFirst(summary, detail), approvalWorkScopeSummary(summary, detail)), + approvalEvidenceGuidance(summary, detail), + approvalDecisionGuidance(summary, detail), + ) +} + +func renderApprovalReviewPlan(resp *brokerapi.ApprovalGetResponse) string { + if resp == nil { + return "Review plan: load an approval to see what is blocked, what to review first, and where to continue the evidence trail." + } + summary := resp.Approval + detail := resp.ApprovalDetail + return compactLines( + tableHeader("Review plan"), + fmt.Sprintf("1. Inspect %s.", approvalReviewFirst(summary, detail)), + fmt.Sprintf("2. Confirm gated scope: %s.", approvalExactObjectAction(summary, detail)), + fmt.Sprintf("3. Continue to %s for the audit + verification trail.", approvalFollowUpRoute(summary)), + fmt.Sprintf("4. Resolve only when broker validation allows it; current status=%s.", approvalResolveStatus(summary, detail)), + ) } diff --git a/cmd/runecode-tui/route_approvals_resolve_helpers.go b/cmd/runecode-tui/route_approvals_resolve_helpers.go new file mode 100644 index 00000000..dffead0d --- /dev/null +++ b/cmd/runecode-tui/route_approvals_resolve_helpers.go @@ -0,0 +1,167 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func validateApprovalResolveInput(resp brokerapi.ApprovalGetResponse) error { + approvalID := strings.TrimSpace(resp.Approval.ApprovalID) + if approvalID == "" { + return fmt.Errorf("approval detail missing approval_id") + } + if resp.SignedApprovalRequest == nil || resp.SignedApprovalDecision == nil { + return fmt.Errorf("approval resolve requires signed approval request and decision envelopes") + } + boundScope := resp.Approval.BoundScope + actionKind := strings.TrimSpace(boundScope.ActionKind) + switch actionKind { + case "backend_posture_change": + if err := validateApprovalResolveEnvelopeBinding(resp); err != nil { + return err + } + selection := resp.ApprovalDetail.BackendPostureSelection + if selection == nil { + return fmt.Errorf("approval resolve requires typed backend posture selection detail") + } + if strings.TrimSpace(selection.TargetInstanceID) == "" || strings.TrimSpace(selection.TargetBackendKind) == "" { + return fmt.Errorf("approval resolve requires backend posture target instance and backend kind") + } + case "promotion": + return fmt.Errorf("resolve unavailable: promotion approvals must be completed in the promotion flow so exact promotion binding stays intact") + default: + return fmt.Errorf("resolve unavailable: this approval action kind is not supported by the current TUI resolve flow") + } + return nil +} + +func approvalResolveUnavailableReason(err error) string { + if err == nil { + return "Resolve unavailable." + } + text := safeUIErrorText(err) + text = strings.TrimSpace(strings.TrimPrefix(text, "resolve unavailable:")) + if text == "" { + return "Resolve unavailable." + } + return "Resolve unavailable: " + text + "." +} + +func validateApprovalResolveEnvelopeBinding(resp brokerapi.ApprovalGetResponse) error { + // The TUI performs only a consistency guard between broker-projected signed + // envelopes. Signature authenticity remains broker-owned and is revalidated + // by ApprovalResolve before any authority-bearing mutation is accepted. + requestID, err := approvalRequestDigestIdentity(*resp.SignedApprovalRequest) + if err != nil { + return fmt.Errorf("approval resolve request envelope invalid: %w", err) + } + if err := approvalDecisionMatchesRequest(*resp.SignedApprovalDecision, requestID); err != nil { + return fmt.Errorf("approval resolve decision envelope invalid: %w", err) + } + return nil +} + +func approvalRequestDigestIdentity(envelope trustpolicy.SignedObjectEnvelope) (string, error) { + if envelope.PayloadSchemaID != trustpolicy.ApprovalRequestSchemaID { + return "", fmt.Errorf("unexpected request payload schema") + } + digest, err := digestIdentityFromEnvelopePayload(envelope.Payload) + if err != nil { + return "", err + } + return digest, nil +} + +func approvalDecisionMatchesRequest(envelope trustpolicy.SignedObjectEnvelope, requestID string) error { + if envelope.PayloadSchemaID != trustpolicy.ApprovalDecisionSchemaID { + return fmt.Errorf("unexpected decision payload schema") + } + if len(envelope.Payload) == 0 { + return fmt.Errorf("decision payload missing") + } + payload := map[string]json.RawMessage{} + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + return fmt.Errorf("decode decision payload: %w", err) + } + rawDigest, ok := payload["approval_request_hash"] + if !ok { + return fmt.Errorf("approval_request_hash missing from decision payload") + } + var digest trustpolicy.Digest + if err := json.Unmarshal(rawDigest, &digest); err != nil { + return fmt.Errorf("decode approval_request_hash: %w", err) + } + identity, err := digest.Identity() + if err != nil { + return fmt.Errorf("invalid approval_request_hash: %w", err) + } + if identity != requestID { + return fmt.Errorf("decision payload does not match approval request: decision=%s request=%s", shortIdentity(identity), shortIdentity(requestID)) + } + return nil +} + +func digestIdentityFromEnvelopePayload(payload json.RawMessage) (string, error) { + hexDigest, err := canonicalJSONPayload(payload) + if err != nil { + return "", err + } + return (trustpolicy.Digest{HashAlg: "sha256", Hash: hexDigest}).Identity() +} + +func canonicalJSONPayload(payload json.RawMessage) (string, error) { + transformed, err := jsoncanonicalizerTransform(payload) + if err != nil { + return "", fmt.Errorf("canonicalize approval request payload: %w", err) + } + return sha256HexDigest(transformed), nil +} + +func jsoncanonicalizerTransform(payload json.RawMessage) ([]byte, error) { + return jsoncanonicalizer.Transform(payload) +} + +func sha256HexDigest(payload []byte) string { + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func approvalResolveRequestFromDetail(resp brokerapi.ApprovalGetResponse) (brokerapi.ApprovalResolveRequest, error) { + if err := validateApprovalResolveInput(resp); err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + summary := resp.Approval + boundScope := summary.BoundScope + if strings.TrimSpace(boundScope.SchemaID) == "" { + return brokerapi.ApprovalResolveRequest{}, fmt.Errorf("approval resolve requires broker-provided bound scope schema id") + } + if strings.TrimSpace(boundScope.SchemaVersion) == "" { + return brokerapi.ApprovalResolveRequest{}, fmt.Errorf("approval resolve requires broker-provided bound scope schema version") + } + resolveReq := brokerapi.ApprovalResolveRequest{ + SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", + SchemaVersion: localAPISchemaVersion, + ApprovalID: strings.TrimSpace(summary.ApprovalID), + BoundScope: boundScope, + ResolutionDetails: brokerapi.ApprovalResolveDetails{SchemaID: "runecode.protocol.v0.ApprovalResolveDetails", SchemaVersion: "0.1.0"}, + SignedApprovalRequest: *resp.SignedApprovalRequest, + SignedApprovalDecision: *resp.SignedApprovalDecision, + } + if strings.TrimSpace(boundScope.ActionKind) == "backend_posture_change" && resp.ApprovalDetail.BackendPostureSelection != nil { + selection := resp.ApprovalDetail.BackendPostureSelection + resolveReq.ResolutionDetails.BackendPostureSelection = &brokerapi.ApprovalResolveBackendPostureSelectionDetail{ + SchemaID: "runecode.protocol.v0.ApprovalResolveBackendPostureSelectionDetail", + SchemaVersion: "0.1.0", + TargetInstanceID: strings.TrimSpace(selection.TargetInstanceID), + TargetBackendKind: strings.TrimSpace(selection.TargetBackendKind), + } + } + return resolveReq, nil +} diff --git a/cmd/runecode-tui/route_approvals_state_helpers.go b/cmd/runecode-tui/route_approvals_state_helpers.go new file mode 100644 index 00000000..2e24fd72 --- /dev/null +++ b/cmd/runecode-tui/route_approvals_state_helpers.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func approvalPrimaryStateBadge(summary brokerapi.ApprovalSummary) string { + return workflowApprovalBadge(summary, brokerapi.ApprovalDetail{}) +} + +func approvalSupportBadge(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + state := workflowApprovalState(summary, detail) + if state == "resolved" { + return successBadge("RESOLVED") + } + if state == "expired" { + return dangerBadge("EXPIRED") + } + if state == "denied" { + return dangerBadge("DENIED") + } + if approvalResolveSupported(summary, detail) { + return successBadge("RESOLVE_SUPPORTED") + } + return dangerBadge("RESOLVE_UNAVAILABLE") +} + +func workflowApprovalBadge(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + switch workflowApprovalState(summary, detail) { + case "resolved": + return successBadge("READY_TO_CONTINUE") + case "expired": + return dangerBadge("EXPIRED") + case "denied": + return dangerBadge("DENIED") + case "unsupported": + return dangerBadge("RESOLVE_UNAVAILABLE") + case "approval required": + return approvalRequiredBadge("APPROVAL_REQUIRED") + default: + return warnBadge("PENDING_REVIEW") + } +} + +func workflowApprovalState(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + status := strings.ToLower(strings.TrimSpace(summary.Status)) + lifecycle := approvalLifecycleState(detail) + if lifecycle == "expired" || status == "expired" { + return "expired" + } + if lifecycle == "approved" || lifecycle == "consumed" || status == "approved" || status == "consumed" || status == "resolved" { + return "resolved" + } + if lifecycle == "denied" || status == "denied" { + return "denied" + } + if lifecycle == "pending" || status == "pending" { + return "approval required" + } + if strings.TrimSpace(summary.ApprovalID) == "" { + return "pending" + } + return valueOrNA(status) +} + +func approvalLifecycleState(detail brokerapi.ApprovalDetail) string { + return strings.ToLower(strings.TrimSpace(detail.LifecycleDetail.LifecycleState)) +} + +func boolWord(ok bool) string { + if ok { + return "yes" + } + return "no" +} + +func approvalDisplayState(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + state := workflowApprovalState(summary, detail) + if state == "approval required" { + return "approval required" + } + return state +} + +func approvalPrimaryReason(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + switch workflowApprovalState(summary, detail) { + case "expired": + return "the previous approval window has expired, so the blocked action cannot continue until a fresh broker approval exists" + case "resolved": + return "the broker has already recorded a decision for this gate" + case "denied": + return "the broker recorded a denied decision for this gate; workflow progress requires a fresh approved gate before continuing" + default: + policy := strings.TrimSpace(detail.PolicyReasonCode) + trigger := strings.TrimSpace(summary.ApprovalTriggerCode) + if approvalResolveSupported(summary, detail) { + return fmt.Sprintf("approval required before %s can continue; broker validation still requires operator confirmation", approvalDisplayLabel(summary)) + } + if policy != "" { + return fmt.Sprintf("policy requires operator review before %s can continue", approvalDisplayLabel(summary)) + } + if trigger != "" { + return fmt.Sprintf("workflow is waiting on an approval gate triggered by %s", trigger) + } + return "workflow is blocked on an operator approval" + } +} + +func approvalResolveBlockedReason(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + state := workflowApprovalState(summary, detail) + switch state { + case "resolved": + return "not available because the broker already recorded a final decision" + case "expired": + return "not available because the approval expired and broker validation requires a fresh approval object" + } + if approvalResolveSupported(summary, detail) { + return "available after review-first evidence checks" + } + switch strings.TrimSpace(summary.BoundScope.ActionKind) { + case "promotion": + return "promotion approvals must stay in the promotion flow so exact promotion binding remains intact" + default: + return "broker validation does not expose a supported typed resolve path for this approval kind in the current TUI" + } +} + +func approvalBoundScopeCue(summary brokerapi.ApprovalSummary) string { + parts := []string{} + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + parts = append(parts, "run "+run) + } + if stage := strings.TrimSpace(summary.BoundScope.StageID); stage != "" { + parts = append(parts, "stage "+stage) + } + if action := strings.TrimSpace(summary.BoundScope.ActionKind); action != "" { + parts = append(parts, humanizeExecutionToken(action)) + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " • ") +} + +func approvalExactObjectAction(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + scope := detail.BlockedWorkScope + if strings.TrimSpace(scope.ActionKind) == "" { + scope.WorkspaceID = summary.BoundScope.WorkspaceID + scope.RunID = summary.BoundScope.RunID + scope.StageID = summary.BoundScope.StageID + scope.StepID = summary.BoundScope.StepID + scope.RoleInstanceID = summary.BoundScope.RoleInstanceID + scope.ActionKind = summary.BoundScope.ActionKind + } + parts := []string{} + if run := strings.TrimSpace(valueOrBlank(scope.RunID)); run != "" { + parts = append(parts, "run "+run) + } + if stage := strings.TrimSpace(valueOrBlank(scope.StageID)); stage != "" { + parts = append(parts, "stage "+stage) + } + if step := strings.TrimSpace(valueOrBlank(scope.StepID)); step != "" { + parts = append(parts, "step "+step) + } + if role := strings.TrimSpace(valueOrBlank(scope.RoleInstanceID)); role != "" { + parts = append(parts, "role "+role) + } + action := valueOrNA(strings.TrimSpace(scope.ActionKind)) + if len(parts) == 0 { + return humanizeExecutionToken(action) + } + return strings.Join(parts, " • ") + " • " + humanizeExecutionToken(action) +} diff --git a/cmd/runecode-tui/route_approvals_validation_test.go b/cmd/runecode-tui/route_approvals_validation_test.go index df442d65..88e6c689 100644 --- a/cmd/runecode-tui/route_approvals_validation_test.go +++ b/cmd/runecode-tui/route_approvals_validation_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" ) func TestValidateApprovalResolveInputRejectsUnsupportedActionKind(t *testing.T) { @@ -20,7 +21,97 @@ func TestValidateApprovalResolveInputRejectsUnsupportedActionKind(t *testing.T) if err == nil { t.Fatal("expected unsupported action_kind error") } - if got := err.Error(); !strings.Contains(got, "approval resolve does not support this action kind") { + if got := err.Error(); !strings.Contains(got, "resolve unavailable: this approval action kind is not supported by the current TUI resolve flow") { t.Fatalf("unexpected error = %q", got) } } + +func TestApprovalResolveUnavailableReasonUsesSafetyWording(t *testing.T) { + err := validateApprovalResolveInput(brokerapi.ApprovalGetResponse{ + Approval: brokerapi.ApprovalSummary{ + ApprovalID: "ap-promotion", + BoundScope: brokerapi.ApprovalBoundScope{ActionKind: "promotion"}, + }, + SignedApprovalRequest: testSignedApprovalRequest, + SignedApprovalDecision: testSignedApprovalDecision, + }) + if err == nil { + t.Fatal("expected unavailable error") + } + got := approvalResolveUnavailableReason(err) + if !strings.Contains(got, "Resolve unavailable: promotion approvals must be completed in the promotion flow") { + t.Fatalf("expected promotion safety wording, got %q", got) + } + if !strings.Contains(got, "exact promotion binding stays intact") { + t.Fatalf("expected exact binding safety rationale, got %q", got) + } +} + +func TestApprovalDecisionMatchesRequestRequiresBoundHash(t *testing.T) { + requestID, err := approvalRequestDigestIdentity(*testSignedApprovalRequest) + if err != nil { + t.Fatalf("approvalRequestDigestIdentity returned error: %v", err) + } + if err := approvalDecisionMatchesRequest(*testSignedApprovalDecision, requestID); err != nil { + t.Fatalf("expected matching approval_request_hash to pass, got %v", err) + } + + missing := *testSignedApprovalDecision + missing.Payload = []byte(`{"schema_id":"runecode.protocol.v0.ApprovalDecision","schema_version":"0.3.0"}`) + if err := approvalDecisionMatchesRequest(missing, requestID); err == nil || !strings.Contains(err.Error(), "approval_request_hash missing") { + t.Fatalf("expected missing approval_request_hash to fail closed, got %v", err) + } + + mismatch := *testSignedApprovalDecision + mismatch.Payload = []byte(`{"schema_id":"runecode.protocol.v0.ApprovalDecision","schema_version":"0.3.0","approval_request_hash":{"hash_alg":"sha256","hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}`) + if err := approvalDecisionMatchesRequest(mismatch, requestID); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected mismatched approval_request_hash to fail closed, got %v", err) + } +} + +func TestApprovalDecisionMatchesRequestRejectsInvalidHash(t *testing.T) { + requestID, err := approvalRequestDigestIdentity(*testSignedApprovalRequest) + if err != nil { + t.Fatalf("approvalRequestDigestIdentity returned error: %v", err) + } + invalid := *testSignedApprovalDecision + invalid.Payload = []byte(`{"schema_id":"runecode.protocol.v0.ApprovalDecision","schema_version":"0.3.0","approval_request_hash":{"hash_alg":"sha256","hash":"not-hex"}}`) + err = approvalDecisionMatchesRequest(invalid, requestID) + if err == nil { + t.Fatal("expected invalid approval_request_hash to fail closed") + } + if !strings.Contains(err.Error(), "invalid approval_request_hash") && !strings.Contains(err.Error(), "sha256 hash") { + t.Fatalf("unexpected invalid hash error: %v", err) + } + + wrongSchema := *testSignedApprovalDecision + wrongSchema.PayloadSchemaID = trustpolicy.ApprovalRequestSchemaID + if err := approvalDecisionMatchesRequest(wrongSchema, requestID); err == nil || !strings.Contains(err.Error(), "unexpected decision payload schema") { + t.Fatalf("expected wrong payload schema to fail closed, got %v", err) + } +} + +func TestApprovalRequestDigestIdentityRejectsWrongSchema(t *testing.T) { + wrongSchema := *testSignedApprovalRequest + wrongSchema.PayloadSchemaID = trustpolicy.ApprovalDecisionSchemaID + err := validateApprovalResolveInput(brokerapi.ApprovalGetResponse{ + Approval: brokerapi.ApprovalSummary{ApprovalID: "ap-1", BoundScope: brokerapi.ApprovalBoundScope{ActionKind: "backend_posture_change"}}, + ApprovalDetail: brokerapi.ApprovalDetail{BackendPostureSelection: &brokerapi.ApprovalBackendPostureSelection{TargetInstanceID: "instance-1", TargetBackendKind: "container"}}, + SignedApprovalRequest: &wrongSchema, + SignedApprovalDecision: testSignedApprovalDecision, + }) + if err == nil || !strings.Contains(err.Error(), "unexpected request payload schema") { + t.Fatalf("expected wrong request payload schema to fail closed, got %v", err) + } +} + +func TestApprovalResolveStatusDistinguishesDenied(t *testing.T) { + summary := brokerapi.ApprovalSummary{ApprovalID: "ap-denied", Status: "denied"} + detail := brokerapi.ApprovalDetail{LifecycleDetail: brokerapi.ApprovalLifecycleDetail{LifecycleState: "denied"}} + if got := workflowApprovalState(summary, detail); got != "denied" { + t.Fatalf("workflowApprovalState = %q, want denied", got) + } + if got := approvalResolveStatus(summary, detail); got != "denied" { + t.Fatalf("approvalResolveStatus = %q, want denied", got) + } +} diff --git a/cmd/runecode-tui/route_artifacts.go b/cmd/runecode-tui/route_artifacts.go index adcc8ae4..76ce69cc 100644 --- a/cmd/runecode-tui/route_artifacts.go +++ b/cmd/runecode-tui/route_artifacts.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -155,14 +154,14 @@ func (m artifactsRouteModel) View(width, height int, focus focusArea) string { } body := []string{ sectionTitle("Artifacts") + " " + focusBadge(focus), - fmt.Sprintf("Filter data_class=%q", artifactClassFilters[m.classIndex]), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderArtifactOverviewCard(m.active, artifactClassFilters[m.classIndex]), + renderArtifactEvidenceTrail(m.active), + renderArtifactClassFilterLine(artifactClassFilters[m.classIndex]), renderDirectory("Artifact directory", renderArtifactDirectoryItems(m.items), m.selected), } if len(m.items) == 0 { body = append(body, muted("No artifacts match the current class filter; switch filters or reload after new evidence arrives.")) } - body = append(body, keyHint("Route keys: j/k move, enter load detail, [/] class filter, m cycle detail mode, v cycle rendered/raw/structured, i toggle inspector, r reload")) return compactLines(body...) } @@ -187,14 +186,12 @@ func (m artifactsRouteModel) ShellSurface(ctx routeShellContext) routeSurface { Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Title: "Artifact workspace", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, Inspector: routeSurfaceRegion{Title: "Artifact inspector", Body: inspector}, - Bottom: routeSurfaceRegion{Body: keyHint("Route keys: j/k move, enter load detail, [/] class filter, m cycle detail mode, v cycle rendered/raw/structured, i toggle inspector, r reload")}, + Bottom: routeSurfaceRegion{Body: keyHint("Keys: j/k move, enter detail, [/] filter, m detail view, v mode, r reload")}, Status: routeSurfaceRegion{Body: status}, }, Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: m.inspectorOn}}, Chrome: routeSurfaceChrome{Breadcrumbs: breadcrumbs}, Actions: routeSurfaceActions{ - ModeTabs: []string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, - ActiveTab: string(normalizePresentationMode(m.presentation)), CopyActions: artifactRouteCopyActions(m.active, m.content), ReferenceActions: artifactInspectorReferenceActions(m.active), LocalActions: artifactInspectorLocalActions(), @@ -314,9 +311,25 @@ func (m artifactsRouteModel) loadArtifactDetail(ctx context.Context, items []bro if err != nil { return artifactsLoadedMsg{err: err, seq: seq} } + headResp = enrichArtifactHeadFromList(headResp, items, digest) return m.loadArtifactContent(ctx, items, digest, headResp, seq) } +func enrichArtifactHeadFromList(head brokerapi.LocalArtifactHeadResponse, items []brokerapi.ArtifactSummary, digest string) brokerapi.LocalArtifactHeadResponse { + if strings.TrimSpace(head.Artifact.RunID) != "" { + return head + } + digest = strings.TrimSpace(digest) + for _, item := range items { + if strings.TrimSpace(item.Reference.Digest) != digest { + continue + } + head.Artifact.RunID = item.RunID + return head + } + return head +} + func (m artifactsRouteModel) loadArtifactContent(ctx context.Context, items []brokerapi.ArtifactSummary, digest string, headResp brokerapi.LocalArtifactHeadResponse, seq uint64) artifactsLoadedMsg { readReq := brokerapi.ArtifactReadRequest{Digest: digest, ProducerRole: "workspace", ConsumerRole: "model_gateway", DataClass: string(headResp.Artifact.Reference.DataClass)} events, readErr := m.client.ArtifactRead(ctx, readReq) diff --git a/cmd/runecode-tui/route_artifacts_detail.go b/cmd/runecode-tui/route_artifacts_detail.go index 532005e1..85c2422c 100644 --- a/cmd/runecode-tui/route_artifacts_detail.go +++ b/cmd/runecode-tui/route_artifacts_detail.go @@ -7,6 +7,9 @@ import ( "github.com/runecode-ai/runecode/internal/brokerapi" ) +const artifactPreviewLineLimit = 10 +const artifactCopyPreviewLineLimit = 8 + func renderArtifactList(items []brokerapi.ArtifactSummary, selected int) string { if len(items) == 0 { return " - no artifacts" @@ -17,7 +20,7 @@ func renderArtifactList(items []brokerapi.ArtifactSummary, selected int) string if i == selected { marker = ">" } - line += selectedLine(i == selected, fmt.Sprintf(" %s %s class=%s bytes=%d run=%s", marker, item.Reference.Digest, item.Reference.DataClass, item.Reference.SizeBytes, item.RunID)) + "\n" + line += selectedLine(i == selected, fmt.Sprintf(" %s %s", marker, renderArtifactDirectoryRow(item))) + "\n" } return line } @@ -25,11 +28,23 @@ func renderArtifactList(items []brokerapi.ArtifactSummary, selected int) string func renderArtifactDirectoryItems(items []brokerapi.ArtifactSummary) []string { out := make([]string, 0, len(items)) for _, item := range items { - out = append(out, fmt.Sprintf("%s class=%s bytes=%d", item.Reference.Digest, item.Reference.DataClass, item.Reference.SizeBytes)) + out = append(out, renderArtifactDirectoryRow(item)) } return out } +func renderArtifactDirectoryRow(item brokerapi.ArtifactSummary) string { + parts := []string{artifactDisplayLabel(item), shortIdentity(item.Reference.Digest)} + meta := []string{} + if preview := artifactPreviewIdentity(item.Reference.SizeBytes, item.Reference.ContentType); preview != "" { + meta = append(meta, preview) + } + if len(meta) > 0 { + parts = append(parts, "• "+strings.Join(meta, " • ")) + } + return strings.Join(parts, " ") +} + func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode artifactDetailMode, presentation contentPresentationMode, content, contentErr string, document *longFormDocumentState) string { if head == nil { return " Select an artifact and press enter to load detail." @@ -44,8 +59,11 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art contentView := renderArtifactContent(mode, presentation, content, contentErr) kind := artifactContentKind(mode, a.Reference.ContentType, presentation) document.SetDocument(workbenchObjectRef{Kind: "artifact", ID: strings.TrimSpace(a.Reference.Digest)}, kind, fmt.Sprintf("%s content", mode), compactLines( + fmt.Sprintf("Evidence label: %s", artifactDisplayLabel(a)), fmt.Sprintf("Data class: %s", a.Reference.DataClass), - fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), + fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for final checks and receipts", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), + fmt.Sprintf("Detail mode: %s (typed metadata stays authoritative)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), "Inspectable content is supplemental evidence, not authoritative run/approval truth.", @@ -53,12 +71,14 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art )) return renderInspectorShell(inspectorShellSpec{ Title: "Artifact inspector", - Summary: fmt.Sprintf("artifact=%s class=%s bytes=%d", a.Reference.Digest, a.Reference.DataClass, a.Reference.SizeBytes), - Identity: fmt.Sprintf("digest=%s", a.Reference.Digest), - Status: fmt.Sprintf("data_class=%s content_type=%s", a.Reference.DataClass, a.Reference.ContentType), - Badges: []string{stateBadgeWithLabel("class", fmt.Sprintf("%v", a.Reference.DataClass)), appTheme.InspectorHint.Render("typed metadata first")}, + Summary: fmt.Sprintf("%s ready to inspect.", artifactDisplayLabel(a)), + Identity: fmt.Sprintf("Artifact %s • digest %s", artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)), + Status: fmt.Sprintf("From run %s • open Audit next for final checks or receipts", valueOrNA(a.RunID)), + Badges: []string{stateBadgeWithLabel("class", fmt.Sprintf("%v", a.Reference.DataClass)), appTheme.InspectorHint.Render("evidence details")}, References: []inspectorReference{{Label: "run", Items: mapReferenceIDs([]string{a.RunID}, func(id string) paletteActionMsg { return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: id}} + })}, {Label: "artifact", Items: mapReferenceIDs([]string{a.Reference.Digest}, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "artifact", RouteID: routeArtifacts, Digest: id}} })}}, LocalActions: artifactInspectorLocalActions(), CopyActions: artifactRouteCopyActions(head, content), @@ -74,6 +94,7 @@ func artifactInspectorLocalActions() []routeActionItem { {Label: "jump:audit", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}}}, {Label: "copy:digest"}, {Label: "copy:provenance_receipt"}, + {Label: "copy:artifact_preview"}, } } @@ -84,9 +105,16 @@ func artifactInspectorReferenceActions(head *brokerapi.LocalArtifactHeadResponse items := mapReferenceIDs([]string{head.Artifact.RunID}, func(id string) paletteActionMsg { return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "run", RouteID: routeRuns, RunID: id}} }) + items = append(items, mapReferenceIDs([]string{head.Artifact.Reference.Digest}, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "artifact", RouteID: routeArtifacts, Digest: id}} + })...) out := make([]routeActionItem, 0, len(items)) for _, item := range items { - out = append(out, routeActionItem{Label: "run:" + item.Label, Action: item.Action}) + prefix := "run:" + if strings.Contains(item.Label, "sha256:") { + prefix = "artifact:" + } + out = append(out, routeActionItem{Label: prefix + item.Label, Action: item.Action}) } return out } @@ -97,12 +125,7 @@ func artifactRouteCopyActions(head *brokerapi.LocalArtifactHeadResponse, content } ref := head.Artifact.Reference preview := strings.TrimSpace(content) - if preview != "" { - lines := strings.Split(preview, "\n") - if len(lines) > 8 { - preview = strings.Join(lines[:8], "\n") + fmt.Sprintf("\n... (%d more lines)", len(lines)-8) - } - } + preview = boundedArtifactPreview(preview, artifactCopyPreviewLineLimit) return compactCopyActions([]routeCopyAction{ {ID: "digest", Label: "artifact digest", Text: ref.Digest}, {ID: "provenance_receipt", Label: "provenance receipt", Text: ref.ProvenanceReceiptHash}, @@ -185,12 +208,24 @@ func renderArtifactContent(mode artifactDetailMode, presentation contentPresenta } return fmt.Sprintf(" %s structured:\n - lines=%d\n - non_empty=%d\n - preview=%q", mode, len(lines), countNonEmptyLines(lines), redactSecrets(strings.TrimSpace(content))) } - if len(lines) > 10 { - return fmt.Sprintf(" %s preview (secrets redacted):\n%s\n ... (%d more lines)", mode, redactSecrets(strings.Join(lines[:10], "\n")), len(lines)-10) + if len(lines) > artifactPreviewLineLimit { + return fmt.Sprintf(" %s preview (secrets redacted):\n%s\n ... (%d more lines)", mode, redactSecrets(strings.Join(lines[:artifactPreviewLineLimit], "\n")), len(lines)-artifactPreviewLineLimit) } return fmt.Sprintf(" %s preview (secrets redacted):\n%s", mode, redactSecrets(content)) } +func boundedArtifactPreview(content string, maxLines int) string { + preview := strings.TrimSpace(content) + if preview == "" { + return "" + } + lines := strings.Split(preview, "\n") + if len(lines) > maxLines { + preview = strings.Join(lines[:maxLines], "\n") + fmt.Sprintf("\n... (%d more lines)", len(lines)-maxLines) + } + return redactSecrets(preview) +} + func countNonEmptyLines(lines []string) int { total := 0 for _, line := range lines { @@ -212,8 +247,11 @@ func (m *artifactsRouteModel) syncDetailDocument() { contentView := renderArtifactContent(mode, presentation, m.content, m.contentErr) kind := artifactContentKind(mode, a.Reference.ContentType, presentation) content := compactLines( + fmt.Sprintf("Evidence label: %s", artifactDisplayLabel(a)), fmt.Sprintf("Data class: %s", a.Reference.DataClass), - fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), + fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for final checks and receipts", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), + fmt.Sprintf("Detail mode: %s (typed metadata stays authoritative)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), "Inspectable content is supplemental evidence, not authoritative run/approval truth.", @@ -222,3 +260,93 @@ func (m *artifactsRouteModel) syncDetailDocument() { ref := workbenchObjectRef{Kind: "artifact", ID: strings.TrimSpace(a.Reference.Digest)} m.detailDoc.SetDocument(ref, kind, fmt.Sprintf("%s content", mode), content) } + +func renderArtifactOverviewCard(head *brokerapi.LocalArtifactHeadResponse, classFilter string) string { + if head == nil { + message := "Select evidence to review what the run produced before you continue to Audit." + if strings.TrimSpace(classFilter) != "" { + message = fmt.Sprintf("Select %s evidence to review what the run produced before you continue to Audit.", artifactClassLabel(classFilter)) + } + return renderStateCardSpec(stateCardSpec{State: routeLoadStateWaiting, Title: "Evidence workspace", Message: message, Reason: "No evidence is open yet.", NextAction: "Choose an artifact, then continue to Audit for final checks or receipts.", ShortcutCue: "enter", RouteCue: "Artifacts → Audit"}) + } + a := head.Artifact + return renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Evidence workspace", Message: fmt.Sprintf("%s is ready to inspect.", artifactDisplayLabel(a)), Reason: fmt.Sprintf("%s from run %s with %s ready in the inspector.", artifactClassLabel(a.Reference.DataClass), valueOrNA(a.RunID), artifactPreviewIdentity(a.Reference.SizeBytes, a.Reference.ContentType)), NextAction: "Review the evidence here, then open Audit to confirm final checks, export, or receipts.", ShortcutCue: "enter / m", RouteCue: "Audit"}) +} + +func renderArtifactEvidenceTrail(head *brokerapi.LocalArtifactHeadResponse) string { + if head == nil { + return "Evidence path: run result -> artifact evidence -> Audit review and receipts." + } + a := head.Artifact + return fmt.Sprintf("Evidence path: run %s -> %s -> Audit review and receipts.", valueOrNA(a.RunID), artifactDisplayLabel(a)) +} + +func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { + class := artifactClassLabel(item.Reference.DataClass) + run := strings.TrimSpace(item.RunID) + if class != "" && run != "" { + return fmt.Sprintf("%s for %s", class, run) + } + if class != "" { + return class + " artifact" + } + return shortIdentity(item.Reference.Digest) +} + +func renderArtifactClassFilterLine(classFilter string) string { + classFilter = strings.TrimSpace(classFilter) + if classFilter == "" { + return "Filter: all artifact classes" + } + return "Filter: " + artifactClassLabel(classFilter) +} + +func artifactClassLabel(dataClass any) string { + value := strings.TrimSpace(fmt.Sprintf("%v", dataClass)) + if value == "" { + return "" + } + return humanizeExecutionToken(value) +} + +func artifactSourceRunLabel(runID string) string { + runID = strings.TrimSpace(runID) + if runID == "" { + return "" + } + return "source run " + runID +} + +func artifactPreviewIdentity(sizeBytes int64, contentType string) string { + parts := []string{} + if sizeBytes > 0 { + parts = append(parts, fmt.Sprintf("%d bytes", sizeBytes)) + } + if label := artifactContentTypeLabel(contentType); label != "" { + parts = append(parts, label) + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " ") + " preview" +} + +func artifactContentTypeLabel(contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if contentType == "" { + return "" + } + if idx := strings.Index(contentType, ";"); idx >= 0 { + contentType = strings.TrimSpace(contentType[:idx]) + } + switch contentType { + case "text/plain": + return "plain text" + case "text/markdown": + return "markdown" + case "application/json": + return "json" + default: + return contentType + } +} diff --git a/cmd/runecode-tui/route_audit.go b/cmd/runecode-tui/route_audit.go index c8fb6bf9..5e9a9ed4 100644 --- a/cmd/runecode-tui/route_audit.go +++ b/cmd/runecode-tui/route_audit.go @@ -169,7 +169,7 @@ func (m auditRouteModel) handleAuditAnchorCompleted(msg auditAnchorCompletedMsg) m.statusText = fmt.Sprintf("Anchor action: failed seal=%s reason=no response", msg.sealDigest) return m, nil } - if strings.TrimSpace(msg.response.AnchoringStatus) != "ok" { + if !strings.EqualFold(strings.TrimSpace(msg.response.AnchoringStatus), "ok") { reason := strings.TrimSpace(msg.response.FailureCode) if reason == "" { reason = valueOrNA(strings.TrimSpace(msg.response.FailureMessage)) @@ -210,20 +210,16 @@ func (m auditRouteModel) View(width, height int, focus focusArea) string { } body := []string{ sectionTitle("Audit") + " " + focusBadge(focus), - renderAuditSafetyAlertStrip(m.verify), - renderAuditFinalizeSummary(m.finalize), - renderAuditAnchorActionSummary(m), - renderAuditPageSummary(m.cursor, m.nextCursor, len(m.prevCursors), len(m.timeline)), - renderAuditSummary(m.verify), - renderAuditFindings(m.verify, m.presentation), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderAuditOverviewCard(m.verify, m.active), + renderAuditWorkbenchSummary(m.verify, m.finalize, m.active, m.cursor, m.nextCursor, len(m.prevCursors), len(m.timeline), m.anchoring, m.exportCopy, m.statusText), renderDirectory("Timeline directory", renderAuditDirectoryItems(m.timeline), m.selected), - renderAuditTimeline(m.timeline, m.selected), + renderAuditSelectedEntryCue(m.timeline, m.selected), + renderAuditFindings(m.verify, m.presentation), } if len(m.timeline) == 0 { body = append(body, muted("The audit timeline is empty; retry after the broker persists verification posture or sealed timeline records.")) } - body = append(body, keyHint("Route keys: j/k move, enter record detail, f finalize+verify sealed segment posture, a anchor selected/latest sealed segment, x toggle anchor export-copy, n next page, p previous page, v cycle rendered/raw/structured, i toggle inspector, r reload")) + body = append(body, keyHint("Keys: j/k move, enter detail, f verify, a anchor, x export copy, n/p page, v mode, r reload")) return compactLines(body...) } @@ -248,7 +244,7 @@ func (m auditRouteModel) ShellSurface(ctx routeShellContext) routeSurface { Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Title: "Audit workspace", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, Inspector: routeSurfaceRegion{Title: "Audit inspector", Body: inspector}, - Bottom: routeSurfaceRegion{Body: keyHint("Route keys: j/k move, enter record detail, f finalize+verify sealed segment posture, a anchor selected/latest sealed segment, x toggle anchor export-copy, n next page, p previous page, v cycle rendered/raw/structured, i toggle inspector, r reload")}, + Bottom: routeSurfaceRegion{Body: keyHint("Keys: j/k move, enter detail, f verify, a anchor, x export copy, n/p page, v mode, r reload")}, Status: routeSurfaceRegion{Body: status}, }, Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: m.inspectorOn}}, @@ -307,7 +303,7 @@ func (m auditRouteModel) handlePresentationAndInspectorKey(key string) (routeMod if m.exportCopy { state = "enabled" } - m.statusText = fmt.Sprintf("Anchor receipt export copy %s for next action.", state) + m.statusText = fmt.Sprintf("Anchor receipt export copy %s; use the receipt for offline verification or evidence handoff when the broker returns one.", state) return m, nil, true case "v": m.presentation = nextPresentationMode(m.presentation) diff --git a/cmd/runecode-tui/route_audit_anchor_action.go b/cmd/runecode-tui/route_audit_anchor_action.go index 849887a3..fdcf8322 100644 --- a/cmd/runecode-tui/route_audit_anchor_action.go +++ b/cmd/runecode-tui/route_audit_anchor_action.go @@ -17,7 +17,7 @@ func (m auditRouteModel) anchorSelectedOrLatestSeal() (routeModel, tea.Cmd) { } m.anchoring = true m.errText = "" - m.statusText = fmt.Sprintf("Anchor action: requesting broker anchor for seal=%s export_copy=%t", sealDigest, m.exportCopy) + m.statusText = fmt.Sprintf("Anchor action: requesting broker anchor for seal=%s export_copy=%t; a receipt copy supports offline verification handoff when returned.", shortIdentity(sealDigest), m.exportCopy) return m, m.anchorSealCmd(sealDigest, m.exportCopy) } @@ -134,19 +134,19 @@ func auditAnchorPresenceAttestationRequired(mode string) bool { func validateAuditAnchorPreflightForTUI(preflight brokerapi.AuditAnchorPreflightGetResponse, requested trustpolicy.Digest) error { if !preflight.SignerReadiness.Ready { if code := strings.TrimSpace(preflight.SignerReadiness.ReasonCode); code != "" { - return fmt.Errorf("%s", code) + return fmt.Errorf("%s", sanitizeUIText(code)) } return fmt.Errorf("anchor signer unavailable") } if !preflight.VerifierReadiness.Ready { if code := strings.TrimSpace(preflight.VerifierReadiness.ReasonCode); code != "" { - return fmt.Errorf("%s", code) + return fmt.Errorf("%s", sanitizeUIText(code)) } return fmt.Errorf("audit verifier unavailable") } if preflight.PresenceRequirements.Required && !preflight.PresenceRequirements.AttestationReady { if code := strings.TrimSpace(preflight.PresenceRequirements.ReasonCode); code != "" { - return fmt.Errorf("%s", code) + return fmt.Errorf("%s", sanitizeUIText(code)) } return fmt.Errorf("presence attestation unavailable") } @@ -169,7 +169,7 @@ func validateAuditAnchorPreflightForTUI(preflight brokerapi.AuditAnchorPreflight func renderAuditAnchorActionSummary(m auditRouteModel) string { if m.anchoring { - return tableHeader("Anchor action") + " request in flight via broker audit_anchor_segment" + return tableHeader("Anchor action") + " request in flight; broker is preparing anchored evidence" } exportCopy := "off" if m.exportCopy { @@ -179,5 +179,5 @@ func renderAuditAnchorActionSummary(m auditRouteModel) string { if strings.TrimSpace(status) == "" { status = "idle" } - return fmt.Sprintf("%s export_copy=%s status=%s", tableHeader("Anchor action"), exportCopy, status) + return fmt.Sprintf("%s export_copy=%s status=%s next=use receipt for export/offline verification when available", tableHeader("Anchor action"), exportCopy, status) } diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index 20a70b29..18ca475b 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -9,16 +9,16 @@ import ( func renderAuditSummary(verify *brokerapi.AuditVerificationGetResponse) string { if verify == nil { - return "Verification posture unavailable" + return "Audit health: unavailable" } s := verify.Summary anchorLabel := renderAnchoringPostureLabel(s.AnchoringStatus) - return fmt.Sprintf("Verification posture: integrity=%s %s anchoring=%s (%s) storage=%s lifecycle=%s degraded=%t %s findings=%d", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.StoragePostureStatus, s.SegmentLifecycleStatus, s.CurrentlyDegraded, boolBadge("degraded", s.CurrentlyDegraded), s.FindingCount) + return fmt.Sprintf("Audit health: integrity=%s %s • receipts=%s (%s) • findings=%d • %s", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.FindingCount, boolBadge("degraded", s.CurrentlyDegraded)) } func renderAuditFinalizeSummary(finalize *brokerapi.AuditFinalizeVerifyResponse) string { if finalize == nil { - return tableHeader("Finalize/verify") + " status=unavailable" + return "Finalize/verify: unavailable" } status := strings.TrimSpace(finalize.ActionStatus) if status == "" { @@ -30,30 +30,30 @@ func renderAuditFinalizeSummary(finalize *brokerapi.AuditFinalizeVerifyResponse) report = id } } - line := fmt.Sprintf("%s status=%s segment=%s report=%s", tableHeader("Finalize/verify"), status, valueOrNA(strings.TrimSpace(finalize.SegmentID)), report) + line := fmt.Sprintf("Finalize/verify: %s • segment %s • report %s", status, valueOrNA(strings.TrimSpace(finalize.SegmentID)), shortIdentity(report)) if status == "ok" { - return line + return line + " • next review findings or anchor/export the latest verified segment" } - reason := strings.TrimSpace(finalize.FailureCode) + reason := sanitizeUIText(strings.TrimSpace(finalize.FailureCode)) if reason == "" { - reason = strings.TrimSpace(finalize.FailureMessage) + reason = sanitizeUIText(strings.TrimSpace(finalize.FailureMessage)) } if reason != "" { - line += " reason=" + reason + line += " • reason " + reason } return line } func renderAuditPageSummary(cursor, next string, backDepth, count int) string { - page := "page=1" + page := "page 1" if cursor != "" { - page = fmt.Sprintf("page_cursor=%s", cursor) + page = fmt.Sprintf("page %s", cursor) } - nextLabel := "no" + nextLabel := "no more pages" if next != "" { - nextLabel = "yes" + nextLabel = "next page available" } - return fmt.Sprintf("Timeline paging: %s entries=%d has_next=%s back_stack=%d", page, count, nextLabel, backDepth) + return fmt.Sprintf("Timeline: %s • %d records • %s • back %d", page, count, nextLabel, backDepth) } func renderAuditFindings(verify *brokerapi.AuditVerificationGetResponse, presentation contentPresentationMode) string { @@ -62,12 +62,12 @@ func renderAuditFindings(verify *brokerapi.AuditVerificationGetResponse, present return "Verification findings: unavailable" } if len(verify.Report.Findings) == 0 { - return "Verification findings: none" + return "Verification findings: none; the current audit posture does not report degraded or failed reasons." } if presentation == presentationStructured { return fmt.Sprintf("Verification findings (structured): total=%d degraded_reasons=%d hard_failures=%d", len(verify.Report.Findings), len(verify.Report.DegradedReasons), len(verify.Report.HardFailures)) } - line := "Verification findings (machine-readable):" + line := "Findings to review:" for i, finding := range verify.Report.Findings { if presentation == presentationRendered && i >= 4 { line += fmt.Sprintf("\n ... (%d more)", len(verify.Report.Findings)-i) @@ -77,35 +77,13 @@ func renderAuditFindings(verify *brokerapi.AuditVerificationGetResponse, present line += fmt.Sprintf("\n ... (%d more in raw mode)", len(verify.Report.Findings)-i) break } - line += fmt.Sprintf("\n - code=%s severity=%s dimension=%s", finding.Code, finding.Severity, finding.Dimension) + line += fmt.Sprintf("\n - %s • %s • %s", finding.Code, finding.Severity, finding.Dimension) } if len(verify.Report.DegradedReasons) > 0 { - line += fmt.Sprintf("\n degraded_reason_codes=%s", joinCSV(verify.Report.DegradedReasons)) + line += fmt.Sprintf("\n degraded reasons: %s", joinCSV(verify.Report.DegradedReasons)) } if len(verify.Report.HardFailures) > 0 { - line += fmt.Sprintf("\n hard_failure_codes=%s", joinCSV(verify.Report.HardFailures)) - } - return line -} - -func renderAuditTimeline(timeline []brokerapi.AuditTimelineViewEntry, selected int) string { - if len(timeline) == 0 { - return " - no audit entries" - } - line := "" - for i, entry := range timeline { - marker := " " - if i == selected { - marker = ">" - } - digest, _ := entry.RecordDigest.Identity() - posture := "n/a" - reasonCodes := "" - if entry.VerificationPosture != nil { - posture = entry.VerificationPosture.Status - reasonCodes = joinCSV(entry.VerificationPosture.ReasonCodes) - } - line += selectedLine(i == selected, fmt.Sprintf(" %s %s event=%s posture=%s reasons=%s summary=%s", marker, digest, entry.EventType, posture, valueOrNA(reasonCodes), entry.Summary)) + "\n" + line += fmt.Sprintf("\n hard failures: %s", joinCSV(verify.Report.HardFailures)) } return line } @@ -113,12 +91,53 @@ func renderAuditTimeline(timeline []brokerapi.AuditTimelineViewEntry, selected i func renderAuditDirectoryItems(timeline []brokerapi.AuditTimelineViewEntry) []string { items := make([]string, 0, len(timeline)) for _, entry := range timeline { - digest, _ := entry.RecordDigest.Identity() - items = append(items, fmt.Sprintf("%s event=%s", valueOrNA(digest), valueOrNA(entry.EventType))) + items = append(items, auditTimelineDirectoryItem(entry)) } return items } +func renderAuditSelectedEntryCue(timeline []brokerapi.AuditTimelineViewEntry, selected int) string { + if len(timeline) == 0 || selected < 0 || selected >= len(timeline) { + return "" + } + entry := timeline[selected] + label := auditRecordDisplayLabel(entry) + posture := auditTimelinePostureLabel(entry) + refs := auditReferenceCue(entry.LinkedReferences) + parts := []string{fmt.Sprintf("Selected record: %s", label), posture, "press enter for inspector detail"} + if entry.VerificationPosture != nil && len(entry.VerificationPosture.ReasonCodes) > 0 { + parts = append(parts, "reasons "+joinCSV(entry.VerificationPosture.ReasonCodes)) + } + if refs != "" { + parts = append(parts, refs) + } + return strings.Join(parts, " • ") +} + +func renderAuditWorkbenchSummary(verify *brokerapi.AuditVerificationGetResponse, finalize *brokerapi.AuditFinalizeVerifyResponse, record *brokerapi.AuditRecordGetResponse, cursor, next string, backDepth, count int, anchoring, exportCopy bool, statusText string) string { + recordLabel := "latest audit record" + if record != nil { + identity, _ := record.Record.RecordDigest.Identity() + recordLabel = fmt.Sprintf("record %s", shortIdentity(identity)) + } + health := renderAuditSafetyAlertStrip(verify) + evidence := renderAuditEvidenceTrail(verify, record) + finalizeLine := renderAuditFinalizeSummary(finalize) + anchorLine := renderAuditAnchorWorkbenchLine(anchoring, exportCopy, statusText) + paging := renderAuditPageSummary(cursor, next, backDepth, count) + return compactLines( + tableHeader("Evidence workbench"), + fmt.Sprintf("Health: %s", health), + fmt.Sprintf("Record in focus: %s", recordLabel), + evidence, + fmt.Sprintf("Next actions: %s", renderAuditNextActionCue(verify, finalize)), + fmt.Sprintf("Audit summary: %s", renderAuditSummary(verify)), + finalizeLine, + anchorLine, + paging, + ) +} + func renderAuditInspector(record *brokerapi.AuditRecordGetResponse, presentation contentPresentationMode, document *longFormDocumentState) string { if record == nil { return " Select a timeline record and press enter to load detail." @@ -140,10 +159,10 @@ func renderAuditInspector(record *brokerapi.AuditRecordGetResponse, presentation document.SetDocument(workbenchObjectRef{Kind: "audit", ID: strings.TrimSpace(identity)}, contentKind, "audit record", content) return renderInspectorShell(inspectorShellSpec{ Title: "Audit inspector", - Summary: fmt.Sprintf("event=%s family=%s linked_refs=%d", valueOrNA(r.EventType), valueOrNA(r.RecordFamily), len(r.LinkedReferences)), - Identity: fmt.Sprintf("record_family=%s event_type=%s", valueOrNA(r.RecordFamily), valueOrNA(r.EventType)), - Status: fmt.Sprintf("verification=%s reasons=%d", valueOrNA(status), reasons), - Badges: []string{stateBadgeWithLabel("posture", status), appTheme.InspectorHint.Render("typed record details")}, + Summary: fmt.Sprintf("%s with %d linked reference(s).", auditEventLabel(valueOrNA(r.EventType)), len(r.LinkedReferences)), + Identity: fmt.Sprintf("Record %s • %s", shortIdentity(identity), valueOrNA(r.RecordFamily)), + Status: fmt.Sprintf("Audit health %s • %d reason code(s)", valueOrNA(status), reasons), + Badges: []string{stateBadgeWithLabel("posture", status), appTheme.InspectorHint.Render("record details")}, References: []inspectorReference{{Label: "records", Items: referenceItems}, {Label: "reason codes", Items: reasonItems}}, LocalActions: auditInspectorLocalActions(), CopyActions: auditRouteCopyActions(record), @@ -170,6 +189,7 @@ func auditInspectorContent(record brokerapi.AuditRecordDetail, status string, re return compactLines( fmt.Sprintf("Structured record: family=%s event=%s", record.RecordFamily, record.EventType), fmt.Sprintf("Link counts: references=%d reasons=%d", len(record.LinkedReferences), reasons), + "Evidence trail: audit record -> verification posture -> export/offline verification or anchoring where available", ) } if presentation == presentationRaw { @@ -182,8 +202,11 @@ func auditInspectorContent(record brokerapi.AuditRecordDetail, status string, re return compactLines( fmt.Sprintf("Record family: %s event=%s", record.RecordFamily, record.EventType), fmt.Sprintf("Occurred at: %s", record.OccurredAt), - fmt.Sprintf("Verification posture: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), + fmt.Sprintf("Primary digest display: %s (copy raw digest below)", auditRecordDigestDetail(record)), + fmt.Sprintf("Audit health: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), fmt.Sprintf("Linked references: %d", len(record.LinkedReferences)), + "Evidence trail: workflow result -> artifacts -> audit record -> final checks -> offline review or receipts.", + "Trust posture: linked records stay authoritative; copied digests and raw content are supplemental proof material.", ) } @@ -291,18 +314,49 @@ func renderAnchoringPostureLabel(status string) string { func renderAuditSafetyAlertStrip(verify *brokerapi.AuditVerificationGetResponse) string { if verify == nil { - return tableHeader("Audit safety strip") + " " + dangerBadge("AUDIT_POSTURE_UNAVAILABLE") + " audit verification unavailable" + return tableHeader("Audit health") + " " + dangerBadge("Audit unavailable") + " audit verification unavailable" } s := verify.Summary - parts := []string{tableHeader("Audit safety strip")} + parts := []string{tableHeader("Audit health")} if strings.EqualFold(strings.TrimSpace(s.AnchoringStatus), "degraded") || s.CurrentlyDegraded { - parts = append(parts, auditDegradedBadge("UNANCHORED_OR_DEGRADED_AUDIT")) + parts = append(parts, auditDegradedBadge("Receipts need review")) } if strings.EqualFold(strings.TrimSpace(s.AnchoringStatus), "failed") || strings.EqualFold(strings.TrimSpace(s.IntegrityStatus), "failed") || strings.EqualFold(strings.TrimSpace(s.IntegrityStatus), "invalid") { - parts = append(parts, dangerBadge("INVALID_OR_FAILED_ANCHORING")) + parts = append(parts, dangerBadge("Audit failed")) } if len(parts) == 1 { - parts = append(parts, successBadge("AUDIT_ANCHORED_AND_VALID")) + parts = append(parts, successBadge("Audit ready")) } return strings.Join(parts, " ") } + +func renderAuditOverviewCard(verify *brokerapi.AuditVerificationGetResponse, record *brokerapi.AuditRecordGetResponse) string { + if verify == nil { + return renderStateCardSpec(stateCardSpec{State: routeLoadStateWaiting, Title: "Verification trail", Message: "Audit verification posture is not loaded yet.", Reason: "The route needs broker verification data before it can explain export, offline verification, or anchoring posture.", NextAction: "Reload this route, then inspect findings and anchoring actions.", ShortcutCue: "r", RouteCue: "Audit"}) + } + state := routeLoadStateReady + if verify.Summary.CurrentlyDegraded || strings.EqualFold(strings.TrimSpace(verify.Summary.AnchoringStatus), "degraded") { + state = routeLoadStateDegraded + } + if strings.EqualFold(strings.TrimSpace(verify.Summary.IntegrityStatus), "failed") || strings.EqualFold(strings.TrimSpace(verify.Summary.AnchoringStatus), "failed") { + state = routeLoadStateBlocked + } + recordLabel := "latest verification posture" + if record != nil { + identity, _ := record.Record.RecordDigest.Identity() + recordLabel = "record " + shortIdentity(identity) + } + return renderStateCardSpec(stateCardSpec{State: state, Title: "Verification trail", Message: fmt.Sprintf("Audit explains the current health of %s.", recordLabel), Reason: renderAuditSummary(verify), NextAction: "Review findings, then use finalize/verify, export copy, or receipt actions as needed.", ShortcutCue: "f / a / x", RouteCue: "Audit"}) +} + +func renderAuditEvidenceTrail(verify *brokerapi.AuditVerificationGetResponse, record *brokerapi.AuditRecordGetResponse) string { + recordLabel := "timeline records" + if record != nil { + identity, _ := record.Record.RecordDigest.Identity() + recordLabel = "record " + shortIdentity(identity) + } + if verify == nil { + return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> audit health unavailable until broker verification data loads", recordLabel) + } + return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> final checks -> export/offline review -> receipts", recordLabel) +} diff --git a/cmd/runecode-tui/route_audit_status_test.go b/cmd/runecode-tui/route_audit_status_test.go index 53674b3a..226043e6 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -21,16 +21,18 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Audit safety strip", - "Finalize/verify", - "status=unavailable", - "UNANCHORED_OR_DEGRADED_AUDIT", - "Timeline paging: page=1 entries=1 has_next=yes", - "anchoring=degraded (unanchored/degraded)", - "Verification findings (machine-readable):", - "code=anchor_receipt_missing severity=warning dimension=anchoring", - "degraded_reason_codes=", - "posture=degraded reasons=anchor_receipt_missing", + "Verification trail", + "Audit health", + "Evidence workbench", + "Finalize/verify: unavailable", + "Receipts need review", + "Timeline: page 1 • 1 records • next page available • back 0", + "receipts=degraded (unanchored/degraded)", + "Evidence trail:", + "Findings to review:", + "anchor_receipt_missing • warning • anchoring", + "degraded reasons:", + "verification degraded", ) updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) @@ -39,10 +41,10 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { } updated, _ = updated.Update(cmd()) view = updated.View(120, 40, focusContent) - if !strings.Contains(view, "page_cursor=page-2") { + if !strings.Contains(view, "Timeline: page page-2") { t.Fatalf("expected second page cursor in view, got %q", view) } - if !strings.Contains(view, "posture=failed reasons=anchor_receipt_invalid") { + if !strings.Contains(view, "Selected record: Approval consumed • verification failed") || !strings.Contains(view, "reasons anchor_receipt_invalid") { t.Fatalf("expected failed anchoring posture on page 2, got %q", view) } @@ -54,12 +56,15 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { view = updated.View(120, 40, focusContent) surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body - if !strings.Contains(inspector, "Copy actions: linked references | raw block") { + if !strings.Contains(inspector, "Copy actions: record digest | linked references | raw block") { t.Fatalf("expected copy actions after loading record detail, got %q", inspector) } - if !strings.Contains(inspector, "Verification posture: degraded (unanchored/degraded)") { + if !strings.Contains(inspector, "Audit health: degraded (unanchored/degraded)") { t.Fatalf("expected record inspector posture rendering, got %q", inspector) } + if !strings.Contains(inspector, "Evidence trail: workflow result -> artifacts -> audit record -> final checks -> offline review") { + t.Fatalf("expected evidence trail guidance in inspector, got %q", inspector) + } } func TestRenderAuditSafetyAlertStripUsesVerifierAnchoringStatusOnly(t *testing.T) { @@ -68,7 +73,7 @@ func TestRenderAuditSafetyAlertStripUsesVerifierAnchoringStatusOnly(t *testing.T IntegrityStatus: "ok", CurrentlyDegraded: true, }}) - if !strings.Contains(strip, "UNANCHORED_OR_DEGRADED_AUDIT") { + if !strings.Contains(strip, "Receipts need review") { t.Fatalf("expected degraded badge when currently_degraded=true, got %q", strip) } @@ -92,14 +97,13 @@ func TestStatusRouteExplainsDegradedSubsystemPosture(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Runtime/audit readiness strip", - "RUNTIME_POSTURE_AUTH_UNAVAILABLE", - "AUDIT_STORAGE_NOMINAL", - "Broker ready=true local_only=true", - "Subsystem posture:", - "Diagnostics: degraded subsystems=verifier_material=missing", - "Version posture: product=0.1.0", - "Protocol posture: bundle=0.9.0", + "System health", + "Runtime proof missing", + "Audit ready", + "Overview: • broker connected • normal work available • local workspace mode", + "Broker health: runtime verification material is unavailable.", + "Version: RuneCode 0.1.0", + "protocol 0.9.0", ) } @@ -163,13 +167,14 @@ func TestAuditRouteAnchorActionDispatchesToBrokerAndRendersSuccess(t *testing.T) "Anchor action: ok", "receipt=sha256:", "export_copy=off", + "Anchoring: Anchor action: ok", ) updated = mustRunAuditRouteKey(t, updated, 'f', "expected finalize+verify command") view = updated.View(120, 40, focusContent) mustContainAll(t, view, - "Finalize/verify", - "status=ok", - "segment=segment-000001", + "Finalize/verify: ok", + "segment segment-000001", + "next review findings or anchor/export the latest verified segment", ) } diff --git a/cmd/runecode-tui/route_audit_timeline_labels.go b/cmd/runecode-tui/route_audit_timeline_labels.go new file mode 100644 index 00000000..ea21f244 --- /dev/null +++ b/cmd/runecode-tui/route_audit_timeline_labels.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { + digest, _ := entry.RecordDigest.Identity() + if strings.TrimSpace(entry.Summary) != "" { + return strings.TrimSpace(entry.Summary) + } + if label := auditEventLabel(entry.EventType); label != "Audit record" { + return label + } + return shortIdentity(digest) +} + +func auditRecordDigestLabel(entry brokerapi.AuditTimelineViewEntry) string { + digest, _ := entry.RecordDigest.Identity() + return shortIdentity(digest) +} + +func auditRecordDigestDetail(record brokerapi.AuditRecordDetail) string { + digest, _ := record.RecordDigest.Identity() + return shortIdentity(digest) +} + +func auditTimelinePostureLabel(entry brokerapi.AuditTimelineViewEntry) string { + if entry.VerificationPosture == nil { + return "verification unavailable" + } + return auditVerificationCue(entry.VerificationPosture.Status, entry.VerificationPosture.ReasonCodes) +} + +func auditTimelineDirectoryItem(entry brokerapi.AuditTimelineViewEntry) string { + label := auditRecordDisplayLabel(entry) + cue := auditTimelinePostureLabel(entry) + refs := auditReferenceCue(entry.LinkedReferences) + digest := auditRecordDigestLabel(entry) + parts := []string{label, digest, cue} + if refs != "" { + parts = append(parts, refs) + } + return strings.Join(parts, " • ") +} + +func auditReferenceCue(refs []brokerapi.AuditRecordLinkedReference) string { + if len(refs) == 0 { + return "" + } + kinds := make([]string, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + kind := strings.TrimSpace(ref.ReferenceKind) + if kind == "" { + kind = "linked item" + } + if _, ok := seen[kind]; ok { + continue + } + seen[kind] = struct{}{} + kinds = append(kinds, kind) + if len(kinds) == 2 { + break + } + } + label := fmt.Sprintf("%d linked item", len(refs)) + if len(refs) != 1 { + label += "s" + } + if len(kinds) == 0 { + return label + } + return fmt.Sprintf("%s: %s", label, strings.Join(kinds, ", ")) +} diff --git a/cmd/runecode-tui/route_audit_workbench_helpers.go b/cmd/runecode-tui/route_audit_workbench_helpers.go new file mode 100644 index 00000000..af879ae7 --- /dev/null +++ b/cmd/runecode-tui/route_audit_workbench_helpers.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func auditEventLabel(eventType string) string { + switch strings.TrimSpace(eventType) { + case "run_state": + return "Run state update" + case "approval": + return "Approval event" + case "audit_receipt": + return "Anchor receipt recorded" + case "artifact": + return "Artifact evidence updated" + case "verification": + return "Verification record" + default: + if strings.TrimSpace(eventType) == "" { + return "Audit record" + } + words := strings.Fields(strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")) + for i, word := range words { + if word == "id" { + words[i] = "ID" + continue + } + if word == "" { + continue + } + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + return strings.Join(words, " ") + } +} + +func auditVerificationCue(status string, reasonCodes []string) string { + status = valueOrNA(strings.TrimSpace(status)) + switch status { + case "ok": + return "verification healthy" + case "degraded": + return "verification degraded" + case "failed", "invalid": + return "verification failed" + default: + return "verification " + status + } +} + +func renderAuditNextActionCue(verify *brokerapi.AuditVerificationGetResponse, finalize *brokerapi.AuditFinalizeVerifyResponse) string { + if verify == nil { + return "reload verification, then review findings and anchoring options" + } + if verify.Summary.CurrentlyDegraded || strings.EqualFold(strings.TrimSpace(verify.Summary.AnchoringStatus), "degraded") { + return "review findings first, then finalize/verify or export evidence for offline review" + } + if finalize == nil || !strings.EqualFold(strings.TrimSpace(finalize.ActionStatus), "ok") { + return "finalize/verify if needed, then export or anchor the latest verified segment" + } + return "use export copy for offline verification or anchor the latest verified segment" +} + +func renderAuditAnchorWorkbenchLine(anchoring, exportCopy bool, statusText string) string { + if anchoring { + return "Anchoring: request in flight; broker is preparing anchored evidence" + } + copyState := "export copy off" + if exportCopy { + copyState = "export copy on" + } + status := strings.TrimSpace(statusText) + if status == "" { + return "Anchoring: ready • " + copyState + " • use receipt for offline verification when available" + } + return fmt.Sprintf("Anchoring: %s • %s", status, copyState) +} diff --git a/cmd/runecode-tui/route_chat.go b/cmd/runecode-tui/route_chat.go index afb23fd7..2be09e98 100644 --- a/cmd/runecode-tui/route_chat.go +++ b/cmd/runecode-tui/route_chat.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "strings" "time" @@ -12,6 +11,8 @@ import ( type chatLoadedMsg struct { sessions []brokerapi.SessionSummary detail *brokerapi.SessionDetail + runDetail *brokerapi.RunDetail + posture *brokerapi.ProjectSubstratePostureGetResponse activeSessionID string err error seq uint64 @@ -20,6 +21,7 @@ type chatLoadedMsg struct { type chatMessageSentMsg struct { sessions []brokerapi.SessionSummary detail *brokerapi.SessionDetail + runDetail *brokerapi.RunDetail ack *brokerapi.SessionExecutionTriggerResponse turnExecution *brokerapi.SessionTurnExecution posture *brokerapi.ProjectSubstratePostureGetResponse @@ -36,6 +38,7 @@ type chatExecutionWatchLoadedMsg struct { triggerID string turnExecution *brokerapi.SessionTurnExecution detail *brokerapi.SessionDetail + runDetail *brokerapi.RunDetail sessions []brokerapi.SessionSummary posture *brokerapi.ProjectSubstratePostureGetResponse continueWatch bool @@ -47,29 +50,34 @@ type chatSelectSessionMsg struct { } type chatRouteModel struct { - def routeDefinition - client localBrokerClient - loading bool - sending bool - errText string - statusText string - sessions []brokerapi.SessionSummary - selected int - active *brokerapi.SessionDetail - activeID string - inspectorOn bool - composeOn bool - presentation contentPresentationMode - draft string - composer composeTextarea - actionText string - loadSeq uint64 - watchSeq uint64 - watchStreamID string - watching bool - watchSession string - watchTrigger string - detailDoc longFormDocumentState + def routeDefinition + client localBrokerClient + loading bool + sending bool + errText string + statusText string + sessions []brokerapi.SessionSummary + selected int + active *brokerapi.SessionDetail + activeID string + inspectorOn bool + composeOn bool + presentation contentPresentationMode + draft string + composer composeTextarea + actionText string + loadSeq uint64 + watchSeq uint64 + watchStreamID string + watching bool + watchPollCount int + watchSession string + watchTrigger string + posture *brokerapi.ProjectSubstratePostureGetResponse + runDetail *brokerapi.RunDetail + detailDoc longFormDocumentState + detailDocSession *brokerapi.SessionDetail + detailDocMode contentPresentationMode } func newChatRouteModel(def routeDefinition, client localBrokerClient) routeModel { @@ -111,29 +119,23 @@ func (m chatRouteModel) View(width, height int, focus focusArea) string { _ = width _ = height if m.loading { - return renderStateCard(routeLoadStateLoading, "Chat", "Loading chat route from broker session contracts...") + return renderStateCard(routeLoadStateLoading, "Chat", "Loading sessions and workflow evidence...") } if m.sending { - return renderStateCard(routeLoadStateLoading, "Chat", "Submitting execution trigger via broker SessionExecutionTrigger...") + return renderStateCard(routeLoadStateLoading, "Chat", "Starting the broker-owned workflow from this session...") } if m.errText != "" { return renderStateCard(routeLoadStateError, "Chat", "Load failed: "+m.errText+" (press r to retry)") } - active := "none" - if m.activeID != "" { - active = m.activeID - } body := []string{ sectionTitle("Chat") + " " + focusBadge(focus), - fmt.Sprintf("Sessions: %d active=%s", len(m.sessions), active), - "Main pane default: one active canonical session", - fmt.Sprintf("Composer: %s", composerState(m.composeOn)), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), - renderStateCard(routeLoadStateReady, "Active session", activeSessionSummaryLine(m.active)), + renderStateCardSpec(chatExecutionStateCard(m.active, m.posture, m.runDetail)), + renderActiveSessionSummaryBlock(m.active, len(m.sessions)), + renderDirectory("Session directory", renderSessionDirectoryItems(m.sessions), m.selected), renderComposer(m.composeOn, m.draft, m.composer.View()), } if m.active == nil { - body = append(body, muted("Select a canonical session to load transcript detail or open the composer against the active session.")) + body = append(body, muted("Select a canonical session to review the transcript and start the next workflow.")) } if m.statusText != "" { body = append(body, "Status: "+m.statusText) @@ -141,8 +143,8 @@ func (m chatRouteModel) View(width, height int, focus focusArea) string { if strings.TrimSpace(m.actionText) != "" { body = append(body, "Follow-up: "+m.actionText) } - body = append(body, muted("Transcript is durable checkpoints; execution watch is advisory live state.")) - body = append(body, keyHint("Route keys: j/k move, enter load detail, i toggle inspector, c compose, alt+enter send, enter newline, v cycle rendered/raw/structured, r reload")) + body = append(body, muted("Transcript remains the durable conversation record. Inspectors keep raw detail and evidence links available.")) + body = append(body, keyHint("Keys: j/k move • enter review • c compose • alt+enter send • i inspector • v mode • r reload")) return compactLines(body...) } @@ -168,7 +170,7 @@ func (m chatRouteModel) ShellSurface(ctx routeShellContext) routeSurface { Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Title: "Chat workspace", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, Inspector: routeSurfaceRegion{Title: "Session inspector", Body: inspector}, - Bottom: routeSurfaceRegion{Body: keyHint("Route keys: j/k move, enter load detail, i toggle inspector, c compose, alt+enter send, enter newline, v cycle rendered/raw/structured, r reload")}, + Bottom: routeSurfaceRegion{Body: keyHint("Keys: j/k move • enter review • c compose • alt+enter send • i inspector • v mode • r reload")}, Status: routeSurfaceRegion{Body: status}, }, Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: m.inspectorOn}}, diff --git a/cmd/runecode-tui/route_chat_execution_helpers.go b/cmd/runecode-tui/route_chat_execution_helpers.go index 6ebbdebf..b8fcf386 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers.go +++ b/cmd/runecode-tui/route_chat_execution_helpers.go @@ -1,77 +1,306 @@ package main import ( + "fmt" "strings" "github.com/runecode-ai/runecode/internal/brokerapi" ) -func chatExecutionTerminal(exec brokerapi.SessionTurnExecution) bool { +func chatVisibleExecution(detail *brokerapi.SessionDetail) *brokerapi.SessionTurnExecution { + if detail == nil { + return nil + } + if detail.CurrentTurnExecution != nil { + return detail.CurrentTurnExecution + } + return detail.LatestTurnExecution +} + +func chatExecutionStateCard(detail *brokerapi.SessionDetail, posture *brokerapi.ProjectSubstratePostureGetResponse, run *brokerapi.RunDetail) stateCardSpec { + exec := chatVisibleExecution(detail) + if detail == nil { + return stateCardSpec{ + State: routeLoadStateEmpty, + Title: "Workflow execution", + Message: "Select a canonical session to review workflow progress.", + Reason: "Chat follows the broker-owned session selected in this workspace.", + NextAction: "Pick a session from the directory or reload if none are listed.", + ShortcutCue: "j/k move • enter review", + RouteCue: "Chat", + } + } + if exec == nil { + return stateCardSpec{ + State: routeLoadStateEmpty, + Title: "Workflow execution", + Message: "No workflow execution is attached to this session yet.", + Reason: chatSessionExecutionSummary(detail), + NextAction: "Open the composer when you are ready to start the next broker-owned workflow.", + ShortcutCue: "c compose", + RouteCue: "Runs after start", + } + } + state := chatExecutionCardState(*exec, run) + message := chatExecutionHeadline(*exec, run) + reason := chatExecutionReason(*exec, detail, posture, run) + nextAction := chatExecutionNextAction(*exec, posture, run) + return stateCardSpec{ + State: state, + Title: "Workflow execution", + Message: message, + Reason: reason, + NextAction: nextAction, + ShortcutCue: chatExecutionShortcutCue(*exec), + RouteCue: chatExecutionRouteCue(*exec, detail, run), + } +} + +func chatExecutionCardState(exec brokerapi.SessionTurnExecution, run *brokerapi.RunDetail) routeLoadState { + if chatExecutionApprovalRequired(exec, run) { + return routeLoadStateApprovalRequired + } state := strings.ToLower(strings.TrimSpace(exec.ExecutionState)) - if state == "completed" || state == "failed" { - return true + switch { + case state == "waiting": + return routeLoadStateWaiting + case state == "blocked": + return routeLoadStateBlocked + case state == "completed" || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "completed"): + return routeLoadStateCompleted + case state == "failed" || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "failed"): + return routeLoadStateDegraded + case run != nil && run.Coordination.Blocked: + return routeLoadStateBlocked + default: + return routeLoadStateReady } - outcome := strings.ToLower(strings.TrimSpace(exec.TerminalOutcome)) - return outcome == "completed" || outcome == "failed" || outcome == "cancelled" } -func chatExecutionStatusAndAction(exec brokerapi.SessionTurnExecution) (string, string) { - state := strings.TrimSpace(exec.ExecutionState) - if state == "" { - state = "unknown" +func chatExecutionHeadline(exec brokerapi.SessionTurnExecution, run *brokerapi.RunDetail) string { + state := strings.ToLower(strings.TrimSpace(exec.ExecutionState)) + switch { + case chatExecutionApprovalRequired(exec, run): + return "Workflow is waiting for approval." + case state == "waiting": + return "Workflow is waiting for the next broker-owned signal." + case state == "blocked": + return "Workflow cannot continue yet." + case state == "completed" || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "completed"): + return "Workflow completed." + case state == "failed" || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "failed"): + return "Workflow failed." + default: + if run != nil && runCheckpointCode(run) != "" { + return "Workflow is active and reporting progress." + } + if chatRunIDFromExecution(exec) != "" { + return "Workflow is active in a linked run." + } + return "Workflow is active." } - status := "Execution progress: " + state - if wait := strings.TrimSpace(exec.WaitState); wait != "" { - status += " (" + wait + ")" +} + +func chatExecutionReason(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, posture *brokerapi.ProjectSubstratePostureGetResponse, run *brokerapi.RunDetail) string { + parts := []string{} + if summary := chatSessionExecutionSummary(detail); summary != "" { + parts = append(parts, summary) } - action := blockedExecutionAction(exec) - if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") { - action = waitingExecutionAction(exec) + if reason := chatExecutionWaitReason(exec, posture, run); reason != "" { + parts = append(parts, reason) + } + if stages := chatExecutionStageSummary(exec, detail, run); stages != "" { + parts = append(parts, stages) + } + if evidence := chatExecutionEvidenceSummary(detail, run); evidence != "" { + parts = append(parts, evidence) + } + return strings.Join(parts, " • ") +} + +func chatSessionExecutionSummary(detail *brokerapi.SessionDetail) string { + if detail == nil { + return "" + } + parts := []string{} + if current := detail.CurrentTurnExecution; current != nil { + parts = append(parts, "Current broker state: "+chatExecutionSnapshotLabel(*current)) + } + if latest := detail.LatestTurnExecution; latest != nil { + parts = append(parts, "Latest broker state: "+chatExecutionSnapshotLabel(*latest)) } - if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "blocked") && strings.TrimSpace(exec.WaitKind) == "project_blocked" { - action = "Execution is blocked by project posture; diagnostics/remediation attach remains inspect-only." + if len(parts) == 0 { + parts = append(parts, "No broker-owned execution snapshot is attached to this session.") + } + return strings.Join(parts, " • ") +} + +func chatExecutionSnapshotLabel(exec brokerapi.SessionTurnExecution) string { + state := humanizeExecutionToken(exec.ExecutionState) + parts := []string{state} + if strings.TrimSpace(exec.WaitState) != "" { + parts = append(parts, "wait "+humanizeExecutionToken(exec.WaitState)) + } + if strings.TrimSpace(exec.TerminalOutcome) != "" { + parts = append(parts, "outcome "+humanizeExecutionToken(exec.TerminalOutcome)) } - return status, strings.TrimSpace(action) + return strings.Join(parts, " / ") } -func blockedExecutionAction(exec brokerapi.SessionTurnExecution) string { +func chatExecutionWaitReason(exec brokerapi.SessionTurnExecution, posture *brokerapi.ProjectSubstratePostureGetResponse, run *brokerapi.RunDetail) string { + if chatExecutionApprovalRequired(exec, run) { + return "Approval is still required before the broker can continue this workflow." + } + if wait := strings.TrimSpace(exec.WaitState); wait != "" { + switch strings.TrimSpace(exec.WaitKind) { + case "operator_input": + return "Waiting for more operator input." + case "external_dependency": + return "Waiting for an external dependency to become ready." + case "project_blocked": + if posture != nil && strings.TrimSpace(posture.BlockedExplanation) != "" { + return sanitizeUIText(posture.BlockedExplanation) + } + return "Project setup is currently blocking normal workflow execution." + default: + return fmt.Sprintf("Waiting reason: %s.", humanizeExecutionToken(wait)) + } + } if blocked := strings.TrimSpace(exec.BlockedReasonCode); blocked != "" { - return "Blocked reason: " + blocked + return fmt.Sprintf("Blocked reason: %s.", humanizeExecutionToken(blocked)) + } + if run != nil && run.Coordination.Blocked { + if reason := strings.TrimSpace(run.Coordination.WaitReasonCode); reason != "" { + return fmt.Sprintf("Run is blocked by %s.", humanizeExecutionToken(reason)) + } + return "Run coordination is currently blocked." } return "" } -func waitingExecutionAction(exec brokerapi.SessionTurnExecution) string { - switch strings.TrimSpace(exec.WaitKind) { - case "operator_input": - return "Follow-up: provide operator input to continue this dependent scope." - case "approval": - return "Follow-up: open Approvals and resolve the pending formal approval." - case "external_dependency": - return "Follow-up: wait for external dependency readiness, then continue." - case "project_blocked": - return "Follow-up: project substrate posture blocks execution; apply remediation posture before continue." - default: - return blockedExecutionAction(exec) +func chatExecutionEvidenceSummary(detail *brokerapi.SessionDetail, run *brokerapi.RunDetail) string { + if detail == nil { + return "" + } + runs := len(detail.LinkedRunIDs) + approvals := len(detail.LinkedApprovalIDs) + artifacts := len(detail.LinkedArtifactDigests) + audit := len(detail.LinkedAuditRecordDigests) + if run != nil && runs == 0 && strings.TrimSpace(run.Summary.RunID) != "" { + runs = 1 + } + parts := []string{ + countNoun(runs, "linked run", "linked runs"), + countNoun(approvals, "approval", "approvals"), + countNoun(maxInt(artifacts, runArtifactCount(run)), "artifact", "artifacts"), + countNoun(audit, "audit record", "audit records"), } + return "Evidence: " + strings.Join(parts, " • ") } -func chooseActionTextByPosture(existing string, posture brokerapi.ProjectSubstratePostureGetResponse, exec brokerapi.SessionTurnExecution) string { - if strings.TrimSpace(existing) != "" && strings.TrimSpace(exec.WaitKind) != "project_blocked" { - return existing +func chatExecutionStageSummary(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, run *brokerapi.RunDetail) string { + stages := make([]string, 0, 8) + if executionWaiting(exec, run) { + stages = append(stages, "waiting") } - if !strings.EqualFold(strings.TrimSpace(exec.WaitKind), "project_blocked") { - return existing + if run != nil && runPlanAuthorityAvailable(run) { + stages = append(stages, "plan compiled") } - parts := []string{} - if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Remediation: "+joinCSVWithWrapHint(posture.RemediationGuidance)) + if runRunnerActive(run) { + stages = append(stages, "runner active") } - if strings.TrimSpace(posture.BlockedExplanation) != "" { - parts = append(parts, "Project posture: "+sanitizeUIText(posture.BlockedExplanation)) + if runCheckpointCode(run) != "" { + stages = append(stages, "checkpoint received") } - if len(parts) == 0 { - return existing + if chatExecutionApprovalRequired(exec, run) { + stages = append(stages, "approval required") + } + if len(exec.LinkedArtifactDigests) > 0 || runArtifactCount(run) > 0 { + stages = append(stages, "artifact ready") + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "failed") || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "failed") { + stages = append(stages, "failed") + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "completed") || strings.EqualFold(strings.TrimSpace(exec.TerminalOutcome), "completed") { + stages = append(stages, "completed") + } + stages = uniqueStageLabels(stages) + if len(stages) == 0 { + return "" + } + return "Broker-known stages: " + strings.Join(stages, " • ") +} + +func uniqueStageLabels(labels []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(labels)) + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, ok := seen[label]; ok { + continue + } + seen[label] = struct{}{} + out = append(out, label) + } + return out +} + +func chatExecutionNextAction(exec brokerapi.SessionTurnExecution, posture *brokerapi.ProjectSubstratePostureGetResponse, run *brokerapi.RunDetail) string { + if chatExecutionApprovalRequired(exec, run) { + return "Open Approvals and review the exact gated action before deciding." + } + action := strings.TrimSpace(blockedExecutionAction(exec)) + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") { + action = strings.TrimSpace(waitingExecutionAction(exec)) + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "blocked") && strings.EqualFold(strings.TrimSpace(exec.WaitKind), "project_blocked") { + action = strings.TrimSpace(waitingExecutionAction(exec)) + } + if posture != nil { + action = strings.TrimSpace(chooseActionTextByPosture(action, *posture, exec)) + } + if action != "" { + return action + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "completed") { + return "Review the linked run, artifacts, or audit evidence if you want the full result trail." + } + return "Stay in Chat for follow-up, or open Runs for fuller workflow evidence." +} + +func chatExecutionShortcutCue(exec brokerapi.SessionTurnExecution) string { + if strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" { + return "open inspector or palette references" + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "completed") { + return "enter review • c follow-up" + } + return "c compose • enter review" +} + +func chatExecutionRouteCue(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, run *brokerapi.RunDetail) string { + routes := []string{"Runs"} + if chatExecutionApprovalRequired(exec, run) || len(detail.LinkedApprovalIDs) > 0 { + routes = append(routes, "Approvals") + } + if len(detail.LinkedArtifactDigests) > 0 || runArtifactCount(run) > 0 { + routes = append(routes, "Artifacts") + } + if len(detail.LinkedAuditRecordDigests) > 0 { + routes = append(routes, "Audit") + } + return strings.Join(uniqueStageLabels(routes), " / ") +} + +func chatExecutionApprovalRequired(exec brokerapi.SessionTurnExecution, run *brokerapi.RunDetail) bool { + if chatExecutionTerminal(exec) { + return false + } + if strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" { + return true } - return strings.Join(parts, " | ") + return run != nil && len(run.PendingApprovalIDs) > 0 } diff --git a/cmd/runecode-tui/route_chat_execution_helpers_test.go b/cmd/runecode-tui/route_chat_execution_helpers_test.go index 79295c0b..e0720069 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers_test.go +++ b/cmd/runecode-tui/route_chat_execution_helpers_test.go @@ -1,6 +1,7 @@ package main import ( + "strings" "testing" "github.com/runecode-ai/runecode/internal/brokerapi" @@ -58,3 +59,103 @@ func TestChatExecutionTerminal(t *testing.T) { }) } } + +func TestChatExecutionStateCardReflectsApprovalEvidenceAndStages(t *testing.T) { + detail := &brokerapi.SessionDetail{ + Summary: brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}}, + LinkedRunIDs: []string{"run-1"}, + LinkedApprovalIDs: []string{"ap-1"}, + LinkedArtifactDigests: []string{"sha256:bbbb"}, + LinkedAuditRecordDigests: []string{"sha256:aaaa"}, + CurrentTurnExecution: &brokerapi.SessionTurnExecution{ + ExecutionState: "waiting", + WaitKind: "approval", + WaitState: "waiting_approval", + PrimaryRunID: "run-1", + PendingApprovalID: "ap-1", + LinkedRunIDs: []string{"run-1"}, + LinkedApprovalIDs: []string{"ap-1"}, + LinkedArtifactDigests: []string{"sha256:bbbb"}, + }, + } + run := &brokerapi.RunDetail{ + Summary: brokerapi.RunSummary{RunID: "run-1", WorkflowDefinitionHash: "sha256:plan", LifecycleState: "active"}, + PendingApprovalIDs: []string{"ap-1"}, + ArtifactCountsByClass: map[string]int{"change_draft": 1}, + AuthoritativeState: map[string]any{"workflow_projection_reason": "plan_authoritative"}, + AdvisoryState: map[string]any{"runner": "active", "last_checkpoint": map[string]any{"checkpoint_code": "approval_wait_entered"}}, + } + card := chatExecutionStateCard(detail, nil, run) + if card.State != routeLoadStateApprovalRequired { + t.Fatalf("card state = %q, want %q", card.State, routeLoadStateApprovalRequired) + } + if !strings.Contains(card.Reason, "Broker-known stages:") || !strings.Contains(card.Reason, "plan compiled") || !strings.Contains(card.Reason, "waiting") { + t.Fatalf("expected stage summary in reason, got %q", card.Reason) + } + if !strings.Contains(card.Reason, "Current broker state: waiting / wait waiting approval") { + t.Fatalf("expected current/latest execution summary in reason, got %q", card.Reason) + } + if !strings.Contains(card.Reason, "Evidence: 1 linked run • 1 approval • 1 artifact • 1 audit record") { + t.Fatalf("expected evidence summary in reason, got %q", card.Reason) + } + if !strings.Contains(card.NextAction, "Approvals") { + t.Fatalf("expected approvals follow-up, got %q", card.NextAction) + } +} + +func TestChatExecutionStageSummaryOnlyShowsBrokerOwnedStages(t *testing.T) { + detail := &brokerapi.SessionDetail{ + Summary: brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}}, + CurrentTurnExecution: &brokerapi.SessionTurnExecution{ + ExecutionState: "running", + PrimaryRunID: "run-1", + }, + } + run := &brokerapi.RunDetail{Summary: brokerapi.RunSummary{RunID: "run-1"}} + got := chatExecutionStageSummary(*detail.CurrentTurnExecution, detail, run) + if strings.Contains(got, "run linked") { + t.Fatalf("did not expect local-only run linked stage, got %q", got) + } + if strings.Contains(got, "waiting") { + t.Fatalf("did not expect waiting stage without broker-owned waiting state, got %q", got) + } + if got != "" { + t.Fatalf("expected no broker-owned stages, got %q", got) + } +} + +func TestChatExecutionStatusAndActionForProjectBlockedUsesStatusRemediation(t *testing.T) { + exec := brokerapi.SessionTurnExecution{ExecutionState: "blocked", WaitKind: "project_blocked", WaitState: "waiting_project_blocked"} + status, action := chatExecutionStatusAndAction(exec) + if status != "Workflow cannot continue yet." { + t.Fatalf("status = %q", status) + } + if !strings.Contains(action, "Open Status") { + t.Fatalf("expected Status remediation action, got %q", action) + } +} + +func TestRenderSessionDirectoryItemsUsesCalmerProductLanguage(t *testing.T) { + items := renderSessionDirectoryItems([]brokerapi.SessionSummary{{ + Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, + Status: "active", + LastActivityPreview: "Draft a change proposal", + LinkedRunCount: 1, + LinkedArtifactCount: 2, + LinkedApprovalCount: 1, + TurnCount: 2, + }}) + if len(items) != 1 { + t.Fatalf("item count = %d, want 1", len(items)) + } + got := items[0] + if strings.Contains(got, "status=") || strings.Contains(got, "work=") || strings.Contains(got, "turns=") { + t.Fatalf("expected calmer session row, got %q", got) + } + if !strings.Contains(got, "session-1") || !strings.Contains(got, "Draft a change proposal") { + t.Fatalf("expected session id and work cue, got %q", got) + } + if !strings.Contains(got, "1 approval waiting") || !strings.Contains(got, "1 linked run") || !strings.Contains(got, "2 artifacts") { + t.Fatalf("expected follow-up cues, got %q", got) + } +} diff --git a/cmd/runecode-tui/route_chat_execution_state_helpers.go b/cmd/runecode-tui/route_chat_execution_state_helpers.go new file mode 100644 index 00000000..c8297021 --- /dev/null +++ b/cmd/runecode-tui/route_chat_execution_state_helpers.go @@ -0,0 +1,181 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func chatRunIDFromExecution(exec brokerapi.SessionTurnExecution) string { + if runID := strings.TrimSpace(exec.PrimaryRunID); runID != "" { + return runID + } + if len(exec.LinkedRunIDs) > 0 { + return strings.TrimSpace(exec.LinkedRunIDs[0]) + } + return "" +} + +func humanizeExecutionToken(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "_", " ")) + if value == "" { + return "n/a" + } + return value +} + +func maxInt(left, right int) int { + if right > left { + return right + } + return left +} + +func countNoun(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("1 %s", strings.TrimSpace(singular)) + } + return fmt.Sprintf("%d %s", count, strings.TrimSpace(plural)) +} + +func executionWaiting(exec brokerapi.SessionTurnExecution, run *brokerapi.RunDetail) bool { + if chatExecutionTerminal(exec) { + return false + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") { + return true + } + if strings.TrimSpace(exec.WaitState) != "" || strings.TrimSpace(exec.WaitKind) != "" { + return true + } + return run != nil && run.Coordination.Blocked +} + +func runPlanAuthorityAvailable(run *brokerapi.RunDetail) bool { + if run == nil { + return false + } + if strings.TrimSpace(run.Summary.WorkflowDefinitionHash) != "" { + return true + } + if strings.TrimSpace(authoritativeString(run.AuthoritativeState, "workflow_projection_reason")) == "plan_authoritative" { + return true + } + return false +} + +func runRunnerActive(run *brokerapi.RunDetail) bool { + if run == nil { + return false + } + value := strings.ToLower(strings.TrimSpace(authoritativeString(run.AdvisoryState, "runner"))) + return value == "active" || value == "running" +} + +func runCheckpointCode(run *brokerapi.RunDetail) string { + if run == nil { + return "" + } + checkpoint, _ := run.AdvisoryState["last_checkpoint"].(map[string]any) + return authoritativeString(checkpoint, "checkpoint_code") +} + +func runArtifactCount(run *brokerapi.RunDetail) int { + if run == nil { + return 0 + } + total := 0 + for _, count := range run.ArtifactCountsByClass { + total += count + } + return total +} + +func authoritativeString(state map[string]any, key string) string { + if len(state) == 0 { + return "" + } + value, _ := state[key].(string) + return strings.TrimSpace(value) +} + +func chatExecutionTerminal(exec brokerapi.SessionTurnExecution) bool { + state := strings.ToLower(strings.TrimSpace(exec.ExecutionState)) + if state == "completed" || state == "failed" { + return true + } + outcome := strings.ToLower(strings.TrimSpace(exec.TerminalOutcome)) + return outcome == "completed" || outcome == "failed" || outcome == "cancelled" +} + +func chatExecutionStatusAndAction(exec brokerapi.SessionTurnExecution) (string, string) { + status := chatExecutionHeadline(exec, nil) + action := blockedExecutionAction(exec) + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") || chatExecutionApprovalRequired(exec, nil) { + action = waitingExecutionAction(exec) + } + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "blocked") && strings.TrimSpace(exec.WaitKind) == "project_blocked" { + action = waitingExecutionAction(exec) + } + return status, strings.TrimSpace(action) +} + +func blockedExecutionAction(exec brokerapi.SessionTurnExecution) string { + if blocked := strings.TrimSpace(exec.BlockedReasonCode); blocked != "" { + return "Review the blocking reason in Runs or the inspector: " + humanizeExecutionToken(blocked) + "." + } + return "" +} + +func waitingExecutionAction(exec brokerapi.SessionTurnExecution) string { + switch strings.TrimSpace(exec.WaitKind) { + case "operator_input": + return "Provide the missing operator input when you are ready to continue." + case "approval": + return "Open Approvals and review the exact gated action before deciding." + case "external_dependency": + return "Wait for the dependency to become ready, then check Runs for the resumed workflow." + case "project_blocked": + return "Project setup is blocking this workflow. Open Status and complete the recommended remediation before retrying." + default: + return blockedExecutionAction(exec) + } +} + +func chooseActionTextByPosture(existing string, posture brokerapi.ProjectSubstratePostureGetResponse, exec brokerapi.SessionTurnExecution) string { + if strings.TrimSpace(existing) != "" && strings.TrimSpace(exec.WaitKind) != "project_blocked" { + return existing + } + if !strings.EqualFold(strings.TrimSpace(exec.WaitKind), "project_blocked") { + return existing + } + parts := []string{} + if len(posture.RemediationGuidance) > 0 { + parts = append(parts, "Remediation: "+joinCSVWithWrapHint(posture.RemediationGuidance)) + } + if strings.TrimSpace(posture.BlockedExplanation) != "" { + parts = append(parts, "Project posture: "+sanitizeUIText(posture.BlockedExplanation)) + } + if len(parts) == 0 { + return existing + } + if strings.TrimSpace(existing) != "" { + parts = append([]string{strings.TrimSpace(existing)}, parts...) + } else { + parts = append([]string{"Project setup is blocking this workflow. Open Status for broker-owned remediation."}, parts...) + } + return strings.Join(parts, " | ") +} + +func joinCSVWithWrapHint(values []string) string { + clean := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + clean = append(clean, trimmed) + } + return strings.Join(clean, ", ") +} diff --git a/cmd/runecode-tui/route_chat_inspector.go b/cmd/runecode-tui/route_chat_inspector.go index c550f01e..08b65dc3 100644 --- a/cmd/runecode-tui/route_chat_inspector.go +++ b/cmd/runecode-tui/route_chat_inspector.go @@ -24,26 +24,32 @@ func renderSessionList(sessions []brokerapi.SessionSummary, selected int) string func renderSessionInspector(detail *brokerapi.SessionDetail, presentation contentPresentationMode, document *longFormDocumentState) string { if detail == nil { - return " Select a session and press enter to load transcript." + return " Select a session to review the transcript, linked run, and workflow evidence." } if document == nil { fallback := newLongFormDocumentState() document = &fallback } presentation = normalizePresentationMode(presentation) - transcript := renderTranscriptTurns(detail.TranscriptTurns) contentKind := inspectorContentTranscript if presentation == presentationRaw { - transcript = renderTranscriptRaw(detail.TranscriptTurns) contentKind = inspectorContentRaw } if presentation == presentationStructured { - transcript = renderTranscriptStructured(detail.TranscriptTurns) contentKind = inspectorContentStructured } summary := detail.Summary ref := workbenchObjectRef{Kind: "session", ID: strings.TrimSpace(summary.Identity.SessionID), WorkspaceID: strings.TrimSpace(summary.Identity.WorkspaceID), SessionID: strings.TrimSpace(summary.Identity.SessionID)} - document.SetDocument(ref, contentKind, "transcript", transcript) + if document.ObjectRef != ref || document.Kind != contentKind || strings.TrimSpace(document.Label) == "" { + transcript := renderTranscriptTurns(detail.TranscriptTurns) + if presentation == presentationRaw { + transcript = renderTranscriptRaw(detail.TranscriptTurns) + } + if presentation == presentationStructured { + transcript = renderTranscriptStructured(detail.TranscriptTurns) + } + document.SetDocument(ref, contentKind, "transcript", transcript) + } references := chatInspectorReferences(detail) localActions := chatInspectorLocalActions() return renderInspectorShell(inspectorShellSpec{ @@ -51,8 +57,8 @@ func renderSessionInspector(detail *brokerapi.SessionDetail, presentation conten Summary: activeSessionSummaryLine(detail), Identity: fmt.Sprintf("session=%s workspace=%s", summary.Identity.SessionID, valueOrNA(summary.Identity.WorkspaceID)), - Status: fmt.Sprintf("status=%s turn_count=%d", valueOrNA(summary.Status), summary.TurnCount), - Badges: []string{stateBadgeWithLabel("status", summary.Status), appTheme.InspectorHint.Render("linked refs + ordered transcript")}, + Status: fmt.Sprintf("status=%s turns=%d linked runs=%d approvals=%d artifacts=%d audit=%d", valueOrNA(summary.Status), summary.TurnCount, len(detail.LinkedRunIDs), len(detail.LinkedApprovalIDs), len(detail.LinkedArtifactDigests), len(detail.LinkedAuditRecordDigests)), + Badges: []string{stateBadgeWithLabel("status", summary.Status), stateBadgeWithLabel("work", sessionHighLevelCue(summary)), appTheme.InspectorHint.Render("ordered transcript + linked evidence")}, ModeTabs: []string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, ActiveMode: string(presentation), References: references, @@ -84,6 +90,7 @@ func chatInspectorReferences(detail *brokerapi.SessionDetail) []inspectorReferen func chatInspectorLocalActions() []routeActionItem { return []routeActionItem{ + {Label: "jump:session-run", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeRuns}}}, {Label: "jump:runs", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeRuns}}}, {Label: "jump:approvals", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeApprovals}}}, {Label: "jump:artifacts", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeArtifacts}}}, diff --git a/cmd/runecode-tui/route_chat_loading.go b/cmd/runecode-tui/route_chat_loading.go new file mode 100644 index 00000000..5099caae --- /dev/null +++ b/cmd/runecode-tui/route_chat_loading.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func (m chatRouteModel) loadCmd(preferredSessionID string, seq uint64) tea.Cmd { + return func() tea.Msg { + ctx, cancel := withLoadTimeout() + defer cancel() + return m.loadChatInitialState(ctx, preferredSessionID, seq) + } +} + +func (m chatRouteModel) loadChatInitialState(ctx context.Context, preferredSessionID string, seq uint64) tea.Msg { + listResp, err := m.client.SessionList(ctx, 20) + if err != nil { + return chatLoadedMsg{err: err, seq: seq} + } + target := selectedChatSessionID(listResp.Sessions, preferredSessionID) + if target == "" { + return chatLoadedMsg{sessions: listResp.Sessions, activeSessionID: "", seq: seq} + } + state, err := m.loadChatSessionState(ctx, target) + if err != nil { + return chatLoadedMsg{err: err, seq: seq} + } + state.sessions = listResp.Sessions + return state.loadedMsg(seq) +} + +func selectedChatSessionID(sessions []brokerapi.SessionSummary, preferredSessionID string) string { + target := strings.TrimSpace(preferredSessionID) + if target != "" && selectedSessionIndex(sessions, target) >= 0 { + return target + } + if len(sessions) == 0 { + return "" + } + return sessions[0].Identity.SessionID +} + +type chatSessionLoadState struct { + sessions []brokerapi.SessionSummary + detail *brokerapi.SessionDetail + runDetail *brokerapi.RunDetail + posture *brokerapi.ProjectSubstratePostureGetResponse + activeID string +} + +func (s chatSessionLoadState) loadedMsg(seq uint64) chatLoadedMsg { + return chatLoadedMsg{sessions: s.sessions, detail: s.detail, runDetail: s.runDetail, posture: s.posture, activeSessionID: s.activeID, seq: seq} +} + +func (m chatRouteModel) loadChatSessionState(ctx context.Context, sessionID string) (chatSessionLoadState, error) { + getResp, err := m.client.SessionGet(ctx, sessionID) + if err != nil { + return chatSessionLoadState{}, err + } + posture, err := m.client.ProjectSubstratePostureGet(ctx) + if err != nil { + return chatSessionLoadState{}, err + } + runDetail, err := m.loadChatRunDetail(ctx, getResp.Session) + if err != nil { + return chatSessionLoadState{}, err + } + return chatSessionLoadState{detail: &getResp.Session, runDetail: runDetail, posture: &posture, activeID: sessionID}, nil +} + +func (m chatRouteModel) sendCmd(sessionID, content string) tea.Cmd { + m.watchStreamID = newRequestID("chat-session-turn-watch") + return func() tea.Msg { + ctx, cancel := withLoadTimeout() + defer cancel() + sendResp, err := m.client.SessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{SessionID: sessionID, TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: defaultSessionWorkflowRouting(), UserMessageContentText: content}) + if err != nil { + return chatMessageSentMsg{err: err} + } + watchEvents, err := m.client.SessionTurnExecutionWatch(ctx, brokerapi.SessionTurnExecutionWatchRequest{StreamID: m.watchStreamID, SessionID: sessionID, Follow: true, IncludeSnapshot: true}) + if err != nil { + return chatMessageSentMsg{err: err} + } + turnExecution := matchingTurnExecutionFromWatch(watchEvents, sendResp.TriggerID) + state, err := m.loadChatSessionState(ctx, sessionID) + if err != nil { + return chatMessageSentMsg{err: err} + } + listResp, err := m.client.SessionList(ctx, 20) + if err != nil { + return chatMessageSentMsg{err: err} + } + return chatMessageSentMsg{sessions: listResp.Sessions, detail: state.detail, ack: &sendResp, turnExecution: turnExecution, posture: state.posture, runDetail: state.runDetail} + } +} + +func defaultSessionWorkflowRouting() *brokerapi.SessionWorkflowPackRouting { + return &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"} +} + +func (m chatRouteModel) watchLoadCmd(sessionID, triggerID string, seq uint64, refreshSessions bool) tea.Cmd { + return func() tea.Msg { + ctx, cancel := withLoadTimeout() + defer cancel() + return m.loadChatWatchState(ctx, sessionID, triggerID, seq, refreshSessions) + } +} + +func (m chatRouteModel) loadChatWatchState(ctx context.Context, sessionID, triggerID string, seq uint64, refreshSessions bool) tea.Msg { + watchEvents, err := m.client.SessionTurnExecutionWatch(ctx, brokerapi.SessionTurnExecutionWatchRequest{StreamID: m.watchStreamID, SessionID: sessionID, Follow: true, IncludeSnapshot: true}) + if err != nil { + return chatExecutionWatchLoadedMsg{seq: seq, err: err} + } + turnExecution, terminalSeen := watchExecutionState(watchEvents, triggerID) + state, err := m.loadChatSessionState(ctx, sessionID) + if err != nil { + return chatExecutionWatchLoadedMsg{seq: seq, err: err} + } + continueWatch := chatShouldContinueWatch(turnExecution, terminalSeen) + if !continueWatch || refreshSessions { + listResp, err := m.client.SessionList(ctx, 20) + if err != nil { + return chatExecutionWatchLoadedMsg{seq: seq, err: err} + } + state.sessions = listResp.Sessions + } + return chatExecutionWatchLoadedMsg{seq: seq, sessionID: sessionID, triggerID: triggerID, turnExecution: turnExecution, detail: state.detail, runDetail: state.runDetail, sessions: state.sessions, posture: state.posture, continueWatch: continueWatch} +} + +func chatShouldContinueWatch(turnExecution *brokerapi.SessionTurnExecution, terminalSeen bool) bool { + if terminalSeen { + return false + } + if turnExecution == nil { + return true + } + return !chatExecutionTerminal(*turnExecution) +} + +func (m chatRouteModel) loadChatRunDetail(ctx context.Context, detail brokerapi.SessionDetail) (*brokerapi.RunDetail, error) { + runID := "" + if exec := chatVisibleExecution(&detail); exec != nil { + runID = chatRunIDFromExecution(*exec) + } + if strings.TrimSpace(runID) == "" && len(detail.LinkedRunIDs) > 0 { + runID = strings.TrimSpace(detail.LinkedRunIDs[0]) + } + if strings.TrimSpace(runID) == "" { + return nil, nil + } + resp, err := m.client.RunGet(ctx, runID) + if err != nil { + return nil, err + } + run := resp.Run + return &run, nil +} + +func matchingTurnExecutionFromWatch(events []brokerapi.SessionTurnExecutionWatchEvent, triggerID string) *brokerapi.SessionTurnExecution { + turnExecution, _ := watchExecutionState(events, triggerID) + return turnExecution +} + +func watchExecutionState(events []brokerapi.SessionTurnExecutionWatchEvent, triggerID string) (*brokerapi.SessionTurnExecution, bool) { + terminalSeen := false + for i := len(events) - 1; i >= 0; i-- { + terminalSeen = terminalSeen || events[i].Terminal + if events[i].TurnExecution == nil { + continue + } + if strings.TrimSpace(events[i].TurnExecution.TriggerID) != strings.TrimSpace(triggerID) { + continue + } + v := *events[i].TurnExecution + return &v, terminalSeen + } + return nil, terminalSeen +} diff --git a/cmd/runecode-tui/route_chat_rendering.go b/cmd/runecode-tui/route_chat_rendering.go index abf70229..077a6969 100644 --- a/cmd/runecode-tui/route_chat_rendering.go +++ b/cmd/runecode-tui/route_chat_rendering.go @@ -30,36 +30,97 @@ func renderLinkedReferenceLine(prefix string, refs []string) string { func renderSessionDirectoryItems(sessions []brokerapi.SessionSummary) []string { items := make([]string, 0, len(sessions)) for _, s := range sessions { - items = append(items, fmt.Sprintf("%s %s turns=%d", s.Identity.SessionID, stateBadgeWithLabel("status", s.Status), s.TurnCount)) + items = append(items, renderSessionDirectoryItem(s)) } return items } +func renderSessionDirectoryItem(summary brokerapi.SessionSummary) string { + parts := []string{summary.Identity.SessionID, postureBadge(summary.Status)} + if cue := shortSessionWorkCue(summary); cue != "" { + parts = append(parts, cue) + } + if followUp := sessionDirectoryFollowUpCue(summary); followUp != "" { + parts = append(parts, muted("• "+followUp)) + } + return strings.Join(parts, " ") +} + +func shortSessionWorkCue(summary brokerapi.SessionSummary) string { + preview := truncateText(summary.LastActivityPreview, 56) + if preview != "" { + return fmt.Sprintf("— %s", preview) + } + return fmt.Sprintf("— %s work in progress", sessionHighLevelCue(summary)) +} + +func sessionDirectoryFollowUpCue(summary brokerapi.SessionSummary) string { + cues := make([]string, 0, 3) + if summary.LinkedApprovalCount > 0 { + cues = append(cues, countNoun(summary.LinkedApprovalCount, "approval waiting", "approvals waiting")) + } + if summary.LinkedRunCount > 0 { + cues = append(cues, countNoun(summary.LinkedRunCount, "linked run", "linked runs")) + } + if summary.LinkedArtifactCount > 0 { + cues = append(cues, countNoun(summary.LinkedArtifactCount, "artifact", "artifacts")) + } + if len(cues) == 0 && summary.TurnCount > 0 { + cues = append(cues, countNoun(summary.TurnCount, "turn so far", "turns so far")) + } + return strings.Join(cues, " • ") +} + func activeSessionSummaryLine(detail *brokerapi.SessionDetail) string { if detail == nil { - return "none selected" + return "No session selected." } s := detail.Summary - executionCue := "execution=n/a" - if detail.CurrentTurnExecution != nil { - executionCue = fmt.Sprintf("execution=%s", detail.CurrentTurnExecution.ExecutionState) - if strings.TrimSpace(detail.CurrentTurnExecution.WaitState) != "" { - executionCue += fmt.Sprintf("(%s)", detail.CurrentTurnExecution.WaitState) + exec := chatVisibleExecution(detail) + statusParts := []string{fmt.Sprintf("Canonical session %s in workspace %s is %s.", s.Identity.SessionID, valueOrNA(s.Identity.WorkspaceID), sessionHighLevelCue(s))} + if exec != nil { + statusParts = append(statusParts, fmt.Sprintf("Current workflow state: %s", humanizeExecutionToken(exec.ExecutionState))) + if wait := strings.TrimSpace(exec.WaitState); wait != "" { + statusParts = append(statusParts, fmt.Sprintf("waiting on %s", humanizeExecutionToken(wait))) } - } else if detail.LatestTurnExecution != nil { - executionCue = fmt.Sprintf("latest_execution=%s", detail.LatestTurnExecution.ExecutionState) - } - return fmt.Sprintf("%s | ws=%s | cue=%s | activity=%s/%s | preview=%q | incomplete=%t | runs=%d approvals=%d", - s.Identity.SessionID, - s.Identity.WorkspaceID, - sessionHighLevelCue(s)+" "+executionCue, - defaultPlaceholder(s.LastActivityAt, "n/a"), - defaultPlaceholder(s.LastActivityKind, "n/a"), - truncateText(s.LastActivityPreview, 64), - s.HasIncompleteTurn, - s.LinkedRunCount, - s.LinkedApprovalCount, - ) + } + if preview := truncateText(s.LastActivityPreview, 72); preview != "" { + statusParts = append(statusParts, fmt.Sprintf("last activity %q", preview)) + } + return strings.Join(statusParts, " • ") +} + +func renderActiveSessionSummaryBlock(detail *brokerapi.SessionDetail, sessionCount int) string { + if detail == nil { + return muted("No active session selected. Pick a session from the directory to resume the work loop.") + } + lines := []string{ + fmt.Sprintf("Active session %s · workspace %s", detail.Summary.Identity.SessionID, valueOrNA(detail.Summary.Identity.WorkspaceID)), + muted(activeSessionSummaryLine(detail)), + } + if cue := shortSessionWorkCue(detail.Summary); cue != "" { + lines = append(lines, muted(strings.TrimPrefix(cue, "— "))) + } + meta := []string{} + if sessionCount > 0 { + meta = append(meta, countNoun(sessionCount, "session in directory", "sessions in directory")) + } + if followUp := sessionDirectoryFollowUpCue(detail.Summary); followUp != "" { + meta = append(meta, followUp) + } + if len(meta) > 0 { + lines = append(lines, muted(strings.Join(meta, " • "))) + } + return compactLines(lines...) +} + +func composeDraftStatusLine(draft string) string { + draft = strings.TrimSpace(draft) + if draft == "" { + return "Composer is active with an empty draft." + } + lines := strings.Count(draft, "\n") + 1 + return fmt.Sprintf("Composer has a local draft (%d chars across %d line(s)).", len([]rune(draft)), lines) } func renderTranscriptTurns(turns []brokerapi.SessionTranscriptTurn) string { @@ -100,7 +161,7 @@ func flattenRelatedLinks(links brokerapi.SessionTranscriptLinks) string { func renderComposer(on bool, draft string, textareaView string) string { if !on { - return "Composer: press c to compose and send to active session" + return muted("Composer closed. Press c to continue the conversation. Composer is idle.") } if strings.TrimSpace(textareaView) != "" { return compactLines("Compose draft:", textareaView) @@ -108,13 +169,6 @@ func renderComposer(on bool, draft string, textareaView string) string { return fmt.Sprintf("Compose draft: %q", redactSecrets(draft)) } -func composerState(on bool) string { - if on { - return "active" - } - return "idle" -} - func renderTranscriptRaw(turns []brokerapi.SessionTranscriptTurn) string { orderedTurns := sortTranscriptTurns(turns) if len(orderedTurns) == 0 { diff --git a/cmd/runecode-tui/route_chat_state.go b/cmd/runecode-tui/route_chat_state.go index 9531eff4..b7d995f2 100644 --- a/cmd/runecode-tui/route_chat_state.go +++ b/cmd/runecode-tui/route_chat_state.go @@ -79,7 +79,10 @@ func (m chatRouteModel) applyLoaded(typed chatLoadedMsg) (routeModel, tea.Cmd) { m.selected = selectedSessionIndex(m.sessions, typed.activeSessionID) m.activeID = typed.activeSessionID m.active = typed.detail + m.runDetail = typed.runDetail + m.posture = typed.posture m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" m.syncDetailDocument() @@ -114,6 +117,7 @@ func (m *chatRouteModel) resetSendSuccessState() { m.composer.Blur() m.composeOn = false m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" } @@ -128,6 +132,8 @@ func (m *chatRouteModel) applySentActiveState(typed chatMessageSentMsg) { } func (m *chatRouteModel) applySentExecutionState(typed chatMessageSentMsg) { + m.runDetail = typed.runDetail + m.posture = typed.posture if m.active != nil && typed.turnExecution != nil { exec := *typed.turnExecution m.active.CurrentTurnExecution = &exec @@ -163,6 +169,7 @@ func (m *chatRouteModel) startSentExecutionWatch(typed chatMessageSentMsg) tea.C } m.watchSeq++ m.watching = true + m.watchPollCount = 0 m.watchSession = sessionID m.watchTrigger = triggerID m.watchStreamID = newRequestID("chat-session-turn-watch") @@ -179,7 +186,9 @@ func (m chatRouteModel) handleExecutionWatchPoll(msg chatExecutionWatchPollMsg) m.watching = false return m, nil } - return m, m.watchLoadCmd(sessionID, triggerID, msg.seq) + nextPoll := m.watchPollCount + 1 + refreshSessions := nextPoll%4 == 0 + return m, m.watchLoadCmd(sessionID, triggerID, msg.seq, refreshSessions) } func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMsg) (routeModel, tea.Cmd) { @@ -197,6 +206,9 @@ func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMs } m.errText = "" m.activeID = strings.TrimSpace(msg.sessionID) + m.runDetail = msg.runDetail + m.posture = msg.posture + m.watchPollCount++ if msg.detail != nil { m.active = msg.detail } @@ -215,6 +227,7 @@ func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMs } if !msg.continueWatch { m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" m.syncDetailDocument() @@ -230,6 +243,7 @@ func (m chatRouteModel) reload() (routeModel, tea.Cmd) { m.actionText = "" m.statusText = "" m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" m.loadSeq++ @@ -238,10 +252,15 @@ func (m chatRouteModel) reload() (routeModel, tea.Cmd) { func (m *chatRouteModel) syncDetailDocument() { if m.active == nil { + m.detailDocSession = nil + m.detailDocMode = normalizePresentationMode(m.presentation) m.detailDoc.SetDocument(workbenchObjectRef{Kind: "session", ID: "none"}, inspectorContentTranscript, "transcript", "") return } presentation := normalizePresentationMode(m.presentation) + if m.detailDocSession == m.active && m.detailDocMode == presentation { + return + } transcript := renderTranscriptTurns(m.active.TranscriptTurns) kind := inspectorContentTranscript if presentation == presentationRaw { @@ -255,129 +274,6 @@ func (m *chatRouteModel) syncDetailDocument() { summary := m.active.Summary ref := workbenchObjectRef{Kind: "session", ID: strings.TrimSpace(summary.Identity.SessionID), WorkspaceID: strings.TrimSpace(summary.Identity.WorkspaceID), SessionID: strings.TrimSpace(summary.Identity.SessionID)} m.detailDoc.SetDocument(ref, kind, "transcript", transcript) -} - -func (m chatRouteModel) loadCmd(preferredSessionID string, seq uint64) tea.Cmd { - return func() tea.Msg { - ctx, cancel := withLoadTimeout() - defer cancel() - listResp, err := m.client.SessionList(ctx, 20) - if err != nil { - return chatLoadedMsg{err: err, seq: seq} - } - target := preferredSessionID - if target != "" && selectedSessionIndex(listResp.Sessions, target) < 0 { - target = "" - } - if target == "" && len(listResp.Sessions) > 0 { - target = listResp.Sessions[0].Identity.SessionID - } - if target == "" { - return chatLoadedMsg{sessions: listResp.Sessions, activeSessionID: "", seq: seq} - } - getResp, err := m.client.SessionGet(ctx, target) - if err != nil { - return chatLoadedMsg{err: err, seq: seq} - } - return chatLoadedMsg{sessions: listResp.Sessions, detail: &getResp.Session, activeSessionID: target, seq: seq} - } -} - -func (m chatRouteModel) sendCmd(sessionID, content string) tea.Cmd { - m.watchStreamID = newRequestID("chat-session-turn-watch") - return func() tea.Msg { - ctx, cancel := withLoadTimeout() - defer cancel() - sendResp, err := m.client.SessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{ - SessionID: sessionID, - TriggerSource: "interactive_user", - RequestedOperation: "start", - WorkflowRouting: defaultSessionWorkflowRouting(), - UserMessageContentText: content, - }) - if err != nil { - return chatMessageSentMsg{err: err} - } - watchEvents, err := m.client.SessionTurnExecutionWatch(ctx, brokerapi.SessionTurnExecutionWatchRequest{StreamID: m.watchStreamID, SessionID: sessionID, Follow: true, IncludeSnapshot: true}) - if err != nil { - return chatMessageSentMsg{err: err} - } - turnExecution := matchingTurnExecutionFromWatch(watchEvents, sendResp.TriggerID) - posture, err := m.client.ProjectSubstratePostureGet(ctx) - if err != nil { - return chatMessageSentMsg{err: err} - } - getResp, err := m.client.SessionGet(ctx, sessionID) - if err != nil { - return chatMessageSentMsg{err: err} - } - listResp, err := m.client.SessionList(ctx, 20) - if err != nil { - return chatMessageSentMsg{err: err} - } - return chatMessageSentMsg{sessions: listResp.Sessions, detail: &getResp.Session, ack: &sendResp, turnExecution: turnExecution, posture: &posture} - } -} - -func defaultSessionWorkflowRouting() *brokerapi.SessionWorkflowPackRouting { - return &brokerapi.SessionWorkflowPackRouting{ - SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", - SchemaVersion: "0.1.0", - WorkflowFamily: "runecontext", - WorkflowOperation: "change_draft", - } -} - -func (m chatRouteModel) watchLoadCmd(sessionID, triggerID string, seq uint64) tea.Cmd { - return func() tea.Msg { - ctx, cancel := withLoadTimeout() - defer cancel() - watchEvents, err := m.client.SessionTurnExecutionWatch(ctx, brokerapi.SessionTurnExecutionWatchRequest{StreamID: m.watchStreamID, SessionID: sessionID, Follow: true, IncludeSnapshot: true}) - if err != nil { - return chatExecutionWatchLoadedMsg{seq: seq, err: err} - } - turnExecution := matchingTurnExecutionFromWatch(watchEvents, triggerID) - posture, err := m.client.ProjectSubstratePostureGet(ctx) - if err != nil { - return chatExecutionWatchLoadedMsg{seq: seq, err: err} - } - getResp, err := m.client.SessionGet(ctx, sessionID) - if err != nil { - return chatExecutionWatchLoadedMsg{seq: seq, err: err} - } - listResp, err := m.client.SessionList(ctx, 20) - if err != nil { - return chatExecutionWatchLoadedMsg{seq: seq, err: err} - } - continueWatch := false - if turnExecution != nil { - continueWatch = !chatExecutionTerminal(*turnExecution) - } else { - continueWatch = true - } - return chatExecutionWatchLoadedMsg{ - seq: seq, - sessionID: sessionID, - triggerID: triggerID, - turnExecution: turnExecution, - detail: &getResp.Session, - sessions: listResp.Sessions, - posture: &posture, - continueWatch: continueWatch, - } - } -} - -func matchingTurnExecutionFromWatch(events []brokerapi.SessionTurnExecutionWatchEvent, triggerID string) *brokerapi.SessionTurnExecution { - for i := range events { - if events[i].TurnExecution == nil { - continue - } - if strings.TrimSpace(events[i].TurnExecution.TriggerID) != strings.TrimSpace(triggerID) { - continue - } - v := *events[i].TurnExecution - return &v - } - return nil + m.detailDocSession = m.active + m.detailDocMode = presentation } diff --git a/cmd/runecode-tui/route_chat_test.go b/cmd/runecode-tui/route_chat_test.go index f6383cc5..cce72bc8 100644 --- a/cmd/runecode-tui/route_chat_test.go +++ b/cmd/runecode-tui/route_chat_test.go @@ -9,6 +9,15 @@ import ( "github.com/runecode-ai/runecode/internal/brokerapi" ) +func chatMustContainAll(t *testing.T, haystack string, needles ...string) { + t.Helper() + for _, needle := range needles { + if !strings.Contains(haystack, needle) { + t.Fatalf("expected %q in view, got %q", needle, haystack) + } + } +} + type chatBrokerClientSpy struct { fakeBrokerClient sentReq *brokerapi.SessionExecutionTriggerRequest @@ -116,10 +125,10 @@ func TestChatRouteRendersOrderedTranscriptAndLinkedReferences(t *testing.T) { if !strings.Contains(inspector, "Linked audit: sha256:aaaa") { t.Fatalf("expected linked audit reference in inspector, got %q", inspector) } - mustContainAll(t, inspector, + chatMustContainAll(t, inspector, "Summary:", "Identity: session=session-1 workspace=ws-1", - "Local actions: jump:runs | jump:approvals | jump:artifacts | jump:audit | copy:session_id", + "Local actions: jump:session-run | jump:runs | jump:approvals | jump:artifacts | jump:audit | copy:session_id", "Copy actions: session id | workspace id | transcript excerpt | linked references", "Long-form transcript:", ) @@ -147,20 +156,36 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { } updated, _ = updated.Update(cmd()) - if spy.sentReq == nil { - t.Fatal("expected SessionExecutionTrigger request to be captured") + assertChatSendAndWatchRequests(t, spy) + + view := updated.View(120, 40, focusContent) + chatMustContainAll(t, view, + "Workflow is waiting for approval.", + "Current broker state: running", + "Approval is still required before the broker can continue this workflow.", + "Evidence: 1 linked run • 1 approval • 3 artifacts • 1 audit record", + "Active session session-1 · workspace ws-1", + "Session directory", + "Composer closed. Press c to continue the conversation.", + ) + if strings.Contains(view, "SessionExecutionTrigger") { + t.Fatalf("expected product language instead of protocol phrasing in primary view, got %q", view) } - if spy.sentReq.SessionID != "session-1" { - t.Fatalf("expected session-1 send target, got %q", spy.sentReq.SessionID) + if strings.Contains(view, "Modes:") { + t.Fatalf("expected no duplicate mode tabs in main chat view, got %q", view) } - if spy.sentReq.TriggerSource != "interactive_user" { - t.Fatalf("expected interactive_user trigger source, got %q", spy.sentReq.TriggerSource) + if strings.Contains(view, "status=") || strings.Contains(view, "work=") { + t.Fatalf("expected calmer session directory rows without dump-style labels, got %q", view) } - if spy.sentReq.RequestedOperation != "start" { - t.Fatalf("expected requested operation start, got %q", spy.sentReq.RequestedOperation) +} + +func assertChatSendAndWatchRequests(t *testing.T, spy *chatBrokerClientSpy) { + t.Helper() + if spy.sentReq == nil { + t.Fatal("expected SessionExecutionTrigger request to be captured") } - if spy.sentReq.UserMessageContentText != "hi" { - t.Fatalf("expected content hi, got %q", spy.sentReq.UserMessageContentText) + if spy.sentReq.SessionID != "session-1" || spy.sentReq.TriggerSource != "interactive_user" || spy.sentReq.RequestedOperation != "start" || spy.sentReq.UserMessageContentText != "hi" { + t.Fatalf("unexpected send request: %+v", *spy.sentReq) } if spy.sentReq.WorkflowRouting == nil || spy.sentReq.WorkflowRouting.WorkflowFamily != "runecontext" || spy.sentReq.WorkflowRouting.WorkflowOperation != "change_draft" { t.Fatalf("unexpected workflow routing: %+v", spy.sentReq.WorkflowRouting) @@ -168,16 +193,115 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { if spy.watchReq == nil { t.Fatal("expected SessionTurnExecutionWatch request to be captured") } - if spy.watchReq.SessionID != "session-1" { - t.Fatalf("expected session-1 watch target, got %q", spy.watchReq.SessionID) + if spy.watchReq.SessionID != "session-1" || !spy.watchReq.IncludeSnapshot || !spy.watchReq.Follow { + t.Fatalf("unexpected watch request: %+v", *spy.watchReq) } - if !spy.watchReq.IncludeSnapshot || !spy.watchReq.Follow { - t.Fatalf("expected watch include_snapshot+follow true, got %+v", *spy.watchReq) +} + +func TestChatRouteLoadIncludesExecutionTruthAndEvidenceCounts(t *testing.T) { + model := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, &fakeBrokerClient{}) + + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeChat}) + if cmd == nil { + t.Fatal("expected activation load command") } + updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Status: Execution progress: running") { - t.Fatalf("expected broker-owned execution progress status in view, got %q", view) + chatMustContainAll(t, view, + "Current broker state: waiting / wait waiting approval", + "Broker-known stages: waiting • plan compiled • runner active • checkpoint received • approval required • artifact ready", + "Evidence: 1 linked run • 1 approval • 3 artifacts • 1 audit record", + ) +} + +func TestChatRouteBlockedWorkflowUsesStatusRemediationLanguage(t *testing.T) { + spy := &chatBrokerClientSpy{} + blocked := brokerapi.SessionTurnExecution{ + TurnID: "turn-1", + SessionID: "session-1", + ExecutionIndex: 1, + TriggerID: "trigger-send", + TriggerSource: "interactive_user", + RequestedOperation: "start", + ExecutionState: "blocked", + WaitKind: "project_blocked", + WaitState: "waiting_project_blocked", + ApprovalProfile: "moderate", + AutonomyPosture: "balanced", + CreatedAt: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-01-01T00:00:00Z", + } + spy.watchResp = []brokerapi.SessionTurnExecutionWatchEvent{ + {EventType: "session_turn_execution_watch_snapshot", Seq: 1, TurnExecution: &blocked}, + {EventType: "session_turn_execution_watch_terminal", Seq: 2, Terminal: true, TerminalStatus: "completed"}, + } + model := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, spy) + + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeChat}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) + if cmd == nil { + t.Fatal("expected send command from compose alt+enter") + } + updated, _ = updated.Update(cmd()) + + view := updated.View(120, 40, focusContent) + if !strings.Contains(view, "Project setup is blocking this workflow. Open Status") { + t.Fatalf("expected Status remediation in view, got %q", view) + } + if !strings.Contains(view, "Follow-up: Project setup is blocking this workflow") || !strings.Contains(view, "Remediation:") { + t.Fatalf("expected posture guidance in view, got %q", view) + } +} + +func TestChatRouteWatchPollingSkipsRepeatedSessionListRefreshes(t *testing.T) { + spy := &chatBrokerClientSpy{} + model := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, spy) + + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeChat}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) + if cmd == nil { + t.Fatal("expected send command from compose alt+enter") + } + updated, cmd = updated.Update(cmd()) + if cmd == nil { + t.Fatal("expected watch polling command after send") + } + + chat := updated.(chatRouteModel) + watchMsg, ok := cmd().(chatExecutionWatchPollMsg) + if !ok { + t.Fatalf("expected chatExecutionWatchPollMsg, got %T", cmd()) + } + updated, cmd = chat.Update(watchMsg) + if cmd == nil { + t.Fatal("expected watch load command") + } + updated, _ = updated.Update(cmd()) + chat = updated.(chatRouteModel) + if chat.watching { + t.Fatal("expected terminal watch envelope to stop polling") + } + if chat.watchPollCount != 0 { + t.Fatalf("watchPollCount = %d, want reset after terminal", chat.watchPollCount) + } + if chat.sessions == nil || len(chat.sessions) == 0 { + t.Fatal("expected sessions retained during watch updates") } } @@ -240,53 +364,3 @@ func TestChatRouteComposeSupportsMultilineBracketedPaste(t *testing.T) { t.Fatalf("expected newline retained in composer, got %q", chat.composer.Value()) } } - -func TestChatRouteComposeUsesTurnExecutionWatchStateOverTriggerAck(t *testing.T) { - spy := &chatBrokerClientSpy{} - blocked := brokerapi.SessionTurnExecution{ - TurnID: "turn-1", - SessionID: "session-1", - ExecutionIndex: 1, - TriggerID: "trigger-send", - TriggerSource: "interactive_user", - RequestedOperation: "start", - ExecutionState: "blocked", - WaitKind: "project_blocked", - WaitState: "waiting_project_blocked", - ApprovalProfile: "moderate", - AutonomyPosture: "balanced", - CreatedAt: "2026-01-01T00:00:00Z", - UpdatedAt: "2026-01-01T00:00:00Z", - } - spy.watchResp = []brokerapi.SessionTurnExecutionWatchEvent{ - {EventType: "session_turn_execution_watch_snapshot", Seq: 1, TurnExecution: &blocked}, - {EventType: "session_turn_execution_watch_terminal", Seq: 2, Terminal: true, TerminalStatus: "completed"}, - } - model := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, spy) - - updated, cmd := model.Update(routeActivatedMsg{RouteID: routeChat}) - if cmd == nil { - t.Fatal("expected activation load command") - } - updated, _ = updated.Update(cmd()) - - updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) - updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) - updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) - if cmd == nil { - t.Fatal("expected send command from compose alt+enter") - } - updated, _ = updated.Update(cmd()) - - view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Status: Execution progress: blocked (waiting_project_blocked)") { - t.Fatalf("expected status from turn execution watch blocked state, got %q", view) - } - if !strings.Contains(view, "Follow-up: Remediation:") { - t.Fatalf("expected remediation posture follow-up in view, got %q", view) - } - if !strings.Contains(view, "execution watch is advisory live state") { - t.Fatalf("expected transcript/execution separation hint in view, got %q", view) - } -} diff --git a/cmd/runecode-tui/route_contract_usage_test.go b/cmd/runecode-tui/route_contract_usage_test.go index 64c472d9..fa38c7a7 100644 --- a/cmd/runecode-tui/route_contract_usage_test.go +++ b/cmd/runecode-tui/route_contract_usage_test.go @@ -29,7 +29,7 @@ var routeActivationCaseData = []routeActivationCase{ newModel: func(client localBrokerClient) routeModel { return newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, client) }, - expected: []string{"SessionList", "SessionGet"}, + expected: []string{"SessionList", "SessionGet", "ProjectSubstratePostureGet", "RunGet"}, }, { name: "runs uses typed run contracts", @@ -48,12 +48,12 @@ var routeActivationCaseData = []routeActivationCase{ expected: []string{"ApprovalList", "ApprovalGet"}, }, { - name: "action center uses typed approval/run/audit contracts", + name: "action center uses typed approval/run/project/audit contracts", routeID: routeAction, newModel: func(client localBrokerClient) routeModel { return newActionCenterRouteModel(routeDefinition{ID: routeAction, Label: "Action Center"}, client) }, - expected: []string{"ApprovalList", "RunList", "AuditVerificationGet"}, + expected: []string{"ApprovalList", "RunList", "ProjectSubstratePostureGet", "AuditVerificationGet"}, }, { name: "artifacts uses typed artifact contracts", @@ -143,7 +143,7 @@ func TestApprovalsResolveUsesTypedBrokerContract(t *testing.T) { } view := updated.View(120, 40, focusContent) - if want := "promotion approvals must be resolved via promote-excerpt to preserve exact promotion binding"; !containsSubstring(view, want) { + if want := "promotion approvals must be completed in the promotion flow so exact promotion binding stays intact"; !containsSubstring(view, want) { t.Fatalf("expected fail-closed status %q in view, got %q", want, view) } diff --git a/cmd/runecode-tui/route_dashboard.go b/cmd/runecode-tui/route_dashboard.go index c34b1e6d..c41ae3c9 100644 --- a/cmd/runecode-tui/route_dashboard.go +++ b/cmd/runecode-tui/route_dashboard.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -110,51 +109,58 @@ func (m dashboardRouteModel) Update(msg tea.Msg) (routeModel, tea.Cmd) { func (m dashboardRouteModel) View(width, height int, focus focusArea) string { _ = height if m.loading { - return renderStateCard(routeLoadStateLoading, "Dashboard", "Loading dashboard from broker API...") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Dashboard", + Message: "Refreshing the executive overview.", + Reason: "Dashboard waits for broker-owned status before updating.", + NextAction: "Wait for the broker response or press r to retry.", + ShortcutCue: "r reload", + RouteCue: "Action Center", + }) } if m.errText != "" { - return renderStateCard(routeLoadStateError, "Dashboard", "Load failed: "+m.errText+" (press r to retry)") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateError, + Title: "Dashboard", + Message: "Dashboard is temporarily unavailable.", + Reason: m.errText, + NextAction: "Press r to retry. Use Status or Action Center if needed.", + ShortcutCue: "r reload", + RouteCue: "Status or Action Center", + }) } - focusLabel := "inactive" - if focus == focusContent { - focusLabel = "active" - } - primaryRun := primaryDashboardRun(m.data.runs) innerWidth := dashboardContentWidth(width) + cardWidth := innerWidth - 4 + if cardWidth < 1 { + cardWidth = 1 + } + snapshot := buildDashboardSnapshot(m.data) sections := []string{ - compactLines(sectionTitle("Dashboard")+" "+focusBadge(focus)+" "+navStateBadge(focusLabel == "active"), tableHeader("Now")+" "+renderDashboardNowBar(primaryRun, len(m.data.approvals), focusLabel == "active", innerWidth)), - compactLines(tableHeader("Safety Summary"), renderRunSafetyStrip(primaryDashboardRun(m.data.runs), innerWidth), wrapDashboardLine(renderDashboardSafetyAlerts(m.data), innerWidth)), - m.controlPlaneSection(innerWidth), - compactLines(tableHeader("Live Activity"), wrapDashboardLine("Live activity (typed watch families; logs are supplemental inspection only):", innerWidth), wrapDashboardLine(muted("Live activity uses semantic watch families with explicit event types."), innerWidth), renderWatchFamilySummary(m.data.live.runWatch), renderWatchFamilySummary(m.data.live.approvalWatch), renderWatchFamilySummary(m.data.live.sessionWatch), renderLiveActivityFeed(m.data.live.feed)), - compactLines(tableHeader("Highlights"), wrapDashboardLine(renderRunHighlights(m.data.runs), innerWidth), wrapDashboardLine(renderApprovalHighlights(m.data.approvals), innerWidth)), - compactLines(tableHeader("Actions")+" "+keyHint("r reload")+" "+muted("tab moves focus • : opens command surface"), keyHint("Route keys: r reload")), + compactLines( + sectionTitle("Dashboard")+" "+focusBadge(focus), + renderDashboardHero(snapshot, cardWidth), + ), + renderProductCard(productCardSpec{Tone: dashboardCardTone(snapshot), Title: "Current work", Lines: []string{ + wrapDashboardLine(renderDashboardCurrentWork(m.data, snapshot), cardWidth), + wrapDashboardLine(renderDashboardApprovalCue(m.data, snapshot), cardWidth), + }}), + renderProductCard(productCardSpec{Tone: visualToneInfo, Title: "At a glance", Lines: []string{ + wrapDashboardLine(dashboardMetricStrip(snapshot), cardWidth), + }}), + renderProductCard(productCardSpec{Tone: dashboardCardTone(snapshot), Title: "Next action", Lines: []string{ + wrapDashboardLine(renderDashboardNextActions(m.data, snapshot), cardWidth), + wrapDashboardLine(renderDashboardDetailCue(snapshot), cardWidth), + }}), } return joinDashboardSections(sections...) } -func (m dashboardRouteModel) controlPlaneSection(width int) string { - parts := []string{ - tableHeader("Control Plane"), - wrapPartsByWidth([]string{tableHeader("Readiness"), boolBadge("ready", m.data.readiness.Ready), boolBadge("local_only", m.data.readiness.LocalOnly), boolBadge("recovery_complete", m.data.readiness.RecoveryComplete)}, " ", width), - wrapPartsByWidth([]string{tableHeader("Safety posture"), stateBadgeWithLabel("integrity", m.data.audit.Summary.IntegrityStatus), stateBadgeWithLabel("anchoring", m.data.audit.Summary.AnchoringStatus), boolBadge("degraded", m.data.audit.Summary.CurrentlyDegraded)}, " ", width), - } - if notice := strings.TrimSpace(renderDashboardAuditFallbackNotice(m.data.auditErr)); notice != "" { - parts = append(parts, wrapDashboardLine(notice, width)) - } - parts = append(parts, - wrapDashboardLine(renderDashboardProjectSubstrateLine(m.data.project), width), - wrapDashboardLine(renderDashboardProjectSubstrateGuidance(m.data.project), width), - wrapDashboardLine(fmt.Sprintf("Workflow posture: runs=%d pending_approvals=%d", len(m.data.runs), pendingApprovalCount(m.data.runs, m.data.approvals)), width), - wrapDashboardLine(fmt.Sprintf("Version: %s (%s) protocol bundle=%s", m.data.version.ProductVersion, m.data.version.BuildRevision, m.data.version.ProtocolBundleVersion), width), - ) - return compactLines(parts...) -} - func joinDashboardSections(sections ...string) string { nonEmpty := make([]string, 0, len(sections)) for _, section := range sections { - section = strings.TrimSpace(section) - if section == "" { + section = strings.Trim(section, "\n") + if strings.TrimSpace(section) == "" { continue } nonEmpty = append(nonEmpty, section) @@ -215,7 +221,8 @@ func (m dashboardRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) return routeSurface{ Regions: routeSurfaceRegions{ - Main: routeSurfaceRegion{Title: "Dashboard", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, + Main: routeSurfaceRegion{Title: "Dashboard", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, + Bottom: routeSurfaceRegion{Body: keyHint("r reload • 5 Action Center • 7 Audit • 8 Status")}, }, Capabilities: routeSurfaceCapabilities{}, Chrome: routeSurfaceChrome{Breadcrumbs: []string{"Home", m.def.Label}}, diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index f10db081..06dc8780 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -8,6 +8,52 @@ import ( "github.com/runecode-ai/runecode/internal/trustpolicy" ) +type dashboardExecutiveSummary struct { + State routeLoadState + Title string + BadgeLabel string + Message string + Reason string + NextAction string +} + +type dashboardSnapshot struct { + PrimaryRun brokerapi.RunSummary + PendingApprovals int + BlockedRuns int + DegradedSignals int + ActiveRuns int + RunRuntimeDegraded int + SetupBlocked bool + SetupNeedsAttention bool + AuditDegraded bool + NoWork bool + Executive dashboardExecutiveSummary +} + +func buildDashboardSnapshot(data dashboardData) dashboardSnapshot { + snapshot := dashboardSnapshot{ + PrimaryRun: primaryDashboardRun(data.runs), + PendingApprovals: pendingApprovalCount(data.runs, data.approvals), + BlockedRuns: dashboardBlockedRunCount(data.runs), + ActiveRuns: dashboardActiveRunCount(data.runs), + RunRuntimeDegraded: dashboardRunRuntimeDegradedCount(data.runs), + SetupBlocked: dashboardProjectSubstrateBlocked(data.project), + SetupNeedsAttention: dashboardProjectSubstrateNeedsAttention(data.project), + AuditDegraded: dashboardAuditDegraded(data.audit), + } + snapshot.DegradedSignals = snapshot.RunRuntimeDegraded + if snapshot.AuditDegraded { + snapshot.DegradedSignals++ + } + if !data.readiness.Ready { + snapshot.DegradedSignals++ + } + snapshot.NoWork = len(data.runs) == 0 && len(data.approvals) == 0 + snapshot.Executive = buildDashboardExecutiveSummary(data, snapshot) + return snapshot +} + func degradedDashboardAuditFallback() brokerapi.AuditVerificationGetResponse { return brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{ CryptographicallyValid: false, @@ -25,235 +71,290 @@ func renderDashboardAuditFallbackNotice(auditErr string) string { if strings.TrimSpace(auditErr) == "" { return "" } - return dangerBadge("AUDIT_VERIFICATION_UNAVAILABLE") + " audit verification unavailable; showing degraded fallback posture (" + auditErr + ")" + return dangerBadge("Evidence verification unavailable") + " Audit verification is unavailable, so RuneCode is showing degraded evidence posture. Reason: " + auditErr } func renderDashboardNowBar(run brokerapi.RunSummary, approvalCount int, focusActive bool, width int) string { parts := []string{} if focusActive { - parts = append(parts, successBadge("CONTENT_READY")) + parts = append(parts, successBadge("Overview focused")) } else { - parts = append(parts, neutralBadge("CONTENT_IDLE")) + parts = append(parts, neutralBadge("Overview visible")) } if strings.TrimSpace(run.RunID) != "" { - parts = append(parts, fmt.Sprintf("run=%s", sanitizeUIText(run.RunID))) - parts = append(parts, stateBadgeWithLabel("state", run.LifecycleState)) - parts = append(parts, stateBadgeWithLabel("backend", sanitizeUIText(run.BackendKind))) + parts = append(parts, fmt.Sprintf("Current run %s", sanitizeUIText(run.RunID))) + parts = append(parts, "state "+postureBadge(run.LifecycleState)) + parts = append(parts, "runtime "+postureBadge(sanitizeUIText(run.BackendKind))) } if approvalCount > 0 { - parts = append(parts, approvalRequiredBadge(fmt.Sprintf("PENDING_APPROVALS=%d", approvalCount))) + parts = append(parts, approvalRequiredBadge(fmt.Sprintf("%d approval waiting", approvalCount))) } else { - parts = append(parts, successBadge("PENDING_APPROVALS=0")) + parts = append(parts, successBadge("No approvals waiting")) } return wrapPartsByWidth(parts, " ", width) } -func summarizeRunWatchEvents(events []brokerapi.RunWatchEvent) watchFamilySummary { - s := watchFamilySummary{family: "run_watch", eventCount: len(events), lastStatus: "ok"} - for _, event := range events { - s.lastEventType = event.EventType - if event.Run != nil { - s.lastSubject = event.Run.RunID - } - if event.Terminal { - s.terminalCount++ - s.lastStatus = event.TerminalStatus - } - if event.Error != nil { - s.errorCount++ - s.lastStatus = "error" - } - switch event.EventType { - case "run_watch_snapshot": - s.snapshotCount++ - case "run_watch_upsert": - s.upsertCount++ - } +func buildDashboardExecutiveSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { + switch { + case snapshot.SetupBlocked || snapshot.BlockedRuns > 0: + return dashboardBlockedSummary(data, snapshot) + case snapshot.DegradedSignals > 0: + return dashboardDegradedSummary(data, snapshot) + case snapshot.PendingApprovals > 0 || snapshot.SetupNeedsAttention: + return dashboardAttentionSummary(data, snapshot) + case snapshot.ActiveRuns > 0: + return dashboardActiveWorkSummary(snapshot.ActiveRuns) + case snapshot.NoWork: + return dashboardNoWorkSummary() + default: + return defaultDashboardExecutiveSummary() } - return s } -func renderLiveActivityFeed(feed []shellLiveActivityEntry) string { - if len(feed) == 0 { - return " feed: waiting for shell watch manager" +func defaultDashboardExecutiveSummary() dashboardExecutiveSummary { + return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Healthy", Message: "RuneCode is ready and no follow-up is waiting.", Reason: "Broker surfaces show normal operation.", NextAction: "Stay here for overview or open Action Center."} +} + +func dashboardNoWorkSummary() dashboardExecutiveSummary { + return dashboardExecutiveSummary{State: routeLoadStateEmpty, Title: "No work yet", Message: "RuneCode is ready, but no work has started yet.", Reason: "No recent runs or approvals are visible.", NextAction: "Start work from Chat, then use Action Center."} +} + +func dashboardBlockedSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { + reasons := []string{} + if snapshot.SetupBlocked { + reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) } - lines := make([]string, 0, len(feed)+1) - lines = append(lines, " feed:") - for i := len(feed) - 1; i >= 0; i-- { - e := feed[i] - lines = append(lines, fmt.Sprintf(" %s event=%s subject=%s status=%s", infoBadge(sanitizeUIText(e.Family)), valueOrNA(sanitizeUIText(e.EventType)), valueOrNA(sanitizeUIText(e.Subject)), valueOrNA(sanitizeUIText(e.Status)))) + if snapshot.BlockedRuns > 0 { + reasons = append(reasons, fmt.Sprintf("%s %s blocked or waiting", dashboardCountPhrase(snapshot.BlockedRuns, "workflow", "workflows"), dashboardIsAre(snapshot.BlockedRuns))) } - return strings.Join(lines, "\n") + return dashboardExecutiveSummary{State: routeLoadStateBlocked, Title: "Blocked", BadgeLabel: "Action needed", Message: "Workflow progress is blocked.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center and clear the blocker."} } -func summarizeApprovalWatchEvents(events []brokerapi.ApprovalWatchEvent) watchFamilySummary { - s := watchFamilySummary{family: "approval_watch", eventCount: len(events), lastStatus: "ok"} - for _, event := range events { - s.lastEventType = event.EventType - if event.Approval != nil { - s.lastSubject = event.Approval.ApprovalID - } - if event.Terminal { - s.terminalCount++ - s.lastStatus = event.TerminalStatus - } - if event.Error != nil { - s.errorCount++ - s.lastStatus = "error" - } - switch event.EventType { - case "approval_watch_snapshot": - s.snapshotCount++ - case "approval_watch_upsert": - s.upsertCount++ - } +func dashboardDegradedSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { + subjects := []string{} + if !data.readiness.Ready { + subjects = append(subjects, "broker readiness") + } + if snapshot.AuditDegraded { + subjects = append(subjects, "evidence posture") + } + if snapshot.RunRuntimeDegraded > 0 { + subjects = append(subjects, dashboardCountPhrase(snapshot.RunRuntimeDegraded, "runtime signal", "runtime signals")) + } + reason := "broker posture needs review" + if len(subjects) > 0 { + reason = fmt.Sprintf("%s %s review", dashboardJoinPhrases(subjects), dashboardNeedsNeed(len(subjects))) } - return s + return dashboardExecutiveSummary{State: routeLoadStateDegraded, Title: "Degraded", BadgeLabel: "Review", Message: "Evidence or runtime posture needs review.", Reason: reason, NextAction: "Open Action Center, then confirm Audit or Runs."} } -func summarizeSessionWatchEvents(events []brokerapi.SessionWatchEvent) watchFamilySummary { - s := watchFamilySummary{family: "session_watch", eventCount: len(events), lastStatus: "ok"} - for _, event := range events { - s.lastEventType = event.EventType - if event.Session != nil { - s.lastSubject = event.Session.Identity.SessionID - } - if event.Terminal { - s.terminalCount++ - s.lastStatus = event.TerminalStatus - } - if event.Error != nil { - s.errorCount++ - s.lastStatus = "error" - } - switch event.EventType { - case "session_watch_snapshot": - s.snapshotCount++ - case "session_watch_upsert": - s.upsertCount++ - } +func dashboardAttentionSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { + reasons := []string{} + if snapshot.PendingApprovals > 0 { + reasons = append(reasons, fmt.Sprintf("%s %s waiting", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals))) } - return s + if snapshot.SetupNeedsAttention { + reasons = append(reasons, dashboardProjectSubstrateAttentionReason(data.project)) + } + return dashboardExecutiveSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", BadgeLabel: "Approval", Message: "Operator follow-up is waiting.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center for the exact follow-up."} } -func renderWatchFamilySummary(summary watchFamilySummary) string { - return fmt.Sprintf( - " %s\n totals events=%d snapshot=%d upsert=%d terminal=%d errors=%d\n last_event=%s subject=%s status=%s", - infoBadge(sanitizeUIText(summary.family)), - summary.eventCount, - summary.snapshotCount, - summary.upsertCount, - summary.terminalCount, - summary.errorCount, - valueOrNA(sanitizeUIText(summary.lastEventType)), - valueOrNA(sanitizeUIText(summary.lastSubject)), - valueOrNA(sanitizeUIText(summary.lastStatus)), - ) +func dashboardActiveWorkSummary(activeRuns int) dashboardExecutiveSummary { + return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%s %s active", dashboardCountPhrase(activeRuns, "workflow", "workflows"), dashboardIsAre(activeRuns)), NextAction: "Use Action Center or open Runs for detail."} } -func pendingApprovalCount(runs []brokerapi.RunSummary, approvals []brokerapi.ApprovalSummary) int { - total := 0 - for _, run := range runs { - total += run.PendingApprovalCount +func renderDashboardHero(snapshot dashboardSnapshot, width int) string { + exec := snapshot.Executive + lines := []string{ + wrapDashboardLine("Message: "+exec.Message, width), + wrapDashboardLine(dashboardReasonLabel(exec)+": "+exec.Reason, width), + wrapDashboardLine("Next: "+exec.NextAction, width), } - if total > 0 { - return total + if cue := dashboardHeroRouteCue(snapshot); cue != "" { + lines = append(lines, muted("Route: "+cue)) } - pending := 0 - for _, approval := range approvals { - if strings.EqualFold(strings.TrimSpace(approval.Status), "pending") { - pending++ - } + badge := dashboardStateBadge(exec) + return renderProductCard(productCardSpec{Tone: dashboardCardTone(snapshot), Title: exec.Title, Badge: badge, Lines: lines}) +} + +func dashboardReasonLabel(exec dashboardExecutiveSummary) string { + if exec.State == routeLoadStateBlocked || exec.State == routeLoadStateDegraded { + return "Why" + } + return "Reason" +} + +func dashboardStateBadge(exec dashboardExecutiveSummary) string { + label := strings.TrimSpace(exec.BadgeLabel) + if label == "" { + return "" } - return pending + if strings.EqualFold(label, strings.TrimSpace(exec.Title)) { + return "" + } + return toneBadge(stateCardTone(exec.State), strings.ToUpper(label)) } -func renderRunHighlights(runs []brokerapi.RunSummary) string { - if len(runs) == 0 { - return " No runs returned" +func dashboardHeroRouteCue(snapshot dashboardSnapshot) string { + if snapshot.PendingApprovals > 0 || snapshot.BlockedRuns > 0 || snapshot.DegradedSignals > 0 || snapshot.SetupNeedsAttention || snapshot.SetupBlocked { + return "Action Center" + } + if snapshot.ActiveRuns > 0 { + return "Action Center or Runs" } - first := runs[0] - return fmt.Sprintf(" Latest run %s %s backend=%s isolation=%s approvals=%d", sanitizeUIText(first.RunID), stateBadgeWithLabel("state", first.LifecycleState), sanitizeUIText(first.BackendKind), sanitizeUIText(first.IsolationAssuranceLevel), first.PendingApprovalCount) + return "" } -func renderApprovalHighlights(approvals []brokerapi.ApprovalSummary) string { - if len(approvals) == 0 { - return " No pending approvals" +func renderDashboardWorkflowPosture(snapshot dashboardSnapshot) string { + parts := []string{} + if snapshot.SetupBlocked { + parts = append(parts, "normal product work is blocked until project setup is remediated") + } else if snapshot.BlockedRuns > 0 { + parts = append(parts, fmt.Sprintf("%s %s blocked or waiting on follow-up", dashboardCountPhrase(snapshot.BlockedRuns, "workflow", "workflows"), dashboardIsAre(snapshot.BlockedRuns))) + } else if snapshot.ActiveRuns > 0 { + parts = append(parts, fmt.Sprintf("%s %s active", dashboardCountPhrase(snapshot.ActiveRuns, "workflow", "workflows"), dashboardIsAre(snapshot.ActiveRuns))) + } else { + parts = append(parts, "no active workflow work is currently running") + } + if snapshot.PendingApprovals > 0 { + parts = append(parts, fmt.Sprintf("%s %s waiting", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals))) + } + if snapshot.SetupNeedsAttention && !snapshot.SetupBlocked { + parts = append(parts, "project setup guidance is available") } - first := approvals[0] - return fmt.Sprintf(" Approval %s %s trigger=%s run=%s", sanitizeUIText(first.ApprovalID), stateBadgeWithLabel("status", first.Status), sanitizeUIText(first.ApprovalTriggerCode), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) + if snapshot.AuditDegraded { + parts = append(parts, "evidence posture needs review") + } + return "Workflow posture: " + strings.Join(parts, "; ") + "." } -func primaryDashboardRun(runs []brokerapi.RunSummary) brokerapi.RunSummary { - if len(runs) == 0 { - return brokerapi.RunSummary{} +func renderDashboardHighValueCounts(snapshot dashboardSnapshot, width int) string { + parts := []string{ + fmt.Sprintf("active work=%d", snapshot.ActiveRuns), + fmt.Sprintf("approval follow-up=%d", snapshot.PendingApprovals), + fmt.Sprintf("blocked or waiting=%d", snapshot.BlockedRuns), + fmt.Sprintf("degraded cues=%d", snapshot.DegradedSignals), } - return runs[0] + return "At a glance: " + wrapPartsByWidth(parts, " | ", width) } -func renderDashboardSafetyAlerts(data dashboardData) string { - alerts := []string{} - run := primaryDashboardRun(data.runs) - if strings.ToLower(strings.TrimSpace(run.ProvisioningPosture)) == "tofu" { - alerts = append(alerts, dangerBadge("ALERT_TOFU_PROVISIONING")+" unsupported legacy TOFU provisioning posture detected") +func dashboardCardTone(snapshot dashboardSnapshot) visualTone { + switch snapshot.Executive.State { + case routeLoadStateBlocked, routeLoadStateError: + return visualToneDanger + case routeLoadStateApprovalRequired, routeLoadStateDegraded, routeLoadStateWaiting: + return visualToneAttention + case routeLoadStateReady: + return visualToneSuccess + default: + return visualToneInfo } - if strings.ToLower(strings.TrimSpace(run.IsolationAssuranceLevel)) == "unknown" || strings.ToLower(strings.TrimSpace(run.IsolationAssuranceLevel)) == "unavailable" { - alerts = append(alerts, dangerBadge("ALERT_RUNTIME_POSTURE_UNAVAILABLE")+" authoritative runtime isolation posture degraded/unavailable") +} + +func renderDashboardCurrentWork(data dashboardData, snapshot dashboardSnapshot) string { + runID := strings.TrimSpace(snapshot.PrimaryRun.RunID) + if runID == "" { + if snapshot.NoWork { + return "Current work: no active run is visible yet." + } + return "Current work: follow-up is waiting, but no active run is highlighted yet." } - if run.RuntimePostureDegraded { - alerts = append(alerts, reducedAssuranceBadge("ALERT_REDUCED_ASSURANCE_RUNTIME")+" reduced-assurance runtime posture is active") + state := humanizeExecutionToken(snapshot.PrimaryRun.LifecycleState) + parts := []string{fmt.Sprintf("Current work: %s is %s", sanitizeUIText(runID), state)} + if snapshot.BlockedRuns > 0 || strings.TrimSpace(snapshot.PrimaryRun.BlockingReasonCode) != "" { + parts = append(parts, "blocked follow-up is waiting") + } else if snapshot.ActiveRuns > 0 { + parts = append(parts, "work is moving") } - if strings.ToLower(strings.TrimSpace(data.audit.Summary.AnchoringStatus)) == "degraded" || data.audit.Summary.CurrentlyDegraded { - alerts = append(alerts, auditDegradedBadge("ALERT_AUDIT_UNANCHORED")+" audit posture unanchored/degraded") + if snapshot.DegradedSignals > 0 { + parts = append(parts, "review is still needed") } - if len(alerts) == 0 { - return "Safety alerts: " + successBadge("NO_ACTIVE_DEGRADATION") + if data.readiness.RecoveryComplete { + parts = append(parts, "recovery is complete") } - return "Safety alerts: " + strings.Join(alerts, " ") + return strings.Join(parts, "; ") + "." } -func renderDashboardProjectSubstrateLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { - summary := posture.PostureSummary - if strings.TrimSpace(summary.SchemaID) == "" { - return "Project substrate: unavailable" +func renderDashboardApprovalCue(data dashboardData, snapshot dashboardSnapshot) string { + if snapshot.PendingApprovals == 0 { + return "Approvals: none waiting." } - return fmt.Sprintf( - "Project substrate: validation=%s compatibility=%s normal_operation_allowed=%t", - sanitizeUIText(summary.ValidationState), - sanitizeUIText(summary.CompatibilityPosture), - summary.NormalOperationAllowed, - ) + first := firstPendingApproval(data.approvals) + if strings.TrimSpace(first.ApprovalID) == "" { + return fmt.Sprintf("Approvals: %s %s waiting.", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals)) + } + return fmt.Sprintf("Approvals: %s %s waiting; next is %s for run %s.", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals), sanitizeUIText(first.ApprovalID), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) } -func renderDashboardProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { - parts := []string{} - if strings.TrimSpace(posture.BlockedExplanation) != "" { - parts = append(parts, "Project substrate block: "+sanitizeUIText(posture.BlockedExplanation)) +func dashboardMetricStrip(snapshot dashboardSnapshot) string { + parts := []string{ + fmt.Sprintf("Work %d", snapshot.ActiveRuns), + fmt.Sprintf("Approvals %d", snapshot.PendingApprovals), + fmt.Sprintf("Blocked %d", snapshot.BlockedRuns), + fmt.Sprintf("Review %d", snapshot.DegradedSignals), } - if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Project substrate remediation:") - parts = append(parts, joinCSVWithWrapHint(posture.RemediationGuidance)) + return strings.Join(parts, " • ") +} + +func renderDashboardDetailCue(snapshot dashboardSnapshot) string { + if snapshot.BlockedRuns > 0 || snapshot.PendingApprovals > 0 || snapshot.DegradedSignals > 0 || snapshot.SetupNeedsAttention { + return "Evidence: Runs, Audit, and Status keep proof details." + } + return "Evidence: open Runs, Audit, or Status for broker proof." +} + +func firstPendingApproval(approvals []brokerapi.ApprovalSummary) brokerapi.ApprovalSummary { + for _, approval := range approvals { + if strings.EqualFold(strings.TrimSpace(approval.Status), "pending") || strings.TrimSpace(approval.Status) == "" { + return approval + } + } + if len(approvals) == 0 { + return brokerapi.ApprovalSummary{} + } + return approvals[0] +} + +func renderDashboardNextActions(data dashboardData, snapshot dashboardSnapshot) string { + if snapshot.SetupBlocked { + return "Open Action Center and clear the setup blocker, then use Status for broker-owned remediation steps." + } + if snapshot.BlockedRuns > 0 { + return "Open Action Center and clear the blocker." } - if strings.TrimSpace(posture.InitPreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate init=%s", posture.InitPreview.Status)) + if snapshot.PendingApprovals > 0 { + return "Open Action Center and review the pending approval." } - if strings.TrimSpace(posture.UpgradePreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate upgrade=%s", posture.UpgradePreview.Status)) + if snapshot.DegradedSignals > 0 { + return "Open Action Center for the follow-up, then inspect Audit or Runs." } - if len(parts) == 0 { - return "Project substrate guidance: none" + if strings.TrimSpace(snapshot.PrimaryRun.RunID) != "" { + return fmt.Sprintf("Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(snapshot.PrimaryRun.RunID)) } - return strings.Join(parts, " | ") + return "Start a workflow from Chat, then return here for the executive overview." } -func joinCSVWithWrapHint(values []string) string { - clean := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue +func renderDashboardActionCenterCue(snapshot dashboardSnapshot) string { + return fmt.Sprintf( + "Action Center has the exact follow-up list: approvals=%d, blocked or waiting=%d, degraded cues=%d.", + snapshot.PendingApprovals, + snapshot.BlockedRuns, + snapshot.DegradedSignals, + ) +} + +func renderDashboardLiveActivitySummary(live dashboardLiveActivity) string { + degraded := 0 + for _, family := range []watchFamilySummary{live.runWatch, live.approvalWatch, live.sessionWatch} { + if family.errorCount > 0 || !strings.EqualFold(strings.TrimSpace(family.lastStatus), "ok") { + degraded++ } - clean = append(clean, trimmed) } - return strings.Join(clean, ", ") + if degraded > 0 { + return fmt.Sprintf("Live updates need attention in %s; Action Center and Status have the details.", dashboardCountPhrase(degraded, "stream", "streams")) + } + if len(live.feed) > 0 { + return fmt.Sprintf("Live updates are healthy; %s visible below.", dashboardCountPhrase(len(live.feed), "recent broker event", "recent broker events")) + } + return "Live updates are quiet; RuneCode will show broker-owned activity here when work changes." } diff --git a/cmd/runecode-tui/route_dashboard_metrics.go b/cmd/runecode-tui/route_dashboard_metrics.go new file mode 100644 index 00000000..95185e2f --- /dev/null +++ b/cmd/runecode-tui/route_dashboard_metrics.go @@ -0,0 +1,146 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func dashboardCountPhrase(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("1 %s", singular) + } + return fmt.Sprintf("%d %s", count, plural) +} + +func dashboardIsAre(count int) string { + if count == 1 { + return "is" + } + return "are" +} + +func dashboardNeedsNeed(count int) string { + if count == 1 { + return "needs" + } + return "need" +} + +func dashboardJoinPhrases(parts []string) string { + if len(parts) == 0 { + return "" + } + if len(parts) == 1 { + return parts[0] + } + if len(parts) == 2 { + return parts[0] + " and " + parts[1] + } + return strings.Join(parts[:len(parts)-1], ", ") + ", and " + parts[len(parts)-1] +} + +func dashboardProjectSubstrateAttentionReason(posture brokerapi.ProjectSubstratePostureGetResponse) string { + if len(posture.RemediationGuidance) > 0 { + return "setup guidance is available" + } + compatibility := strings.ToLower(strings.TrimSpace(posture.PostureSummary.CompatibilityPosture)) + if strings.Contains(compatibility, "upgrade") { + return "setup guidance is available" + } + return dashboardProjectSubstrateReason(posture) +} + +func dashboardActiveRunCount(runs []brokerapi.RunSummary) int { + total := 0 + for _, run := range runs { + state := strings.ToLower(strings.TrimSpace(run.LifecycleState)) + if state == "active" || state == "running" || strings.Contains(state, "in_progress") { + total++ + } + } + return total +} + +func dashboardBlockedRunCount(runs []brokerapi.RunSummary) int { + total := 0 + for _, run := range runs { + state := strings.ToLower(strings.TrimSpace(run.LifecycleState)) + if strings.Contains(state, "block") || strings.Contains(state, "wait") || strings.TrimSpace(run.BlockingReasonCode) != "" { + total++ + } + } + return total +} + +func dashboardRunRuntimeDegradedCount(runs []brokerapi.RunSummary) int { + total := 0 + for _, run := range runs { + if run.RuntimePostureDegraded || run.AuditCurrentlyDegraded { + total++ + } + } + return total +} + +func dashboardAuditDegraded(audit brokerapi.AuditVerificationGetResponse) bool { + summary := audit.Summary + return summary.CurrentlyDegraded || strings.EqualFold(strings.TrimSpace(summary.AnchoringStatus), "degraded") || strings.EqualFold(strings.TrimSpace(summary.AnchoringStatus), "failed") || strings.EqualFold(strings.TrimSpace(summary.IntegrityStatus), "failed") +} + +func dashboardProjectSubstrateBlocked(posture brokerapi.ProjectSubstratePostureGetResponse) bool { + if strings.TrimSpace(posture.PostureSummary.SchemaID) == "" { + return false + } + return !posture.PostureSummary.NormalOperationAllowed +} + +func dashboardProjectSubstrateNeedsAttention(posture brokerapi.ProjectSubstratePostureGetResponse) bool { + if strings.TrimSpace(posture.PostureSummary.SchemaID) == "" { + return false + } + if dashboardProjectSubstrateBlocked(posture) || len(posture.RemediationGuidance) > 0 { + return true + } + compatibility := strings.ToLower(strings.TrimSpace(posture.PostureSummary.CompatibilityPosture)) + return compatibility != "" && compatibility != "supported" && compatibility != "ready" +} + +func dashboardProjectSubstrateReason(posture brokerapi.ProjectSubstratePostureGetResponse) string { + if text := strings.TrimSpace(posture.BlockedExplanation); text != "" { + return sanitizeUIText(text) + } + compatibility := strings.TrimSpace(posture.PostureSummary.CompatibilityPosture) + if compatibility != "" { + return fmt.Sprintf("project setup posture is %s", humanizeExecutionToken(compatibility)) + } + if len(posture.RemediationGuidance) > 0 { + return "project setup guidance is available" + } + return "project setup posture needs review" +} + +func pendingApprovalCount(runs []brokerapi.RunSummary, approvals []brokerapi.ApprovalSummary) int { + total := 0 + for _, run := range runs { + total += run.PendingApprovalCount + } + if total > 0 { + return total + } + pending := 0 + for _, approval := range approvals { + if strings.EqualFold(strings.TrimSpace(approval.Status), "pending") { + pending++ + } + } + return pending +} + +func primaryDashboardRun(runs []brokerapi.RunSummary) brokerapi.RunSummary { + if len(runs) == 0 { + return brokerapi.RunSummary{} + } + return runs[0] +} diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index 24dd3b44..66a36cbb 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -28,31 +28,23 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Now", - "CONTENT_READY", - "Safety Summary", - "Safety strip", - "backend_kind=workspace", - "runtime isolation=sandboxed", - "audit posture=ok/degraded (unanchored/degraded)", - "approval_profile=n/a", - "Safety alerts:", - "ALERT_AUDIT_UNANCHORED", - "Control Plane", - "Project substrate:", - "compatibility=supported_with_upgrade_available", - "Project substrate remediation:", - "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", - "totals events=2 snapshot=1 upsert=0 terminal=1 errors=0", - "last_event=run_watch_terminal subject=run-1 status=completed", - "last_event=approval_watch_terminal subject=ap-1 status=completed", - "last_event=session_watch_terminal subject=session-1 status=completed", - "feed:", - "event=session_watch_terminal subject=session-1 status=completed", - "Actions", - "tab moves focus", + "Degraded", + "Current work", + "run-1 is active", + "Approvals: 1 approval is waiting", + "At a glance", + "Work 1", + "Approvals 1", + "Review 1", + "Next action", + "Open Action Center", + "Evidence: Runs, Audit, and Status keep proof details.", ) + for _, retired := range []string{"Runtime and evidence", "Supporting detail", "ALERT_AUDIT_UNANCHORED", "AUDIT_UNANCHORED_OR_DEGRADED", "Live activity"} { + if strings.Contains(view, retired) { + t.Fatalf("did not expect retired dashboard primary detail %q in view, got %q", retired, view) + } + } } type dashboardAuditUnavailableClient struct{ fakeBrokerClient } @@ -82,17 +74,48 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { mustContainAll(t, view, "Dashboard", - "Now", - "Safety posture", - "Project substrate:", - "FAILED", - "degraded=true", - "AUDIT_VERIFICATION_UNAVAILABLE", - "showing degraded fallback posture (gateway_failure)", - "Control Plane", - "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", + "Degraded", + "Current work", + "At a glance", + "Review 1", + "Next action", + "Open Action Center", ) + if strings.Contains(view, "gateway_failure") || strings.Contains(view, "Evidence verification unavailable") { + t.Fatalf("expected audit fallback detail to stay out of primary dashboard, got %q", view) + } +} + +func TestDashboardExecutiveHierarchyAndCalmPrimaryWording(t *testing.T) { + model := newDashboardRouteModel(routeDefinition{ID: routeDashboard, Label: "Dashboard"}, &fakeBrokerClient{}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeDashboard}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + view := updated.View(120, 40, focusContent) + + cardIndex := strings.Index(view, "Evidence or runtime posture needs review.") + currentIndex := strings.Index(view, "Current work") + countsIndex := strings.Index(view, "At a glance") + nextIndex := strings.Index(view, "Next action") + if cardIndex < 0 || currentIndex < 0 || countsIndex < 0 || nextIndex < 0 { + t.Fatalf("expected executive hierarchy sections in view, got %q", view) + } + if !(cardIndex < currentIndex && currentIndex < countsIndex && countsIndex < nextIndex) { + t.Fatalf("expected state card before current work before counts before next action, got %q", view) + } + if strings.Contains(view, "Protocol bundle") || strings.Contains(view, "watch_family") || strings.Contains(view, "Supporting detail") { + t.Fatalf("expected no debug-heavy primary wording, got %q", view) + } + if !strings.Contains(view, "Route: Action Center") { + t.Fatalf("expected dashboard to point operators to Action Center, got %q", view) + } + for _, unwanted := range []string{"DEGRADED Degraded", "! DEGRADED", "APPROVAL REQUIRED Needs attention", "BLOCKED Blocked", "EMPTY No work yet"} { + if strings.Contains(view, unwanted) { + t.Fatalf("expected dashboard hero without duplicated state/title wording %q, got %q", unwanted, view) + } + } } func TestDashboardViewPreservesSectionGaps(t *testing.T) { @@ -103,7 +126,7 @@ func TestDashboardViewPreservesSectionGaps(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, want := range []string{"PENDING_APPROVALS=1\n\nSafety Summary", "ALERT_AUDIT_UNANCHORED audit posture unanchored/degraded\n\nControl Plane", "protocol bundle=0.9.0\n\nLive Activity"} { + for _, want := range []string{"Current work", "\n\n │ At a glance", "Review 1\n\n │ Next action"} { if !strings.Contains(view, want) { t.Fatalf("expected preserved blank section gap %q in view, got %q", want, view) } @@ -118,7 +141,7 @@ func TestDashboardAuditFallbackWithoutErrorDoesNotAddExtraBlankLine(t *testing.T } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if strings.Contains(view, "Safety posture\n\nWorkflow posture") { + if strings.Contains(view, "\n\n\n") { t.Fatalf("did not expect extra blank line when audit fallback notice absent, got %q", view) } } @@ -136,11 +159,11 @@ func TestDashboardViewWrapsLongRowsToWidth(t *testing.T) { t.Fatalf("expected wrapped dashboard line within content width, got width=%d line=%q", lipgloss.Width(line), line) } } - if !strings.Contains(view, "runtime_posture_degraded=false") { - t.Fatalf("expected wrapped safety strip content retained, got %q", view) + if !strings.Contains(view, "Runs, Audit, and Status keep") { + t.Fatalf("expected wrapped dashboard detail cue retained, got %q", view) } - if !strings.Contains(view, "AUDIT_UNANCHORED_OR_DEGRADED") { - t.Fatalf("expected wrapped long audit cue retained, got %q", view) + if strings.Contains(view, "AUDIT_UNANCHORED_OR_DEGRADED") { + t.Fatalf("expected raw audit badge removed from primary dashboard, got %q", view) } } @@ -156,13 +179,13 @@ func TestDashboardViewNarrowWidthKeepsBoundedLinesAndSectionSpacing(t *testing.T if strings.Contains(view, "\n\n\n") { t.Fatalf("expected no triple blank section gaps in narrow view, got %q", view) } - if !strings.Contains(view, "\n\nSafety Summary") { - t.Fatalf("expected preserved single blank section gap before Safety Summary, got %q", view) + if !strings.Contains(view, "\n\n │ At a glance") { + t.Fatalf("expected preserved single blank section gap before At a glance, got %q", view) } mustContainAll(t, view, "Dashboard", - "Safety Summary", - "Control Plane", - "Live Activity", + "Current work", + "At a glance", + "Next action", ) } diff --git a/cmd/runecode-tui/route_dashboard_watch_helpers.go b/cmd/runecode-tui/route_dashboard_watch_helpers.go new file mode 100644 index 00000000..e25b5387 --- /dev/null +++ b/cmd/runecode-tui/route_dashboard_watch_helpers.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func summarizeRunWatchEvents(events []brokerapi.RunWatchEvent) watchFamilySummary { + s := watchFamilySummary{family: "run_watch", eventCount: len(events), lastStatus: "ok"} + for _, event := range events { + s.lastEventType = event.EventType + if event.Run != nil { + s.lastSubject = event.Run.RunID + } + if event.Terminal { + s.terminalCount++ + s.lastStatus = event.TerminalStatus + } + if event.Error != nil { + s.errorCount++ + s.lastStatus = "error" + } + switch event.EventType { + case "run_watch_snapshot": + s.snapshotCount++ + case "run_watch_upsert": + s.upsertCount++ + } + } + return s +} + +func renderLiveActivityFeed(feed []shellLiveActivityEntry) string { + if len(feed) == 0 { + return " feed: waiting for shell watch manager" + } + lines := make([]string, 0, len(feed)+1) + lines = append(lines, " feed:") + for i := len(feed) - 1; i >= 0; i-- { + e := feed[i] + lines = append(lines, fmt.Sprintf(" %s event=%s subject=%s status=%s", infoBadge(sanitizeUIText(e.Family)), valueOrNA(sanitizeUIText(e.EventType)), valueOrNA(sanitizeUIText(e.Subject)), valueOrNA(sanitizeUIText(e.Status)))) + } + return strings.Join(lines, "\n") +} + +func summarizeApprovalWatchEvents(events []brokerapi.ApprovalWatchEvent) watchFamilySummary { + s := watchFamilySummary{family: "approval_watch", eventCount: len(events), lastStatus: "ok"} + for _, event := range events { + s.lastEventType = event.EventType + if event.Approval != nil { + s.lastSubject = event.Approval.ApprovalID + } + if event.Terminal { + s.terminalCount++ + s.lastStatus = event.TerminalStatus + } + if event.Error != nil { + s.errorCount++ + s.lastStatus = "error" + } + switch event.EventType { + case "approval_watch_snapshot": + s.snapshotCount++ + case "approval_watch_upsert": + s.upsertCount++ + } + } + return s +} + +func summarizeSessionWatchEvents(events []brokerapi.SessionWatchEvent) watchFamilySummary { + s := watchFamilySummary{family: "session_watch", eventCount: len(events), lastStatus: "ok"} + for _, event := range events { + s.lastEventType = event.EventType + if event.Session != nil { + s.lastSubject = event.Session.Identity.SessionID + } + if event.Terminal { + s.terminalCount++ + s.lastStatus = event.TerminalStatus + } + if event.Error != nil { + s.errorCount++ + s.lastStatus = "error" + } + switch event.EventType { + case "session_watch_snapshot": + s.snapshotCount++ + case "session_watch_upsert": + s.upsertCount++ + } + } + return s +} + +func renderWatchFamilySummary(summary watchFamilySummary) string { + return fmt.Sprintf( + " %s\n totals events=%d snapshot=%d upsert=%d terminal=%d errors=%d\n last_event=%s subject=%s status=%s", + infoBadge(sanitizeUIText(summary.family)), + summary.eventCount, + summary.snapshotCount, + summary.upsertCount, + summary.terminalCount, + summary.errorCount, + valueOrNA(sanitizeUIText(summary.lastEventType)), + valueOrNA(sanitizeUIText(summary.lastSubject)), + valueOrNA(sanitizeUIText(summary.lastStatus)), + ) +} diff --git a/cmd/runecode-tui/route_git_remote_mutation.go b/cmd/runecode-tui/route_git_remote_mutation.go index 15d001db..ec382dd2 100644 --- a/cmd/runecode-tui/route_git_remote_mutation.go +++ b/cmd/runecode-tui/route_git_remote_mutation.go @@ -106,15 +106,19 @@ func (m gitRemoteMutationRouteModel) handleLoaded(msg gitRemoteMutationLoadedMsg return m, nil } m.errText = "" + if !gitRemotePreparedBindingMatches(m.prepared, msg.resp.Prepared) { + m.providerAuthLeaseID = "" + } m.prepared = msg.resp.Prepared if m.status == "" { - m.status = "Review derived summary + stable identities, then press e to execute via broker API." + m.status = "Review the prepared change, confirm approval evidence, then press e for guarded broker execution." } return m, nil } func (m gitRemoteMutationRouteModel) handleExecuted(msg gitRemoteMutationExecutedMsg) (routeModel, tea.Cmd) { m.executing = false + m.providerAuthLeaseID = "" if msg.err != nil { m.errText = safeUIErrorText(msg.err) m.status = "" @@ -122,7 +126,7 @@ func (m gitRemoteMutationRouteModel) handleExecuted(msg gitRemoteMutationExecute } m.errText = "" m.prepared = msg.resp.Prepared - m.status = fmt.Sprintf("Execute completed: execution_state=%s lifecycle_state=%s", valueOrNA(msg.resp.ExecutionState), valueOrNA(msg.resp.Prepared.LifecycleState)) + m.status = "Remote execution completed; refreshing prepared state from the broker." m = m.beginLoad() return m, m.loadCmd(m.loadSeq) } @@ -135,7 +139,7 @@ func (m gitRemoteMutationRouteModel) handleLeaseIssued(msg gitRemoteMutationLeas return m, nil } m.providerAuthLeaseID = strings.TrimSpace(msg.resp.ProviderAuthLeaseID) - m.status = fmt.Sprintf("Issued execute lease %s; executing prepared mutation...", valueOrNA(m.providerAuthLeaseID)) + m.status = "Provider credential lease is ready; executing the prepared remote change..." execReq, err := m.buildExecuteRequest() if err != nil { m.executing = false @@ -149,6 +153,7 @@ func (m gitRemoteMutationRouteModel) handleLeaseIssued(msg gitRemoteMutationLeas func (m gitRemoteMutationRouteModel) beginLoad() gitRemoteMutationRouteModel { m.loading = true m.errText = "" + m.providerAuthLeaseID = "" m.loadSeq++ return m } @@ -169,37 +174,137 @@ func (m gitRemoteMutationRouteModel) View(width, height int, focus focusArea) st return renderStateCard(routeLoadStateEmpty, "Git Remote Mutation", "No prepared mutation loaded yet. Press r to fetch prepared state.") } summary := m.prepared.DerivedSummary - requestHash := digestIdentityOrNA(m.prepared.TypedRequestHash) - actionHash := digestIdentityOrNA(m.prepared.ActionRequestHash) - decisionHash := digestIdentityOrNA(m.prepared.PolicyDecisionHash) - approvalReqHash := optionalDigestIdentityOrNA(m.prepared.RequiredApprovalRequestHash) - approvalDecisionHash := optionalDigestIdentityOrNA(m.prepared.RequiredApprovalDecisionHash) - patches := make([]string, 0, len(summary.ReferencedPatchArtifactHashes)) - for _, d := range summary.ReferencedPatchArtifactHashes { - patches = append(patches, digestIdentityOrNA(d)) - } - if len(patches) == 0 { - patches = append(patches, "n/a") - } return compactLines( sectionTitle("Git Remote Mutation")+" "+focusBadge(focus), - "Review-centric broker flow over canonical prepare/get/execute contracts:", - fmt.Sprintf("Prepared mutation: %s", valueOrNA(m.prepared.PreparedMutationID)), - fmt.Sprintf("Lifecycle: lifecycle_state=%s reason=%s execution_state=%s execution_reason=%s", valueOrNA(m.prepared.LifecycleState), valueOrNA(m.prepared.LifecycleReasonCode), valueOrNA(m.prepared.ExecutionState), valueOrNA(m.prepared.ExecutionReasonCode)), - fmt.Sprintf("Scope: run=%s provider=%s destination_ref=%s request_kind=%s", valueOrNA(m.prepared.RunID), valueOrNA(m.prepared.Provider), valueOrNA(m.prepared.DestinationRef), valueOrNA(m.prepared.RequestKind)), - fmt.Sprintf("Stable identities: typed_request_hash=%s", requestHash), - fmt.Sprintf("Bindings: action_request_hash=%s policy_decision_hash=%s", actionHash, decisionHash), - fmt.Sprintf("Approval binding: approval_id=%s approval_request_hash=%s approval_decision_hash=%s", valueOrNA(m.prepared.RequiredApprovalID), approvalReqHash, approvalDecisionHash), - fmt.Sprintf("Execute credential lease: provider_auth_lease_id=%s", valueOrNA(m.providerAuthLeaseID)), - fmt.Sprintf("Derived summary: repository=%s target_refs=%s", valueOrNA(summary.RepositoryIdentity), joinCSV(summary.TargetRefs)), - fmt.Sprintf("Derived result: expected_result_tree_hash=%s patch_artifacts=%s", digestIdentityOrNA(summary.ExpectedResultTreeHash), joinCSV(patches)), - fmt.Sprintf("Derived intent: commit_subject=%s pr_title=%s pr_base=%s pr_head=%s", valueOrNA(summary.CommitSubject), valueOrNA(summary.PullRequestTitle), valueOrNA(summary.PullRequestBaseRef), valueOrNA(summary.PullRequestHeadRef)), - "Fail-closed: execute requires required approval bindings and a broker-issued provider credential lease bound to this prepared mutation.", + renderStateCardSpec(gitRemoteStateCard(m.prepared, m.providerAuthLeaseID)), + fmt.Sprintf("Planned change: %s", gitRemotePreparedSummary(summary)), + fmt.Sprintf("Target: %s", gitRemoteTargetSummary(summary)), + fmt.Sprintf("Approval check: %s", gitRemoteApprovalSummary(m.prepared)), + fmt.Sprintf("Execution access: %s", gitRemoteLeaseSummary(m.providerAuthLeaseID)), + fmt.Sprintf("Execution: %s", gitRemoteExecutionSummary(m.prepared)), + fmt.Sprintf("Next safe action: %s", gitRemoteNextSafeAction(m.prepared, m.providerAuthLeaseID)), + "Safety: RuneCode keeps execution blocked until approval evidence and broker-issued execution access both match this prepared change.", m.status, - keyHint("Route keys: r reload prepared state, e execute prepared mutation"), ) } +func gitRemoteStateCard(prepared brokerapi.GitRemoteMutationPreparedState, leaseID string) stateCardSpec { + state := routeLoadStateWaiting + message := "The prepared remote change is ready for review." + next := "Confirm the planned change, target, and approval before executing." + if strings.TrimSpace(prepared.RequiredApprovalID) != "" && prepared.RequiredApprovalDecisionHash == nil { + state = routeLoadStateApprovalRequired + message = "Remote execution is waiting for approval." + next = "Open Approvals before executing this remote change." + } + if strings.TrimSpace(leaseID) != "" && gitRemoteApprovalBindingComplete(prepared) { + state = routeLoadStateReady + message = "Execution access is ready for this remote change." + next = "Press e only after confirming the planned change and approval state." + } + if strings.Contains(strings.ToLower(prepared.ExecutionState), "fail") { + state = routeLoadStateDegraded + message = "Remote change execution failed." + next = "Review the failure reason before retrying." + } + return stateCardSpec{State: state, Title: "Guarded remote review", Message: message, Reason: "RuneCode only executes remote changes after the review, approval evidence, and execution access all agree.", NextAction: next, ShortcutCue: "r reload • e execute", EvidenceCue: "review, approval evidence, execution access"} +} + +func gitRemotePreparedSummary(summary brokerapi.GitRemoteMutationDerivedSummary) string { + if strings.TrimSpace(summary.CommitSubject) != "" { + return summary.CommitSubject + } + if strings.TrimSpace(summary.PullRequestTitle) != "" { + return summary.PullRequestTitle + } + return "review prepared repository change" +} + +func gitRemoteTargetSummary(summary brokerapi.GitRemoteMutationDerivedSummary) string { + repository := valueOrNA(summary.RepositoryIdentity) + refs := joinCSV(summary.TargetRefs) + if strings.TrimSpace(refs) == "" || refs == "n/a" { + return repository + } + return fmt.Sprintf("%s on %s", repository, refs) +} + +func gitRemoteApprovalSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { + if strings.TrimSpace(prepared.RequiredApprovalID) == "" { + return "approval evidence is missing, so execution stays blocked" + } + if prepared.RequiredApprovalDecisionHash != nil { + return "approval evidence is ready for execution review" + } + return "waiting for approval decision before execution" +} + +func gitRemoteLeaseSummary(leaseID string) string { + if strings.TrimSpace(leaseID) == "" { + return "not ready yet; RuneCode will request execution access when you start the change" + } + return "ready for this prepared change" +} + +func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { + state := strings.TrimSpace(prepared.ExecutionState) + if state == "" { + state = strings.TrimSpace(prepared.LifecycleState) + } + switch strings.ToLower(state) { + case "", "prepared", "not_started": + return "not started; approval and execution checks still gate execution" + case "completed", "complete", "succeeded", "success": + return "completed" + } + if strings.Contains(strings.ToLower(state), "fail") { + return "failed; review execution details before retrying" + } + return valueOrNA(state) +} + +func gitRemoteNextSafeAction(prepared brokerapi.GitRemoteMutationPreparedState, leaseID string) string { + state := strings.ToLower(strings.TrimSpace(prepared.ExecutionState)) + if strings.Contains(state, "fail") { + return "Review the broker failure, then reload before retrying." + } + if state == "completed" || state == "complete" || state == "succeeded" || state == "success" { + return "Review the refreshed state to confirm the remote change landed as expected." + } + if !gitRemoteApprovalBindingComplete(prepared) { + return "Complete the bound approval review before executing this remote change." + } + if strings.TrimSpace(leaseID) != "" { + return "Press e to execute this prepared remote change through the broker." + } + return "Press e to have RuneCode request a broker-bound credential lease and execute safely." +} + +func gitRemoteApprovalBindingComplete(prepared brokerapi.GitRemoteMutationPreparedState) bool { + return strings.TrimSpace(prepared.RequiredApprovalID) != "" && prepared.RequiredApprovalRequestHash != nil && prepared.RequiredApprovalDecisionHash != nil +} + +func gitRemotePreparedBindingMatches(current brokerapi.GitRemoteMutationPreparedState, next brokerapi.GitRemoteMutationPreparedState) bool { + if strings.TrimSpace(current.PreparedMutationID) != strings.TrimSpace(next.PreparedMutationID) { + return false + } + if strings.TrimSpace(current.RequiredApprovalID) != strings.TrimSpace(next.RequiredApprovalID) { + return false + } + return digestPtrString(current.RequiredApprovalRequestHash) == digestPtrString(next.RequiredApprovalRequestHash) && digestPtrString(current.RequiredApprovalDecisionHash) == digestPtrString(next.RequiredApprovalDecisionHash) +} + +func digestPtrString(digest *trustpolicy.Digest) string { + if digest == nil { + return "" + } + identity, err := digest.Identity() + if err != nil { + return strings.TrimSpace(digest.HashAlg) + ":" + strings.TrimSpace(digest.Hash) + } + return identity +} + func (m gitRemoteMutationRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainWidth := routeRegionWidth(ctx.Regions.Main, ctx.Width) mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) @@ -283,22 +388,3 @@ func (m gitRemoteMutationRouteModel) buildExecuteRequest() (brokerapi.GitRemoteM ProviderAuthLeaseID: strings.TrimSpace(m.providerAuthLeaseID), }, nil } - -func digestIdentityOrNA(d trustpolicy.Digest) string { - identity, err := d.Identity() - if err != nil { - return "n/a" - } - return identity -} - -func optionalDigestIdentityOrNA(d *trustpolicy.Digest) string { - if d == nil { - return "n/a" - } - identity, err := d.Identity() - if err != nil { - return "n/a" - } - return identity -} diff --git a/cmd/runecode-tui/route_git_remote_mutation_test.go b/cmd/runecode-tui/route_git_remote_mutation_test.go index 1875cc73..6c4c405b 100644 --- a/cmd/runecode-tui/route_git_remote_mutation_test.go +++ b/cmd/runecode-tui/route_git_remote_mutation_test.go @@ -18,15 +18,28 @@ func TestGitRemoteMutationRouteLoadsPreparedReviewState(t *testing.T) { view := updated.View(120, 40, focusContent) for _, want := range []string{ "Git Remote Mutation", - "Review-centric broker flow over canonical prepare/get/execute contracts", - "Stable identities: typed_request_hash=", - "Approval binding:", - "Fail-closed: execute requires required approval bindings and a broker-issued provider credential lease bound to this prepared mutation.", + "Guarded remote review", + "Planned change:", + "Target:", + "Approval check:", + "Execution access:", + "Next safe action:", + "Safety: RuneCode keeps execution blocked", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q in %q", want, view) } } + for _, unwanted := range []string{ + "Structured/raw detail can show prepared=", + "Route keys: r reload prepared state, e execute prepared mutation", + "repository=", + "approval sha256:", + } { + if strings.Contains(view, unwanted) { + t.Fatalf("view unexpectedly contained %q in %q", unwanted, view) + } + } } func TestGitRemoteMutationRouteExecuteUsesTypedContract(t *testing.T) { @@ -51,7 +64,7 @@ func TestGitRemoteMutationRouteExecuteUsesTypedContract(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Execute completed") { + if !strings.Contains(view, "Remote execution completed") { t.Fatalf("expected execute completion status in %q", view) } assertStringSliceEqual(t, recording.Calls(), []string{"GitRemoteMutationGet", "GitRemoteMutationIssueExecuteLease", "GitRemoteMutationExecute", "GitRemoteMutationGet"}) @@ -73,3 +86,22 @@ func TestGitRemoteMutationRouteExecuteFailsClosedWithoutApprovalBinding(t *testi t.Fatalf("expected fail-closed status in view, got %q", view) } } + +func TestGitRemoteMutationRouteClearsLeaseWhenReloadDropsApprovalBinding(t *testing.T) { + prepared := fakePreparedGitRemoteMutationState("sha256:" + strings.Repeat("8", 64)) + refreshed := prepared + refreshed.RequiredApprovalID = "" + refreshed.RequiredApprovalRequestHash = nil + refreshed.RequiredApprovalDecisionHash = nil + model := gitRemoteMutationRouteModel{def: routeDefinition{ID: routeGitRemote, Label: "Git Remote"}, prepared: prepared, providerAuthLeaseID: "lease-git-provider", loadSeq: 1} + + updated, _ := model.Update(gitRemoteMutationLoadedMsg{resp: brokerapi.GitRemoteMutationGetResponse{Prepared: refreshed}, seq: 1}) + shell := updated.(gitRemoteMutationRouteModel) + if shell.providerAuthLeaseID != "" { + t.Fatalf("expected lease cleared when refreshed approval binding is incomplete, got %q", shell.providerAuthLeaseID) + } + view := shell.View(120, 40, focusContent) + if strings.Contains(view, "Execution access is ready") || strings.Contains(view, "Press e to execute") { + t.Fatalf("expected refreshed incomplete binding to remain not-ready, got %q", view) + } +} diff --git a/cmd/runecode-tui/route_git_setup.go b/cmd/runecode-tui/route_git_setup.go index b203efd2..3d2903be 100644 --- a/cmd/runecode-tui/route_git_setup.go +++ b/cmd/runecode-tui/route_git_setup.go @@ -117,9 +117,6 @@ func (m gitSetupRouteModel) handleLoaded(msg gitSetupLoadedMsg) (routeModel, tea } m.errText = "" m.data = msg.resp - if m.status == "" { - m.status = "Press a (browser auth), d (device-code auth), i (identity profile upsert)." - } return m, nil } @@ -157,7 +154,10 @@ func gitSetupBootstrapStatus(resp brokerapi.GitSetupAuthBootstrapResponse) strin if resp.Status == "pending" && strings.TrimSpace(resp.DeviceVerificationURI) != "" { return fmt.Sprintf("Device-code bootstrap pending. Open %s and enter code %s.", resp.DeviceVerificationURI, resp.DeviceUserCode) } - return fmt.Sprintf("Provider auth bootstrap status=%s mode=%s", resp.Status, resp.Mode) + if resp.Status == "pending" { + return "Browser auth started. Finish sign-in with the provider, then reload if the linked account does not appear." + } + return fmt.Sprintf("Provider sign-in status: %s.", valueOrNA(resp.Status)) } func (m gitSetupRouteModel) beginLoad() gitSetupRouteModel { @@ -186,28 +186,78 @@ func (m gitSetupRouteModel) View(width, height int, focus focusArea) string { auth := m.data.AuthPosture control := m.data.ControlPlaneState profiles := m.data.IdentityProfiles - profileSummary := "none" - if len(profiles) > 0 { - ids := make([]string, 0, len(profiles)) - for _, p := range profiles { - ids = append(ids, p.ProfileID) - } - profileSummary = strings.Join(ids, ", ") - } return compactLines( sectionTitle("Git Setup")+" "+focusBadge(focus), - "Broker-owned setup/config state (non-policy authority):", - fmt.Sprintf("Provider account: provider=%s linked=%t account=%s", valueOrNA(account.Provider), account.Linked, valueOrNA(account.AccountUsername)), - fmt.Sprintf("Auth posture: status=%s bootstrap_mode=%s headless_supported=%t interactive_token_fallback=%t", valueOrNA(auth.AuthStatus), valueOrNA(auth.BootstrapMode), auth.HeadlessBootstrapSupported, auth.InteractiveTokenFallbackSupport), - fmt.Sprintf("Commit identity profiles: count=%d profiles=%s default=%s", len(profiles), profileSummary, valueOrNA(control.DefaultIdentityProfileID)), - fmt.Sprintf("Control-plane convenience state: last_view=%s recent_repositories=%d", valueOrNA(control.LastSetupView), len(control.RecentRepositories)), - fmt.Sprintf("Policy authority: artifact_managed_only=%t inspect_supported=%t prepare_changes_supported=%t direct_mutation_supported=%t", m.data.PolicySurface.ArtifactManagedOnly, m.data.PolicySurface.InspectionSupported, m.data.PolicySurface.PrepareChangesSupport, m.data.PolicySurface.DirectMutationSupport), - "Policy edits must stay artifact-managed. This route only inspects and prepares setup via broker APIs.", + renderStateCardSpec(gitSetupStateCard(account, auth, profiles)), + fmt.Sprintf("Provider link: %s", gitProviderAccountSummary(account, auth)), + fmt.Sprintf("Commit identity: %s", gitIdentitySummary(profiles, control.DefaultIdentityProfileID)), + fmt.Sprintf("Review posture: %s", gitPolicySafetySummary(m.data.PolicySurface)), + fmt.Sprintf("Next safe action: %s", gitNextSafeAction(account, profiles)), m.status, - keyHint("Route keys: r reload, a browser auth, d device-code auth, i identity upsert"), ) } +func gitSetupStateCard(account brokerapi.GitProviderAccountState, auth brokerapi.GitAuthPostureState, profiles []brokerapi.GitCommitIdentityProfile) stateCardSpec { + state := routeLoadStateWaiting + message := "Link a provider account before starting broker-managed review work." + next := "Start browser auth or use device code to connect the provider account." + if account.Linked && len(profiles) > 0 { + state = routeLoadStateReady + message = "Git account and commit identity are ready for broker-managed review flows." + next = "Continue to review flows, or reload here if provider state changed." + } else if account.Linked { + message = "The provider account is linked, but commit identity still needs setup." + next = "Upsert the default broker-managed commit identity before review work." + } else if strings.TrimSpace(auth.AuthStatus) == "pending" { + message = "Provider sign-in is in progress; the account link is not ready yet." + next = "Finish the current sign-in step, then reload to confirm the linked account." + } + return stateCardSpec{State: state, Title: "Git setup", Message: message, Reason: "RuneCode needs a linked account and ready commit identity before broker-managed review work can proceed.", NextAction: next, ShortcutCue: "browser auth • device auth • identity upsert", EvidenceCue: "linked account and commit identity posture"} +} + +func gitProviderAccountSummary(account brokerapi.GitProviderAccountState, auth brokerapi.GitAuthPostureState) string { + if account.Linked { + return fmt.Sprintf("%s is linked as %s.", valueOrNA(account.Provider), valueOrNA(account.AccountUsername)) + } + if strings.TrimSpace(auth.AuthStatus) == "pending" { + return fmt.Sprintf("%s sign-in is in progress.", valueOrNA(account.Provider)) + } + return fmt.Sprintf("%s is not linked yet.", valueOrNA(account.Provider)) +} + +func gitIdentitySummary(profiles []brokerapi.GitCommitIdentityProfile, defaultID string) string { + if len(profiles) == 0 { + return "No commit identity is ready yet." + } + if strings.TrimSpace(defaultID) != "" { + return fmt.Sprintf("Default commit identity %s is ready.", valueOrNA(defaultID)) + } + return fmt.Sprintf("%d commit identity profile(s) are available.", len(profiles)) +} + +func gitPolicySafetySummary(policy brokerapi.GitPolicySurfaceState) string { + if policy.ArtifactManagedOnly && !policy.DirectMutationSupport { + return "Reviews stay artifact-managed and direct remote mutation remains disabled." + } + if policy.ArtifactManagedOnly { + return "Reviews stay artifact-managed; any remote mutation still requires broker-gated policy checks." + } + if !policy.DirectMutationSupport { + return "Direct remote mutation is disabled until broker policy allows it." + } + return "Remote review actions remain broker-gated by the current policy posture." +} + +func gitNextSafeAction(account brokerapi.GitProviderAccountState, profiles []brokerapi.GitCommitIdentityProfile) string { + if !account.Linked { + return "Link the provider account with browser auth or device code." + } + if len(profiles) == 0 { + return "Upsert the default broker-managed commit identity." + } + return "Move on to review flows, and reload here if provider state changes." +} + func (m gitSetupRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainWidth := routeRegionWidth(ctx.Regions.Main, ctx.Width) mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) diff --git a/cmd/runecode-tui/route_git_setup_test.go b/cmd/runecode-tui/route_git_setup_test.go new file mode 100644 index 00000000..4b36b3ec --- /dev/null +++ b/cmd/runecode-tui/route_git_setup_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func TestGitSetupRouteViewUsesGuidedSummaries(t *testing.T) { + model := newGitSetupRouteModel(routeDefinition{ID: routeGitSetup, Label: "Git Setup"}, &fakeBrokerClient{}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeGitSetup}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + + view := updated.View(120, 40, focusContent) + for _, want := range []string{ + "Provider link:", + "Commit identity:", + "Review posture:", + "Next safe action:", + } { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q in %q", want, view) + } + } + for _, unwanted := range []string{"bootstrap_mode=", "headless=", "token_fallback=", "Route keys:"} { + if strings.Contains(view, unwanted) { + t.Fatalf("view unexpectedly contains %q in %q", unwanted, view) + } + } +} + +func TestGitSetupRouteViewGuidesIdentitySetupWhenProviderLinked(t *testing.T) { + model := newGitSetupRouteModel(routeDefinition{ID: routeGitSetup, Label: "Git Setup"}, &fakeBrokerClient{}) + loaded := gitSetupLoadedMsg{resp: brokerapi.GitSetupGetResponse{ + ProviderAccount: brokerapi.GitProviderAccountState{Provider: "github", AccountUsername: "octocat", Linked: true}, + AuthPosture: brokerapi.GitAuthPostureState{Provider: "github", AuthStatus: "linked"}, + ControlPlaneState: brokerapi.GitControlPlaneState{ + DefaultIdentityProfileID: "", + }, + PolicySurface: brokerapi.GitPolicySurfaceState{ArtifactManagedOnly: true, DirectMutationSupport: false}, + }, seq: 0} + + updated, _ := model.Update(loaded) + view := updated.View(120, 40, focusContent) + for _, want := range []string{ + "The provider account is linked, but commit identity still needs setup.", + "Next safe action: Upsert the default broker-managed commit identity.", + } { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q in %q", want, view) + } + } +} diff --git a/cmd/runecode-tui/route_provider_setup.go b/cmd/runecode-tui/route_provider_setup.go index 057d7adc..338ceb03 100644 --- a/cmd/runecode-tui/route_provider_setup.go +++ b/cmd/runecode-tui/route_provider_setup.go @@ -32,7 +32,7 @@ type providerSetupRouteModel struct { } func newProviderSetupRouteModel(def routeDefinition, client localBrokerClient) routeModel { - return providerSetupRouteModel{def: def, client: client, selected: providerSetupDefaultsFor("openai_compatible"), status: "Press s to start direct-credential setup. Press f to switch provider family."} + return providerSetupRouteModel{def: def, client: client, selected: providerSetupDefaultsFor("openai_compatible"), status: "Choose a provider, then press s to add a direct credential through trusted secret ingress."} } func (m providerSetupRouteModel) ID() routeID { return m.def.ID } @@ -69,7 +69,7 @@ func (m providerSetupRouteModel) handleRouteMsg(msg providerSetupRouteMsg) (rout m.errText = "" if msg.beginResp != nil { m.begin = *msg.beginResp - m.status = "Session started. Type API credential (masked), Enter to submit, Esc to cancel." + m.status = "Credential entry is open. Type the credential, press Enter to submit, or Esc to cancel." m.entryActive = true m.secretRunes = nil } @@ -146,7 +146,7 @@ func (m providerSetupRouteModel) handleIdleKey(key tea.KeyMsg) (routeModel, tea. } if key.Type == tea.KeyRunes && string(key.Runes) == "f" { m.selected = providerSetupDefaultsFor(nextProviderFamily(m.selected.ProviderFamily)) - m.status = fmt.Sprintf("Selected provider family: %s (%s%s)", m.selected.ProviderFamily, m.selected.CanonicalHost, m.selected.CanonicalPathPrefix) + m.status = fmt.Sprintf("Selected provider: %s. Press s to add a direct credential.", m.selected.DisplayLabel) return m, nil } if key.Type == tea.KeyRunes && string(key.Runes) == "r" { @@ -212,26 +212,68 @@ func (m providerSetupRouteModel) View(width, height int, focus focusArea) string masked = strings.Repeat("*", len(m.secretRunes)) } current := m.currentProfile() - supportedAuthModes := "(none)" - if len(current.SupportedAuthModes) > 0 { - supportedAuthModes = strings.Join(current.SupportedAuthModes, ", ") - } return compactLines( sectionTitle("Model Providers")+" "+focusBadge(focus), - "Broker-owned direct-credential setup session with trusted secret ingress.", - "Raw secret values stay out of ordinary typed request/response bodies.", - fmt.Sprintf("Selected provider family: %s endpoint=%s%s", valueOrNA(m.selected.ProviderFamily), valueOrNA(m.selected.CanonicalHost), valueOrNA(m.selected.CanonicalPathPrefix)), - fmt.Sprintf("Current session: %s", valueOrNA(m.begin.SetupSession.SetupSessionID)), - fmt.Sprintf("Current profile: %s", valueOrNA(current.ProviderProfileID)), - fmt.Sprintf("Auth modes: supported=[%s] current=%s", supportedAuthModes, valueOrNA(current.CurrentAuthMode)), - fmt.Sprintf("Compatibility posture: %s (readiness=%s)", valueOrNA(current.CompatibilityPosture), valueOrNA(current.ReadinessPosture.EffectiveReadiness)), - fmt.Sprintf("Masked credential input: %s", masked), - fmt.Sprintf("Broker-projected profiles discovered: %d", len(m.profiles)), + renderStateCardSpec(providerSetupStateCard(m, current)), + fmt.Sprintf("Selected provider: %s", valueOrNA(m.selected.DisplayLabel)), + providerSetupSummary(current, m.entryActive, len(m.profiles)), + providerMaskedEntryLine(masked, m.entryActive), + "Credential safety: secrets stay masked in the TUI and move through trusted broker-owned entry flow.", m.status, - keyHint("Route keys: s start setup, f switch family, r refresh posture; during entry type secret, Enter submit, Esc cancel"), ) } +func providerSetupStateCard(m providerSetupRouteModel, current brokerapi.ProviderProfile) stateCardSpec { + providerLabel := valueOrNA(m.selected.DisplayLabel) + state := routeLoadStateWaiting + message := fmt.Sprintf("%s is selected and ready for secure credential setup.", providerLabel) + next := "Press s to start secure credential setup." + if strings.TrimSpace(current.ProviderProfileID) != "" && strings.EqualFold(strings.TrimSpace(current.ReadinessPosture.CredentialState), "present") { + state = routeLoadStateReady + message = fmt.Sprintf("%s already has a stored credential and is ready for use.", providerLabel) + next = "Refresh readiness or continue with Chat when workflow execution needs a model." + } + if m.entryActive { + state = routeLoadStateWaiting + message = fmt.Sprintf("%s is ready for masked credential entry.", providerLabel) + next = "Paste or type the credential, then press Enter to submit or Esc to cancel." + } + return stateCardSpec{State: state, Title: "Provider setup", Message: message, Reason: "RuneCode handles provider credentials through broker-owned secure setup instead of ordinary route input.", NextAction: next, ShortcutCue: "s setup • f provider • r refresh", EvidenceCue: "provider profile and secure setup session"} +} + +func providerCredentialSetupState(profile brokerapi.ProviderProfile, entryActive bool) string { + if entryActive { + return "credential entry in progress" + } + if strings.EqualFold(strings.TrimSpace(profile.ReadinessPosture.CredentialState), "present") { + return "credential stored" + } + return "credential needed" +} + +func providerSetupSummary(profile brokerapi.ProviderProfile, entryActive bool, profileCount int) string { + return fmt.Sprintf( + "Setup status: %s • %s • %s.", + valueOrNA(strings.TrimSpace(profile.ReadinessPosture.EffectiveReadiness)), + providerCredentialSetupState(profile, entryActive), + providerProfileCountSummary(profileCount), + ) +} + +func providerProfileCountSummary(profileCount int) string { + if profileCount == 1 { + return "1 broker profile discovered" + } + return fmt.Sprintf("%d broker profiles discovered", profileCount) +} + +func providerMaskedEntryLine(masked string, entryActive bool) string { + if !entryActive { + return "" + } + return fmt.Sprintf("Credential entry: %s (masked)", masked) +} + func (m providerSetupRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainWidth := routeRegionWidth(ctx.Regions.Main, ctx.Width) mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) diff --git a/cmd/runecode-tui/route_provider_setup_test.go b/cmd/runecode-tui/route_provider_setup_test.go index 6084e5e3..9e431741 100644 --- a/cmd/runecode-tui/route_provider_setup_test.go +++ b/cmd/runecode-tui/route_provider_setup_test.go @@ -17,11 +17,14 @@ func TestProviderSetupRouteActivationLoadsBrokerProjectedProfilePosture(t *testi } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Auth modes: supported=[direct_credential] current=direct_credential") { - t.Fatalf("expected auth-mode posture in provider view, got %q", view) + if !strings.Contains(view, "Selected provider: OpenAI default") { + t.Fatalf("expected selected provider summary in provider view, got %q", view) } - if !strings.Contains(view, "Compatibility posture: unverified") { - t.Fatalf("expected compatibility posture in provider view, got %q", view) + if !strings.Contains(view, "Setup status: not_ready • credential stored • 1 broker profile discovered.") { + t.Fatalf("expected guided setup summary in provider view, got %q", view) + } + if strings.Contains(view, "provider_family=openai_compatible") { + t.Fatalf("expected raw provider family detail to be removed from provider view, got %q", view) } assertStringSliceEqual(t, recording.Calls(), []string{"ProviderProfileList"}) } diff --git a/cmd/runecode-tui/route_runs.go b/cmd/runecode-tui/route_runs.go index 46a54a69..acc2a776 100644 --- a/cmd/runecode-tui/route_runs.go +++ b/cmd/runecode-tui/route_runs.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -136,24 +135,24 @@ func (m runsRouteModel) handleShellPreferences(msg routeShellPreferencesMsg) (ro } func (m runsRouteModel) View(width, height int, focus focusArea) string { - _ = width _ = height if m.loading { - return renderStateCard(routeLoadStateLoading, "Runs", "Loading runs from broker run summaries/details...") + return renderStateCard(routeLoadStateLoading, "Runs", "Loading runs and linked workflow evidence...") } if m.errText != "" { return renderStateCard(routeLoadStateError, "Runs", "Load failed: "+m.errText+" (press r to retry)") } body := []string{ sectionTitle("Runs") + " " + focusBadge(focus), - renderRunSafetyStrip(m.activeSummary(), width-4), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderStateCardSpec(runStateCard(m.active)), + renderRunMainPaneCues(m.active, m.activeSummary(), width-4), renderDirectory("Run directory", renderRunDirectoryItems(m.runs), m.selected), } if len(m.runs) == 0 { body = append(body, muted("No runs are available yet; reload after the broker reports canonical run activity.")) } - body = append(body, keyHint("Route keys: j/k move, enter load detail, i toggle inspector, v cycle rendered/raw/structured, r reload")) + body = append(body, muted("Runs keeps the selected workflow outcome, operator checkpoints, and linked evidence together.")) + body = append(body, keyHint("Keys: j/k move • enter review • i inspector • v mode • r reload")) return compactLines(body...) } @@ -176,7 +175,7 @@ func (m runsRouteModel) ShellSurface(ctx routeShellContext) routeSurface { Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Title: "Run workbench", Body: m.View(mainWidth, mainHeight, ctx.Focus)}, Inspector: routeSurfaceRegion{Title: "Run inspector", Body: inspector}, - Bottom: routeSurfaceRegion{Body: keyHint("Route keys: j/k move, enter load detail, i toggle inspector, v cycle rendered/raw/structured, r reload")}, + Bottom: routeSurfaceRegion{Body: keyHint("Keys: j/k move • enter review • i inspector • v mode • r reload")}, Status: routeSurfaceRegion{Body: status}, }, Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: m.inspectorOn}}, @@ -279,27 +278,3 @@ func containsRunSummary(items []brokerapi.RunSummary, runID string) bool { } return false } - -func renderRunList(runs []brokerapi.RunSummary, selected int) string { - if len(runs) == 0 { - return " - no runs" - } - line := "" - for i, run := range runs { - marker := " " - if i == selected { - marker = ">" - } - line += selectedLine(i == selected, fmt.Sprintf(" %s %s %s approvals=%d", marker, run.RunID, stateBadgeWithLabel("state", run.LifecycleState), run.PendingApprovalCount)) + "\n" - line += fmt.Sprintf(" %s | %s | %s | %s | approval_profile=%s\n", fmt.Sprintf("backend_kind=%s", valueOrNA(run.BackendKind)), renderRuntimeIsolationCue(run.BackendKind, run.IsolationAssuranceLevel), renderProvisioningPostureCue(run.ProvisioningPosture), renderAuditPostureCue(run.AuditIntegrityStatus, run.AuditAnchoringStatus, run.AuditCurrentlyDegraded), valueOrNA(run.ApprovalProfile)) - } - return line -} - -func renderRunDirectoryItems(runs []brokerapi.RunSummary) []string { - items := make([]string, 0, len(runs)) - for _, run := range runs { - items = append(items, fmt.Sprintf("%s %s approvals=%d", run.RunID, stateBadgeWithLabel("state", run.LifecycleState), run.PendingApprovalCount)) - } - return items -} diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index e942a350..f8ca4ea6 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -1,10 +1,12 @@ package main import ( + "context" "strings" "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/runecode-ai/runecode/internal/brokerapi" ) func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { @@ -15,36 +17,42 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) + surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 80, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: run=run-1 lifecycle=n/a pending_approvals=0", - "Identity: run=run-1 backend=workspace", - "Local actions: jump:approvals | jump:artifacts | jump:audit | copy:run_id", - "Copy actions: run id | raw block", + "Summary: Run run-1 is active with 1 pending approval(s).", + "Identity: run=run-1 workspace=ws-1 session=session-1", + "Local actions: jump:session | jump:approvals | jump:artifacts | jump:audit | copy:run_id | copy:session_id", + "Copy actions: run id | workspace id | session id | linked approval ids | audit refs | raw block", + "Workflow operation: change_draft • stage=stage-1", + "Plan authority: workflow definition hash", + "Runner/reporting posture: runner=active • last checkpoint=approval_wait_entered", + "Blocked/failure reason: approval wait", + "Evidence links: approvals 1 • artifact classes 2 • active manifests 1 • policy refs 1", + "Linked approvals: ap-1", + "Artifacts trail: change_draft (1) • diffs (2)", + "Audit evidence: manifest sha256:manifest • policy sha256:policy", + "Navigation cues: session=session-1 approvals=1 artifacts=3 audit=2", "backend_kind=workspace", - "Workflow identity (authoritative): workflow_kind=n/a workflow_definition_hash=n/a current_stage_", - "Runtime isolation assurance (authoritative): runtime isolation=sandboxed", - "Provisioning/binding posture (authoritative): provisioning posture=attested", - "PROVISIONING_OK", - "Attestation posture (authoritative): attestation posture=valid", - "Runtime attestation truthfulness (authoritative): post-handshake verification succeeded; support", - "Verifier class (authoritative): verifier class=trusted_domain_local", - "Supported runtime requirements (authoritative): supported_runtime_requirements_satisfied=true", - "Reduced-assurance posture (authoritative): reduced_assurance=false", - "approval_backed=n/a", - "Audit posture (authoritative): audit posture=ok/degraded (unanchored/degraded)", - "Approval profile (authoritative): approval_profile=n/a", - "Authoritative broker state (control-plane truth):", - "Advisory state", - "Coordination summary: blocked=true wait_reason=approval_wait", - "Blocking cue:", - "APPROVAL_REQUIRED", + "Structured/raw modes expose workflow hashes", + "Runtime assurance: Isolation: sandboxed", + ) + mustContainAll(t, view, + "Run overview", + "Run run-1 is active and waiting on 1 approval.", + "Next: Open Approvals to review 1 pending approval, then return here for the updated result.", + "Safety and evidence", + "Run directory", ) - if strings.Contains(view, "Summary: run=run-1 lifecycle=n/a pending_approvals=0") { + if strings.Contains(view, "Summary: Run run-1 is active with 1 pending approval(s).") { t.Fatalf("expected run detail only in inspector region, got %q", view) } + for _, banned := range []string{"Selected run", "workflow=", "operation=", "backend=", "audit=", "Modes:"} { + if strings.Contains(view, banned) { + t.Fatalf("expected %q to stay out of rendered runs pane, got %q", banned, view) + } + } } func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { @@ -55,31 +63,40 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) + surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 80, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: approval=ap-1 status=pending trigger=policy_gate", - "Identity: approval=ap-1 run=run-1", - "Local actions: resolve:typed | jump:runs | jump:audit | copy:approval_id", - "Copy actions: approval id | bound run id | raw block", - "Approval type: exact-action approval (binding_kind=exact_action)", - "Lifecycle state: pending (stale)", - "Lifecycle reason code: awaiting_decision", - "Policy reason code: requires_human_review", - "Approval trigger code: policy_gate", - "Distinct blocking semantics: trigger=policy_gate", - "Execution/system errors: shown as load failures above", - "What changes if approved: effect=unblock_next_stage summary=Promotion continues", - "Canonical bound identity: request=sha256:req", - "Exact bound scope: workspace=ws-1 run=run-1 stage=stage-1", + "Summary: promotion for run-1 is approval required.", + "Identity: Approval ap-1 • run run-1 • promotion", + "Local actions: resolve:typed | jump:runs | jump:artifacts | jump:audit | copy:approval_id", + "Copy actions: approval id | bound run id | request digest | decision digest | raw block", + "Approval state: approval required", + "Why this approval exists: policy requires operator review before promotion for run-1 can", + "What is gated: run run-1 • stage stage-1 • promotion", + "Review first: run evidence for run-1", + "If approved next: Promotion continues (effect=unblock_next_stage)", + "Continue after review: Artifacts → Audit", + "Resolve availability: continue in the promotion flow after review", + "Resolve unavailable because: promotion approvals must stay in the promotion flow", + "Workflow state: approval required; lifecycle=pending (stale)", + "Structured/raw modes expose exact-action approval", ) - if !strings.Contains(view, "Approval safety strip") { - t.Fatalf("expected approval safety strip in main view, got %q", view) + if !strings.Contains(view, "Approval review") { + t.Fatalf("expected approval overview card in main view, got %q", view) } - if strings.Contains(view, "Summary: approval=ap-1 status=pending trigger=policy_gate") { + mustContainAll(t, view, + "Decision workbench", + "Approval queue", + ) + if strings.Contains(view, "Summary: promotion for run-1 is approval required.") { t.Fatalf("expected approval detail only in inspector region, got %q", view) } + for _, banned := range []string{"Modes:", "policy_reason_code=", "approval_trigger_code=", "reason=", "gate="} { + if strings.Contains(view, banned) { + t.Fatalf("expected %q to stay out of rendered approvals pane, got %q", banned, view) + } + } } func TestArtifactsRouteUsesTypedReadAndInspectableModes(t *testing.T) { @@ -94,18 +111,33 @@ func TestArtifactsRouteUsesTypedReadAndInspectableModes(t *testing.T) { inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: artifact=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb class=diffs bytes=128", - "Identity: digest=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt", + "Summary: diffs for run-1 ready to inspect.", + "Identity: Artifact diffs for run-1 • digest sha256:bbbbbbbbbbbb", + "Status: From run run-1 • open Audit next for final checks or receipts", + "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt | copy:artifact_preview", "Copy actions: artifact digest | provenance receipt | artifact preview", - "Typed detail mode:", + "Evidence label: diffs for run-1", + "Evidence trail: run run-1 -> artifact diffs for run-1 -> Audit for final checks and receipts", + "Primary digest display: sha256:bbbbbbbbbbbb (copy raw digest below)", + "Detail mode:", "Inspectable content is supplemental evidence, not authoritative run/approval truth.", "diff preview (secrets redacted):", "token=[REDACTED]", ) - if strings.Contains(view, "Summary: artifact=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { + mustContainAll(t, view, + "Evidence workspace", + "Evidence path: run run-1 -> diffs for run-1 -> Audit review and receipts.", + "Filter: all artifact classes", + "diffs for run-1 sha256:bbbbbbbbbbbb • 128 bytes plain text preview", + ) + if strings.Contains(view, "Summary: diffs for run-1 ready to inspect.") { t.Fatalf("expected artifact detail only in inspector region, got %q", view) } + for _, banned := range []string{"Modes:", "class=", "bytes=", "run="} { + if strings.Contains(view, banned) { + t.Fatalf("expected %q to stay out of rendered artifacts pane, got %q", banned, view) + } + } updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) surface = updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) @@ -131,11 +163,11 @@ func TestApprovalsRouteSupportsTypedResolveFlowPath(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Flow path: workspace=ws-1 run=run-1 stage=stage-1 action=promotion") { - t.Fatalf("expected typed flow-path summary in view, got %q", view) + if !strings.Contains(view, "Evidence trail: review run evidence for run-1, then open Audit for policy and verification context.") { + t.Fatalf("expected compact evidence guidance in view, got %q", view) } - if !strings.Contains(view, "typed approval_resolve -> resume signal") { - t.Fatalf("expected typed resolve copy in flow path, got %q", view) + if !strings.Contains(view, "Decision path: review here, then continue in Artifacts → Audit for the final workflow-specific step.") { + t.Fatalf("expected decision guidance in view, got %q", view) } updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) @@ -149,7 +181,7 @@ func TestApprovalsRouteSupportsTypedResolveFlowPath(t *testing.T) { } view = updated.View(120, 40, focusContent) - if !strings.Contains(view, "Status: promotion approvals must be resolved via promote-excerpt to preserve exact promotion binding") { + if !strings.Contains(view, "Status: Resolve unavailable: promotion approvals must be completed in the promotion flow so exact promotion binding stays intact.") { t.Fatalf("expected fail-closed approval status in view, got %q", view) } } @@ -186,11 +218,82 @@ func TestApprovalsRouteResolvesBackendPostureViaTypedApprovalResolve(t *testing. t.Fatalf("expected ApprovalResolve to be called, got %v", calls) } view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "resolved via typed ApprovalResolve") { + if !strings.Contains(view, "resolved; broker result=approval_consumed") { t.Fatalf("expected resolve success status in view, got %q", view) } } +func TestRunInspectorContentIncludesPostureAndTrustCues(t *testing.T) { + detail := mustFakeRunDetail(t) + content := runInspectorContent(detail.Summary, detail, pendingApprovalStageCount(detail.StageSummaries), waitingRoleCount(detail.RoleSummaries), buildRunEvidenceLinks(detail), presentationRendered) + mustContainAll(t, content, + "Provisioning posture: Provisioning: attested", + "PROVISIONING_OK", + "Attestation truth: attestation posture=valid", + "post-handshake verification succeeded; supported runtime evidence is present, but beta attested posture still waits for explicit post-handshake gating integration", + "Structured/raw modes expose workflow hashes", + ) +} + +func TestApprovalInspectorContentIncludesPolicyAndTriggerCues(t *testing.T) { + resp := mustFakeApprovalDetail(t) + content := approvalInspectorContent(resp.Approval, resp.ApprovalDetail, resp.ApprovalDetail.BoundIdentity, resp.Approval.BoundScope, approvalBindingLabel(resp.ApprovalDetail.BindingKind), resp.ApprovalDetail.LifecycleDetail.LifecycleState, renderApprovalLifecycleFlags(resp.ApprovalDetail.LifecycleDetail), presentationRendered) + mustContainAll(t, content, + "Why this approval exists: policy requires operator review", + "Safety cue: approval policy, lifecycle, and execution/system errors remain distinct", + "Structured/raw modes expose exact-action approval, binding kind, trigger codes, policy reason codes", + ) +} + +func TestAuditRouteInspectorShowsBoundedTrailAndCopyableDigest(t *testing.T) { + model := newAuditRouteModel(routeDefinition{ID: routeAudit, Label: "Audit"}, &fakeBrokerClient{}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeAudit}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected record detail load command") + } + updated, _ = updated.Update(cmd()) + surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) + inspector := surface.Regions.Inspector.Body + mustContainAll(t, inspector, + "Copy actions: record digest | linked references | raw block", + "Primary digest display: sha256:aaaaaaaaaaaa (copy raw digest below)", + "Evidence trail: workflow result -> artifacts -> audit record -> final checks", + "Trust posture: linked records stay authoritative", + ) + view := updated.View(120, 40, focusContent) + if !strings.Contains(view, "Run state changed • sha256:aaaaaaaaaaaa • verification degraded") { + t.Fatalf("expected product-language timeline directory row, got %q", view) + } + for _, banned := range []string{"digest=", "event=", "posture=", "refs=", "Modes:"} { + if strings.Contains(view, banned) { + t.Fatalf("expected %q to stay out of rendered audit pane, got %q", banned, view) + } + } +} + +func mustFakeRunDetail(t *testing.T) *brokerapi.RunDetail { + t.Helper() + resp, err := (&fakeBrokerClient{}).RunGet(context.Background(), "run-1") + if err != nil { + t.Fatalf("RunGet failed: %v", err) + } + return &resp.Run +} + +func mustFakeApprovalDetail(t *testing.T) brokerapi.ApprovalGetResponse { + t.Helper() + resp, err := (&fakeBrokerClient{}).ApprovalGet(context.Background(), "ap-1") + if err != nil { + t.Fatalf("ApprovalGet failed: %v", err) + } + return resp +} + func TestRunsReloadKeepsSelectedDetailAligned(t *testing.T) { model := newRunsRouteModel(routeDefinition{ID: routeRuns, Label: "Runs"}, &reloadAwareBrokerClient{}) updated, cmd := model.Update(routeActivatedMsg{RouteID: routeRuns}) @@ -207,6 +310,11 @@ func TestRunsReloadKeepsSelectedDetailAligned(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Run overview", + "Run run-2 is blocked.", + "Run directory", + ) if !strings.Contains(view, "> run-2") { t.Fatalf("expected run-2 to remain selected after reload, got %q", view) } @@ -236,7 +344,7 @@ func TestApprovalsReloadKeepsSelectedDetailAligned(t *testing.T) { t.Fatalf("expected ap-2 to remain selected after reload, got %q", view) } surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) - if !strings.Contains(surface.Regions.Inspector.Body, "Policy reason code: stage_sign_off_required") { + if !strings.Contains(surface.Regions.Inspector.Body, "What is gated: run run-2 • stage stage-2 • stage summary sign off") { t.Fatalf("expected ap-2 detail to remain active after reload, got %q", surface.Regions.Inspector.Body) } } @@ -257,7 +365,7 @@ func TestArtifactsReloadKeepsSelectedDetailAligned(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "> sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") { + if !strings.Contains(view, "> build logs for run-2 sha256:cccccccccccc • 256 bytes plain text preview") { t.Fatalf("expected second artifact to remain selected after reload, got %q", view) } surface := updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) @@ -311,7 +419,7 @@ func TestApprovalsReloadFallsBackWhenSelectedApprovalDisappears(t *testing.T) { if !strings.Contains(view, "> ap-1") { t.Fatalf("expected fallback selection to available approval, got %q", view) } - if !strings.Contains(updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body, "approval=ap-1") { + if !strings.Contains(updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body, "Approval ap-1") { t.Fatalf("expected fallback detail for ap-1, got %q", updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body) } } @@ -333,10 +441,10 @@ func TestArtifactsReloadFallsBackWhenSelectedArtifactDisappears(t *testing.T) { if strings.Contains(view, "Load failed") { t.Fatalf("expected graceful fallback instead of load failure, got %q", view) } - if !strings.Contains(view, "> sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { + if !strings.Contains(view, "> diffs for run-1 sha256:bbbbbbbbbbbb • 128 bytes plain text preview") { t.Fatalf("expected fallback selection to first available artifact, got %q", view) } - if !strings.Contains(updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body, "artifact=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { + if !strings.Contains(updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body, "Artifact diffs for run-1") { t.Fatalf("expected fallback detail for first artifact, got %q", updated.ShellSurface(routeShellContext{Width: 120, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}).Regions.Inspector.Body) } } @@ -376,6 +484,121 @@ func TestAttestationPostureCueClarifiesRuntimeIsolationAssurance(t *testing.T) { } } +func TestAttestationPostureCueSanitizesBrokerDerivedPostureAndReasons(t *testing.T) { + cue := renderAttestationPostureCue("invalid\x00", []string{" token=secret123 ", "", "bad\x00reason"}) + mustContainAll(t, cue, + "attestation posture=invalid (evidence rejected reasons=token=[REDACTED],badreason)", + "ATTESTATION_INVALID", + ) + for _, banned := range []string{"secret123", "\x00"} { + if strings.Contains(cue, banned) { + t.Fatalf("expected sanitized attestation cue, found %q in %q", banned, cue) + } + } +} + +func TestRuntimeAttestationTruthfulnessCueSanitizesBrokerDerivedAttestationState(t *testing.T) { + validState := map[string]any{ + "attestation_posture": "valid\x00", + "attestation_verification_succeeded": true, + "supported_runtime_requirements_satisfied": true, + "attestation_evidence_present": true, + } + got := renderRuntimeAttestationTruthfulnessCue(validState) + if !strings.Contains(got, "post-handshake verification succeeded; supported runtime evidence is present, but beta attested posture still waits for explicit post-handshake gating integration") { + t.Fatalf("expected valid posture to keep supported attestation wording provisional, got %q", got) + } + + invalidState := map[string]any{ + "attestation_posture": "invalid", + "attestation_reason_codes": []any{" token=secret123 ", "", "bad\x00reason"}, + "session_binding_present": true, + "attestation_evidence_present": true, + "supported_runtime_requirements_satisfied": false, + } + got = renderRuntimeAttestationTruthfulnessCue(invalidState) + mustContainAll(t, got, + "post-handshake evidence collected but not yet supportable; beta attested story still gated by post-handshake verification; reasons=token=[REDACTED],badreason", + ) + for _, banned := range []string{"secret123", "\x00"} { + if strings.Contains(got, banned) { + t.Fatalf("expected sanitized runtime attestation truthfulness cue, found %q in %q", banned, got) + } + } +} + +func TestReducedAssurancePostureCueSanitizesBrokerDerivedApprovalStatus(t *testing.T) { + got := renderReducedAssurancePostureCue(map[string]any{ + "runtime_posture_degraded": true, + "reduced_assurance_approval_backed": true, + "reduced_assurance_approval_status": " token=secret123\napproved\x00", + }) + mustContainAll(t, got, + "reduced_assurance=true", + "approval_backed=true status=token=[REDACTED]approved", + "REDUCED_ASSURANCE_APPROVAL_BACKED", + ) + for _, banned := range []string{"secret123", "\n", "\x00"} { + if strings.Contains(got, banned) { + t.Fatalf("expected sanitized reduced assurance cue, found %q in %q", banned, got) + } + } +} + +func TestSupportedRuntimeRequirementsCueSanitizesReasonCodesFromStringSlice(t *testing.T) { + got := renderSupportedRuntimeRequirementsCue(map[string]any{ + "supported_runtime_requirements_satisfied": false, + "supported_runtime_requirement_reason_codes": []string{" token=secret123 ", "", "bad\x00reason", "\n"}, + }) + mustContainAll(t, got, + "supported_runtime_requirements_satisfied=false reasons=token=[REDACTED],badreason", + "SUPPORTED_RUNTIME_REQUIREMENTS_UNSATISFIED", + ) + for _, banned := range []string{"secret123", "\x00", "\n"} { + if strings.Contains(got, banned) { + t.Fatalf("expected sanitized supported runtime reasons from []string, found %q in %q", banned, got) + } + } +} + +func TestSupportedRuntimeRequirementsCueSanitizesReasonCodesFromAnySlice(t *testing.T) { + got := renderSupportedRuntimeRequirementsCue(map[string]any{ + "supported_runtime_requirements_satisfied": false, + "supported_runtime_requirement_reason_codes": []any{" token=secret123 ", "", "bad\x00reason", "\n", 17}, + }) + mustContainAll(t, got, + "supported_runtime_requirements_satisfied=false reasons=token=[REDACTED],badreason", + "SUPPORTED_RUNTIME_REQUIREMENTS_UNSATISFIED", + ) + for _, banned := range []string{"secret123", "\x00", "\n"} { + if strings.Contains(got, banned) { + t.Fatalf("expected sanitized supported runtime reasons from []any, found %q in %q", banned, got) + } + } +} + +func TestAuthoritativeVerifierClassCueSanitizesBrokerDerivedClass(t *testing.T) { + got := renderAuthoritativeVerifierClassCue(map[string]any{ + "attestation_verifier_class": " token=secret123\ntrusted\x00", + }) + mustContainAll(t, got, + "verifier class=token=[REDACTED]trusted (source=attestation_verifier_class)", + "VERIFIER_CLASS_REPORTED", + ) + for _, banned := range []string{"secret123", "\n", "\x00"} { + if strings.Contains(got, banned) { + t.Fatalf("expected sanitized verifier class cue, found %q in %q", banned, got) + } + } +} + +func TestRunEvidenceSummaryNilDetailReturnsZeroCounts(t *testing.T) { + got := runEvidenceSummary(nil) + if got != "approvals 0 • artifact classes 0 • active manifests 0 • policy refs 0" { + t.Fatalf("runEvidenceSummary(nil) = %q", got) + } +} + func containsCall(calls []string, want string) bool { for _, call := range calls { if call == want { diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index 22202def..d8d7a6e3 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -7,9 +7,16 @@ import ( "github.com/runecode-ai/runecode/internal/brokerapi" ) +type runEvidenceLinks struct { + SessionID string + ApprovalIDs []string + ArtifactLabels []string + AuditLabels []string +} + func renderRunInspector(detail *brokerapi.RunDetail, presentation contentPresentationMode, document *longFormDocumentState) string { if detail == nil { - return " Select a run and press enter to load detail." + return " Select a run to review workflow status, outcome, and linked evidence." } if document == nil { fallback := newLongFormDocumentState() @@ -19,19 +26,18 @@ func renderRunInspector(detail *brokerapi.RunDetail, presentation contentPresent presentation = normalizePresentationMode(presentation) waitingStages := pendingApprovalStageCount(detail.StageSummaries) waitingRoles := waitingRoleCount(detail.RoleSummaries) - content := runInspectorContent(summary, detail, waitingStages, waitingRoles, presentation) + links := buildRunEvidenceLinks(detail) + content := runInspectorContent(summary, detail, waitingStages, waitingRoles, links, presentation) contentKind := runInspectorContentKind(presentation) ref := workbenchObjectRef{Kind: "run", ID: strings.TrimSpace(summary.RunID), WorkspaceID: strings.TrimSpace(summary.WorkspaceID)} document.SetDocument(ref, contentKind, "run details", content) return renderInspectorShell(inspectorShellSpec{ - Title: "Run inspector", - Summary: fmt.Sprintf("run=%s lifecycle=%s pending_approvals=%d", summary.RunID, valueOrNA(summary.LifecycleState), summary.PendingApprovalCount), - Identity: fmt.Sprintf("run=%s backend=%s", summary.RunID, valueOrNA(summary.BackendKind)), - Status: fmt.Sprintf("lifecycle=%s blocked=%t", valueOrNA(summary.LifecycleState), detail.Coordination.Blocked), - Badges: []string{stateBadgeWithLabel("state", summary.LifecycleState), appTheme.InspectorHint.Render("authoritative vs advisory state shown")}, - References: []inspectorReference{{Label: "approvals", Items: mapReferenceIDs(detail.PendingApprovalIDs, func(id string) paletteActionMsg { - return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: id}} - })}}, + Title: "Run inspector", + Summary: runInspectorSummary(summary, detail), + Identity: runInspectorIdentity(summary, detail), + Status: runInspectorStatus(summary, detail), + Badges: []string{stateBadgeWithLabel("state", summary.LifecycleState), appTheme.InspectorHint.Render("authoritative vs advisory state shown")}, + References: runInspectorReferences(detail, links), LocalActions: runInspectorLocalActions(), CopyActions: runRouteCopyActions(detail), ModeTabs: []string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, @@ -42,23 +48,50 @@ func renderRunInspector(detail *brokerapi.RunDetail, presentation contentPresent func runInspectorLocalActions() []routeActionItem { return []routeActionItem{ + {Label: "jump:session", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeChat}}}, {Label: "jump:approvals", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeApprovals}}}, {Label: "jump:artifacts", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeArtifacts}}}, {Label: "jump:audit", Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}}}, {Label: "copy:run_id"}, + {Label: "copy:session_id"}, } } +func runInspectorReferences(detail *brokerapi.RunDetail, links runEvidenceLinks) []inspectorReference { + if detail == nil { + return nil + } + refs := []inspectorReference{} + if sessionID := strings.TrimSpace(links.SessionID); sessionID != "" { + refs = append(refs, inspectorReference{Label: "session", Items: []inspectorReferenceItem{{Label: sessionID, Action: paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "session", RouteID: routeChat, SessionID: sessionID}}}}}) + } + refs = append(refs, + inspectorReference{Label: "approvals", Items: mapReferenceIDs(links.ApprovalIDs, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: id}} + })}, + inspectorReference{Label: "artifacts", Items: mapReferenceIDs(links.ArtifactLabels, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeArtifacts}} + })}, + inspectorReference{Label: "audit", Items: mapReferenceIDs(links.AuditLabels, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}} + })}, + ) + return refs +} + func runInspectorReferenceActions(detail *brokerapi.RunDetail) []routeActionItem { if detail == nil { return nil } - items := mapReferenceIDs(detail.PendingApprovalIDs, func(id string) paletteActionMsg { - return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: id}} - }) - out := make([]routeActionItem, 0, len(items)) - for _, item := range items { - out = append(out, routeActionItem{Label: "approval:" + item.Label, Action: item.Action}) + refs := runInspectorReferences(detail, buildRunEvidenceLinks(detail)) + out := make([]routeActionItem, 0, 12) + for _, ref := range refs { + for _, item := range ref.Items { + if strings.TrimSpace(item.Label) == "" { + continue + } + out = append(out, routeActionItem{Label: ref.Label + ":" + item.Label, Action: item.Action}) + } } return out } @@ -83,130 +116,276 @@ func waitingRoleCount(roles []brokerapi.RunRoleSummary) int { return count } -func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDetail, waitingStages int, waitingRoles int, presentation contentPresentationMode) string { +func buildRunEvidenceLinks(detail *brokerapi.RunDetail) runEvidenceLinks { + if detail == nil { + return runEvidenceLinks{} + } + return runEvidenceLinks{ + SessionID: strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")), + ApprovalIDs: append([]string(nil), detail.PendingApprovalIDs...), + ArtifactLabels: runArtifactReferenceLabels(detail), + AuditLabels: runAuditReferenceLabels(detail), + } +} + +func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDetail, waitingStages int, waitingRoles int, links runEvidenceLinks, presentation contentPresentationMode) string { if presentation == presentationStructured { return compactLines( - fmt.Sprintf("structured run detail: run=%s", summary.RunID), - fmt.Sprintf("counts: authoritative=%d advisory=%d stages=%d roles=%d pending_approvals=%d", len(detail.AuthoritativeState), len(detail.AdvisoryState), len(detail.StageSummaries), len(detail.RoleSummaries), len(detail.PendingApprovalIDs)), + fmt.Sprintf("structured run detail: run=%s workflow=%s status=%s", summary.RunID, valueOrNA(summary.WorkflowKind), valueOrNA(summary.LifecycleState)), + fmt.Sprintf("counts: authoritative=%d advisory=%d stages=%d roles=%d pending_approvals=%d artifact_classes=%d", len(detail.AuthoritativeState), len(detail.AdvisoryState), len(detail.StageSummaries), len(detail.RoleSummaries), len(detail.PendingApprovalIDs), len(detail.ArtifactCountsByClass)), ) } if presentation == presentationRaw { return compactLines( - fmt.Sprintf("raw summary run_id=%s lifecycle=%s backend_kind=%s", summary.RunID, summary.LifecycleState, summary.BackendKind), + fmt.Sprintf("raw summary run_id=%s lifecycle=%s backend_kind=%s workflow_kind=%s", summary.RunID, summary.LifecycleState, summary.BackendKind, valueOrNA(summary.WorkflowKind)), fmt.Sprintf("raw coordination blocked=%t wait_reason=%s mode=%s", detail.Coordination.Blocked, valueOrNA(detail.Coordination.WaitReasonCode), valueOrNA(detail.Coordination.CoordinationMode)), - fmt.Sprintf("raw maps authoritative_keys=%d advisory_keys=%d", len(detail.AuthoritativeState), len(detail.AdvisoryState)), + fmt.Sprintf("raw maps authoritative_keys=%d advisory_keys=%d artifact_classes=%d", len(detail.AuthoritativeState), len(detail.AdvisoryState), len(detail.ArtifactCountsByClass)), ) } attestationPosture, attestationReasons := attestationPostureFromState(detail.AuthoritativeState) return compactLines( - fmt.Sprintf("backend_kind=%s", summary.BackendKind), - fmt.Sprintf("Workflow identity (authoritative): workflow_kind=%s workflow_definition_hash=%s current_stage_id=%s", valueOrNA(summary.WorkflowKind), valueOrNA(summary.WorkflowDefinitionHash), valueOrNA(summary.CurrentStageID)), - "Runtime isolation assurance (authoritative): "+renderRuntimeIsolationCue(summary.BackendKind, summary.IsolationAssuranceLevel), - "Provisioning/binding posture (authoritative): "+renderProvisioningPostureCue(summary.ProvisioningPosture), - "Attestation posture (authoritative): "+renderAttestationPostureCue(attestationPosture, attestationReasons), - fmt.Sprintf("Runtime attestation truthfulness (authoritative): %s", renderRuntimeAttestationTruthfulnessCue(detail.AuthoritativeState)), - "Verifier class (authoritative): "+renderAuthoritativeVerifierClassCue(detail.AuthoritativeState), - "Supported runtime requirements (authoritative): "+renderSupportedRuntimeRequirementsCue(detail.AuthoritativeState), - "Reduced-assurance posture (authoritative): "+renderReducedAssurancePostureCue(detail.AuthoritativeState), - "Audit posture (authoritative): "+renderAuditPostureCue(summary.AuditIntegrityStatus, summary.AuditAnchoringStatus, summary.AuditCurrentlyDegraded), - fmt.Sprintf("Approval profile (authoritative): %s", renderApprovalProfileCue(summary.ApprovalProfile)), - fmt.Sprintf("Authoritative broker state (control-plane truth): %d keys | Advisory state (non-authoritative runner hints): %d keys %s", len(detail.AuthoritativeState), len(detail.AdvisoryState), renderAdvisoryStateCue(detail.AdvisoryState)), - fmt.Sprintf("Coordination summary: blocked=%t wait_reason=%s mode=%s locks=%d conflicts=%d", detail.Coordination.Blocked, detail.Coordination.WaitReasonCode, detail.Coordination.CoordinationMode, detail.Coordination.LockCount, detail.Coordination.ConflictCount), - fmt.Sprintf("Blocking cue: %s (reason=%s)", renderBlockingStateCue(detail.Coordination.Blocked, detail.Coordination.WaitReasonCode), valueOrNA(detail.Coordination.WaitReasonCode)), - fmt.Sprintf("Stage summaries: %d total, %d with pending approvals", len(detail.StageSummaries), waitingStages), - fmt.Sprintf("Role summaries: %d total, %d reporting coordination waits", len(detail.RoleSummaries), waitingRoles), - fmt.Sprintf("Pending approvals=%d active manifests=%d policy refs=%d", len(detail.PendingApprovalIDs), len(detail.ActiveManifestHashes), len(detail.LatestPolicyDecisionRefs)), + runOutcomeHeadline(summary, detail), + fmt.Sprintf("Workflow operation: %s", runWorkflowOperation(summary, detail)), + fmt.Sprintf("Runtime backend: backend_kind=%s", valueOrNA(summary.BackendKind)), + muted("Structured/raw modes expose workflow hashes, coordination internals, approval profile, manifests, policy refs, and broker map counts."), + fmt.Sprintf("Plan authority: %s", runPlanAuthoritySummary(summary, detail)), + fmt.Sprintf("Runner/reporting posture: %s", runReportingPostureSummary(detail)), + fmt.Sprintf("Blocked/failure reason: %s", runBlockingReasonSummary(summary, detail)), + fmt.Sprintf("Evidence links: %s", runEvidenceSummary(detail)), + fmt.Sprintf("Linked approvals: %s", runJoinedOrNA(links.ApprovalIDs)), + fmt.Sprintf("Artifacts trail: %s", runJoinedOrNA(links.ArtifactLabels)), + fmt.Sprintf("Audit evidence: %s", runJoinedOrNA(links.AuditLabels)), + fmt.Sprintf("Navigation cues: session=%s approvals=%d artifacts=%d audit=%d", valueOrNA(links.SessionID), len(links.ApprovalIDs), runArtifactCount(detail), len(links.AuditLabels)), + "Runtime assurance: "+renderRuntimeIsolationCue(summary.BackendKind, summary.IsolationAssuranceLevel), + "Provisioning posture: "+renderProvisioningPostureCue(summary.ProvisioningPosture), + "Attestation truth: "+renderAttestationPostureCue(attestationPosture, attestationReasons)+"; "+renderRuntimeAttestationTruthfulnessCue(detail.AuthoritativeState), + "Audit posture: "+renderAuditPostureCue(summary.AuditIntegrityStatus, summary.AuditAnchoringStatus, summary.AuditCurrentlyDegraded), + fmt.Sprintf("Work summary: %d stage(s), %d with approval waits, %d waiting role(s), %d pending approval(s).", len(detail.StageSummaries), waitingStages, waitingRoles, len(detail.PendingApprovalIDs)), ) } -func renderRuntimeAttestationTruthfulnessCue(state map[string]any) string { - attestationPosture, reasons := attestationPostureFromState(state) - verificationSucceeded, _ := state["attestation_verification_succeeded"].(bool) - sessionBindingPresent, _ := state["session_binding_present"].(bool) - attestationEvidencePresent, _ := state["attestation_evidence_present"].(bool) - supportedRuntimeSatisfied, _ := state["supported_runtime_requirements_satisfied"].(bool) +func runInspectorSummary(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + return fmt.Sprintf("Run %s is %s with %d pending approval(s).", summary.RunID, valueOrNA(summary.LifecycleState), runPendingApprovalCount(summary, detail)) +} - currentEvidence := "launch-only evidence" - switch { - case verificationSucceeded: - currentEvidence = "post-handshake verification succeeded" - case attestationEvidencePresent: - currentEvidence = "post-handshake evidence collected but not yet supportable" - case sessionBindingPresent: - currentEvidence = "secure session bound without verified attestation" +func runInspectorIdentity(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + return fmt.Sprintf("run=%s workspace=%s session=%s", summary.RunID, valueOrNA(summary.WorkspaceID), valueOrNA(authoritativeString(detail.AuthoritativeState, "session_id"))) +} + +func runInspectorStatus(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + return fmt.Sprintf("lifecycle=%s workflow=%s blocked=%t reason=%s", valueOrNA(summary.LifecycleState), runWorkflowOperation(summary, detail), detail.Coordination.Blocked, valueOrNA(runBlockingReasonCode(summary, detail))) +} + +func runOutcomeHeadline(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + return fmt.Sprintf("Outcome summary: run %s is %s.", summary.RunID, valueOrNA(summary.LifecycleState)) +} + +func runOverviewMessage(detail *brokerapi.RunDetail) string { + if detail == nil { + return "No run selected." + } + summary := detail.Summary + status := humanizeExecutionToken(summary.LifecycleState) + if len(detail.PendingApprovalIDs) > 0 { + return fmt.Sprintf("Run %s is %s and waiting on %s.", summary.RunID, status, countNoun(len(detail.PendingApprovalIDs), "approval", "approvals")) } + if detail.Coordination.Blocked { + return fmt.Sprintf("Run %s is %s and currently blocked.", summary.RunID, status) + } + if strings.Contains(strings.ToLower(strings.TrimSpace(summary.LifecycleState)), "fail") { + return fmt.Sprintf("Run %s finished in a failed state.", summary.RunID) + } + if strings.EqualFold(strings.TrimSpace(summary.LifecycleState), "completed") { + return fmt.Sprintf("Run %s completed successfully.", summary.RunID) + } + return fmt.Sprintf("Run %s is %s.", summary.RunID, status) +} - if supportedRuntimeSatisfied && attestationPosture == "valid" { - return currentEvidence + "; supported attested posture earned from verified post-handshake evidence" +func runOverviewReason(detail *brokerapi.RunDetail) string { + if detail == nil { + return "n/a" } - if len(reasons) > 0 { - return currentEvidence + "; beta attested story still gated by post-handshake verification; reasons=" + strings.Join(reasons, ",") + parts := []string{} + if workflow := strings.TrimSpace(runWorkflowOperatorCue(detail.Summary, detail)); workflow != "" { + parts = append(parts, workflow) } - return currentEvidence + "; beta attested story still gated by post-handshake verification" + if blocked := strings.TrimSpace(runBlockingReasonSummary(detail.Summary, detail)); blocked != "" && blocked != "no blocking or failure reason reported" { + parts = append(parts, blocked) + } + if progress := strings.TrimSpace(runOperatorProgressCue(detail)); progress != "" { + parts = append(parts, progress) + } + if len(parts) == 0 { + return "No additional operator context reported yet." + } + return strings.Join(parts, " • ") } -func attestationPostureFromState(state map[string]any) (string, []string) { - posture, _ := state["attestation_posture"].(string) - reasonsAny, ok := state["attestation_reason_codes"].([]any) - if !ok { - reasons, _ := state["attestation_reason_codes"].([]string) - return posture, reasons +func runOverviewNextAction(detail *brokerapi.RunDetail) string { + if detail == nil { + return "Select a run from the directory." + } + if len(detail.PendingApprovalIDs) > 0 { + return fmt.Sprintf("Open Approvals to review %s, then return here for the updated result.", countNoun(len(detail.PendingApprovalIDs), "pending approval", "pending approvals")) } - reasons := make([]string, 0, len(reasonsAny)) - for _, value := range reasonsAny { - if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { - reasons = append(reasons, s) + if detail.Coordination.Blocked { + if sessionID := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")); sessionID != "" { + return fmt.Sprintf("Check session %s for operator context, then review linked artifacts or audit evidence if the block clears.", sessionID) } + return "Review the linked session or evidence trail to clear the current block." } - return posture, reasons + if runArtifactCount(detail) > 0 { + return "Open Artifacts to review the resulting evidence, or Audit if you need the verification trail." + } + if runAuditReferenceCount(detail) > 0 { + return "Open Audit to confirm the recorded evidence trail." + } + if sessionID := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")); sessionID != "" { + return fmt.Sprintf("Open Chat for session %s if you need the surrounding operator conversation.", sessionID) + } + return "Use the inspector for structured detail if you need the underlying control-plane fields." } -func runInspectorContentKind(presentation contentPresentationMode) inspectorContentKind { - if presentation == presentationRaw { - return inspectorContentRaw +func runOverviewEvidenceCue(detail *brokerapi.RunDetail) string { + if detail == nil { + return "" } - return inspectorContentStructured + parts := []string{} + if approvals := len(detail.PendingApprovalIDs); approvals > 0 { + parts = append(parts, countNoun(approvals, "approval link", "approval links")) + } + if artifacts := runArtifactCount(detail); artifacts > 0 { + parts = append(parts, countNoun(artifacts, "artifact", "artifacts")) + } + if audits := runAuditReferenceCount(detail); audits > 0 { + parts = append(parts, countNoun(audits, "audit reference", "audit references")) + } + if len(parts) == 0 { + return "No linked evidence reported yet." + } + return strings.Join(parts, " • ") +} + +func runWorkflowOperatorCue(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + workflow := strings.TrimSpace(summary.WorkflowKind) + if workflow == "" { + workflow = strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "workflow_kind")) + } + stage := strings.TrimSpace(summary.CurrentStageID) + if stage == "" { + stage = strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "workflow_operation")) + } + parts := []string{} + if workflow != "" { + parts = append(parts, humanizeRunLabel(workflow)) + } + if stage != "" { + parts = append(parts, "current stage "+humanizeRunLabel(stage)) + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " • ") } -func runRouteCopyActions(detail *brokerapi.RunDetail) []routeCopyAction { +func runOperatorProgressCue(detail *brokerapi.RunDetail) string { if detail == nil { - return nil + return "" } - summary := detail.Summary - raw := compactLines( - fmt.Sprintf("run_id=%s", summary.RunID), - fmt.Sprintf("workspace_id=%s", summary.WorkspaceID), - fmt.Sprintf("lifecycle=%s", summary.LifecycleState), - fmt.Sprintf("backend_kind=%s", summary.BackendKind), - ) - return compactCopyActions([]routeCopyAction{ - {ID: "run_id", Label: "run id", Text: summary.RunID}, - {ID: "workspace_id", Label: "workspace id", Text: summary.WorkspaceID}, - {ID: "raw_block", Label: "raw block", Text: raw}, - }) + parts := []string{} + if runRunnerActive(detail) { + parts = append(parts, "runner reporting is active") + } + if checkpoint := strings.TrimSpace(runCheckpointCode(detail)); checkpoint != "" { + parts = append(parts, "last checkpoint "+humanizeRunLabel(checkpoint)) + } + return strings.Join(parts, " • ") } -func (m runsRouteModel) activeSummary() brokerapi.RunSummary { - if m.active != nil { - return m.active.Summary +func humanizeRunLabel(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.ReplaceAll(value, "_", " ") + value = strings.ReplaceAll(value, "-", " ") + return value +} + +func runWorkflowOperation(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + workflow := strings.TrimSpace(summary.WorkflowKind) + if workflow == "" { + workflow = strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "workflow_kind")) + } + operation := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "workflow_operation")) + if operation == "" { + operation = strings.TrimSpace(summary.CurrentStageID) + } + if workflow != "" && operation != "" { + return fmt.Sprintf("%s • stage=%s", workflow, operation) } - if len(m.runs) > 0 { - return m.runs[m.selected] + if workflow != "" { + return workflow } - return brokerapi.RunSummary{} + if operation != "" { + return "stage=" + operation + } + return "n/a" } -func (m *runsRouteModel) syncDetailDocument() { - if m.active == nil { - m.detailDoc.SetDocument(workbenchObjectRef{Kind: "run", ID: "none"}, inspectorContentStructured, "run details", "") - return +func runPendingApprovalCount(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) int { + if detail != nil && len(detail.PendingApprovalIDs) > 0 { + return len(detail.PendingApprovalIDs) } - summary := m.active.Summary - presentation := normalizePresentationMode(m.presentation) - waitingStages := pendingApprovalStageCount(m.active.StageSummaries) - waitingRoles := waitingRoleCount(m.active.RoleSummaries) - content := runInspectorContent(summary, m.active, waitingStages, waitingRoles, presentation) - kind := runInspectorContentKind(presentation) - ref := workbenchObjectRef{Kind: "run", ID: strings.TrimSpace(summary.RunID), WorkspaceID: strings.TrimSpace(summary.WorkspaceID)} - m.detailDoc.SetDocument(ref, kind, "run details", content) + return summary.PendingApprovalCount +} + +func runPlanAuthoritySummary(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + reason := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "workflow_projection_reason")) + if strings.TrimSpace(summary.WorkflowDefinitionHash) != "" { + return fmt.Sprintf("workflow definition hash %s with projection=%s", summary.WorkflowDefinitionHash, valueOrNA(reason)) + } + if reason != "" { + return fmt.Sprintf("projection=%s", reason) + } + return "plan authority not reported" +} + +func runReportingPostureSummary(detail *brokerapi.RunDetail) string { + parts := []string{} + if runner := strings.TrimSpace(authoritativeString(detail.AdvisoryState, "runner")); runner != "" { + parts = append(parts, "runner="+runner) + } + if checkpoint := strings.TrimSpace(runCheckpointCode(detail)); checkpoint != "" { + parts = append(parts, "last checkpoint="+checkpoint) + } + if len(parts) == 0 { + return "runner reporting has not produced advisory progress yet" + } + return strings.Join(parts, " • ") +} + +func runJoinedOrNA(items []string) string { + if len(items) == 0 { + return "n/a" + } + return strings.Join(items, " • ") +} + +func runBlockingReasonCode(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + if reason := strings.TrimSpace(summary.BlockingReasonCode); reason != "" { + return reason + } + return strings.TrimSpace(detail.Coordination.WaitReasonCode) +} + +func runBlockingReasonSummary(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + reason := runBlockingReasonCode(summary, detail) + if reason == "" && !detail.Coordination.Blocked && !strings.Contains(strings.ToLower(strings.TrimSpace(summary.LifecycleState)), "fail") { + return "no blocking or failure reason reported" + } + if reason == "" { + return "state reported without a reason code" + } + return humanizeExecutionToken(reason) } diff --git a/cmd/runecode-tui/route_runs_evidence.go b/cmd/runecode-tui/route_runs_evidence.go new file mode 100644 index 00000000..2bff4a81 --- /dev/null +++ b/cmd/runecode-tui/route_runs_evidence.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func runEvidenceSummary(detail *brokerapi.RunDetail) string { + if detail == nil { + return "approvals 0 • artifact classes 0 • active manifests 0 • policy refs 0" + } + parts := []string{fmt.Sprintf("approvals %d", len(detail.PendingApprovalIDs)), fmt.Sprintf("artifact classes %d", len(detail.ArtifactCountsByClass)), fmt.Sprintf("active manifests %d", len(detail.ActiveManifestHashes)), fmt.Sprintf("policy refs %d", len(detail.LatestPolicyDecisionRefs))} + return strings.Join(parts, " • ") +} + +func runArtifactReferenceLabels(detail *brokerapi.RunDetail) []string { + if detail == nil || len(detail.ArtifactCountsByClass) == 0 { + return nil + } + keys := make([]string, 0, len(detail.ArtifactCountsByClass)) + for class := range detail.ArtifactCountsByClass { + keys = append(keys, class) + } + sort.Strings(keys) + labels := make([]string, 0, len(keys)) + for _, class := range keys { + labels = append(labels, fmt.Sprintf("%s (%d)", class, detail.ArtifactCountsByClass[class])) + } + return labels +} + +func runAuditReferenceLabels(detail *brokerapi.RunDetail) []string { + if detail == nil { + return nil + } + labels := []string{} + for _, digest := range detail.ActiveManifestHashes { + digest = strings.TrimSpace(digest) + if digest != "" { + labels = append(labels, "manifest "+shortIdentity(digest)) + } + } + for _, digest := range detail.LatestPolicyDecisionRefs { + digest = strings.TrimSpace(digest) + if digest != "" { + labels = append(labels, "policy "+shortIdentity(digest)) + } + } + if detail.AuditSummary.FindingCount > 0 || detail.AuditSummary.ErrorFindingCount > 0 || detail.AuditSummary.WarningFindingCount > 0 { + labels = append(labels, fmt.Sprintf("audit findings (%d)", detail.AuditSummary.FindingCount)) + } + return labels +} + +func runAuditReferenceCount(detail *brokerapi.RunDetail) int { + return len(runAuditReferenceLabels(detail)) +} + +func renderRuntimeAttestationTruthfulnessCue(state map[string]any) string { + attestationPosture, reasons := attestationPostureFromState(state) + verificationSucceeded, _ := state["attestation_verification_succeeded"].(bool) + sessionBindingPresent, _ := state["session_binding_present"].(bool) + attestationEvidencePresent, _ := state["attestation_evidence_present"].(bool) + supportedRuntimeSatisfied, _ := state["supported_runtime_requirements_satisfied"].(bool) + currentEvidence := "launch-only evidence" + switch { + case verificationSucceeded: + currentEvidence = "post-handshake verification succeeded" + case attestationEvidencePresent: + currentEvidence = "post-handshake evidence collected but not yet supportable" + case sessionBindingPresent: + currentEvidence = "secure session bound without verified attestation" + } + if supportedRuntimeSatisfied && attestationPosture == "valid" { + return currentEvidence + "; supported runtime evidence is present, but beta attested posture still waits for explicit post-handshake gating integration" + } + if len(reasons) > 0 { + return currentEvidence + "; beta attested story still gated by post-handshake verification; reasons=" + strings.Join(reasons, ",") + } + return currentEvidence + "; beta attested story still gated by post-handshake verification" +} + +func attestationPostureFromState(state map[string]any) (string, []string) { + posture, _ := state["attestation_posture"].(string) + reasonsAny, ok := state["attestation_reason_codes"].([]any) + if !ok { + reasons, _ := state["attestation_reason_codes"].([]string) + return sanitizeAttestationPostureAndReasons(posture, reasons) + } + reasons := make([]string, 0, len(reasonsAny)) + for _, value := range reasonsAny { + if s, ok := value.(string); ok { + reasons = append(reasons, s) + } + } + return sanitizeAttestationPostureAndReasons(posture, reasons) +} + +func runInspectorContentKind(presentation contentPresentationMode) inspectorContentKind { + if presentation == presentationRaw { + return inspectorContentRaw + } + return inspectorContentStructured +} + +func runRouteCopyActions(detail *brokerapi.RunDetail) []routeCopyAction { + if detail == nil { + return nil + } + summary := detail.Summary + sessionID := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")) + auditRefs := append([]string{}, detail.ActiveManifestHashes...) + auditRefs = append(auditRefs, detail.LatestPolicyDecisionRefs...) + raw := compactLines(fmt.Sprintf("run_id=%s", summary.RunID), fmt.Sprintf("workspace_id=%s", summary.WorkspaceID), fmt.Sprintf("session_id=%s", valueOrNA(sessionID)), fmt.Sprintf("lifecycle=%s", summary.LifecycleState), fmt.Sprintf("backend_kind=%s", summary.BackendKind)) + return compactCopyActions([]routeCopyAction{{ID: "run_id", Label: "run id", Text: summary.RunID}, {ID: "workspace_id", Label: "workspace id", Text: summary.WorkspaceID}, {ID: "session_id", Label: "session id", Text: sessionID}, {ID: "approval_ids", Label: "linked approval ids", Text: strings.Join(detail.PendingApprovalIDs, "\n")}, {ID: "audit_refs", Label: "audit refs", Text: strings.Join(auditRefs, "\n")}, {ID: "raw_block", Label: "raw block", Text: raw}}) +} + +func (m runsRouteModel) activeSummary() brokerapi.RunSummary { + if m.active != nil { + return m.active.Summary + } + if len(m.runs) > 0 { + return m.runs[m.selected] + } + return brokerapi.RunSummary{} +} + +func (m *runsRouteModel) syncDetailDocument() { + if m.active == nil { + m.detailDoc.SetDocument(workbenchObjectRef{Kind: "run", ID: "none"}, inspectorContentStructured, "run details", "") + return + } + summary := m.active.Summary + presentation := normalizePresentationMode(m.presentation) + waitingStages := pendingApprovalStageCount(m.active.StageSummaries) + waitingRoles := waitingRoleCount(m.active.RoleSummaries) + content := runInspectorContent(summary, m.active, waitingStages, waitingRoles, buildRunEvidenceLinks(m.active), presentation) + kind := runInspectorContentKind(presentation) + ref := workbenchObjectRef{Kind: "run", ID: strings.TrimSpace(summary.RunID), WorkspaceID: strings.TrimSpace(summary.WorkspaceID)} + m.detailDoc.SetDocument(ref, kind, "run details", content) +} diff --git a/cmd/runecode-tui/route_runs_render.go b/cmd/runecode-tui/route_runs_render.go new file mode 100644 index 00000000..b7dfecd6 --- /dev/null +++ b/cmd/runecode-tui/route_runs_render.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func renderRunList(runs []brokerapi.RunSummary, selected int) string { + if len(runs) == 0 { + return " - no runs" + } + line := "" + for i, run := range runs { + marker := " " + if i == selected { + marker = ">" + } + line += selectedLine(i == selected, fmt.Sprintf(" %s %s %s approvals=%d", marker, run.RunID, stateBadgeWithLabel("state", run.LifecycleState), run.PendingApprovalCount)) + "\n" + line += fmt.Sprintf(" %s | %s | %s | %s | approval_profile=%s\n", fmt.Sprintf("backend_kind=%s", valueOrNA(run.BackendKind)), renderRuntimeIsolationCue(run.BackendKind, run.IsolationAssuranceLevel), renderProvisioningPostureCue(run.ProvisioningPosture), renderAuditPostureCue(run.AuditIntegrityStatus, run.AuditAnchoringStatus, run.AuditCurrentlyDegraded), valueOrNA(run.ApprovalProfile)) + } + return line +} + +func renderRunDirectoryItems(runs []brokerapi.RunSummary) []string { + items := make([]string, 0, len(runs)) + for _, run := range runs { + items = append(items, strings.TrimSpace(strings.Join([]string{ + run.RunID, + postureBadge(run.LifecycleState), + runDirectoryWorkflowCue(run), + runDirectoryAttentionCue(run), + runDirectoryEvidenceCue(run), + }, " • "))) + } + return items +} + +func runStateCard(detail *brokerapi.RunDetail) stateCardSpec { + if detail == nil { + return stateCardSpec{ + State: routeLoadStateEmpty, + Title: "Run overview", + Message: "Choose a run to review what happened and what needs attention.", + Reason: "The directory keeps each run tied to its approvals, artifacts, and audit trail.", + NextAction: "Select a run from the directory to open its operator summary.", + ShortcutCue: "j/k move • enter review", + RouteCue: "Approvals / Artifacts / Audit", + } + } + state := routeLoadStateReady + if detail.Coordination.Blocked { + state = routeLoadStateBlocked + } + if len(detail.PendingApprovalIDs) > 0 { + state = routeLoadStateApprovalRequired + } + if strings.Contains(strings.ToLower(strings.TrimSpace(detail.Summary.LifecycleState)), "fail") { + state = routeLoadStateDegraded + } + if strings.EqualFold(strings.TrimSpace(detail.Summary.LifecycleState), "completed") { + state = routeLoadStateCompleted + } + return stateCardSpec{State: state, Title: "Run overview", Message: runOverviewMessage(detail), Reason: runOverviewReason(detail), NextAction: runOverviewNextAction(detail), ShortcutCue: "enter review • i inspector", RouteCue: "Chat / Approvals / Artifacts / Audit", EvidenceCue: runOverviewEvidenceCue(detail)} +} + +func renderRunMainPaneCues(detail *brokerapi.RunDetail, summary brokerapi.RunSummary, width int) string { + if strings.TrimSpace(summary.RunID) == "" { + return "" + } + cues := []string{} + if attention := strings.TrimSpace(runDirectoryAttentionCue(summary)); attention != "" { + cues = append(cues, attention) + } + if runtime := strings.TrimSpace(runMainPaneRuntimeCue(summary)); runtime != "" { + cues = append(cues, runtime) + } + if provisioning := strings.TrimSpace(runMainPaneProvisioningCue(summary)); provisioning != "" { + cues = append(cues, provisioning) + } + if audit := strings.TrimSpace(runMainPaneAuditCue(summary)); audit != "" { + cues = append(cues, audit) + } + if detail != nil { + if evidence := strings.TrimSpace(runOverviewEvidenceCue(detail)); evidence != "" { + cues = append(cues, evidence) + } + } + if len(cues) == 0 { + return "" + } + return compactLines(tableHeader("Safety and evidence"), wrapPartsByWidth(cues, " • ", width)) +} + +func runDirectoryWorkflowCue(summary brokerapi.RunSummary) string { + workflow := strings.TrimSpace(summary.WorkflowKind) + if workflow == "" { + workflow = strings.TrimSpace(summary.CurrentStageID) + } + if workflow == "" { + return "Run activity" + } + return humanizeRunLabel(workflow) +} + +func runDirectoryAttentionCue(summary brokerapi.RunSummary) string { + switch { + case summary.PendingApprovalCount > 0: + return countNoun(summary.PendingApprovalCount, "approval waiting", "approvals waiting") + case strings.Contains(strings.ToLower(strings.TrimSpace(summary.LifecycleState)), "fail"): + return "needs operator review" + case strings.EqualFold(strings.TrimSpace(summary.LifecycleState), "blocked"): + return "operator follow-up needed" + case summary.RuntimePostureDegraded: + return "runtime posture needs review" + default: + return "progress healthy" + } +} + +func runDirectoryEvidenceCue(summary brokerapi.RunSummary) string { + if summary.AuditCurrentlyDegraded || !strings.EqualFold(strings.TrimSpace(summary.AuditAnchoringStatus), "ok") || !strings.EqualFold(strings.TrimSpace(summary.AuditIntegrityStatus), "ok") { + return "audit trail needs review" + } + return "audit trail available" +} + +func runMainPaneRuntimeCue(summary brokerapi.RunSummary) string { + nBackend := strings.ToLower(strings.TrimSpace(summary.BackendKind)) + nIsolation := strings.ToLower(strings.TrimSpace(summary.IsolationAssuranceLevel)) + switch { + case summary.RuntimePostureDegraded || nIsolation == "degraded" || nIsolation == "unavailable" || nIsolation == "unknown": + return "runtime posture needs review" + case nBackend == "container" || nIsolation == "reduced": + return "runtime uses reduced assurance" + case nIsolation == "sandboxed" || nIsolation == "isolated" || nIsolation == "microvm": + return "runtime is isolated" + default: + return "runtime posture reported" + } +} + +func runMainPaneProvisioningCue(summary brokerapi.RunSummary) string { + switch strings.ToLower(strings.TrimSpace(summary.ProvisioningPosture)) { + case "ok", "trusted", "bound", "attested": + return "provisioning evidence is in good standing" + case "tofu", "degraded", "unavailable", "unknown": + return "provisioning evidence needs review" + default: + return "provisioning posture reported" + } +} + +func runMainPaneAuditCue(summary brokerapi.RunSummary) string { + if summary.AuditCurrentlyDegraded || !strings.EqualFold(strings.TrimSpace(summary.AuditAnchoringStatus), "ok") || !strings.EqualFold(strings.TrimSpace(summary.AuditIntegrityStatus), "ok") { + return "audit trail needs review" + } + return "audit trail looks healthy" +} diff --git a/cmd/runecode-tui/route_status.go b/cmd/runecode-tui/route_status.go index 9f3cb695..57c62bac 100644 --- a/cmd/runecode-tui/route_status.go +++ b/cmd/runecode-tui/route_status.go @@ -24,25 +24,32 @@ type postureChangedMsg struct { } type projectSubstrateActionResultMsg struct { - status string - err error + status string + err error + adoption *brokerapi.ProjectSubstrateAdoptResponse + initPreview *brokerapi.ProjectSubstrateInitPreviewResponse + upgradePreview *brokerapi.ProjectSubstrateUpgradePreviewResponse + actionCard *stateCardSpec + reload bool } -const statusRouteKeyHintText = "Route keys: r reload, c request backend posture change, a adopt substrate, i init preview, I init apply, u upgrade preview, U upgrade apply" +const statusRouteKeyHintText = "Keys: r reload • c backend • a adopt • i/I init • u/U upgrade" const projectSubstrateHandleAcquiredText = "" type statusRouteModel struct { - def routeDefinition - client localBrokerClient - loading bool - changing bool - actioning bool - actionMsg string - errText string - status string - data statusLoadedMsg - loadSeq uint64 + def routeDefinition + client localBrokerClient + loading bool + changing bool + actioning bool + validatingProjectSubstrate bool + actionMsg string + errText string + status string + projectSubstrateActionCard *stateCardSpec + data statusLoadedMsg + loadSeq uint64 } func newStatusRouteModel(def routeDefinition, client localBrokerClient) routeModel { @@ -74,6 +81,7 @@ func (m statusRouteModel) handleRouteActivated(msg routeActivatedMsg) (routeMode if msg.RouteID != m.def.ID { return m, nil } + m.validatingProjectSubstrate = false m = m.beginLoad() return m, m.loadCmd(m.loadSeq) } @@ -81,6 +89,7 @@ func (m statusRouteModel) handleRouteActivated(msg routeActivatedMsg) (routeMode func (m statusRouteModel) handleKeyMsg(msg tea.KeyMsg) (routeModel, tea.Cmd) { key := msg.String() if key == "r" { + m.validatingProjectSubstrate = false m = m.beginLoad() return m, m.loadCmd(m.loadSeq) } @@ -104,15 +113,28 @@ func (m statusRouteModel) handleStatusLoaded(msg statusLoadedMsg) (routeModel, t if msg.seq != m.loadSeq { return m, nil } + wasValidating := m.validatingProjectSubstrate m.loading = false + m.validatingProjectSubstrate = false if msg.err != nil { + if wasValidating { + m.projectSubstrateActionCard = projectSubstrateValidationFailureCard(m.data.project, safeUIErrorText(msg.err)) + m.errText = "" + m.status = "Project setup validation could not be refreshed. Review the broker guidance below." + return m, nil + } m.errText = safeUIErrorText(msg.err) return m, nil } m.data = msg m.errText = "" + m.projectSubstrateActionCard = nil + if wasValidating { + m.status = "Project setup validation refreshed. Review the updated managed-operation and setup posture below." + return m, nil + } if m.status == "" { - m.status = "Press c to request backend posture change (microvm/container)." + m.status = "Status refreshed. Review managed-operation and project setup guidance below." } return m, nil } @@ -127,25 +149,48 @@ func (m statusRouteModel) handlePostureChanged(msg postureChangedMsg) (routeMode m.errText = "" m = m.beginLoad() if msg.resp.Outcome.Outcome == "approval_required" { - m.status = "Backend posture change requires approval; opening shared Approvals route." + m.status = "Backend selection needs approval; opening Approvals." return m, tea.Batch(func() tea.Msg { return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeApprovals}} }, m.loadCmd(m.loadSeq)) } - m.status = fmt.Sprintf("Backend posture outcome=%s reason=%s", msg.resp.Outcome.Outcome, msg.resp.Outcome.OutcomeReasonCode) + m.status = fmt.Sprintf("Backend selection result: outcome=%s reason=%s", msg.resp.Outcome.Outcome, msg.resp.Outcome.OutcomeReasonCode) return m, m.loadCmd(m.loadSeq) } func (m statusRouteModel) handleProjectSubstrateActionResult(msg projectSubstrateActionResultMsg) (routeModel, tea.Cmd) { m.actioning = false m.actionMsg = "" + if msg.actionCard != nil { + m.projectSubstrateActionCard = msg.actionCard + } if msg.err != nil { + m.validatingProjectSubstrate = false + if msg.actionCard != nil { + m.errText = "" + m.status = strings.TrimSpace(msg.status) + return m, nil + } m.errText = safeUIErrorText(msg.err) - m.status = "" + m.status = strings.TrimSpace(msg.status) return m, nil } m.errText = "" + if msg.adoption != nil { + m.data.project.Adoption = msg.adoption.Adoption + } + if msg.initPreview != nil { + m.data.project.InitPreview = msg.initPreview.Preview + } + if msg.upgradePreview != nil { + m.data.project.UpgradePreview = msg.upgradePreview.Preview + } m.status = msg.status + if !msg.reload { + m.validatingProjectSubstrate = false + return m, nil + } + m.validatingProjectSubstrate = true m = m.beginLoad() return m, m.loadCmd(m.loadSeq) } @@ -160,34 +205,79 @@ func (m statusRouteModel) beginLoad() statusRouteModel { func (m statusRouteModel) View(width, height int, focus focusArea) string { _ = width _ = height + if card := m.renderTransientStatusCard(); card != "" { + return card + } + return m.renderReadyStatusView(focus) +} + +func (m statusRouteModel) renderTransientStatusCard() string { if m.loading { - return renderStateCard(routeLoadStateLoading, "Status", "Loading status route from readiness/version/lifecycle contracts...") + if m.validatingProjectSubstrate { + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Project setup", + Message: "Validating broker-owned project setup after apply.", + Reason: valueOrNA(strings.TrimSpace(m.status)), + NextAction: "Wait for refreshed managed-operation and project setup status before continuing normal work.", + }) + } + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Status", + Message: "Refreshing broker-owned readiness, lifecycle, and project setup posture.", + Reason: "RuneCode waits for authoritative broker status before updating this route.", + ShortcutCue: "r reload", + }) } if m.changing { - return renderStateCard(routeLoadStateLoading, "Status", "Submitting backend posture change through broker local API...") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Backend selection", + Message: "Submitting backend selection through the broker-owned flow.", + Reason: "RuneCode waits for broker approval or completion before updating local status.", + NextAction: "Wait for the broker response or review Approvals if one is required.", + RouteCue: "Approvals", + }) } if m.actioning { - return renderStateCard(routeLoadStateLoading, "Status", valueOrNA(strings.TrimSpace(m.actionMsg))) + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateLoading, + Title: "Project setup", + Message: valueOrNA(strings.TrimSpace(m.actionMsg)), + Reason: "Setup and remediation stay broker-owned; the TUI only reflects the resulting posture.", + NextAction: "Wait for broker status, then review the refreshed setup guidance below.", + }) } if m.errText != "" { - return renderStateCard(routeLoadStateError, "Status", "Load failed: "+m.errText+" (press r to retry)") + return renderStateCardSpec(stateCardSpec{ + State: routeLoadStateError, + Title: "Status", + Message: "Status is temporarily unavailable.", + Reason: m.errText, + NextAction: "Press r to retry. If a setup apply failed, rerun preview before applying again.", + ShortcutCue: "r reload", + }) } + return "" +} + +func (m statusRouteModel) renderReadyStatusView(focus focusArea) string { r := m.data.readiness v := m.data.version - diagnostics := renderReadinessDiagnostics(r) return compactLines( sectionTitle("Status")+" "+focusBadge(focus), renderStatusSafetyStrip(r), - fmt.Sprintf("Broker ready=%t local_only=%t channel=%s", r.Ready, r.LocalOnly, r.ConsumptionChannel), - fmt.Sprintf("Broker cues: %s %s", boolBadge("ready", r.Ready), boolBadge("local_only", r.LocalOnly)), - fmt.Sprintf("Subsystem posture: recovery=%t append=%t writable=%t verifier_material=%t derived_index=%t", r.RecoveryComplete, r.AppendPositionStable, r.CurrentSegmentWritable, r.VerifierMaterialAvailable, r.DerivedIndexCaughtUp), - diagnostics, - fmt.Sprintf("Version posture: product=%s revision=%s build=%s", v.ProductVersion, v.BuildRevision, v.BuildTime), - fmt.Sprintf("Protocol posture: bundle=%s manifest=%s api=%s/%s", v.ProtocolBundleVersion, v.ProtocolBundleManifestHash, v.APIFamily, v.APIVersion), + renderStatusOverviewLine(r, m.data.lifecycle, m.data.project), + renderStatusSetupLine(m.data.project), + renderReadinessDiagnostics(r), + renderVersionPostureLine(v), + renderLifecycleOperationCard(m.data.lifecycle), renderLifecycleStatusLine(m.data.lifecycle), renderLifecycleBlockedReasonLine(m.data.lifecycle), renderLifecycleDegradedReasonLine(m.data.lifecycle), - renderLifecycleAttachGuidance(m.data.lifecycle), + renderProjectSubstrateStatusCard(m.data.project), + renderProjectSubstrateActionCard(m.projectSubstrateActionCard), renderProjectSubstrateStatusLine(m.data.project), renderProjectSubstrateGuidance(m.data.project), renderBackendPostureLine(m.data.posture), @@ -196,57 +286,6 @@ func (m statusRouteModel) View(width, height int, focus focusArea) string { ) } -func renderLifecycleStatusLine(posture brokerapi.BrokerProductLifecyclePosture) string { - if strings.TrimSpace(posture.SchemaID) == "" { - return "Broker lifecycle posture: unavailable" - } - return fmt.Sprintf( - "Broker lifecycle posture: instance=%s generation=%s attach_mode=%s lifecycle_posture=%s attachable=%t normal_operation_allowed=%t", - valueOrNA(posture.ProductInstanceID), - valueOrNA(posture.LifecycleGeneration), - valueOrNA(posture.AttachMode), - valueOrNA(posture.LifecyclePosture), - posture.Attachable, - posture.NormalOperationAllowed, - ) -} - -func renderLifecycleBlockedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { - if strings.TrimSpace(posture.SchemaID) == "" { - return "Broker lifecycle blocked reasons: unavailable" - } - if len(posture.BlockedReasonCodes) == 0 { - return "Broker lifecycle blocked reasons: none" - } - return "Broker lifecycle blocked reasons: " + joinCSV(posture.BlockedReasonCodes) -} - -func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { - if strings.TrimSpace(posture.SchemaID) == "" { - return "Broker lifecycle degraded reasons: unavailable" - } - if len(posture.DegradedReasonCodes) == 0 { - return "Broker lifecycle degraded reasons: none" - } - return "Broker lifecycle degraded reasons: " + joinCSV(posture.DegradedReasonCodes) -} - -func renderLifecycleAttachGuidance(posture brokerapi.BrokerProductLifecyclePosture) string { - if strings.TrimSpace(posture.SchemaID) == "" { - return "Attach guidance: unavailable" - } - if !posture.Attachable { - return "Attach guidance: broker lifecycle is not currently attachable/inspectable." - } - if !posture.NormalOperationAllowed { - if strings.EqualFold(strings.TrimSpace(posture.AttachMode), "diagnostics_only") { - return "Attach guidance: diagnostics/remediation-only attach is available; normal operation is blocked by current project-substrate posture." - } - return "Attach guidance: attach is available, but normal operation is blocked by current project-substrate posture." - } - return "Attach guidance: full attach and normal operation are allowed." -} - func (m statusRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainWidth := routeRegionWidth(ctx.Regions.Main, ctx.Width) mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) @@ -265,87 +304,6 @@ func (m statusRouteModel) ShellSurface(ctx routeShellContext) routeSurface { } } -func renderBackendPostureLine(posture brokerapi.BackendPostureState) string { - if strings.TrimSpace(posture.InstanceID) == "" { - return "Backend posture: unavailable" - } - return fmt.Sprintf("Backend posture: instance=%s backend=%s reduced_assurance=%t pending_approval=%t", valueOrNA(posture.InstanceID), valueOrNA(posture.BackendKind), posture.ReducedAssuranceActive, posture.PendingApproval) -} - -func renderStatusSafetyStrip(r brokerapi.BrokerReadiness) string { - parts := []string{tableHeader("Runtime/audit readiness strip")} - if !r.VerifierMaterialAvailable { - parts = append(parts, dangerBadge("RUNTIME_POSTURE_AUTH_UNAVAILABLE")) - } else { - parts = append(parts, successBadge("RUNTIME_POSTURE_AUTH_AVAILABLE")) - } - if !r.RecoveryComplete || !r.AppendPositionStable || !r.CurrentSegmentWritable { - parts = append(parts, auditDegradedBadge("AUDIT_POSTURE_DEGRADED_OR_UNAVAILABLE")) - } else { - parts = append(parts, successBadge("AUDIT_STORAGE_NOMINAL")) - } - if !r.Ready { - parts = append(parts, systemFailureBadge("SYSTEM_FAILURE_BROKER_NOT_READY")) - } - return compactLines(parts...) -} - -func renderReadinessDiagnostics(r brokerapi.BrokerReadiness) string { - issues := []string{} - if !r.RecoveryComplete { - issues = append(issues, "recovery=incomplete") - } - if !r.AppendPositionStable { - issues = append(issues, "ledger_append=unstable") - } - if !r.CurrentSegmentWritable { - issues = append(issues, "current_segment=read_only_or_unavailable") - } - if !r.VerifierMaterialAvailable { - issues = append(issues, "verifier_material=missing") - } - if !r.DerivedIndexCaughtUp { - issues = append(issues, "derived_index=lagging") - } - if len(issues) == 0 { - return "Diagnostics: all readiness subsystems report nominal posture." - } - return fmt.Sprintf("Diagnostics: degraded subsystems=%s", joinCSV(issues)) -} - -func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { - summary := posture.PostureSummary - if strings.TrimSpace(summary.SchemaID) == "" { - return "Project substrate posture: unavailable" - } - return fmt.Sprintf( - "Project substrate posture: state=%s compatibility=%s normal_operation_allowed=%t", - valueOrNA(summary.ValidationState), - valueOrNA(summary.CompatibilityPosture), - summary.NormalOperationAllowed, - ) -} - -func renderProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { - parts := []string{} - if strings.TrimSpace(posture.BlockedExplanation) != "" { - parts = append(parts, "Project substrate block: "+sanitizeUIText(posture.BlockedExplanation)) - } - if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Project substrate remediation: "+joinCSV(posture.RemediationGuidance)) - } - if strings.TrimSpace(posture.InitPreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate init: status=%s", posture.InitPreview.Status)) - } - if strings.TrimSpace(posture.UpgradePreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate upgrade: status=%s", posture.UpgradePreview.Status)) - } - if len(parts) == 0 { - return "Project substrate guidance: none" - } - return compactLines(parts...) -} - func (m statusRouteModel) loadCmd(seq uint64) tea.Cmd { return func() tea.Msg { ctx, cancel := withLoadTimeout() diff --git a/cmd/runecode-tui/route_status_project_substrate.go b/cmd/runecode-tui/route_status_project_substrate.go index 4bf09ac6..53fe7c1e 100644 --- a/cmd/runecode-tui/route_status_project_substrate.go +++ b/cmd/runecode-tui/route_status_project_substrate.go @@ -5,6 +5,8 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/projectsubstrate" ) func (m statusRouteModel) beginProjectSubstrateAction(key string) (routeModel, tea.Cmd) { @@ -16,6 +18,7 @@ func (m statusRouteModel) beginProjectSubstrateAction(key string) (routeModel, t return m, nil } m.actioning = true + m.projectSubstrateActionCard = nil m.actionMsg = actionMsg m.errText = "" m.status = "" @@ -25,15 +28,15 @@ func (m statusRouteModel) beginProjectSubstrateAction(key string) (routeModel, t func (m statusRouteModel) projectSubstrateActionForKey(key string) (string, tea.Cmd) { switch key { case "a": - return "Submitting project substrate adoption through broker local API...", m.projectSubstrateAdoptCmd() + return "Inspecting compatible existing project setup for broker-owned adoption...", m.projectSubstrateAdoptCmd() case "i": - return "Loading project substrate init preview through broker local API...", m.projectSubstrateInitPreviewCmd() + return "Loading broker-owned init preview for project setup...", m.projectSubstrateInitPreviewCmd() case "I": - return "Applying project substrate init with broker preview token...", m.projectSubstrateInitApplyCmd() + return "Applying broker-owned init preview for project setup...", m.projectSubstrateInitApplyCmd() case "u": - return "Loading project substrate upgrade preview through broker local API...", m.projectSubstrateUpgradePreviewCmd() + return "Loading broker-owned upgrade preview for project setup...", m.projectSubstrateUpgradePreviewCmd() case "U": - return "Applying project substrate upgrade with broker preview digest...", m.projectSubstrateUpgradeApplyCmd() + return "Applying broker-owned upgrade preview for project setup...", m.projectSubstrateUpgradeApplyCmd() default: return "", nil } @@ -45,13 +48,17 @@ func (m statusRouteModel) projectSubstrateAdoptCmd() tea.Cmd { defer cancel() resp, err := m.client.ProjectSubstrateAdopt(ctx) if err != nil { - return projectSubstrateActionResultMsg{err: err} + return projectSubstrateActionResultMsg{ + err: err, + status: "Project setup adoption was not completed.", + actionCard: projectSubstrateActionFailureCard("Compatible adoption", "RuneCode could not confirm compatible existing project setup.", m.data.project, safeUIErrorText(err), "Adoption is read-only recognition only. Reload or inspect current broker posture before trying again."), + } } - status := fmt.Sprintf("Project substrate adopt status=%s", valueOrNA(resp.Adoption.Status)) - if len(resp.Adoption.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(resp.Adoption.ReasonCodes) + return projectSubstrateActionResultMsg{ + status: fmt.Sprintf("Project setup adoption refreshed: status=%s", valueOrNA(resp.Adoption.Status)), + adoption: &resp, + actionCard: projectSubstrateAdoptionCard(resp.Adoption, m.data.project), } - return projectSubstrateActionResultMsg{status: status} } } @@ -61,13 +68,17 @@ func (m statusRouteModel) projectSubstrateInitPreviewCmd() tea.Cmd { defer cancel() resp, err := m.client.ProjectSubstrateInitPreview(ctx) if err != nil { - return projectSubstrateActionResultMsg{err: err} + return projectSubstrateActionResultMsg{ + err: err, + status: "Project setup init preview was not loaded.", + actionCard: projectSubstrateActionFailureCard("Init preview", "RuneCode could not load the broker-owned init preview.", m.data.project, safeUIErrorText(err), "Reload or retry init preview before any init apply."), + } } - status := fmt.Sprintf("Project substrate init preview status=%s token=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewToken)) - if len(resp.Preview.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(resp.Preview.ReasonCodes) + return projectSubstrateActionResultMsg{ + status: fmt.Sprintf("Project setup init preview refreshed: status=%s", valueOrNA(resp.Preview.Status)), + initPreview: &resp, + actionCard: projectSubstrateInitPreviewCard(resp.Preview, m.data.project), } - return projectSubstrateActionResultMsg{status: status} } } @@ -78,17 +89,26 @@ func (m statusRouteModel) projectSubstrateInitApplyCmd() tea.Cmd { preview := m.data.project.InitPreview token := strings.TrimSpace(preview.PreviewToken) if token == "" { - return projectSubstrateActionResultMsg{err: fmt.Errorf("project substrate init preview token unavailable; reload or run preview first")} + return projectSubstrateActionResultMsg{ + err: fmt.Errorf("project setup init apply unavailable: preview handle missing; reload or run init preview first"), + status: "Project setup init apply is unavailable.", + actionCard: projectSubstrateApplyUnavailableCard("Init apply", "Init apply is unavailable because no preview handle is currently published.", m.data.project, "Run init preview first, then apply only after reviewing the planned mutation."), + } } + // Preview freshness is broker-enforced; stale or superseded tokens must fail closed in ProjectSubstrateInitApply. applyResp, err := m.client.ProjectSubstrateInitApply(ctx, token) if err != nil { - return projectSubstrateActionResultMsg{err: err} + return projectSubstrateActionResultMsg{ + err: err, + status: "Project setup init apply failed.", + actionCard: projectSubstrateActionFailureCard("Init apply", "RuneCode could not apply the broker-owned init preview; the preview handle may be stale or superseded.", m.data.project, safeUIErrorText(err), "Rerun init preview before trying init apply again."), + } } - status := fmt.Sprintf("Project substrate init apply status=%s preview_status=%s token=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(token)) - if len(applyResp.ApplyResult.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(applyResp.ApplyResult.ReasonCodes) + return projectSubstrateActionResultMsg{ + status: fmt.Sprintf("Project setup init apply completed: status=%s", valueOrNA(applyResp.ApplyResult.Status)), + actionCard: projectSubstrateInitApplyCard(preview, applyResp.ApplyResult, m.data.project), + reload: true, } - return projectSubstrateActionResultMsg{status: status} } } @@ -98,13 +118,17 @@ func (m statusRouteModel) projectSubstrateUpgradePreviewCmd() tea.Cmd { defer cancel() resp, err := m.client.ProjectSubstrateUpgradePreview(ctx) if err != nil { - return projectSubstrateActionResultMsg{err: err} + return projectSubstrateActionResultMsg{ + err: err, + status: "Project setup upgrade preview was not loaded.", + actionCard: projectSubstrateActionFailureCard("Upgrade preview", "RuneCode could not load the broker-owned upgrade preview.", m.data.project, safeUIErrorText(err), "Reload or retry upgrade preview before any upgrade apply."), + } } - status := fmt.Sprintf("Project substrate upgrade preview status=%s digest=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewDigest)) - if len(resp.Preview.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(resp.Preview.ReasonCodes) + return projectSubstrateActionResultMsg{ + status: fmt.Sprintf("Project setup upgrade preview refreshed: status=%s", valueOrNA(resp.Preview.Status)), + upgradePreview: &resp, + actionCard: projectSubstrateUpgradePreviewCard(resp.Preview, m.data.project), } - return projectSubstrateActionResultMsg{status: status} } } @@ -115,17 +139,26 @@ func (m statusRouteModel) projectSubstrateUpgradeApplyCmd() tea.Cmd { preview := m.data.project.UpgradePreview digest := strings.TrimSpace(preview.PreviewDigest) if digest == "" { - return projectSubstrateActionResultMsg{err: fmt.Errorf("project substrate upgrade preview digest unavailable; reload or run preview first")} + return projectSubstrateActionResultMsg{ + err: fmt.Errorf("project setup upgrade apply unavailable: preview digest missing; reload or run upgrade preview first"), + status: "Project setup upgrade apply is unavailable.", + actionCard: projectSubstrateApplyUnavailableCard("Upgrade apply", "Upgrade apply is unavailable because no preview digest is currently published.", m.data.project, "Run upgrade preview first, then apply only after reviewing the planned mutation."), + } } + // Preview freshness is broker-enforced; stale or superseded digests must fail closed in ProjectSubstrateUpgradeApply. applyResp, err := m.client.ProjectSubstrateUpgradeApply(ctx, digest) if err != nil { - return projectSubstrateActionResultMsg{err: err} + return projectSubstrateActionResultMsg{ + err: err, + status: "Project setup upgrade apply failed.", + actionCard: projectSubstrateActionFailureCard("Upgrade apply", "RuneCode could not apply the broker-owned upgrade preview; the preview digest may be stale or superseded.", m.data.project, safeUIErrorText(err), "Rerun upgrade preview before trying upgrade apply again."), + } } - status := fmt.Sprintf("Project substrate upgrade apply status=%s preview_status=%s digest=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(digest)) - if len(applyResp.ApplyResult.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(applyResp.ApplyResult.ReasonCodes) + return projectSubstrateActionResultMsg{ + status: fmt.Sprintf("Project setup upgrade apply completed: status=%s", valueOrNA(applyResp.ApplyResult.Status)), + actionCard: projectSubstrateUpgradeApplyCard(preview, applyResp.ApplyResult, m.data.project), + reload: true, } - return projectSubstrateActionResultMsg{status: status} } } @@ -135,3 +168,120 @@ func projectSubstrateHandleDisplay(value string) string { } return projectSubstrateHandleAcquiredText } + +func projectSubstrateAdoptionCard(adoption projectsubstrate.AdoptionResult, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return &stateCardSpec{ + State: routeLoadStateReady, + Title: "Compatible adoption", + Message: "RuneCode found an existing compatible setup it can recognize without changing repository files.", + Reason: projectSubstrateActionReason(adoption.ReasonCodes, posture), + NextAction: "If managed work is still blocked, use init or upgrade preview next; otherwise continue with normal work.", + ShortcutCue: "i preview init • u preview upgrade • r reload", + } +} + +func projectSubstrateInitPreviewCard(preview projectsubstrate.InitPreview, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return previewCard("Init preview", "Handle", valueOrNA(preview.Status), projectSubstrateHandleDisplay(preview.PreviewToken), projectSubstratePreviewMutationLine(preview.PreviewToken, preview.Status), projectSubstrateActionReason(preview.ReasonCodes, posture), preview.RequiredFollowUp, "Review this preview, then press I only if you want the broker-owned init mutation applied.") +} + +func projectSubstrateUpgradePreviewCard(preview projectsubstrate.UpgradePreview, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return previewCard("Upgrade preview", "Digest", valueOrNA(preview.Status), projectSubstrateHandleDisplay(preview.PreviewDigest), projectSubstratePreviewMutationLine(preview.PreviewDigest, preview.Status), projectSubstrateActionReason(preview.ReasonCodes, posture), preview.RequiredFollowUp, "Review this preview, then press U only if you want the broker-owned upgrade mutation applied.") +} + +func previewCard(title, identityLabel, status, handle, mutation, reason string, followUp []string, next string) *stateCardSpec { + message := fmt.Sprintf("Preview is %s. %s %s is %s.", projectSubstratePreviewStatusSummary(status, handle), strings.TrimSpace(mutationSentence(mutation)), identityLabel, handle) + if handle == "n/a" { + message = fmt.Sprintf("Preview is %s. %s No %s is currently available.", projectSubstratePreviewStatusSummary(status, handle), strings.TrimSpace(mutationSentence(mutation)), strings.ToLower(identityLabel)) + } + return &stateCardSpec{State: previewCardState(handle, status), Title: title, Message: message, Reason: projectSubstrateReasonWithFollowUp(reason, followUp), NextAction: next, ShortcutCue: "I apply init • U apply upgrade • r reload"} +} + +func projectSubstrateInitApplyCard(preview projectsubstrate.InitPreview, result projectsubstrate.InitApplyResult, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return &stateCardSpec{State: routeLoadStateCompleted, Title: "Init apply", Message: "RuneCode applied the broker-owned project setup initialization and is now rechecking the resulting posture.", Reason: fmt.Sprintf("Preview was %s • %s", projectSubstratePreviewStatusSummary(preview.Status, preview.PreviewToken), projectSubstrateActionReason(result.ReasonCodes, posture)), NextAction: "RuneCode is reloading broker posture now to validate the resulting project setup before normal work resumes."} +} + +func projectSubstrateUpgradeApplyCard(preview projectsubstrate.UpgradePreview, result projectsubstrate.UpgradeApplyResult, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return &stateCardSpec{State: routeLoadStateCompleted, Title: "Upgrade apply", Message: "RuneCode applied the broker-owned project setup upgrade and is now rechecking the resulting posture.", Reason: fmt.Sprintf("Preview was %s • %s", projectSubstratePreviewStatusSummary(preview.Status, preview.PreviewDigest), projectSubstrateActionReason(result.ReasonCodes, posture)), NextAction: "RuneCode is reloading broker posture now to validate the resulting project setup before normal work resumes."} +} + +func projectSubstrateApplyUnavailableCard(title, message string, posture brokerapi.ProjectSubstratePostureGetResponse, next string) *stateCardSpec { + return &stateCardSpec{State: routeLoadStateBlocked, Title: title, Message: message, Reason: projectSubstrateBlockingReason(posture), NextAction: next, ShortcutCue: "i preview init • u preview upgrade • r reload"} +} + +func projectSubstrateActionFailureCard(title, message string, posture brokerapi.ProjectSubstratePostureGetResponse, failure string, next string) *stateCardSpec { + reason := projectSubstrateBlockingReason(posture) + if strings.TrimSpace(failure) != "" { + reason = reason + " • broker_response=" + sanitizeUIText(failure) + } + return &stateCardSpec{State: routeLoadStateError, Title: title, Message: message, Reason: reason, NextAction: next, ShortcutCue: "r reload"} +} + +func projectSubstrateValidationFailureCard(posture brokerapi.ProjectSubstratePostureGetResponse, failure string) *stateCardSpec { + reason := projectSubstrateBlockingReason(posture) + if strings.TrimSpace(failure) != "" { + reason = reason + " • validation_refresh=" + sanitizeUIText(failure) + } + return &stateCardSpec{State: routeLoadStateError, Title: "Post-apply validation", Message: "Project setup apply finished, but refreshed validation status is currently unavailable.", Reason: reason, NextAction: "Press r to retry validation refresh, then confirm managed-operation and setup posture before continuing."} +} + +func projectSubstratePreviewMutationLine(handle, status string) string { + if strings.TrimSpace(handle) == "" { + return "No changes are available yet because the broker has not published a preview" + } + if strings.EqualFold(strings.TrimSpace(status), "ready_for_apply") { + return "No changes have been made yet; apply is available if you explicitly choose it" + } + return "No changes have been made yet; this preview stays advisory until you explicitly apply it" +} + +func previewCardState(handle, status string) routeLoadState { + if strings.TrimSpace(handle) == "" { + return routeLoadStateBlocked + } + if strings.EqualFold(strings.TrimSpace(status), "ready_for_apply") { + return routeLoadStateReady + } + return routeLoadStateWaiting +} + +func projectSubstrateReasonWithFollowUp(reason string, followUp []string) string { + if len(followUp) == 0 { + return reason + } + return reason + " • next broker steps=" + joinCSV(followUp) +} + +func projectSubstrateActionReason(reasons []string, posture brokerapi.ProjectSubstratePostureGetResponse) string { + parts := []string{} + if len(reasons) > 0 { + parts = append(parts, "broker reasons="+joinCSV(reasons)) + } + if blocking := strings.TrimSpace(projectSubstrateBlockingReason(posture)); blocking != "" { + parts = append(parts, blocking) + } + return strings.Join(parts, " • ") +} + +func projectSubstrateBlockingReason(posture brokerapi.ProjectSubstratePostureGetResponse) string { + if explanation := strings.TrimSpace(posture.BlockedExplanation); explanation != "" { + return "normal work blocked: " + sanitizeUIText(explanation) + } + if len(posture.PostureSummary.BlockedReasonCodes) > 0 { + return "normal work blocked: " + joinCSV(posture.PostureSummary.BlockedReasonCodes) + } + if len(posture.PostureSummary.ReasonCodes) > 0 { + return "broker guidance: " + joinCSV(posture.PostureSummary.ReasonCodes) + } + return "normal work blocked: not reported" +} + +func mutationSentence(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + if strings.HasSuffix(text, ".") { + return text + } + return text + "." +} diff --git a/cmd/runecode-tui/route_status_render.go b/cmd/runecode-tui/route_status_render.go new file mode 100644 index 00000000..bc716ff0 --- /dev/null +++ b/cmd/runecode-tui/route_status_render.go @@ -0,0 +1,420 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func renderLifecycleOperationCard(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return renderStateCardSpec(stateCardSpec{State: routeLoadStateEmpty, Title: "Managed operation", Message: "Managed-operation posture is unavailable.", Reason: "The broker has not published current attach and lifecycle state for this route yet.", NextAction: "Press r to reload or inspect broker health if the route stays unavailable.", ShortcutCue: "r reload"}) + } + state := routeLoadStateReady + message := "Attach and normal managed operation are available." + reason := lifecycleReasonSummary(posture) + nextAction := "Normal managed work can continue; use Chat or Runs for active execution details." + routeCue := "Chat or Runs" + if !posture.Attachable { + state = routeLoadStateBlocked + message = "RuneCode cannot attach to the current broker lifecycle right now." + reason = lifecycleReasonSummary(posture) + nextAction = "Reload the route, then inspect broker status or remediation guidance before retrying." + routeCue = "Status" + } else if !posture.NormalOperationAllowed { + state = routeLoadStateBlocked + reason = lifecycleReasonSummary(posture) + nextAction = "Use the project setup guidance below, then reload once the broker posture changes." + routeCue = "Status" + if strings.EqualFold(strings.TrimSpace(posture.AttachMode), "diagnostics_only") { + message = "You can attach for diagnostics and remediation only; managed work stays blocked." + } else { + message = "You can attach to inspect current state, but managed work stays blocked." + } + } + return renderStateCardSpec(stateCardSpec{State: state, Title: "Managed operation", Message: message, Reason: reason, NextAction: nextAction, RouteCue: routeCue}) +} + +func renderLifecycleStatusLine(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return "Managed-operation summary: unavailable" + } + parts := []string{} + if strings.TrimSpace(posture.AttachMode) != "" { + parts = append(parts, attachModeSummary(posture.AttachMode)) + } + if posture.ActiveSessionCount > 0 || posture.ActiveRunCount > 0 { + parts = append(parts, fmt.Sprintf("Active work: %d sessions • %d runs", posture.ActiveSessionCount, posture.ActiveRunCount)) + } + if posture.Attachable && posture.NormalOperationAllowed { + parts = append(parts, "Normal work is available now") + } else if posture.Attachable { + parts = append(parts, "Attach is available for inspection and remediation") + } else { + parts = append(parts, "Attach is not currently available") + } + return "Managed-operation summary: " + strings.Join(parts, " • ") +} + +func renderLifecycleBlockedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return "Managed-operation blockers: unavailable" + } + if len(posture.BlockedReasonCodes) == 0 { + return "Managed-operation blockers: none reported" + } + return "Managed-operation blockers: " + humanizeStatusCodes(posture.BlockedReasonCodes) +} + +func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return "Managed-operation watchouts: unavailable" + } + if len(posture.DegradedReasonCodes) == 0 { + return "Managed-operation watchouts: none reported" + } + return "Managed-operation watchouts: " + humanizeStatusCodes(posture.DegradedReasonCodes) +} + +func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) string { + parts := []string{} + if len(posture.BlockedReasonCodes) > 0 { + parts = append(parts, "blocked because "+humanizeStatusCodes(posture.BlockedReasonCodes)) + } + if len(posture.DegradedReasonCodes) > 0 { + parts = append(parts, "watchouts: "+humanizeStatusCodes(posture.DegradedReasonCodes)) + } + if len(parts) == 0 { + return fmt.Sprintf("Broker reports %s operation with %s access.", lifecyclePostureSummary(posture.LifecyclePosture), attachModeSummary(posture.AttachMode)) + } + return strings.Join(parts, " • ") +} + +func renderBackendPostureLine(posture brokerapi.BackendPostureState) string { + if strings.TrimSpace(posture.InstanceID) == "" { + return "Backend: unavailable" + } + parts := []string{fmt.Sprintf("Backend: %s", valueOrNA(posture.BackendKind))} + if posture.PendingApproval { + parts = append(parts, "approval pending") + } + if posture.ReducedAssuranceActive { + parts = append(parts, "reduced assurance active") + } + return strings.Join(parts, " • ") +} + +func renderStatusSafetyStrip(r brokerapi.BrokerReadiness) string { + parts := []string{tableHeader("System health")} + if !r.VerifierMaterialAvailable { + parts = append(parts, dangerBadge("Runtime proof missing")) + } else { + parts = append(parts, successBadge("Runtime proof ready")) + } + if !r.RecoveryComplete || !r.AppendPositionStable || !r.CurrentSegmentWritable { + parts = append(parts, auditDegradedBadge("Audit needs review")) + } else { + parts = append(parts, successBadge("Audit ready")) + } + if !r.Ready { + parts = append(parts, systemFailureBadge("Broker not ready")) + } + return compactLines(parts...) +} + +func renderReadinessDiagnostics(r brokerapi.BrokerReadiness) string { + issues := []string{} + if !r.RecoveryComplete { + issues = append(issues, "recovery still in progress") + } + if !r.AppendPositionStable { + issues = append(issues, "audit ledger append is unstable") + } + if !r.CurrentSegmentWritable { + issues = append(issues, "current audit segment is not writable") + } + if !r.VerifierMaterialAvailable { + issues = append(issues, "runtime verification material is unavailable") + } + if !r.DerivedIndexCaughtUp { + issues = append(issues, "derived index still catching up") + } + if len(issues) == 0 { + return "Broker health: ready for normal work." + } + return fmt.Sprintf("Broker health: %s.", strings.Join(issues, "; ")) +} + +func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + if strings.TrimSpace(summary.SchemaID) == "" { + return "Project setup summary: unavailable" + } + parts := []string{projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)} + if summary.NormalOperationAllowed { + parts = append(parts, "Normal work can continue") + } else { + parts = append(parts, "Normal work stays blocked until setup is fixed") + } + return "Project setup summary: " + strings.Join(parts, " • ") +} + +func renderProjectSubstrateActionCard(spec *stateCardSpec) string { + if spec == nil { + return "" + } + return renderStateCardSpec(*spec) +} + +func renderProjectSubstrateStatusCard(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + if strings.TrimSpace(summary.SchemaID) == "" { + return renderStateCardSpec(stateCardSpec{State: routeLoadStateEmpty, Title: "Project setup", Message: "Project setup posture is unavailable.", Reason: "The broker has not returned current inspect/adopt/init/upgrade posture for this repository yet.", NextAction: "Press r to reload. If this persists, inspect broker health before attempting setup changes.", ShortcutCue: "r reload"}) + } + state := routeLoadStateReady + message := "Project setup supports normal managed work." + reason := projectSubstrateReasonSummary(posture) + nextAction := "Review upgrade guidance if recommended; otherwise continue normal work." + if !summary.NormalOperationAllowed { + state = routeLoadStateBlocked + message = "Project setup needs attention before managed work can continue." + reason = projectSubstrateReasonSummary(posture) + nextAction = "Inspect the guided flow below, then adopt, preview, or apply the broker-owned remediation that fits this repository posture." + } else if strings.Contains(strings.ToLower(strings.TrimSpace(summary.CompatibilityPosture)), "upgrade") { + state = routeLoadStateDegraded + message = "Project setup is usable, but a broker-owned upgrade is available." + reason = projectSubstrateReasonSummary(posture) + nextAction = "Review the upgrade preview before choosing whether to apply it." + } + return renderStateCardSpec(stateCardSpec{State: state, Title: "Project setup", Message: message, Reason: reason, NextAction: nextAction, RouteCue: "Status"}) +} + +func renderProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + parts := []string{tableHeader("Project setup guidance")} + parts = append(parts, renderProjectSubstrateActionSummaryLine("Adopt (a)", posture.Adoption.Status, "Use when RuneCode should recognize an already-compatible setup without changing files.")) + parts = append(parts, renderProjectSubstratePreviewSummaryLine("Init (i/I)", posture.InitPreview.Status, posture.InitPreview.PreviewToken, "Preview setup creation, then apply only if you want RuneCode to make that change.")) + parts = append(parts, renderProjectSubstratePreviewSummaryLine("Upgrade (u/U)", posture.UpgradePreview.Status, posture.UpgradePreview.PreviewDigest, "Preview the recommended upgrade, then apply only after review.")) + if strings.TrimSpace(posture.BlockedExplanation) != "" { + parts = append(parts, "What blocks normal work: "+sanitizeUIText(posture.BlockedExplanation)) + } + if len(posture.RemediationGuidance) > 0 { + parts = append(parts, "Broker guidance: "+humanizeStatusCodes(posture.RemediationGuidance)) + } + if !summary.NormalOperationAllowed { + parts = append(parts, "Next action: choose the lightest broker-owned fix that matches this repository, then reload to confirm managed work is restored.") + } else if strings.Contains(strings.ToLower(strings.TrimSpace(summary.CompatibilityPosture)), "upgrade") { + parts = append(parts, "Next action: managed work can continue, but review the upgrade preview when you are ready to bring setup back to the recommended posture.") + } else { + parts = append(parts, "Next action: no setup remediation is required right now.") + } + if strings.TrimSpace(posture.InitPreview.Status) == "" && strings.TrimSpace(posture.UpgradePreview.Status) == "" && strings.TrimSpace(posture.BlockedExplanation) == "" && len(posture.RemediationGuidance) == 0 { + parts = append(parts, "No preview or remediation details are currently published.") + } + return compactLines(parts...) +} + +func projectSubstrateReasonSummary(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + parts := []string{} + if len(summary.BlockedReasonCodes) > 0 { + parts = append(parts, "blocked because "+humanizeStatusCodes(summary.BlockedReasonCodes)) + } + if len(summary.ReasonCodes) > 0 { + parts = append(parts, "notes: "+humanizeStatusCodes(summary.ReasonCodes)) + } + if explanation := strings.TrimSpace(posture.BlockedExplanation); explanation != "" { + parts = append(parts, sanitizeUIText(explanation)) + } + if len(parts) == 0 { + return fmt.Sprintf("%s • %s", projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)) + } + return strings.Join(parts, " • ") +} + +func renderStatusOverviewLine(r brokerapi.BrokerReadiness, lifecycle brokerapi.BrokerProductLifecyclePosture, posture brokerapi.ProjectSubstratePostureGetResponse) string { + parts := []string{"Overview:"} + if r.Ready { + parts = append(parts, "broker connected") + } else { + parts = append(parts, "broker not ready") + } + if lifecycle.NormalOperationAllowed && posture.PostureSummary.NormalOperationAllowed { + parts = append(parts, "normal work available") + } else { + parts = append(parts, "normal work blocked") + } + if r.LocalOnly { + parts = append(parts, "local workspace mode") + } + return strings.Join(parts, " • ") +} + +func renderStatusSetupLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + if strings.TrimSpace(summary.SchemaID) == "" { + return "Setup: unavailable" + } + return fmt.Sprintf("Setup: %s • %s", projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)) +} + +func renderVersionPostureLine(v brokerapi.BrokerVersionInfo) string { + parts := []string{fmt.Sprintf("Version: RuneCode %s", valueOrNA(v.ProductVersion))} + if strings.TrimSpace(v.ProtocolBundleVersion) != "" { + parts = append(parts, fmt.Sprintf("protocol %s", sanitizeUIText(v.ProtocolBundleVersion))) + } + if strings.TrimSpace(v.APIFamily) != "" || strings.TrimSpace(v.APIVersion) != "" { + parts = append(parts, fmt.Sprintf("broker API ready (%s)", versionAPIShortLabel(v.APIFamily, v.APIVersion))) + } + return strings.Join(parts, " • ") +} + +func humanizeStatusCodes(values []string) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + parts = append(parts, humanizeExecutionToken(value)) + } + if len(parts) == 0 { + return "n/a" + } + return strings.Join(parts, ", ") +} + +func renderProjectSubstrateActionSummaryLine(label, status, guidance string) string { + return fmt.Sprintf("%s: %s. %s", label, projectSubstrateActionStatusSummary(status), guidance) +} + +func renderProjectSubstratePreviewSummaryLine(label, status, handle, guidance string) string { + summary := projectSubstratePreviewStatusSummary(status, handle) + if strings.TrimSpace(handle) != "" { + summary += fmt.Sprintf(" (%s ready)", projectSubstrateHandleLabel(handle)) + } + return fmt.Sprintf("%s: %s. %s", label, summary, guidance) +} + +func lifecyclePostureSummary(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "ready", "normal": + return "normal" + case "blocked": + return "blocked" + case "degraded": + return "degraded" + default: + return valueOrNA(value) + } +} + +func attachModeSummary(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "managed", "full": + return "full managed access" + case "diagnostics_only": + return "diagnostics-only access" + case "": + return "access posture not reported" + default: + return sanitizeUIText(value) + } +} + +func projectSetupValidationSummary(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "valid": + return "setup validated" + case "missing": + return "setup missing" + case "incompatible": + return "setup incompatible" + case "": + return "validation not reported" + default: + return "validation: " + sanitizeUIText(value) + } +} + +func projectSetupCompatibilitySummary(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "supported": + return "fully supported" + case "supported_with_upgrade_available": + return "upgrade available" + case "missing": + return "no compatible setup detected" + case "unsupported": + return "not supported for normal work" + case "": + return "compatibility not reported" + default: + return "compatibility: " + sanitizeUIText(value) + } +} + +func projectSubstrateActionStatusSummary(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "compatible_existing": + return "compatible existing setup can be recognized without changes" + case "unavailable", "": + return "not currently available" + default: + return "broker reports " + sanitizeUIText(status) + } +} + +func projectSubstratePreviewStatusSummary(status, handle string) string { + if strings.TrimSpace(handle) == "" { + return "not ready yet" + } + if strings.EqualFold(strings.TrimSpace(status), "ready_for_apply") { + return "preview ready for review and optional apply" + } + if strings.TrimSpace(status) == "" { + return "not currently available" + } + return "broker reports " + sanitizeUIText(status) +} + +func projectSubstrateHandleLabel(handle string) string { + if strings.TrimSpace(handle) == "" { + return "preview" + } + return "preview handle" +} + +func versionAPIShortLabel(family, version string) string { + family = strings.TrimSpace(family) + version = strings.TrimSpace(version) + switch { + case family == "" && version == "": + return "not reported" + case family == "": + return sanitizeUIText(version) + case version == "": + return sanitizeUIText(family) + default: + return sanitizeUIText(family) + " " + sanitizeUIText(version) + } +} + +func projectSubstrateStepStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "unavailable" + } + return status +} + +func projectSubstrateMutationLabel(status string, handle string) string { + if strings.TrimSpace(handle) == "" { + return "no mutation until broker preview is available" + } + if strings.EqualFold(strings.TrimSpace(status), "ready_for_apply") { + return "apply will mutate the repository through the broker-owned flow" + } + return "preview only; no mutation yet" +} diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index c29eb139..f3bb2930 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -18,22 +18,23 @@ func TestStatusRouteRendersProjectSubstratePostureAndGuidance(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Broker lifecycle posture:", - "attach_mode=full", - "attachable=true", - "normal_operation_allowed=true", - "Broker lifecycle blocked reasons: none", - "Broker lifecycle degraded reasons: none", - "Attach guidance: full attach and normal operation are allowed.", - "Project substrate posture:", - "compatibility=supported_with_upgrade_available", - "Project substrate remediation:", - "Project substrate upgrade:", - "a adopt substrate", - "i init preview", - "I init apply", - "u upgrade preview", - "U upgrade apply", + "Managed operation", + "Attach and normal managed operation are available.", + "Overview: • broker connected • normal work available • local workspace mode", + "Setup: setup validated • upgrade available", + "Managed-operation summary:", + "full managed access", + "Normal work is available now", + "Managed-operation blockers: none reported", + "Managed-operation watchouts: none reported", + "Project setup", + "Project setup is usable, but a broker-owned upgrade is available.", + "Project setup summary:", + "Adopt (a): not currently available.", + "Init (i/I): preview ready for review and optional apply (preview handle ready).", + "Upgrade (u/U): preview ready for review and optional apply (preview handle ready).", + "Project setup guidance", + "r reload", ) } @@ -52,18 +53,20 @@ func TestStatusRouteProjectSubstrateActionsUseTypedContracts(t *testing.T) { } type statusRouteActionCase struct { - key rune - expectedStatus string - expectedRPCCall []string + key rune + expectedStatus string + expectedSnippets []string + expectedRPCCall []string + expectReload bool } func statusRouteActionCases() []statusRouteActionCase { return []statusRouteActionCase{ - {key: 'a', expectedStatus: "Project substrate adopt status=", expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, - {key: 'i', expectedStatus: "Project substrate init preview status=", expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, - {key: 'I', expectedStatus: "Project substrate init apply status=", expectedRPCCall: []string{"ProjectSubstrateInitApply"}}, - {key: 'u', expectedStatus: "Project substrate upgrade preview status=", expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, - {key: 'U', expectedStatus: "Project substrate upgrade apply status=", expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}}, + {key: 'a', expectedStatus: "Project setup adoption refreshed: status=compatible_existing", expectedSnippets: []string{"Compatible adoption", "existing compatible setup", "without changing repository files"}, expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, + {key: 'i', expectedStatus: "Project setup init preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Init preview", "Preview is preview ready for review and optional apply.", "Handle is ."}, expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, + {key: 'I', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Managed operation", "Project setup validation refreshed.", "Project setup guidance"}, expectedRPCCall: []string{"ProjectSubstrateInitApply"}, expectReload: true}, + {key: 'u', expectedStatus: "Project setup upgrade preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Upgrade preview", "Preview is preview ready for review and optional apply.", "Digest is ."}, expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, + {key: 'U', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Managed operation", "Project setup validation refreshed.", "Project setup guidance"}, expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}, expectReload: true}, } } @@ -75,25 +78,155 @@ func assertStatusRouteActionUsesTypedContracts(t *testing.T, model routeModel, r t.Fatalf("expected action command for key %q", string(tc.key)) } updated, cmd = updated.Update(cmd()) - if cmd == nil { + if tc.expectReload && cmd == nil { t.Fatalf("expected reload command after key %q", string(tc.key)) } - updated, _ = updated.Update(cmd()) + if tc.expectReload { + updated, _ = updated.Update(cmd()) + } view := updated.View(120, 40, focusContent) if !strings.Contains(view, tc.expectedStatus) { t.Fatalf("expected status %q in view after key %q, got %q", tc.expectedStatus, string(tc.key), view) } + for _, snippet := range tc.expectedSnippets { + if !strings.Contains(view, snippet) { + t.Fatalf("expected snippet %q in view after key %q, got %q", snippet, string(tc.key), view) + } + } assertStatusRouteViewRedactsPreviewHandles(t, view, tc.key) afterCalls := recording.Calls()[before:] assertStatusRouteCallsInclude(t, afterCalls, tc.expectedRPCCall, tc.key) - if !containsCall(afterCalls, "ProjectSubstratePostureGet") { + if tc.expectReload && !containsCall(afterCalls, "ProjectSubstratePostureGet") { t.Fatalf("expected post-action posture reload after key %q; got %v", string(tc.key), afterCalls) } - if !containsCall(afterCalls, "ProductLifecyclePostureGet") { + if tc.expectReload && !containsCall(afterCalls, "ProductLifecyclePostureGet") { t.Fatalf("expected post-action lifecycle posture reload after key %q; got %v", string(tc.key), afterCalls) } } +type failingProjectSubstrateActionClient struct { + *fakeBrokerClient + initPreviewErr error + postureErr error + postureCalls int +} + +func (f *failingProjectSubstrateActionClient) ProjectSubstrateInitPreview(ctx context.Context) (brokerapi.ProjectSubstrateInitPreviewResponse, error) { + _, _ = f.fakeBrokerClient.ProjectSubstrateInitPreview(ctx) + if f.initPreviewErr != nil { + return brokerapi.ProjectSubstrateInitPreviewResponse{}, f.initPreviewErr + } + return f.fakeBrokerClient.ProjectSubstrateInitPreview(ctx) +} + +func (f *failingProjectSubstrateActionClient) ProjectSubstratePostureGet(ctx context.Context) (brokerapi.ProjectSubstratePostureGetResponse, error) { + f.postureCalls++ + if f.postureErr != nil && f.postureCalls > 1 { + return brokerapi.ProjectSubstratePostureGetResponse{}, f.postureErr + } + return f.fakeBrokerClient.ProjectSubstratePostureGet(ctx) +} + +func TestStatusRouteProjectSubstratePreviewFailureGuidesRetry(t *testing.T) { + model := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, &failingProjectSubstrateActionClient{fakeBrokerClient: &fakeBrokerClient{}, initPreviewErr: context.DeadlineExceeded}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeStatus}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + if cmd == nil { + t.Fatal("expected init preview action command") + } + updated, _ = updated.Update(cmd()) + view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Init preview", + "RuneCode could not load the broker-owned init preview.", + "Reload or retry init preview before any init apply.", + "normal work blocked: project substrate posture blocks execution while validation is incompatible", + ) +} + +type missingPreviewHandleClient struct { + *fakeBrokerClient +} + +func (f *missingPreviewHandleClient) ProjectSubstratePostureGet(ctx context.Context) (brokerapi.ProjectSubstratePostureGetResponse, error) { + resp, err := f.fakeBrokerClient.ProjectSubstratePostureGet(ctx) + if err != nil { + return brokerapi.ProjectSubstratePostureGetResponse{}, err + } + resp.InitPreview.Status = "" + resp.InitPreview.PreviewToken = "" + return resp, nil +} + +func TestStatusRouteProjectSubstrateApplyUnavailableExplainsPreviewRequirement(t *testing.T) { + model := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, &missingPreviewHandleClient{fakeBrokerClient: &fakeBrokerClient{}}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeStatus}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'I'}}) + if cmd == nil { + t.Fatal("expected init apply action command") + } + updated, _ = updated.Update(cmd()) + view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Init apply", + "Init apply is unavailable because no preview handle is currently published.", + "Run init preview first, then apply only after reviewing the planned mutation.", + ) +} + +func TestStatusRouteProjectSubstrateValidationReloadFailureStaysScoped(t *testing.T) { + model := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, &failingProjectSubstrateActionClient{fakeBrokerClient: &fakeBrokerClient{}, postureErr: context.DeadlineExceeded}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeStatus}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'U'}}) + if cmd == nil { + t.Fatal("expected upgrade apply action command") + } + updated, cmd = updated.Update(cmd()) + if cmd == nil { + t.Fatal("expected validation reload command") + } + updated, _ = updated.Update(cmd()) + view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Post-apply validation", + "Project setup apply finished, but refreshed validation status is currently unavailable.", + "Press r to retry validation refresh, then confirm managed-operation and setup posture before continuing.", + ) + if strings.Contains(view, "Status is temporarily unavailable.") { + t.Fatalf("expected scoped project-setup validation guidance, got %q", view) + } +} + +func TestStatusRouteSuccessfulValidationClearsFailureCard(t *testing.T) { + model := statusRouteModel{ + def: routeDefinition{ID: routeStatus, Label: "Status"}, + validatingProjectSubstrate: true, + loadSeq: 1, + projectSubstrateActionCard: projectSubstrateValidationFailureCard(brokerapi.ProjectSubstratePostureGetResponse{}, "open /home/user/.config/runecode/state.json: permission denied"), + } + updated, _ := model.Update(statusLoadedMsg{seq: 1, project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{ValidationState: "valid"}}}) + shell := updated.(statusRouteModel) + if shell.projectSubstrateActionCard != nil { + t.Fatal("expected successful validation reload to clear stale project substrate action card") + } + view := shell.View(120, 40, focusContent) + if strings.Contains(view, "Post-apply validation") || strings.Contains(view, "permission denied") { + t.Fatalf("expected stale validation failure card to be removed after success, got %q", view) + } +} + func assertStatusRouteViewRedactsPreviewHandles(t *testing.T, view string, key rune) { t.Helper() if strings.Contains(view, "sha256:"+strings.Repeat("1", 64)) || strings.Contains(view, "sha256:"+strings.Repeat("0", 64)) { @@ -125,10 +258,9 @@ func TestStatusRouteActivationUsesLifecyclePostureAndStatusContracts(t *testing. t.Fatal("expected adopt action command") } updated, cmd = updated.Update(cmd()) - if cmd == nil { - t.Fatal("expected reload command after adopt") + if cmd != nil { + t.Fatal("did not expect reload command after adopt") } - updated, _ = updated.Update(cmd()) calls := recording.Calls() if !containsCall(calls, "ProjectSubstrateAdopt") { t.Fatalf("expected ProjectSubstrateAdopt call, got %v", calls) @@ -164,13 +296,12 @@ func TestStatusRouteRendersDiagnosticsOnlyAttachGuidanceWhenNormalOperationBlock updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "attach_mode=diagnostics_only", - "lifecycle_posture=blocked", - "attachable=true", - "normal_operation_allowed=false", - "Broker lifecycle blocked reasons: project_substrate_unsupported_too_new", - "Broker lifecycle degraded reasons: project_substrate_upgrade_available", - "Attach guidance: diagnostics/remediation-only attach is available; normal operation is blocked by current project-substrate posture.", + "Managed operation", + "You can attach for diagnostics and remediation only; managed work stays blocked.", + "diagnostics-only access", + "Attach is available for inspection and remediation", + "Managed-operation blockers: project substrate unsupported too new", + "Managed-operation watchouts: project substrate upgrade available", ) } @@ -224,11 +355,13 @@ func TestStatusRouteRendersBlockedProjectSubstrateGuidance(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Project substrate posture:", - "state=missing", - "compatibility=missing", - "normal_operation_allowed=false", - "Project substrate block: normal operation blocked by project substrate posture: project_substrate_missing", - "Project substrate remediation: inspect_project_substrate_posture,initialize_canonical_runecontext_substrate,revalidate_project_substrate", + "Project setup", + "Project setup needs attention before managed work can continue.", + "Project setup summary:", + "setup missing", + "no compatible setup detected", + "Normal work stays blocked until setup is fixed", + "What blocks normal work: normal operation blocked by project substrate posture: project_substrate_missing", + "Broker guidance: inspect project substrate posture, initialize canonical runecontext substrate, revalidate project substrate", ) } diff --git a/cmd/runecode-tui/route_tests_helpers_test.go b/cmd/runecode-tui/route_tests_helpers_test.go index d9616ebb..b6e0d1c8 100644 --- a/cmd/runecode-tui/route_tests_helpers_test.go +++ b/cmd/runecode-tui/route_tests_helpers_test.go @@ -12,26 +12,6 @@ import ( "github.com/runecode-ai/runecode/internal/trustpolicy" ) -var testSignedApprovalRequest = &trustpolicy.SignedObjectEnvelope{ - SchemaID: trustpolicy.EnvelopeSchemaID, - SchemaVersion: trustpolicy.EnvelopeSchemaVersion, - PayloadSchemaID: trustpolicy.ApprovalRequestSchemaID, - PayloadSchemaVersion: trustpolicy.ApprovalRequestSchemaVersion, - Payload: []byte(`{"schema_id":"runecode.protocol.v0.ApprovalRequest","schema_version":"0.3.0"}`), - SignatureInput: trustpolicy.SignatureInputProfile, - Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: strings.Repeat("a", 64), Signature: "c2ln"}, -} - -var testSignedApprovalDecision = &trustpolicy.SignedObjectEnvelope{ - SchemaID: trustpolicy.EnvelopeSchemaID, - SchemaVersion: trustpolicy.EnvelopeSchemaVersion, - PayloadSchemaID: trustpolicy.ApprovalDecisionSchemaID, - PayloadSchemaVersion: trustpolicy.ApprovalDecisionSchemaVersion, - Payload: []byte(`{"schema_id":"runecode.protocol.v0.ApprovalDecision","schema_version":"0.3.0"}`), - SignatureInput: trustpolicy.SignatureInputProfile, - Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: strings.Repeat("b", 64), Signature: "c2ln"}, -} - const fakeSessionExecutionTriggerID = "trigger-1" type fakeBrokerClient struct{} @@ -43,7 +23,7 @@ type backendResolveReadyBrokerClient struct{ *fakeBrokerClient } func (f *fakeBrokerClient) RunList(ctx context.Context, limit int) (brokerapi.RunListResponse, error) { _ = ctx _ = limit - return brokerapi.RunListResponse{Runs: []brokerapi.RunSummary{{RunID: "run-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1, ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}}}, nil + return brokerapi.RunListResponse{Runs: []brokerapi.RunSummary{{RunID: "run-1", WorkspaceID: "ws-1", WorkflowKind: "change_draft", WorkflowDefinitionHash: "sha256:" + strings.Repeat("d", 64), CurrentStageID: "stage-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1, ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}}}, nil } func (f *fakeBrokerClient) RunGet(ctx context.Context, runID string) (brokerapi.RunGetResponse, error) { @@ -51,7 +31,7 @@ func (f *fakeBrokerClient) RunGet(ctx context.Context, runID string) (brokerapi. if runID == "" { return brokerapi.RunGetResponse{}, fmt.Errorf("run id required") } - return brokerapi.RunGetResponse{Run: brokerapi.RunDetail{Summary: brokerapi.RunSummary{RunID: runID, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}, Coordination: brokerapi.RunCoordinationSummary{Blocked: true, WaitReasonCode: "approval_wait", CoordinationMode: "stage_gate"}, StageSummaries: []brokerapi.RunStageSummary{{StageID: "stage-1", PendingApprovalCount: 1}, {StageID: "stage-2", PendingApprovalCount: 0}}, RoleSummaries: []brokerapi.RunRoleSummary{{RoleInstanceID: "role-1", WaitReasonCode: "approval_wait"}, {RoleInstanceID: "role-2"}}, PendingApprovalIDs: []string{"ap-1"}, ActiveManifestHashes: []string{"sha256:manifest"}, LatestPolicyDecisionRefs: []string{"sha256:policy"}, AuthoritativeState: map[string]any{"phase": "active", "attestation_posture": "valid", "session_binding_present": true, "attestation_evidence_present": true, "attestation_verification_succeeded": true, "attestation_verifier_class": "trusted_domain_local", "supported_runtime_requirements_satisfied": true, "runtime_posture_degraded": false}, AdvisoryState: map[string]any{"runner": "active"}}}, nil + return brokerapi.RunGetResponse{Run: brokerapi.RunDetail{Summary: brokerapi.RunSummary{RunID: runID, WorkspaceID: "ws-1", WorkflowKind: "change_draft", WorkflowDefinitionHash: "sha256:" + strings.Repeat("d", 64), CurrentStageID: "stage-1", LifecycleState: "active", BlockingReasonCode: "approval_wait", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}, Coordination: brokerapi.RunCoordinationSummary{Blocked: true, WaitReasonCode: "approval_wait", CoordinationMode: "stage_gate"}, StageSummaries: []brokerapi.RunStageSummary{{StageID: "stage-1", PendingApprovalCount: 1, ArtifactCount: 2}, {StageID: "stage-2", PendingApprovalCount: 0, ArtifactCount: 1}}, RoleSummaries: []brokerapi.RunRoleSummary{{RoleInstanceID: "role-1", WaitReasonCode: "approval_wait"}, {RoleInstanceID: "role-2"}}, PendingApprovalIDs: []string{"ap-1"}, ActiveManifestHashes: []string{"sha256:manifest"}, LatestPolicyDecisionRefs: []string{"sha256:policy"}, ArtifactCountsByClass: map[string]int{"change_draft": 1, "diffs": 2}, AuthoritativeState: map[string]any{"phase": "active", "workflow_projection_reason": "plan_authoritative", "workflow_kind": "change_draft", "session_id": "session-1", "attestation_posture": "valid", "session_binding_present": true, "attestation_evidence_present": true, "attestation_verification_succeeded": true, "attestation_verifier_class": "trusted_domain_local", "supported_runtime_requirements_satisfied": true, "runtime_posture_degraded": false}, AdvisoryState: map[string]any{"runner": "active", "last_checkpoint": map[string]any{"checkpoint_code": "approval_wait_entered"}}}}, nil } func (f *fakeBrokerClient) RunWatch(ctx context.Context, req brokerapi.RunWatchRequest) ([]brokerapi.RunWatchEvent, error) { @@ -69,7 +49,7 @@ func (f *fakeBrokerClient) RunWatch(ctx context.Context, req brokerapi.RunWatchR func (f *fakeBrokerClient) SessionList(ctx context.Context, limit int) (brokerapi.SessionListResponse, error) { _ = ctx _ = limit - return brokerapi.SessionListResponse{Sessions: []brokerapi.SessionSummary{{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "active", TurnCount: 2}, {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-1"}, Status: "active", TurnCount: 3}}}, nil + return brokerapi.SessionListResponse{Sessions: []brokerapi.SessionSummary{{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Draft a change proposal", LinkedRunCount: 1, LinkedArtifactCount: 2, LinkedApprovalCount: 1, TurnCount: 2}, {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Continue reviewed implementation", LinkedRunCount: 1, LinkedArtifactCount: 1, LinkedApprovalCount: 1, TurnCount: 3}}}, nil } func (f *fakeBrokerClient) SessionGet(ctx context.Context, sessionID string) (brokerapi.SessionGetResponse, error) { @@ -82,7 +62,8 @@ func (f *fakeBrokerClient) SessionGet(ctx context.Context, sessionID string) (br if strings.Contains(sessionID, "2") { turnTwo.Messages[0].RelatedLinks = brokerapi.SessionTranscriptLinks{ApprovalIDs: []string{"ap-1"}, ArtifactDigests: []string{"sha256:bbbb"}, AuditRecordDigests: []string{"sha256:aaaa"}} } - return brokerapi.SessionGetResponse{Session: brokerapi.SessionDetail{Summary: brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: sessionID, WorkspaceID: "ws-1"}}, TranscriptTurns: []brokerapi.SessionTranscriptTurn{turnTwo, turnOne}, LinkedRunIDs: []string{"run-1"}, LinkedApprovalIDs: []string{"ap-1"}, LinkedArtifactDigests: []string{"sha256:bbbb"}, LinkedAuditRecordDigests: []string{"sha256:aaaa"}}}, nil + exec := &brokerapi.SessionTurnExecution{SessionID: sessionID, TurnID: "turn-2", ExecutionState: "waiting", WaitKind: "approval", WaitState: "waiting_approval", RequestedOperation: "start", WorkflowRouting: brokerapi.SessionWorkflowPackRouting{WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, PrimaryRunID: "run-1", PendingApprovalID: "ap-1", LinkedRunIDs: []string{"run-1"}, LinkedApprovalIDs: []string{"ap-1"}, LinkedArtifactDigests: []string{"sha256:bbbb"}, LinkedAuditRecordDigests: []string{"sha256:aaaa"}} + return brokerapi.SessionGetResponse{Session: brokerapi.SessionDetail{Summary: brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: sessionID, WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Awaiting approval for linked change draft", LinkedRunCount: 1, LinkedArtifactCount: 1, LinkedApprovalCount: 1}, TranscriptTurns: []brokerapi.SessionTranscriptTurn{turnTwo, turnOne}, CurrentTurnExecution: exec, LatestTurnExecution: exec, LinkedRunIDs: []string{"run-1"}, LinkedApprovalIDs: []string{"ap-1"}, LinkedArtifactDigests: []string{"sha256:bbbb"}, LinkedAuditRecordDigests: []string{"sha256:aaaa"}}}, nil } func (f *fakeBrokerClient) SessionSendMessage(ctx context.Context, req brokerapi.SessionSendMessageRequest) (brokerapi.SessionSendMessageResponse, error) { @@ -130,19 +111,27 @@ func (f *fakeBrokerClient) SessionTurnExecutionWatch(ctx context.Context, req br return nil, fmt.Errorf("stream id required") } exec := brokerapi.SessionTurnExecution{ - SchemaID: "runecode.protocol.v0.SessionTurnExecution", - SchemaVersion: "0.1.0", - TurnID: "turn-exec-1", - SessionID: "session-1", - ExecutionIndex: 1, - TriggerID: fakeSessionExecutionTriggerID, - TriggerSource: "interactive_user", - RequestedOperation: "start", - ExecutionState: "running", - ApprovalProfile: "moderate", - AutonomyPosture: "balanced", - CreatedAt: "2026-01-01T00:00:00Z", - UpdatedAt: "2026-01-01T00:00:00Z", + SchemaID: "runecode.protocol.v0.SessionTurnExecution", + SchemaVersion: "0.1.0", + TurnID: "turn-exec-1", + SessionID: "session-1", + ExecutionIndex: 1, + TriggerID: fakeSessionExecutionTriggerID, + TriggerSource: "interactive_user", + RequestedOperation: "start", + ExecutionState: "waiting", + WaitKind: "approval", + WaitState: "waiting_approval", + PrimaryRunID: "run-1", + PendingApprovalID: "ap-1", + LinkedRunIDs: []string{"run-1"}, + LinkedApprovalIDs: []string{"ap-1"}, + LinkedArtifactDigests: []string{"sha256:bbbb"}, + LinkedAuditRecordDigests: []string{"sha256:aaaa"}, + ApprovalProfile: "moderate", + AutonomyPosture: "balanced", + CreatedAt: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-01-01T00:00:00Z", } return []brokerapi.SessionTurnExecutionWatchEvent{ {EventType: "session_turn_execution_watch_snapshot", Seq: 1, TurnExecution: &exec}, @@ -208,20 +197,23 @@ func (f *fakeBrokerClient) BackendPostureChange(ctx context.Context, req brokera func (f *reloadAwareBrokerClient) RunList(ctx context.Context, limit int) (brokerapi.RunListResponse, error) { _ = ctx _ = limit - return brokerapi.RunListResponse{Runs: []brokerapi.RunSummary{{RunID: "run-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1, ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}, {RunID: "run-2", LifecycleState: "blocked", BackendKind: "container", IsolationAssuranceLevel: "reduced", PendingApprovalCount: 0, ProvisioningPosture: "attested", AuditIntegrityStatus: "degraded", AuditAnchoringStatus: "degraded"}}}, nil + return brokerapi.RunListResponse{Runs: []brokerapi.RunSummary{{RunID: "run-1", WorkspaceID: "ws-1", WorkflowKind: "change_draft", WorkflowDefinitionHash: "sha256:" + strings.Repeat("d", 64), CurrentStageID: "stage-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1, ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"}, {RunID: "run-2", WorkspaceID: "ws-1", WorkflowKind: "approved_change_implementation", WorkflowDefinitionHash: "sha256:" + strings.Repeat("e", 64), CurrentStageID: "stage-2", LifecycleState: "blocked", BackendKind: "container", IsolationAssuranceLevel: "reduced", PendingApprovalCount: 0, ProvisioningPosture: "attested", AuditIntegrityStatus: "degraded", AuditAnchoringStatus: "degraded"}}}, nil } func (f *reloadAwareBrokerClient) RunGet(ctx context.Context, runID string) (brokerapi.RunGetResponse, error) { _ = ctx - summary := brokerapi.RunSummary{RunID: runID, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"} + summary := brokerapi.RunSummary{RunID: runID, WorkspaceID: "ws-1", WorkflowKind: "change_draft", WorkflowDefinitionHash: "sha256:" + strings.Repeat("d", 64), CurrentStageID: "stage-1", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded"} coordination := brokerapi.RunCoordinationSummary{Blocked: true, WaitReasonCode: "approval_wait", CoordinationMode: "stage_gate"} if runID == "run-2" { - summary = brokerapi.RunSummary{RunID: runID, BackendKind: "container", IsolationAssuranceLevel: "reduced", ProvisioningPosture: "attested", AuditIntegrityStatus: "degraded", AuditAnchoringStatus: "degraded"} + summary = brokerapi.RunSummary{RunID: runID, WorkspaceID: "ws-1", WorkflowKind: "approved_change_implementation", WorkflowDefinitionHash: "sha256:" + strings.Repeat("e", 64), CurrentStageID: "stage-2", LifecycleState: "blocked", BackendKind: "container", IsolationAssuranceLevel: "reduced", ProvisioningPosture: "attested", AuditIntegrityStatus: "degraded", AuditAnchoringStatus: "degraded"} coordination = brokerapi.RunCoordinationSummary{Blocked: false, WaitReasonCode: "", CoordinationMode: "free"} } - detail := brokerapi.RunDetail{Summary: summary, Coordination: coordination} + detail := brokerapi.RunDetail{Summary: summary, Coordination: coordination, ArtifactCountsByClass: map[string]int{"diffs": 1}, AuthoritativeState: map[string]any{"session_id": "session-1", "workflow_projection_reason": "plan_authoritative", "workflow_kind": summary.WorkflowKind}, AdvisoryState: map[string]any{"runner": "active"}} if runID == "run-2" { detail.AuthoritativeState = map[string]any{ + "workflow_projection_reason": "plan_authoritative", + "workflow_kind": summary.WorkflowKind, + "session_id": "session-2", "attestation_posture": "unavailable", "session_binding_present": true, "attestation_evidence_present": false, @@ -274,6 +266,7 @@ func (f *reloadAwareBrokerClient) ApprovalGet(ctx context.Context, approvalID st } if approvalID == "ap-2" { resp.Approval.BoundScope = brokerapi.ApprovalBoundScope{WorkspaceID: "ws-1", RunID: "run-2", StageID: "stage-2", ActionKind: "stage_summary_sign_off"} + resp.ApprovalDetail.BlockedWorkScope = brokerapi.ApprovalBlockedWorkScope{WorkspaceID: "ws-1", RunID: "run-2", StageID: "stage-2", ActionKind: "stage_summary_sign_off"} resp.ApprovalDetail.BindingKind = "stage_sign_off" resp.ApprovalDetail.PolicyReasonCode = "stage_sign_off_required" resp.ApprovalDetail.BoundStageSummaryHash = "sha256:stage" @@ -391,78 +384,6 @@ func (f *reloadAwareBrokerClient) AuditAnchorSegment(ctx context.Context, req br return (&fakeBrokerClient{}).AuditAnchorSegment(ctx, req) } -func (f *reloadAwareBrokerClient) GitSetupGet(ctx context.Context, provider string) (brokerapi.GitSetupGetResponse, error) { - return (&fakeBrokerClient{}).GitSetupGet(ctx, provider) -} - -func (f *reloadAwareBrokerClient) ProviderSetupSessionBegin(ctx context.Context, req brokerapi.ProviderSetupSessionBeginRequest) (brokerapi.ProviderSetupSessionBeginResponse, error) { - return (&fakeBrokerClient{}).ProviderSetupSessionBegin(ctx, req) -} - -func (f *reloadAwareBrokerClient) ProviderSetupSecretIngressPrepare(ctx context.Context, req brokerapi.ProviderSetupSecretIngressPrepareRequest) (brokerapi.ProviderSetupSecretIngressPrepareResponse, error) { - return (&fakeBrokerClient{}).ProviderSetupSecretIngressPrepare(ctx, req) -} - -func (f *reloadAwareBrokerClient) ProviderSetupSecretIngressSubmit(ctx context.Context, req brokerapi.ProviderSetupSecretIngressSubmitRequest, secret []byte) (brokerapi.ProviderSetupSecretIngressSubmitResponse, error) { - return (&fakeBrokerClient{}).ProviderSetupSecretIngressSubmit(ctx, req, secret) -} - -func (f *reloadAwareBrokerClient) ProviderCredentialLeaseIssue(ctx context.Context, req brokerapi.ProviderCredentialLeaseIssueRequest) (brokerapi.ProviderCredentialLeaseIssueResponse, error) { - return (&fakeBrokerClient{}).ProviderCredentialLeaseIssue(ctx, req) -} - -func (f *reloadAwareBrokerClient) ProviderProfileList(ctx context.Context) (brokerapi.ProviderProfileListResponse, error) { - return (&fakeBrokerClient{}).ProviderProfileList(ctx) -} - -func (f *reloadAwareBrokerClient) ProviderProfileGet(ctx context.Context, providerProfileID string) (brokerapi.ProviderProfileGetResponse, error) { - return (&fakeBrokerClient{}).ProviderProfileGet(ctx, providerProfileID) -} - -func (f *reloadAwareBrokerClient) GitSetupAuthBootstrap(ctx context.Context, req brokerapi.GitSetupAuthBootstrapRequest) (brokerapi.GitSetupAuthBootstrapResponse, error) { - return (&fakeBrokerClient{}).GitSetupAuthBootstrap(ctx, req) -} - -func (f *reloadAwareBrokerClient) GitSetupIdentityUpsert(ctx context.Context, req brokerapi.GitSetupIdentityUpsertRequest) (brokerapi.GitSetupIdentityUpsertResponse, error) { - return (&fakeBrokerClient{}).GitSetupIdentityUpsert(ctx, req) -} - -func (f *reloadAwareBrokerClient) ReadinessGet(ctx context.Context) (brokerapi.ReadinessGetResponse, error) { - return (&fakeBrokerClient{}).ReadinessGet(ctx) -} - -func (f *reloadAwareBrokerClient) VersionInfoGet(ctx context.Context) (brokerapi.VersionInfoGetResponse, error) { - return (&fakeBrokerClient{}).VersionInfoGet(ctx) -} - -func (f *reloadAwareBrokerClient) ProductLifecyclePostureGet(ctx context.Context) (brokerapi.ProductLifecyclePostureGetResponse, error) { - return (&fakeBrokerClient{}).ProductLifecyclePostureGet(ctx) -} - -func (f *reloadAwareBrokerClient) ProjectSubstratePostureGet(ctx context.Context) (brokerapi.ProjectSubstratePostureGetResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstratePostureGet(ctx) -} - -func (f *reloadAwareBrokerClient) ProjectSubstrateAdopt(ctx context.Context) (brokerapi.ProjectSubstrateAdoptResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstrateAdopt(ctx) -} - -func (f *reloadAwareBrokerClient) ProjectSubstrateInitPreview(ctx context.Context) (brokerapi.ProjectSubstrateInitPreviewResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstrateInitPreview(ctx) -} - -func (f *reloadAwareBrokerClient) ProjectSubstrateInitApply(ctx context.Context, expectedPreviewToken string) (brokerapi.ProjectSubstrateInitApplyResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstrateInitApply(ctx, expectedPreviewToken) -} - -func (f *reloadAwareBrokerClient) ProjectSubstrateUpgradePreview(ctx context.Context) (brokerapi.ProjectSubstrateUpgradePreviewResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstrateUpgradePreview(ctx) -} - -func (f *reloadAwareBrokerClient) ProjectSubstrateUpgradeApply(ctx context.Context, expectedPreviewDigest string) (brokerapi.ProjectSubstrateUpgradeApplyResponse, error) { - return (&fakeBrokerClient{}).ProjectSubstrateUpgradeApply(ctx, expectedPreviewDigest) -} - func (f *fakeBrokerClient) ApprovalWatch(ctx context.Context, req brokerapi.ApprovalWatchRequest) ([]brokerapi.ApprovalWatchEvent, error) { _ = ctx if req.StreamID == "" { @@ -644,7 +565,8 @@ func (f *fakeBrokerClient) AuditRecordGet(ctx context.Context, digest string) (b if digest == "" { return brokerapi.AuditRecordGetResponse{}, fmt.Errorf("digest required") } - return brokerapi.AuditRecordGetResponse{Record: brokerapi.AuditRecordDetail{RecordFamily: "audit_event", EventType: "run_state", OccurredAt: "2026-01-01T00:00:00Z", LinkedReferences: []brokerapi.AuditRecordLinkedReference{{ReferenceKind: "run", ReferenceID: "run-1"}}, VerificationPosture: &brokerapi.AuditRecordVerificationPosture{Status: "degraded", ReasonCodes: []string{"anchor_delayed"}}}}, nil + recordDigest := parseDigestIdentity(digest) + return brokerapi.AuditRecordGetResponse{Record: brokerapi.AuditRecordDetail{RecordDigest: recordDigest, RecordFamily: "audit_event", EventType: "run_state", OccurredAt: "2026-01-01T00:00:00Z", LinkedReferences: []brokerapi.AuditRecordLinkedReference{{ReferenceKind: "run", ReferenceID: "run-1"}}, VerificationPosture: &brokerapi.AuditRecordVerificationPosture{Status: "degraded", ReasonCodes: []string{"anchor_delayed"}}}}, nil } func (f *fakeBrokerClient) AuditAnchorPresenceGet(ctx context.Context, req brokerapi.AuditAnchorPresenceGetRequest) (brokerapi.AuditAnchorPresenceGetResponse, error) { @@ -877,31 +799,67 @@ func (f *fakeBrokerClient) ProjectSubstratePostureGet(ctx context.Context) (brok func (f *fakeBrokerClient) ProjectSubstrateAdopt(ctx context.Context) (brokerapi.ProjectSubstrateAdoptResponse, error) { _ = ctx - return brokerapi.ProjectSubstrateAdoptResponse{Adoption: brokerapi.ProjectSubstrateAdoptResponse{}.Adoption}, nil + return brokerapi.ProjectSubstrateAdoptResponse{Adoption: projectsubstrate.AdoptionResult{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateAdoptionResult", + SchemaVersion: "0.1.0", + RepositoryRoot: "/repo", + Status: "compatible_existing", + ReasonCodes: []string{"compatible_existing_substrate"}, + }}, nil } func (f *fakeBrokerClient) ProjectSubstrateInitPreview(ctx context.Context) (brokerapi.ProjectSubstrateInitPreviewResponse, error) { _ = ctx - return brokerapi.ProjectSubstrateInitPreviewResponse{Preview: brokerapi.ProjectSubstrateInitPreviewResponse{}.Preview}, nil + return brokerapi.ProjectSubstrateInitPreviewResponse{Preview: projectsubstrate.InitPreview{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateInitPreview", + SchemaVersion: "0.1.0", + RepositoryRoot: "/repo", + Status: "ready_for_apply", + ReasonCodes: []string{"project_substrate_missing"}, + RequiredFollowUp: []string{"review_planned_mutation", "apply_init_if_intended"}, + PreviewToken: "sha256:" + strings.Repeat("2", 64), + }}, nil } func (f *fakeBrokerClient) ProjectSubstrateInitApply(ctx context.Context, expectedPreviewToken string) (brokerapi.ProjectSubstrateInitApplyResponse, error) { _ = ctx if strings.TrimSpace(expectedPreviewToken) == "" { - return brokerapi.ProjectSubstrateInitApplyResponse{ApplyResult: brokerapi.ProjectSubstrateInitApplyResponse{}.ApplyResult}, nil + return brokerapi.ProjectSubstrateInitApplyResponse{ApplyResult: projectsubstrate.InitApplyResult{}}, nil } - return brokerapi.ProjectSubstrateInitApplyResponse{ApplyResult: brokerapi.ProjectSubstrateInitApplyResponse{}.ApplyResult}, nil + return brokerapi.ProjectSubstrateInitApplyResponse{ApplyResult: projectsubstrate.InitApplyResult{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateInitApplyResult", + SchemaVersion: "0.1.0", + RepositoryRoot: "/repo", + Status: "applied", + ReasonCodes: []string{"project_substrate_initialized"}, + PreviewToken: expectedPreviewToken, + }}, nil } func (f *fakeBrokerClient) ProjectSubstrateUpgradePreview(ctx context.Context) (brokerapi.ProjectSubstrateUpgradePreviewResponse, error) { _ = ctx - return brokerapi.ProjectSubstrateUpgradePreviewResponse{Preview: brokerapi.ProjectSubstrateUpgradePreviewResponse{}.Preview}, nil + return brokerapi.ProjectSubstrateUpgradePreviewResponse{Preview: projectsubstrate.UpgradePreview{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradePreview", + SchemaVersion: "0.1.0", + RepositoryRoot: "/repo", + Status: "ready_for_apply", + ReasonCodes: []string{"project_substrate_upgrade_available"}, + RequiredFollowUp: []string{"review_upgrade_mutation", "apply_upgrade_if_intended"}, + PreviewDigest: "sha256:" + strings.Repeat("3", 64), + }}, nil } func (f *fakeBrokerClient) ProjectSubstrateUpgradeApply(ctx context.Context, expectedPreviewDigest string) (brokerapi.ProjectSubstrateUpgradeApplyResponse, error) { _ = ctx if strings.TrimSpace(expectedPreviewDigest) == "" { - return brokerapi.ProjectSubstrateUpgradeApplyResponse{ApplyResult: brokerapi.ProjectSubstrateUpgradeApplyResponse{}.ApplyResult}, nil + return brokerapi.ProjectSubstrateUpgradeApplyResponse{ApplyResult: projectsubstrate.UpgradeApplyResult{}}, nil } - return brokerapi.ProjectSubstrateUpgradeApplyResponse{ApplyResult: brokerapi.ProjectSubstrateUpgradeApplyResponse{}.ApplyResult}, nil + return brokerapi.ProjectSubstrateUpgradeApplyResponse{ApplyResult: projectsubstrate.UpgradeApplyResult{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradeApplyResult", + SchemaVersion: "0.1.0", + RepositoryRoot: "/repo", + Status: "applied", + ReasonCodes: []string{"project_substrate_upgraded"}, + PreviewDigest: expectedPreviewDigest, + }}, nil } diff --git a/cmd/runecode-tui/route_tests_project_substrate_helpers_test.go b/cmd/runecode-tui/route_tests_project_substrate_helpers_test.go new file mode 100644 index 00000000..35a83bb6 --- /dev/null +++ b/cmd/runecode-tui/route_tests_project_substrate_helpers_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func (f *reloadAwareBrokerClient) ReadinessGet(ctx context.Context) (brokerapi.ReadinessGetResponse, error) { + return (&fakeBrokerClient{}).ReadinessGet(ctx) +} + +func (f *reloadAwareBrokerClient) VersionInfoGet(ctx context.Context) (brokerapi.VersionInfoGetResponse, error) { + return (&fakeBrokerClient{}).VersionInfoGet(ctx) +} + +func (f *reloadAwareBrokerClient) ProductLifecyclePostureGet(ctx context.Context) (brokerapi.ProductLifecyclePostureGetResponse, error) { + return (&fakeBrokerClient{}).ProductLifecyclePostureGet(ctx) +} + +func (f *reloadAwareBrokerClient) ProjectSubstratePostureGet(ctx context.Context) (brokerapi.ProjectSubstratePostureGetResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstratePostureGet(ctx) +} + +func (f *reloadAwareBrokerClient) ProjectSubstrateAdopt(ctx context.Context) (brokerapi.ProjectSubstrateAdoptResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstrateAdopt(ctx) +} + +func (f *reloadAwareBrokerClient) ProjectSubstrateInitPreview(ctx context.Context) (brokerapi.ProjectSubstrateInitPreviewResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstrateInitPreview(ctx) +} + +func (f *reloadAwareBrokerClient) ProjectSubstrateInitApply(ctx context.Context, expectedPreviewToken string) (brokerapi.ProjectSubstrateInitApplyResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstrateInitApply(ctx, expectedPreviewToken) +} + +func (f *reloadAwareBrokerClient) ProjectSubstrateUpgradePreview(ctx context.Context) (brokerapi.ProjectSubstrateUpgradePreviewResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstrateUpgradePreview(ctx) +} + +func (f *reloadAwareBrokerClient) ProjectSubstrateUpgradeApply(ctx context.Context, expectedPreviewDigest string) (brokerapi.ProjectSubstrateUpgradeApplyResponse, error) { + return (&fakeBrokerClient{}).ProjectSubstrateUpgradeApply(ctx, expectedPreviewDigest) +} diff --git a/cmd/runecode-tui/route_tests_provider_helpers_test.go b/cmd/runecode-tui/route_tests_provider_helpers_test.go new file mode 100644 index 00000000..2ba5f261 --- /dev/null +++ b/cmd/runecode-tui/route_tests_provider_helpers_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func (f *reloadAwareBrokerClient) GitSetupGet(ctx context.Context, provider string) (brokerapi.GitSetupGetResponse, error) { + return (&fakeBrokerClient{}).GitSetupGet(ctx, provider) +} + +func (f *reloadAwareBrokerClient) ProviderSetupSessionBegin(ctx context.Context, req brokerapi.ProviderSetupSessionBeginRequest) (brokerapi.ProviderSetupSessionBeginResponse, error) { + return (&fakeBrokerClient{}).ProviderSetupSessionBegin(ctx, req) +} + +func (f *reloadAwareBrokerClient) ProviderSetupSecretIngressPrepare(ctx context.Context, req brokerapi.ProviderSetupSecretIngressPrepareRequest) (brokerapi.ProviderSetupSecretIngressPrepareResponse, error) { + return (&fakeBrokerClient{}).ProviderSetupSecretIngressPrepare(ctx, req) +} + +func (f *reloadAwareBrokerClient) ProviderSetupSecretIngressSubmit(ctx context.Context, req brokerapi.ProviderSetupSecretIngressSubmitRequest, secret []byte) (brokerapi.ProviderSetupSecretIngressSubmitResponse, error) { + return (&fakeBrokerClient{}).ProviderSetupSecretIngressSubmit(ctx, req, secret) +} + +func (f *reloadAwareBrokerClient) ProviderCredentialLeaseIssue(ctx context.Context, req brokerapi.ProviderCredentialLeaseIssueRequest) (brokerapi.ProviderCredentialLeaseIssueResponse, error) { + return (&fakeBrokerClient{}).ProviderCredentialLeaseIssue(ctx, req) +} + +func (f *reloadAwareBrokerClient) ProviderProfileList(ctx context.Context) (brokerapi.ProviderProfileListResponse, error) { + return (&fakeBrokerClient{}).ProviderProfileList(ctx) +} + +func (f *reloadAwareBrokerClient) ProviderProfileGet(ctx context.Context, providerProfileID string) (brokerapi.ProviderProfileGetResponse, error) { + return (&fakeBrokerClient{}).ProviderProfileGet(ctx, providerProfileID) +} + +func (f *reloadAwareBrokerClient) GitSetupAuthBootstrap(ctx context.Context, req brokerapi.GitSetupAuthBootstrapRequest) (brokerapi.GitSetupAuthBootstrapResponse, error) { + return (&fakeBrokerClient{}).GitSetupAuthBootstrap(ctx, req) +} + +func (f *reloadAwareBrokerClient) GitSetupIdentityUpsert(ctx context.Context, req brokerapi.GitSetupIdentityUpsertRequest) (brokerapi.GitSetupIdentityUpsertResponse, error) { + return (&fakeBrokerClient{}).GitSetupIdentityUpsert(ctx, req) +} diff --git a/cmd/runecode-tui/routes.go b/cmd/runecode-tui/routes.go index d6325643..9e72af2d 100644 --- a/cmd/runecode-tui/routes.go +++ b/cmd/runecode-tui/routes.go @@ -38,10 +38,10 @@ func shellRoutes() []routeDefinition { {ID: routeAction, Label: "Action Center", Description: "Interactive/operator-attention queues and blocked-work impact", Index: 5, QuickJumpKey: "5"}, {ID: routeArtifacts, Label: "Artifacts", Description: "Artifact browsing and drill-down", Index: 6, QuickJumpKey: "6"}, {ID: routeAudit, Label: "Audit", Description: "Audit timeline and verification posture", Index: 7, QuickJumpKey: "7"}, - {ID: routeStatus, Label: "Status", Description: "Broker readiness and subsystem posture", Index: 8, QuickJumpKey: "8"}, + {ID: routeStatus, Label: "Status", Description: "Managed operation, setup, and broker health", Index: 8, QuickJumpKey: "8"}, {ID: routeProviders, Label: "Model Providers", Description: "Broker-owned direct credential setup", Index: 9, QuickJumpKey: "9"}, {ID: routeGitSetup, Label: "Git Setup", Description: "Broker-owned git setup and auth posture", Index: 10, QuickJumpKey: "0"}, - {ID: routeGitRemote, Label: "Git Remote", Description: "Review prepared git remote mutations and execute", Index: 11, QuickJumpKey: "-"}, + {ID: routeGitRemote, Label: "Git Remote", Description: "Review prepared remote changes before execution", Index: 11, QuickJumpKey: "-"}, } } diff --git a/cmd/runecode-tui/routes_model.go b/cmd/runecode-tui/routes_model.go index 05c2f9d5..ca722025 100644 --- a/cmd/runecode-tui/routes_model.go +++ b/cmd/runecode-tui/routes_model.go @@ -131,11 +131,15 @@ func newRouteModels(defs []routeDefinition) map[routeID]routeModel { func compactLines(lines ...string) string { nonEmpty := make([]string, 0, len(lines)) for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { + block := strings.Trim(line, "\n") + if strings.TrimSpace(block) == "" { continue } - nonEmpty = append(nonEmpty, trimmed) + if strings.Contains(block, "\n") { + nonEmpty = append(nonEmpty, block) + continue + } + nonEmpty = append(nonEmpty, strings.TrimSpace(block)) } return strings.Join(nonEmpty, "\n") } diff --git a/cmd/runecode-tui/safety_ux.go b/cmd/runecode-tui/safety_ux.go index c6063f60..22077560 100644 --- a/cmd/runecode-tui/safety_ux.go +++ b/cmd/runecode-tui/safety_ux.go @@ -11,11 +11,15 @@ import ( func renderRunSafetyStrip(summary brokerapi.RunSummary, width int) string { runtimeDegraded := summary.RuntimePostureDegraded parts := []string{ - tableHeader("Safety strip"), - fmt.Sprintf("backend_kind=%s", valueOrNA(summary.BackendKind)), + tableHeader("Runtime and evidence"), + fmt.Sprintf("Backend: %s", valueOrNA(summary.BackendKind)), } parts = append(parts, runtimeIsolationCueParts(summary.BackendKind, summary.IsolationAssuranceLevel)...) - parts = append(parts, fmt.Sprintf("runtime_posture_degraded=%t", runtimeDegraded), renderRuntimePostureDegradedBadge(runtimeDegraded)) + if runtimeDegraded { + parts = append(parts, "Runtime posture needs review", renderRuntimePostureDegradedBadge(true)) + } else { + parts = append(parts, "Runtime posture nominal", renderRuntimePostureDegradedBadge(false)) + } parts = append(parts, provisioningPostureCueParts(summary.ProvisioningPosture)...) parts = append(parts, auditPostureCueParts(summary.AuditIntegrityStatus, summary.AuditAnchoringStatus, summary.AuditCurrentlyDegraded)...) parts = append(parts, approvalProfileCueParts(summary.ApprovalProfile)...) @@ -42,6 +46,7 @@ func reducedAssurancePostureCueParts(state map[string]any) []string { parts := []string{fmt.Sprintf("reduced_assurance=%t", runtimeDegraded)} approvalBacked, _ := state["reduced_assurance_approval_backed"].(bool) approvalStatus, _ := state["reduced_assurance_approval_status"].(string) + approvalStatus = sanitizeUIText(approvalStatus) if approvalBacked { parts = append(parts, fmt.Sprintf("approval_backed=true status=%s", valueOrNA(approvalStatus))) parts = append(parts, infoBadge("REDUCED_ASSURANCE_APPROVAL_BACKED")) @@ -87,34 +92,41 @@ func renderSupportedRuntimeRequirementsCue(state map[string]any) string { func firstAuthoritativeString(state map[string]any, keys ...string) (string, string) { for _, key := range keys { value, _ := state[key].(string) - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed, key + sanitized := sanitizeUIText(strings.TrimSpace(value)) + if sanitized != "" { + return sanitized, sanitizeUIText(key) } } return "", "" } func authoritativeStringSlice(state map[string]any, key string) []string { + sanitizeValues := func(rawValues []string) []string { + values := make([]string, 0, len(rawValues)) + for _, rawValue := range rawValues { + sanitized := sanitizeUIText(strings.TrimSpace(rawValue)) + if sanitized != "" { + values = append(values, sanitized) + } + } + return values + } if values, ok := state[key].([]string); ok { - return append([]string{}, values...) + return sanitizeValues(values) } valuesAny, ok := state[key].([]any) if !ok { return nil } - values := make([]string, 0, len(valuesAny)) + rawValues := make([]string, 0, len(valuesAny)) for _, value := range valuesAny { stringValue, ok := value.(string) if !ok { continue } - trimmed := strings.TrimSpace(stringValue) - if trimmed != "" { - values = append(values, trimmed) - } + rawValues = append(rawValues, stringValue) } - return values + return sanitizeValues(rawValues) } func wrapPartsByWidth(parts []string, separator string, width int) string { @@ -147,17 +159,17 @@ func runtimeIsolationCueParts(backendKind, isolation string) []string { nBackend := strings.ToLower(strings.TrimSpace(backendKind)) nIsolation := strings.ToLower(strings.TrimSpace(isolation)) if nBackend == "container" || strings.Contains(nIsolation, "container") { - return []string{"runtime isolation=container (reduced assurance)", reducedAssuranceBadge("RUNTIME_REDUCED_CONTAINER")} + return []string{"Isolation: container (reduced assurance)", reducedAssuranceBadge("RUNTIME_REDUCED_CONTAINER")} } switch nIsolation { case "sandboxed", "isolated", "microvm": - return []string{fmt.Sprintf("runtime isolation=%s", valueOrNA(isolation)), successBadge("RUNTIME_ASSURED")} + return []string{fmt.Sprintf("Isolation: %s", valueOrNA(isolation)), successBadge("RUNTIME_ASSURED")} case "reduced": - return []string{fmt.Sprintf("runtime isolation=%s", valueOrNA(isolation)), reducedAssuranceBadge("RUNTIME_REDUCED")} + return []string{fmt.Sprintf("Isolation: %s", valueOrNA(isolation)), reducedAssuranceBadge("RUNTIME_REDUCED")} case "degraded", "unknown", "unavailable": - return []string{fmt.Sprintf("runtime isolation=%s (authoritative posture degraded/unavailable)", valueOrNA(isolation)), dangerBadge("RUNTIME_POSTURE_DEGRADED")} + return []string{fmt.Sprintf("Isolation: %s (degraded or unavailable)", valueOrNA(isolation)), dangerBadge("RUNTIME_POSTURE_DEGRADED")} default: - return []string{fmt.Sprintf("runtime isolation=%s", valueOrNA(isolation)), infoBadge("RUNTIME_POSTURE_REPORTED")} + return []string{fmt.Sprintf("Isolation: %s", valueOrNA(isolation)), infoBadge("RUNTIME_POSTURE_REPORTED")} } } @@ -169,13 +181,13 @@ func provisioningPostureCueParts(posture string) []string { n := strings.ToLower(strings.TrimSpace(posture)) switch n { case "ok", "trusted", "bound", "attested": - return []string{fmt.Sprintf("provisioning posture=%s", valueOrNA(posture)), successBadge("PROVISIONING_OK")} + return []string{fmt.Sprintf("Provisioning: %s", valueOrNA(posture)), successBadge("PROVISIONING_OK")} case "tofu": - return []string{fmt.Sprintf("provisioning posture=%s (unsupported legacy TOFU posture)", valueOrNA(posture)), dangerBadge("PROVISIONING_TOFU_UNSUPPORTED")} + return []string{fmt.Sprintf("Provisioning: %s (unsupported legacy TOFU posture)", valueOrNA(posture)), dangerBadge("PROVISIONING_TOFU_UNSUPPORTED")} case "degraded", "unavailable", "unknown": - return []string{fmt.Sprintf("provisioning posture=%s (degraded)", valueOrNA(posture)), provisioningDegradedBadge("PROVISIONING_DEGRADED")} + return []string{fmt.Sprintf("Provisioning: %s (degraded)", valueOrNA(posture)), provisioningDegradedBadge("PROVISIONING_DEGRADED")} default: - return []string{fmt.Sprintf("provisioning posture=%s", valueOrNA(posture)), infoBadge("PROVISIONING_REPORTED")} + return []string{fmt.Sprintf("Provisioning: %s", valueOrNA(posture)), infoBadge("PROVISIONING_REPORTED")} } } @@ -183,8 +195,24 @@ func renderProvisioningPostureCue(posture string) string { return strings.Join(provisioningPostureCueParts(posture), " ") } +func sanitizeAttestationPostureAndReasons(posture string, reasonCodes []string) (string, []string) { + posture = sanitizeUIText(strings.TrimSpace(posture)) + if len(reasonCodes) == 0 { + return posture, nil + } + sanitizedReasons := make([]string, 0, len(reasonCodes)) + for _, reasonCode := range reasonCodes { + reasonCode = sanitizeUIText(strings.TrimSpace(reasonCode)) + if reasonCode != "" { + sanitizedReasons = append(sanitizedReasons, reasonCode) + } + } + return posture, sanitizedReasons +} + func attestationPostureCueParts(posture string, reasonCodes []string) []string { - n := strings.ToLower(strings.TrimSpace(posture)) + posture, reasonCodes = sanitizeAttestationPostureAndReasons(posture, reasonCodes) + n := strings.ToLower(posture) reason := "" if len(reasonCodes) > 0 { reason = " reasons=" + strings.Join(reasonCodes, ",") @@ -213,12 +241,12 @@ func auditPostureCueParts(integrity, anchoring string, degraded bool) []string { nAnchoring := strings.ToLower(strings.TrimSpace(anchoring)) nIntegrity := strings.ToLower(strings.TrimSpace(integrity)) if nAnchoring == "failed" || nIntegrity == "failed" || nIntegrity == "invalid" { - return []string{fmt.Sprintf("audit posture=%s/%s (invalid/failed anchoring)", valueOrNA(integrity), valueOrNA(anchoring)), dangerBadge("AUDIT_FAILED")} + return []string{fmt.Sprintf("Audit evidence: %s/%s (failed anchoring)", valueOrNA(integrity), valueOrNA(anchoring)), dangerBadge("AUDIT_FAILED")} } if degraded || nAnchoring == "degraded" { - return []string{fmt.Sprintf("audit posture=%s/%s (unanchored/degraded)", valueOrNA(integrity), valueOrNA(anchoring)), auditDegradedBadge("AUDIT_UNANCHORED_OR_DEGRADED")} + return []string{fmt.Sprintf("Audit evidence: %s/%s (unanchored or degraded)", valueOrNA(integrity), valueOrNA(anchoring)), auditDegradedBadge("AUDIT_UNANCHORED_OR_DEGRADED")} } - return []string{fmt.Sprintf("audit posture=%s/%s", valueOrNA(integrity), valueOrNA(anchoring)), successBadge("AUDIT_ANCHORED")} + return []string{fmt.Sprintf("Audit evidence: %s/%s", valueOrNA(integrity), valueOrNA(anchoring)), successBadge("AUDIT_ANCHORED")} } func renderAuditPostureCue(integrity, anchoring string, degraded bool) string { @@ -228,9 +256,9 @@ func renderAuditPostureCue(integrity, anchoring string, degraded bool) string { func approvalProfileCueParts(profile string) []string { n := strings.ToLower(strings.TrimSpace(profile)) if n == "" || n == "unknown" { - return []string{fmt.Sprintf("approval_profile=%s", valueOrNA(profile)), warnBadge("APPROVAL_PROFILE_UNKNOWN")} + return []string{fmt.Sprintf("Approval profile: %s", valueOrNA(profile)), warnBadge("APPROVAL_PROFILE_UNKNOWN")} } - return []string{fmt.Sprintf("approval_profile=%s", profile), infoBadge("APPROVAL_PROFILE_ACTIVE")} + return []string{fmt.Sprintf("Approval profile: %s", profile), infoBadge("APPROVAL_PROFILE_ACTIVE")} } func renderApprovalProfileCue(profile string) string { diff --git a/cmd/runecode-tui/session_workspace.go b/cmd/runecode-tui/session_workspace.go index 77bef5d2..352be78e 100644 --- a/cmd/runecode-tui/session_workspace.go +++ b/cmd/runecode-tui/session_workspace.go @@ -11,11 +11,12 @@ import ( ) type sessionSwitcherModel struct { - open bool - query string - selectedIndex int - matches []brokerapi.SessionSummary - sessions []brokerapi.SessionSummary + open bool + query string + selectedIndex int + matches []brokerapi.SessionSummary + sessions []brokerapi.SessionSummary + normalizedSessions []string } func newSessionSwitcherModel() sessionSwitcherModel { @@ -30,7 +31,7 @@ func (m sessionSwitcherModel) Open(sessions []brokerapi.SessionSummary) sessionS m.open = true m.query = "" m.selectedIndex = 0 - m.sessions = append([]brokerapi.SessionSummary(nil), sessions...) + m.setSessions(sessions) m.rebuildMatches() return m } @@ -41,7 +42,7 @@ func (m sessionSwitcherModel) Close() sessionSwitcherModel { } func (m sessionSwitcherModel) UpdateSessions(sessions []brokerapi.SessionSummary) sessionSwitcherModel { - m.sessions = append([]brokerapi.SessionSummary(nil), sessions...) + m.setSessions(sessions) m.rebuildMatches() return m } @@ -100,18 +101,20 @@ func (m sessionSwitcherModel) DeleteQueryRune() sessionSwitcherModel { return m } +func (m *sessionSwitcherModel) setSessions(sessions []brokerapi.SessionSummary) { + m.sessions = append([]brokerapi.SessionSummary(nil), sessions...) + m.normalizedSessions = buildSessionSwitcherNormalizedSessions(m.sessions) +} + func (m *sessionSwitcherModel) rebuildMatches() { needle := strings.ToLower(strings.TrimSpace(m.query)) m.matches = m.matches[:0] if needle == "" { m.matches = append(m.matches, m.sessions...) } else { - for _, s := range m.sessions { - if strings.Contains(strings.ToLower(s.Identity.SessionID), needle) || - strings.Contains(strings.ToLower(s.Identity.WorkspaceID), needle) || - strings.Contains(strings.ToLower(s.LastActivityKind), needle) || - strings.Contains(strings.ToLower(s.LastActivityPreview), needle) { - m.matches = append(m.matches, s) + for i, session := range m.sessions { + if strings.Contains(m.normalizedSessions[i], needle) { + m.matches = append(m.matches, session) } } } @@ -124,6 +127,30 @@ func (m *sessionSwitcherModel) rebuildMatches() { } } +func buildSessionSwitcherNormalizedSessions(sessions []brokerapi.SessionSummary) []string { + if len(sessions) == 0 { + return nil + } + normalized := make([]string, len(sessions)) + for i, session := range sessions { + normalized[i] = normalizeSessionSwitcherSearch(session) + } + return normalized +} + +func normalizeSessionSwitcherSearch(summary brokerapi.SessionSummary) string { + var b strings.Builder + b.Grow(len(summary.Identity.SessionID) + len(summary.Identity.WorkspaceID) + len(summary.LastActivityKind) + len(summary.LastActivityPreview) + 3) + b.WriteString(strings.TrimSpace(summary.Identity.SessionID)) + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(summary.Identity.WorkspaceID)) + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(summary.LastActivityKind)) + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(summary.LastActivityPreview)) + return strings.ToLower(b.String()) +} + func parseTimestamp(ts string) time.Time { parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(ts)) if err != nil { @@ -178,20 +205,66 @@ func sessionDirectoryItems(summaries []brokerapi.SessionSummary, activeSessionID } func sessionDirectoryLine(summary brokerapi.SessionSummary, activeSessionID string, pinned map[string]struct{}, recentOrder map[string]int, viewed map[string]string, active shellActivityFocus) string { - sid := summary.Identity.SessionID - markerText := formatSessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewed[sid], active) - return fmt.Sprintf("%s%s | ws=%s | at=%s kind=%s | preview=%q | incomplete=%t cue=%s | runs=%d approvals=%d", - sid, - markerText, - summary.Identity.WorkspaceID, - defaultPlaceholder(summary.LastActivityAt, "n/a"), - defaultPlaceholder(summary.LastActivityKind, "n/a"), - truncateText(summary.LastActivityPreview, 52), - summary.HasIncompleteTurn, + rawSessionID := strings.TrimSpace(summary.Identity.SessionID) + sid := sanitizeUIText(rawSessionID) + workspaceID := sanitizeUIText(summary.Identity.WorkspaceID) + markerText := formatSessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewed[rawSessionID], active) + parts := []string{ + fmt.Sprintf("%s%s", sid, markerText), + fmt.Sprintf("workspace %s", valueOrNA(workspaceID)), sessionHighLevelCue(summary), - summary.LinkedRunCount, - summary.LinkedApprovalCount, - ) + } + if summary.LinkedRunCount > 0 { + parts = append(parts, fmt.Sprintf("%d run(s)", summary.LinkedRunCount)) + } + if summary.LinkedApprovalCount > 0 { + parts = append(parts, fmt.Sprintf("%d approval(s)", summary.LinkedApprovalCount)) + } + if preview := strings.TrimSpace(summary.LastActivityPreview); preview != "" { + parts = append(parts, truncateText(sanitizeUIText(preview), 36)) + } + return strings.Join(parts, " | ") +} + +func sessionSidebarLine(summary brokerapi.SessionSummary, activeSessionID string, pinned map[string]struct{}, recentOrder map[string]int, viewed map[string]string, active shellActivityFocus) string { + rawSessionID := strings.TrimSpace(summary.Identity.SessionID) + sid := sanitizeUIText(rawSessionID) + if sid == "" { + sid = "session" + } + markers := conciseSessionMarkers(summary, activeSessionID, pinned, recentOrder, viewed[rawSessionID], active) + parts := []string{sid} + if cue := sessionHighLevelCue(summary); cue != "" { + parts = append(parts, cue) + } + if summary.LinkedRunCount > 0 { + parts = append(parts, countNoun(summary.LinkedRunCount, "run", "runs")) + } + if summary.LinkedApprovalCount > 0 { + parts = append(parts, countNoun(summary.LinkedApprovalCount, "approval", "approvals")) + } + if len(markers) > 0 { + parts = append(parts, strings.Join(markers, ",")) + } + return strings.Join(parts, " · ") +} + +func conciseSessionMarkers(summary brokerapi.SessionSummary, activeSessionID string, pinned map[string]struct{}, recentOrder map[string]int, viewedAt string, active shellActivityFocus) []string { + markers := sessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewedAt, active) + out := make([]string, 0, len(markers)) + for _, marker := range markers { + switch marker { + case "active": + out = append(out, "active") + case "pin": + out = append(out, "pinned") + case "new": + out = append(out, "new") + case "running": + out = append(out, "live") + } + } + return out } func recentSessionOrder(recents []string) map[string]int { diff --git a/cmd/runecode-tui/session_workspace_test.go b/cmd/runecode-tui/session_workspace_test.go index 314f787b..ff17a19e 100644 --- a/cmd/runecode-tui/session_workspace_test.go +++ b/cmd/runecode-tui/session_workspace_test.go @@ -32,13 +32,11 @@ func TestSessionDirectoryItemsRenderRequiredMetadataAndLocalMarkers(t *testing.T for _, want := range []string{ "session-1", "[active,pin,recent,new,running]", - "ws=ws-1", - "kind=chat_message", - "preview=\"hello world\"", - "incomplete=true", - "cue=active", - "runs=2", - "approvals=1", + "workspace ws-1", + "active", + "2 run(s)", + "1 approval(s)", + "hello world", } { if !strings.Contains(line, want) { t.Fatalf("expected %q in %q", want, line) @@ -46,25 +44,88 @@ func TestSessionDirectoryItemsRenderRequiredMetadataAndLocalMarkers(t *testing.T } } +func TestSessionSidebarLineSanitizesRenderedSessionID(t *testing.T) { + rawSessionID := " session-1\nspoof\r\x1b[31m" + line := sessionSidebarLine( + brokerapi.SessionSummary{ + Identity: brokerapi.SessionIdentity{SessionID: rawSessionID}, + Status: "active", + LastActivityAt: "2026-01-03T00:00:00Z", + }, + rawSessionID, + map[string]struct{}{rawSessionID: {}}, + map[string]int{rawSessionID: 0}, + map[string]string{rawSessionID: ""}, + shellActivityFocus{Kind: "session", ID: rawSessionID}, + ) + + if !strings.Contains(line, sanitizeUIText(rawSessionID)) { + t.Fatalf("expected sanitized session ID in %q", line) + } + for _, unsafe := range []string{"\n", "\r", "\x1b"} { + if strings.Contains(line, unsafe) { + t.Fatalf("expected sidebar line to exclude %q, got %q", unsafe, line) + } + } + for _, want := range []string{"active", "pinned", "new", "live"} { + if !strings.Contains(line, want) { + t.Fatalf("expected %q marker in %q", want, line) + } + } +} + +func TestSessionDirectoryLineUsesRawSessionIDForViewedLookup(t *testing.T) { + rawSessionID := "session-1" + lastViewed := "2026-01-03T00:00:00Z" + line := sessionDirectoryLine( + brokerapi.SessionSummary{ + Identity: brokerapi.SessionIdentity{SessionID: rawSessionID, WorkspaceID: "ws-1"}, + LastActivityAt: lastViewed, + Status: "active", + }, + "", + nil, + nil, + map[string]string{rawSessionID: lastViewed}, + shellActivityFocus{}, + ) + + if strings.Contains(line, "new") { + t.Fatalf("expected viewed state to use raw session ID and suppress new marker, got %q", line) + } + if !strings.Contains(line, sanitizeUIText(rawSessionID)) { + t.Fatalf("expected sanitized session ID in %q", line) + } +} + func TestSessionQuickSwitcherIncludesCanonicalSessionMetadata(t *testing.T) { m := newShellModel() + rawSessionID := " session-1\nspoof\r\x1b[31m" + rawWorkspaceID := " ws-1\nspoof\r\x1b[31m" + rawActivityKind := " token=secret123\nrun_progress\x00" m.applySessionWorkspaceLoaded(sessionWorkspaceLoadedMsg{sessions: []brokerapi.SessionSummary{{ - Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, + Identity: brokerapi.SessionIdentity{SessionID: rawSessionID, WorkspaceID: rawWorkspaceID}, LastActivityAt: "2026-01-03T00:00:00Z", - LastActivityKind: "run_progress", + LastActivityKind: rawActivityKind, LastActivityPreview: "preview", HasIncompleteTurn: false, LinkedRunCount: 1, LinkedApprovalCount: 0, Status: "active", }}}) + m.watch.projection.Activity.Active = shellActivityFocus{Kind: "session", ID: rawSessionID} m.sessions = m.sessions.Open(m.sessionItems) v := m.renderSessionQuickSwitcher() - for _, want := range []string{"session-1", "ws=ws-1", "activity=2026-01-03T00:00:00Z/run_progress", "runs=1 approvals=0"} { + for _, want := range []string{"● " + sanitizeUIText(rawSessionID), "Workspace " + sanitizeUIText(rawWorkspaceID), "Recent activity " + sanitizeUIText(rawActivityKind), "1 run"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in switcher view %q", want, v) } } + for _, unsafe := range []string{"\nspoof", "\r", "\x1b", "secret123"} { + if strings.Contains(v, unsafe) { + t.Fatalf("expected quick switcher view to exclude %q, got %q", unsafe, v) + } + } } func TestSessionQuickSwitcherBoundsMatchListWithGapMarkers(t *testing.T) { @@ -92,3 +153,26 @@ func TestSessionQuickSwitcherBoundsMatchListWithGapMarkers(t *testing.T) { t.Fatalf("expected bounded render to omit at least one edge row, got %q", v) } } + +func TestSessionSwitcherFilteringUsesCachedNormalizedSessionText(t *testing.T) { + sessions := []brokerapi.SessionSummary{ + {Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, LastActivityKind: "chat_message", LastActivityPreview: "alpha preview"}, + {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-2"}, LastActivityKind: "run_progress", LastActivityPreview: "Needs Follow-Up"}, + } + m := newSessionSwitcherModel().Open(sessions) + if got := len(m.normalizedSessions); got != len(sessions) { + t.Fatalf("expected %d cached normalized sessions, got %d", len(sessions), got) + } + + m.query = "WS-2" + m.rebuildMatches() + if len(m.matches) != 1 || m.matches[0].Identity.SessionID != "session-2" { + t.Fatalf("expected workspace match for session-2, got %+v", m.matches) + } + + m.query = "FOLLOW-UP" + m.rebuildMatches() + if len(m.matches) != 1 || m.matches[0].Identity.SessionID != "session-2" { + t.Fatalf("expected preview match for session-2, got %+v", m.matches) + } +} diff --git a/cmd/runecode-tui/shared_components.go b/cmd/runecode-tui/shared_components.go index 6c64b955..a4562ba9 100644 --- a/cmd/runecode-tui/shared_components.go +++ b/cmd/runecode-tui/shared_components.go @@ -23,6 +23,18 @@ type boundedListSpec struct { ActiveFill bool } +type boundedListWindowedSpec struct { + TotalRows int + SelectedRow int + Width int + Height int + GapMarker string + Empty string + ApplySelected bool + ActiveFill bool + RenderRow func(int) boundedListRow +} + type inspectorContentKind string const ( @@ -92,7 +104,37 @@ func renderBoundedList(spec boundedListSpec) string { } selectedRow := boundedListSelectedRow(rows, spec.Selected) - windowStart, windowEnd, showTopGap, showBottomGap := boundedListWindow(len(rows), selectedRow, spec.Height) + return renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: len(rows), + SelectedRow: selectedRow, + Width: spec.Width, + Height: spec.Height, + GapMarker: spec.GapMarker, + Empty: spec.Empty, + ApplySelected: spec.ApplySelected, + ActiveFill: spec.ActiveFill, + RenderRow: func(index int) boundedListRow { + return rows[index] + }, + }) +} + +func renderBoundedListWindowed(spec boundedListWindowedSpec) string { + if spec.TotalRows <= 0 { + empty := strings.TrimSpace(spec.Empty) + if empty == "" { + empty = "no items" + } + return renderStateCard(routeLoadStateEmpty, titleOrFallback(spec.Empty, "Directory"), empty) + } + if spec.RenderRow == nil { + return "" + } + selectedRow := spec.SelectedRow + if selectedRow >= 0 { + selectedRow = clampBoundedListSelectedRow(selectedRow, spec.TotalRows) + } + windowStart, windowEnd, showTopGap, showBottomGap := boundedListWindow(spec.TotalRows, selectedRow, spec.Height) gap := strings.TrimSpace(spec.GapMarker) if gap == "" { gap = "..." @@ -103,8 +145,9 @@ func renderBoundedList(spec boundedListSpec) string { lines = append(lines, clipBoundedListText(gap, spec.Width)) } for rowIdx := windowStart; rowIdx < windowEnd; rowIdx++ { - line := clipBoundedListText(rows[rowIdx].Text, spec.Width) - line = renderSelectableRow(line, spec.Width, spec.ApplySelected && rowIdx == selectedRow && rows[rowIdx].Selectable, spec.ActiveFill && rows[rowIdx].Active) + row := spec.RenderRow(rowIdx) + line := clipBoundedListText(row.Text, spec.Width) + line = renderSelectableRow(line, spec.Width, spec.ApplySelected && rowIdx == selectedRow && row.Selectable, spec.ActiveFill && row.Active) lines = append(lines, line) } if showBottomGap { @@ -221,17 +264,7 @@ func boundedListWindowStart(totalRows int, selectedRow int, dataSlots int) int { } func clipBoundedListText(text string, width int) string { - if width <= 0 { - return text - } - runes := []rune(text) - if len(runes) <= width { - return text - } - if width <= 3 { - return string(runes[:width]) - } - return string(runes[:width-3]) + "..." + return clipDisplayText(text, width) } func renderInspectorHeader(title string, badges ...string) string { @@ -385,33 +418,6 @@ func renderInspectorDetailViewport(spec inspectorShellSpec) []string { return []string{tableHeader("Detail viewport"), "Long-form " + contentLabel + ":", renderedContent} } -func renderStateCard(state routeLoadState, title, message string) string { - label := string(state) - if label == "" { - label = string(routeLoadStateReady) - } - if strings.TrimSpace(message) == "" { - message = "n/a" - } - headline := tableHeader(strings.ToUpper(label)) + " " + strings.TrimSpace(title) - detail := appTheme.SurfaceCard.Padding(0, 1).Render(strings.TrimSpace(message)) - next := muted(stateCardNextStep(state)) - return compactLines(headline, detail, next) -} - -func stateCardNextStep(state routeLoadState) string { - switch state { - case routeLoadStateLoading: - return "Waiting for the broker response to settle." - case routeLoadStateError: - return "Use the route reload shortcut to try again." - case routeLoadStateEmpty: - return "No matching records are available for the current route state." - default: - return "" - } -} - func titleOrFallback(title string, fallback string) string { title = strings.TrimSpace(title) if title == "" { diff --git a/cmd/runecode-tui/shared_components_test.go b/cmd/runecode-tui/shared_components_test.go index 07499f17..64c34d55 100644 --- a/cmd/runecode-tui/shared_components_test.go +++ b/cmd/runecode-tui/shared_components_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "testing" @@ -216,3 +217,56 @@ func TestLongFormDocumentStatePersistsViewportAndResetsOnDocumentChange(t *testi t.Fatalf("expected offset reset on document swap, got %q", got) } } + +func TestRenderStateCardSpecShowsConsistentReasonNextAndCueLines(t *testing.T) { + got := renderStateCardSpec(stateCardSpec{ + State: routeLoadStateApprovalRequired, + Title: "Approvals", + Message: "A policy gate is waiting on review.", + Reason: "Exact action approval is required before execution can continue.", + NextAction: "Review the request and decide when ready.", + ShortcutCue: ":open approvals", + RouteCue: "Approvals", + }) + mustContainAll(t, got, + "APPROVAL REQUIRED", + "Message: A policy gate is waiting on review.", + "Reason: Exact action approval is required before execution can continue.", + "Next: Review the request and decide when ready.", + "Shortcut: :open approvals • Route: Approvals", + ) +} + +func TestRenderStateCardProvidesDefaultNextStepForBlockedAndCompleted(t *testing.T) { + blocked := renderStateCard(routeLoadStateBlocked, "Run", "Execution cannot continue yet.") + completed := renderStateCard(routeLoadStateCompleted, "Run", "Execution finished and evidence is available.") + mustContainAll(t, blocked, + "BLOCKED", + "Next: Review the blocking reason and complete the required operator action before retrying.", + ) + mustContainAll(t, completed, + "COMPLETED", + "Next: Review the resulting artifacts, approvals, or audit evidence if you need more detail.", + ) +} + +func TestRenderBoundedListWindowedOnlyRendersVisibleRows(t *testing.T) { + calls := make([]int, 0, 4) + got := renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: 100, + SelectedRow: 50, + Height: 5, + GapMarker: "...", + RenderRow: func(index int) boundedListRow { + calls = append(calls, index) + return boundedListRow{Text: fmt.Sprintf("row-%d", index), Selectable: true} + }, + }) + wantCalls := []int{49, 50, 51} + if fmt.Sprint(calls) != fmt.Sprint(wantCalls) { + t.Fatalf("expected visible row renders %v, got %v", wantCalls, calls) + } + if strings.Contains(got, "row-0") || strings.Contains(got, "row-99") { + t.Fatalf("expected off-window rows omitted, got %q", got) + } +} diff --git a/cmd/runecode-tui/shared_state_card.go b/cmd/runecode-tui/shared_state_card.go new file mode 100644 index 00000000..17e51cac --- /dev/null +++ b/cmd/runecode-tui/shared_state_card.go @@ -0,0 +1,97 @@ +package main + +import "strings" + +type stateCardSpec struct { + State routeLoadState + Title string + Message string + Reason string + NextAction string + ShortcutCue string + RouteCue string + EvidenceCue string +} + +func renderStateCard(state routeLoadState, title, message string) string { + return renderStateCardSpec(stateCardSpec{State: state, Title: title, Message: message}) +} + +func renderStateCardSpec(spec stateCardSpec) string { + label := strings.TrimSpace(string(spec.State)) + if label == "" { + label = string(routeLoadStateReady) + } + title := titleOrFallback(spec.Title, "Status") + message := strings.TrimSpace(spec.Message) + if message == "" { + message = "n/a" + } + reason := strings.TrimSpace(spec.Reason) + if reason == "" { + reason = message + } + nextAction := strings.TrimSpace(spec.NextAction) + if nextAction == "" { + nextAction = stateCardNextStep(spec.State) + } + cues := stateCardCueLine(spec.ShortcutCue, spec.RouteCue) + lines := []string{compactLines("Message: "+message, "Reason: "+reason)} + if nextAction != "" { + lines = append(lines, "Next: "+nextAction) + } + if evidence := strings.TrimSpace(spec.EvidenceCue); evidence != "" { + lines = append(lines, muted("Evidence: "+evidence)) + } + if cues != "" { + lines = append(lines, muted(cues)) + } + return renderProductCard(productCardSpec{Tone: stateCardTone(spec.State), Title: strings.ToUpper(label) + " " + strings.TrimSpace(title), Badge: toneBadge(stateCardTone(spec.State), strings.ToUpper(label)), Lines: lines}) +} + +func stateCardTone(state routeLoadState) visualTone { + switch state { + case routeLoadStateReady, routeLoadStateCompleted: + return visualToneSuccess + case routeLoadStateWaiting, routeLoadStateApprovalRequired, routeLoadStateLoading, routeLoadStateEmpty: + return visualToneAttention + case routeLoadStateBlocked, routeLoadStateDegraded, routeLoadStateError: + return visualToneDanger + default: + return visualToneInfo + } +} + +func stateCardCueLine(shortcutCue, routeCue string) string { + parts := []string{} + if shortcut := strings.TrimSpace(shortcutCue); shortcut != "" { + parts = append(parts, "Shortcut: "+shortcut) + } + if route := strings.TrimSpace(routeCue); route != "" { + parts = append(parts, "Route: "+route) + } + return strings.Join(parts, " • ") +} + +func stateCardNextStep(state routeLoadState) string { + switch state { + case routeLoadStateLoading: + return "Waiting for the broker response to settle." + case routeLoadStateWaiting: + return "Wait for the broker-owned state to advance or inspect the linked route for evidence." + case routeLoadStateBlocked: + return "Review the blocking reason and complete the required operator action before retrying." + case routeLoadStateDegraded: + return "Inspect the degraded details and use Status or Audit to confirm current evidence posture." + case routeLoadStateError: + return "Use the route reload shortcut to try again." + case routeLoadStateCompleted: + return "Review the resulting artifacts, approvals, or audit evidence if you need more detail." + case routeLoadStateApprovalRequired: + return "Open Approvals and review the exact gated action before deciding." + case routeLoadStateEmpty: + return "No matching records are available for the current route state." + default: + return "" + } +} diff --git a/cmd/runecode-tui/shared_visual.go b/cmd/runecode-tui/shared_visual.go new file mode 100644 index 00000000..2a539b2e --- /dev/null +++ b/cmd/runecode-tui/shared_visual.go @@ -0,0 +1,142 @@ +package main + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type visualTone string + +const ( + visualToneNeutral visualTone = "neutral" + visualToneInfo visualTone = "info" + visualToneSuccess visualTone = "success" + visualToneAttention visualTone = "attention" + visualToneDanger visualTone = "danger" + visualToneCommand visualTone = "command" +) + +type productCardSpec struct { + Tone visualTone + Title string + Badge string + Lines []string +} + +func renderProductCard(spec productCardSpec) string { + title := strings.TrimSpace(spec.Title) + if title == "" { + title = "Status" + } + header := tableHeader(title) + if badge := strings.TrimSpace(spec.Badge); badge != "" { + header += " " + badge + } + lines := []string{header} + for _, line := range spec.Lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, line) + } + return renderAccentRail(spec.Tone, compactLines(lines...)) +} + +func renderAccentRail(tone visualTone, body string) string { + body = strings.TrimRight(body, "\n") + if strings.TrimSpace(body) == "" { + return "" + } + rail := toneAccentStyle(tone).Render("│") + lines := strings.Split(body, "\n") + for i, line := range lines { + lines[i] = " " + rail + " " + line + } + return strings.Join(lines, "\n") +} + +func toneAccentStyle(tone visualTone) lipgloss.Style { + switch tone { + case visualToneSuccess: + return lipgloss.NewStyle().Foreground(appTheme.StateSuccess.GetBackground()) + case visualToneAttention: + return lipgloss.NewStyle().Foreground(appTheme.StateWarn.GetBackground()) + case visualToneDanger: + return lipgloss.NewStyle().Foreground(appTheme.StateDanger.GetBackground()) + case visualToneCommand: + return lipgloss.NewStyle().Foreground(appTheme.Selected.GetForeground()) + case visualToneInfo: + return lipgloss.NewStyle().Foreground(appTheme.StateInfo.GetBackground()) + default: + return lipgloss.NewStyle().Foreground(appTheme.BorderStrong.GetForeground()) + } +} + +func toneBadge(tone visualTone, label string) string { + label = strings.TrimSpace(label) + if label == "" { + label = "STATUS" + } + switch tone { + case visualToneSuccess: + return successBadge(label) + case visualToneAttention: + return warnBadge(label) + case visualToneDanger: + return dangerBadge(label) + case visualToneCommand: + return infoBadge(label) + case visualToneInfo: + return infoBadge(label) + default: + return neutralBadge(label) + } +} + +func clipDisplayText(text string, width int) string { + if width <= 0 || lipgloss.Width(text) <= width { + return text + } + if width <= 3 { + return clipDisplayRunes(text, width) + } + limit := width - 3 + return clipDisplayRunes(text, limit) + "..." +} + +func clipDisplayRunes(text string, width int) string { + if width <= 0 { + return "" + } + var b strings.Builder + for _, r := range text { + candidate := b.String() + string(r) + if lipgloss.Width(candidate) > width { + break + } + b.WriteRune(r) + } + return b.String() +} + +func rightAlignMetadata(left, right string, width int) string { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + if right == "" || width <= 0 { + return left + } + renderedRight := muted(right) + leftWidth := lipgloss.Width(left) + rightWidth := lipgloss.Width(renderedRight) + if leftWidth+rightWidth+2 > width { + left = clipDisplayText(left, width-rightWidth-2) + leftWidth = lipgloss.Width(left) + } + gap := width - leftWidth - rightWidth + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + renderedRight +} diff --git a/cmd/runecode-tui/shell_action_metadata_test.go b/cmd/runecode-tui/shell_action_metadata_test.go new file mode 100644 index 00000000..641f0a9a --- /dev/null +++ b/cmd/runecode-tui/shell_action_metadata_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestHelpIncludesActionMetadataFromUnifiedDefinitions(t *testing.T) { + m := newShellModel() + m.width = 150 + m.height = 40 + cmd := m.commands.commands["shell.focus_main"] + cmd.HelpText = "focus main pane — custom help text" + cmd.LeaderPath = []string{"w", "m"} + cmd.LeaderGroup = "Workbench" + m.commands.Register(cmd) + m.actions = newShellActionGraph(m.routes, m.commands) + + help := renderHelp(m.keys, false, m.actions) + if !strings.Contains(help, "ctrl+p commands") { + t.Fatalf("expected compact footer help, got %q", help) + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + v := updated.(shellModel).View() + if !strings.Contains(v, "Move focus to main content") || strings.Contains(v, "custom help text") { + t.Fatalf("expected leader overlay to use compact action descriptions without long help text, got %q", v) + } +} diff --git a/cmd/runecode-tui/shell_activity_summary.go b/cmd/runecode-tui/shell_activity_summary.go new file mode 100644 index 00000000..c46275c6 --- /dev/null +++ b/cmd/runecode-tui/shell_activity_summary.go @@ -0,0 +1,47 @@ +package main + +import "strings" + +func humanFocusLabel(focus focusArea) string { + label := sanitizeUIText(focus.Label()) + if label == "" { + return "Unknown" + } + return strings.ToUpper(label[:1]) + label[1:] +} + +func humanShellActivitySummary(activity shellActivitySemantics) string { + switch activity.State { + case shellActivityStateLoading: + return "Loading broker state" + case shellActivityStateWaiting: + if target := strings.TrimSpace(humanShellActivityTarget(activity.Active)); target != "" { + return "Waiting on " + target + } + return "Waiting on broker follow-up" + case shellActivityStateRunning: + if target := strings.TrimSpace(humanShellActivityTarget(activity.Active)); target != "" { + return "Working on " + target + } + return "Work is in progress" + case shellActivityStateDegradedSync: + return "Sync needs attention" + default: + return "" + } +} + +func humanShellActivityTarget(active shellActivityFocus) string { + kind := strings.TrimSpace(active.Kind) + id := strings.TrimSpace(active.ID) + if kind == "" && id == "" { + return "" + } + if kind == "" { + return sanitizeUIText(id) + } + if id == "" { + return sanitizeUIText(kind) + } + return sanitizeUIText(kind) + " " + sanitizeUIText(id) +} diff --git a/cmd/runecode-tui/shell_chrome_test.go b/cmd/runecode-tui/shell_chrome_test.go new file mode 100644 index 00000000..997e5af1 --- /dev/null +++ b/cmd/runecode-tui/shell_chrome_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "strings" + "testing" +) + +func TestShellBottomStripSelectionHintUsesCtrlT(t *testing.T) { + m := newShellModel() + m.width = 150 + v := m.View() + if strings.Contains(v, "Broker-owned truth stays in Status, inspectors, and command discovery") { + t.Fatalf("expected footer diagnostic hint removed from always-visible chrome, got %q", v) + } +} + +func TestShellChromeShowsCalmWorkbenchSummary(t *testing.T) { + m := newShellModel() + m.width = 150 + m.height = 40 + m.activeSessionID = "session-1" + m.watch.projection.Activity = shellActivitySemantics{State: shellActivityStateRunning, Active: shellActivityFocus{Kind: "session", ID: "session-1"}} + + v := m.View() + for _, want := range []string{"ROUTE", "Sidebar focus", "Working on session session-1", "Action Center", "Approvals", "Commands ctrl+p/:", "Quick action: Quit RuneCode", "space leader", "tab focus"} { + if !strings.Contains(v, want) { + t.Fatalf("expected calm chrome cue %q in view, got %q", want, v) + } + } + for _, retired := range []string{"Local broker API only via broker local IPC; OS peer auth is broker-enforced where supported", "Next actions:", "No route composer or status actions for this screen."} { + if strings.Contains(v, retired) { + t.Fatalf("did not expect dense or retired chrome text %q in view, got %q", retired, v) + } + } +} diff --git a/cmd/runecode-tui/shell_command_mode.go b/cmd/runecode-tui/shell_command_mode.go index cf0c6af0..14ce61a4 100644 --- a/cmd/runecode-tui/shell_command_mode.go +++ b/cmd/runecode-tui/shell_command_mode.go @@ -66,7 +66,10 @@ func (s shellCommandModeState) RenderPrompt() string { errText := clampCommandModeDisplayText(s.error) if s.active { if strings.TrimSpace(errText) != "" { - return ":[input hidden] [error: " + errText + "]" + if draft == "" || !revealCommandDraftOnError(s.draft) { + return ": [error: " + errText + "]" + } + return ":" + draft + " [error: " + errText + "]" } return ":" + draft } @@ -76,6 +79,14 @@ func (s shellCommandModeState) RenderPrompt() string { return "" } +func revealCommandDraftOnError(draft string) bool { + draft = strings.TrimSpace(draft) + if draft == "" { + return false + } + return strings.Contains(draft, " ") +} + func clampCommandModeDisplayText(text string) string { text = sanitizeUIText(strings.TrimSpace(text)) if len(text) <= commandModeDisplayLimit { diff --git a/cmd/runecode-tui/shell_commands.go b/cmd/runecode-tui/shell_commands.go index 03353c6e..fcc63f26 100644 --- a/cmd/runecode-tui/shell_commands.go +++ b/cmd/runecode-tui/shell_commands.go @@ -89,10 +89,11 @@ func moveShellFocus(m *shellModel, next bool) { if m.focus == focusPalette { m.setFocus(m.overlayReturn) } + layout := m.focusTraversalLayout() if next { - m.focusManager.Next(m.planShellLayout(m.activeShellSurface()), m.commandOverlayOpen()) + m.focusManager.Next(layout, m.commandOverlayOpen()) } else { - m.focusManager.Prev(m.planShellLayout(m.activeShellSurface()), m.commandOverlayOpen()) + m.focusManager.Prev(layout, m.commandOverlayOpen()) } m.focus = m.focusManager.Current() } @@ -125,7 +126,7 @@ func openPaletteOverlay(m *shellModel) { m.narrowInspectOn = false m.beginOverlaySession() m.sessions = m.sessions.Close() - m.palette = m.palette.UpdateEntries(m.buildPaletteEntries()).Open() + m.palette = m.palette.UpdateEntries(m.paletteImmediateEntries()).Open() m.setFocus(focusPalette) m.syncOverlayStack() } diff --git a/cmd/runecode-tui/shell_compositor.go b/cmd/runecode-tui/shell_compositor.go index 29820b48..441c1869 100644 --- a/cmd/runecode-tui/shell_compositor.go +++ b/cmd/runecode-tui/shell_compositor.go @@ -40,8 +40,8 @@ func renderShellPane(spec shellPaneSpec) string { if spec.Focused { header = appTheme.FocusLine.Render(title) + " " + infoBadge("FOCUS") } - body := strings.TrimSpace(spec.Body) - if body == "" { + body := strings.Trim(spec.Body, "\n") + if strings.TrimSpace(body) == "" { body = "(empty pane)" } diff --git a/cmd/runecode-tui/shell_contracts.go b/cmd/runecode-tui/shell_contracts.go index f4075e07..175ae551 100644 --- a/cmd/runecode-tui/shell_contracts.go +++ b/cmd/runecode-tui/shell_contracts.go @@ -110,10 +110,15 @@ type shellWorkbenchLocation struct { type routeLoadState string const ( - routeLoadStateReady routeLoadState = "ready" - routeLoadStateLoading routeLoadState = "loading" - routeLoadStateEmpty routeLoadState = "empty" - routeLoadStateError routeLoadState = "error" + routeLoadStateReady routeLoadState = "ready" + routeLoadStateLoading routeLoadState = "loading" + routeLoadStateWaiting routeLoadState = "waiting" + routeLoadStateEmpty routeLoadState = "empty" + routeLoadStateBlocked routeLoadState = "blocked" + routeLoadStateDegraded routeLoadState = "degraded" + routeLoadStateError routeLoadState = "error" + routeLoadStateCompleted routeLoadState = "completed" + routeLoadStateApprovalRequired routeLoadState = "approval required" ) func combineLegacyRouteView(surface routeSurface) string { diff --git a/cmd/runecode-tui/shell_focus_overlay.go b/cmd/runecode-tui/shell_focus_overlay.go index 5b1de183..ba7a584e 100644 --- a/cmd/runecode-tui/shell_focus_overlay.go +++ b/cmd/runecode-tui/shell_focus_overlay.go @@ -21,7 +21,7 @@ func (m *shellModel) setFocus(area focusArea) { } func (m *shellModel) normalizeFocusForLayout() { - layout := m.planShellLayout(m.activeShellSurface()) + layout := m.focusTraversalLayout() m.focusManager.Normalize(layout, m.commandOverlayOpen()) m.focus = m.focusManager.Current() } @@ -30,7 +30,7 @@ func (m *shellModel) restoreFocusAfterOverlayClose() { if m.overlayOpen() { return } - layout := m.planShellLayout(m.activeShellSurface()) + layout := m.focusTraversalLayout() m.focusManager.Set(m.overlayReturn) m.focusManager.Normalize(layout, false) m.focus = m.focusManager.Current() diff --git a/cmd/runecode-tui/shell_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index b3c729b0..e03e73d3 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -1,35 +1,43 @@ package main -import ( - "fmt" - "strings" -) +import "strings" func (m shellModel) renderBottomStrip(surface routeSurface) string { - bottom := "" + routeKeys := strings.TrimSpace(surface.Regions.Bottom.Body) + commandLine := m.renderBottomCommandLine() + width := m.width + if width <= 0 { + width = 120 + } + return constrainShellBlock(routeKeys, width, 1) + "\n" + commandLine +} + +func (m shellModel) renderStatusSurface(surface routeSurface) string { + parts := []string{} + if toast := strings.TrimSpace(m.toasts.Latest()); toast != "" { + parts = append(parts, "Toast: "+sanitizeUIText(toast)) + } + if status := strings.TrimSpace(surface.Regions.Status.Body); status != "" { + parts = append(parts, status) + } + return strings.Join(parts, " • ") +} + +func (m shellModel) renderBottomCommandLine() string { if m.emergencyQuit.pending { - bottom = "Emergency quit armed — press ctrl+c once more to quit." - } else { - bottom = strings.TrimSpace(m.commandMode.RenderPrompt()) + return "Emergency quit armed: press ctrl+c once more to quit." } - if bottom == "" { - bottom = strings.TrimSpace(surface.Regions.Bottom.Body) + if prompt := strings.TrimSpace(m.commandMode.RenderPrompt()); prompt != "" { + return prompt } - if bottom == "" { - bottom = muted("No route composer or status actions for this screen.") + parts := []string{"ctrl+p commands", ": command", m.keys.LeaderStart.label() + " leader", "tab focus"} + if m.width > 0 && m.width < shellMediumMinWidth { + parts = []string{"^P commands", ": command", m.keys.LeaderStart.label() + " leader", "tab focus"} } - selectionHint := "Selection mode off; mouse capture remains enabled." - if m.selectionMode { - selectionHint = "Selection mode on; drag-to-select is enabled until you exit it." + if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { + parts = append(parts, quit) } - selectionHint = m.renderQuitDiscoverabilityHint() + " | " + selectionHint - return compactLines( - tableHeader("Bottom strip"), - bottom, - m.renderRouteActionHints(surface), - m.renderRouteCopyActions(), - selectionHint, - ) + return strings.Join(parts, " • ") } func (m shellModel) renderQuitDiscoverabilityHint() string { @@ -41,53 +49,8 @@ func (m shellModel) renderQuitDiscoverabilityHint() string { if title := strings.TrimSpace(action.Title); title != "" { label = title } - return "Quick action: " + label + " (:quit)" -} - -func (m shellModel) renderRouteActionHints(surface routeSurface) string { - parts := []string{} - if len(surface.Actions.ReferenceActions) > 0 { - parts = append(parts, fmt.Sprintf("Linked refs actionable=%d", len(surface.Actions.ReferenceActions))) + if m.width > 0 && m.width < shellMediumMinWidth { + return "Quit :quit" } - if len(surface.Actions.LocalActions) > 0 { - parts = append(parts, fmt.Sprintf("Local actions executable=%d", len(surface.Actions.LocalActions))) - } - if len(parts) == 0 { - return muted("Actionable refs/actions: none for the current view") - } - return "Actionable refs/actions: " + strings.Join(parts, " | ") -} - -func (m shellModel) renderStatusSurface(surface routeSurface) string { - status := strings.TrimSpace(surface.Regions.Status.Body) - if status == "" { - status = fmt.Sprintf("route=%s", m.routeLabel(m.currentRouteID())) - } - selection := "selection=off" - if m.selectionMode { - selection = "selection=on" - } - return "Status: " + status + " | " + selection + " | clipboard=" + sanitizeUIText(m.clipboard.IntegrationHint()) -} - -func (m shellModel) renderRouteCopyActions() string { - actions := m.activeShellSurface().Actions.CopyActions - if len(actions) == 0 { - return muted("Copy actions: none (use terminal selection for long-form text).") - } - items := make([]string, 0, len(actions)) - for i, action := range actions { - label := strings.TrimSpace(action.Label) - if label == "" { - label = strings.TrimSpace(action.ID) - } - if label == "" { - label = fmt.Sprintf("copy-%d", i+1) - } - if i == m.copyActionIndex { - label = "[next:" + label + "]" - } - items = append(items, label) - } - return "Copy actions (use action entry): " + strings.Join(items, " | ") + return "Quick action: " + label + " (:quit)" } diff --git a/cmd/runecode-tui/shell_input.go b/cmd/runecode-tui/shell_input.go index d835b2c2..3fa8db2c 100644 --- a/cmd/runecode-tui/shell_input.go +++ b/cmd/runecode-tui/shell_input.go @@ -6,6 +6,16 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +func (m *shellModel) refreshLeaderBindingsIfNeeded() { + bindings := m.actions.leaderBindings(*m) + signature := shellLeaderBindingsSignature(bindings) + if signature == m.leaderBindingsSignature && len(m.leader.bindings) > 0 { + return + } + m.leader.Rebind(bindings) + m.leaderBindingsSignature = signature +} + func (m shellModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { for _, handler := range []func(tea.KeyMsg) (tea.Model, tea.Cmd, bool){ m.handleCommandModeActiveKey, @@ -38,8 +48,9 @@ func (m shellModel) handleOpenLeaderKey(key tea.KeyMsg) (tea.Model, tea.Cmd, boo if !m.shellPowerKeysAllowed() { return m, nil, false } - m.leader.Rebind(m.actions.leaderBindings(m)) + m.refreshLeaderBindingsIfNeeded() m.beginOverlaySession() + m.invalidateOverlayFrameCache() m.leader.Start() m.setFocus(focusPalette) m.syncOverlayStack() @@ -52,6 +63,7 @@ func (m shellModel) handleLeaderActiveKey(key tea.KeyMsg) (tea.Model, tea.Cmd, b } if key.Type == tea.KeyEsc { m.leader.Abort() + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() m.toasts.Push(toastInfo, "Leader mode aborted.") @@ -61,6 +73,7 @@ func (m shellModel) handleLeaderActiveKey(key tea.KeyMsg) (tea.Model, tea.Cmd, b if !ok { prior := append([]string(nil), m.leader.prefix...) m.leader.Abort() + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() m.toasts.Push(toastWarn, formatLeaderInvalidKeyMessage(prior, key.String())) @@ -70,6 +83,7 @@ func (m shellModel) handleLeaderActiveKey(key tea.KeyMsg) (tea.Model, tea.Cmd, b action, complete := m.leader.Step(token) if !complete { if !m.leader.Active() { + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() m.toasts.Push(toastWarn, formatLeaderInvalidKeyMessage(prior, token)) @@ -78,6 +92,7 @@ func (m shellModel) handleLeaderActiveKey(key tea.KeyMsg) (tea.Model, tea.Cmd, b m.syncOverlayStack() return m, nil, true } + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() updated, cmd := m.applyPaletteAction(action) @@ -91,14 +106,17 @@ func (m shellModel) handleOpenPaletteKey(key tea.KeyMsg) (tea.Model, tea.Cmd, bo if !m.shellPowerKeysAllowed() { return m, nil, false } + m.invalidateOverlayFrameCache() m.narrowSidebarOn = false m.narrowInspectOn = false m.beginOverlaySession() + m.invalidateOverlayFrameCache() m.sessions = m.sessions.Close() - m.palette = m.palette.UpdateEntries(m.buildPaletteEntries()).Open() + palette, request := m.palette.UpdateEntries(m.paletteImmediateEntries()).Open().BeginEntriesRefresh() + m.palette = palette m.setFocus(focusPalette) m.syncOverlayStack() - return m, nil, true + return m, m.loadPaletteEntriesCmd(request), true } func (m shellModel) handleOpenSessionQuickSwitchKey(key tea.KeyMsg) (tea.Model, tea.Cmd, bool) { @@ -108,9 +126,11 @@ func (m shellModel) handleOpenSessionQuickSwitchKey(key tea.KeyMsg) (tea.Model, if !m.shellPowerKeysAllowed() { return m, nil, false } + m.invalidateOverlayFrameCache() m.narrowSidebarOn = false m.narrowInspectOn = false m.beginOverlaySession() + m.invalidateOverlayFrameCache() m.palette = m.palette.Close() m.sessions = m.sessions.Open(m.sessionItems) m.setFocus(focusPalette) @@ -133,6 +153,7 @@ func (m shellModel) handleToggleSidebarKey(key tea.KeyMsg) (tea.Model, tea.Cmd, } if m.breakpoint() == shellBreakpointNarrow { m.beginOverlaySession() + m.invalidateOverlayFrameCache() m.narrowSidebarOn = !m.narrowSidebarOn if m.narrowSidebarOn { m.narrowInspectOn = false @@ -144,6 +165,7 @@ func (m shellModel) handleToggleSidebarKey(key tea.KeyMsg) (tea.Model, tea.Cmd, m.toasts.Push(toastInfo, "Sidebar overlay toggled for narrow layout.") return m, nil, true } + m.invalidateOverlayFrameCache() m.sidebarVisible = !m.sidebarVisible m.sidebarFolded = false m.normalizeFocusForLayout() @@ -236,6 +258,7 @@ func (m shellModel) handleLayoutToggleInspectorCollapseKey(key tea.KeyMsg) (tea. } if m.breakpoint() == shellBreakpointNarrow { m.beginOverlaySession() + m.invalidateOverlayFrameCache() return m.toggleNarrowInspectorOverlay(), nil, true } cmd := m.commands.Execute("shell.layout.toggle_inspector_collapse", &m) @@ -270,8 +293,10 @@ func (m shellModel) handleEscapeCloseNarrowOverlaysKey(key tea.KeyMsg) (tea.Mode if key.Type != tea.KeyEsc || (!m.narrowSidebarOn && !m.narrowInspectOn) { return m, nil, false } + m.invalidateOverlayFrameCache() m.narrowSidebarOn = false m.narrowInspectOn = false + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() return m, nil, true @@ -300,7 +325,7 @@ func (m shellModel) handleCycleFocusNextKey(key tea.KeyMsg) (tea.Model, tea.Cmd, if !m.shellFocusTraversalAllowed() { return m, nil, false } - m.focusManager.Next(m.planShellLayout(m.activeShellSurface()), m.commandOverlayOpen()) + m.focusManager.Next(m.focusTraversalLayout(), m.commandOverlayOpen()) m.focus = m.focusManager.Current() return m, nil, true } @@ -312,7 +337,7 @@ func (m shellModel) handleCycleFocusPrevKey(key tea.KeyMsg) (tea.Model, tea.Cmd, if !m.shellFocusTraversalAllowed() { return m, nil, false } - m.focusManager.Prev(m.planShellLayout(m.activeShellSurface()), m.commandOverlayOpen()) + m.focusManager.Prev(m.focusTraversalLayout(), m.commandOverlayOpen()) m.focus = m.focusManager.Current() return m, nil, true } diff --git a/cmd/runecode-tui/shell_layout.go b/cmd/runecode-tui/shell_layout.go index 8093be4e..de2008d3 100644 --- a/cmd/runecode-tui/shell_layout.go +++ b/cmd/runecode-tui/shell_layout.go @@ -10,12 +10,12 @@ type shellLayoutPlan struct { const ( shellTopStatusHeight = 2 shellSyncHealthHeight = 1 - shellBreadcrumbHeight = 1 - shellHistoryHeight = 1 + shellBreadcrumbHeight = 0 + shellHistoryHeight = 0 shellPaneSpacerHeight = 1 - shellBottomStripHeight = 5 + shellBottomStripHeight = 2 shellStatusHeight = 1 - shellFooterHeight = 3 + shellFooterHeight = 1 ) func shellChromeReservedHeight() int { @@ -66,7 +66,7 @@ func (m shellModel) planShellRegions(viewportWidth int, viewportHeight int, brea mainWidth = viewportWidth } - paneFrameHeight := viewportHeight - shellChromeReservedHeight() - 2 + paneFrameHeight := viewportHeight - shellChromeReservedHeight() if modeTabsVisible { paneFrameHeight-- } diff --git a/cmd/runecode-tui/shell_layout_test.go b/cmd/runecode-tui/shell_layout_test.go index 344ce9d0..d5634c23 100644 --- a/cmd/runecode-tui/shell_layout_test.go +++ b/cmd/runecode-tui/shell_layout_test.go @@ -131,7 +131,7 @@ func TestShellLayoutPlannerBudgetsMainHeightFromShellChrome(t *testing.T) { m.height = 40 plan := m.planShellLayout(routeSurface{}) - want := 40 - shellChromeReservedHeight() - 2 + want := 40 - shellChromeReservedHeight() if got := plan.Regions.Main.Height; got != want { t.Fatalf("expected main height=%d from viewport-shell chrome budget, got %d", want, got) } @@ -149,7 +149,7 @@ func TestShellLayoutPlannerBudgetsModeTabsAsVerticalChrome(t *testing.T) { m.height = 40 plan := m.planShellLayout(routeSurface{Actions: routeSurfaceActions{ModeTabs: []string{"rendered", "raw"}}}) - want := 40 - shellChromeReservedHeight() - 2 - 1 + want := 40 - shellChromeReservedHeight() - 1 if got := plan.Regions.Main.Height; got != want { t.Fatalf("expected mode tabs to reserve one line and main height=%d, got %d", want, got) } diff --git a/cmd/runecode-tui/shell_leader.go b/cmd/runecode-tui/shell_leader.go index 6eb6844a..f63c1e5d 100644 --- a/cmd/runecode-tui/shell_leader.go +++ b/cmd/runecode-tui/shell_leader.go @@ -23,16 +23,26 @@ type shellLeaderChoice struct { Completes bool } +type leaderChoiceAggregate struct { + label string + description string + completes bool +} + +const leaderPrefixSeparator = "::" + type shellLeaderState struct { - active bool - prefix []string - bindings []shellLeaderBinding - choices []shellLeaderChoice + active bool + prefix []string + bindings []shellLeaderBinding + choices []shellLeaderChoice + exactActions map[string]paletteActionMsg + choicesByPrefix map[string][]shellLeaderChoice } func newShellLeaderState(bindings []shellLeaderBinding) shellLeaderState { - state := shellLeaderState{bindings: append([]shellLeaderBinding(nil), bindings...)} - state.choices = state.choicesForPrefix(nil) + state := shellLeaderState{} + state.Rebind(bindings) return state } @@ -43,25 +53,28 @@ func (s shellLeaderState) Active() bool { func (s *shellLeaderState) Start() { s.active = true s.prefix = nil - s.choices = s.choicesForPrefix(nil) + s.choices = cloneLeaderChoices(s.choicesByPrefix[""]) } func (s *shellLeaderState) Rebind(bindings []shellLeaderBinding) { s.bindings = append([]shellLeaderBinding(nil), bindings...) + s.exactActions, s.choicesByPrefix = buildShellLeaderIndexes(s.bindings) if s.active { - s.choices = s.choicesForPrefix(s.prefix) - if len(s.choices) == 0 { + choices := cloneLeaderChoices(s.choicesByPrefix[leaderPrefixKey(s.prefix)]) + if len(choices) == 0 { s.Abort() + return } + s.choices = choices return } - s.choices = s.choicesForPrefix(nil) + s.choices = cloneLeaderChoices(s.choicesByPrefix[""]) } func (s *shellLeaderState) Abort() { s.active = false s.prefix = nil - s.choices = s.choicesForPrefix(nil) + s.choices = cloneLeaderChoices(s.choicesByPrefix[""]) } func (s *shellLeaderState) Step(token string) (paletteActionMsg, bool) { @@ -71,13 +84,11 @@ func (s *shellLeaderState) Step(token string) (paletteActionMsg, bool) { return paletteActionMsg{}, false } nextPrefix := append(append([]string(nil), s.prefix...), token) - for _, binding := range s.bindings { - if sequenceEqual(binding.Sequence, nextPrefix) { - s.Abort() - return binding.Action, true - } + if action, ok := s.exactActions[leaderPrefixKey(nextPrefix)]; ok { + s.Abort() + return action, true } - choices := s.choicesForPrefix(nextPrefix) + choices := cloneLeaderChoices(s.choicesByPrefix[leaderPrefixKey(nextPrefix)]) if len(choices) == 0 { s.Abort() return paletteActionMsg{}, false @@ -100,81 +111,124 @@ func (s shellLeaderState) Choices() []shellLeaderChoice { return out } -func (s shellLeaderState) choicesForPrefix(prefix []string) []shellLeaderChoice { - type agg struct { - label string - description string - completes bool +func buildShellLeaderIndexes(bindings []shellLeaderBinding) (map[string]paletteActionMsg, map[string][]shellLeaderChoice) { + exactActions := map[string]paletteActionMsg{} + choicesByPrefix := map[string]map[string]leaderChoiceAggregate{"": {}} + for _, binding := range bindings { + registerLeaderBindingIndexes(binding, exactActions, choicesByPrefix) } - byKey := map[string]agg{} - for _, binding := range s.bindings { - if len(binding.Sequence) <= len(prefix) || !sequenceHasPrefix(binding.Sequence, prefix) { - continue - } - next := binding.Sequence[len(prefix)] - existing := byKey[next] - candidate := agg{ - label: firstNonEmpty(binding.Group, binding.Label), - description: binding.Description, - completes: len(binding.Sequence) == len(prefix)+1, - } - if strings.TrimSpace(existing.label) == "" { - byKey[next] = candidate - continue + choices := make(map[string][]shellLeaderChoice, len(choicesByPrefix)) + for prefixKey, byKey := range choicesByPrefix { + keys := make([]string, 0, len(byKey)) + for key := range byKey { + keys = append(keys, key) } - if existing.label != candidate.label { - existing.label = "(group)" + sort.Strings(keys) + entries := make([]shellLeaderChoice, 0, len(keys)) + for _, key := range keys { + entry := byKey[key] + entries = append(entries, shellLeaderChoice{Key: key, Label: entry.label, Description: entry.description, Completes: entry.completes}) } - if existing.description != candidate.description { - existing.description = "multiple actions" - } - existing.completes = existing.completes || candidate.completes - byKey[next] = existing + choices[prefixKey] = entries } - keys := make([]string, 0, len(byKey)) - for key := range byKey { - keys = append(keys, key) + if _, ok := choices[""]; !ok { + choices[""] = nil } - sort.Strings(keys) - choices := make([]shellLeaderChoice, 0, len(keys)) - for _, key := range keys { - entry := byKey[key] - choices = append(choices, shellLeaderChoice{Key: key, Label: entry.label, Description: entry.description, Completes: entry.completes}) + return exactActions, choices +} + +func registerLeaderBindingIndexes(binding shellLeaderBinding, exactActions map[string]paletteActionMsg, choicesByPrefix map[string]map[string]leaderChoiceAggregate) { + sequence := normalizeLeaderSequence(binding.Sequence) + if len(sequence) == 0 { + return + } + exactActions[leaderPrefixKey(sequence)] = binding.Action + for i := 0; i < len(sequence); i++ { + prefixKey := leaderPrefixKey(sequence[:i]) + next := sequence[i] + choicesByPrefix[prefixKey] = mergeLeaderChoiceAggregate(choicesByPrefix[prefixKey], next, binding, len(sequence) == i+1) } - return choices } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if text := strings.TrimSpace(value); text != "" { - return text - } +func mergeLeaderChoiceAggregate(byKey map[string]leaderChoiceAggregate, next string, binding shellLeaderBinding, completes bool) map[string]leaderChoiceAggregate { + if byKey == nil { + byKey = map[string]leaderChoiceAggregate{} } - return "" + existing := byKey[next] + candidate := leaderChoiceAggregate{ + label: firstNonEmpty(binding.Group, binding.Label), + description: binding.Description, + completes: completes, + } + if strings.TrimSpace(existing.label) == "" { + byKey[next] = candidate + return byKey + } + if existing.label != candidate.label { + existing.label = "(group)" + } + if existing.description != candidate.description { + existing.description = "multiple actions" + } + existing.completes = existing.completes || candidate.completes + byKey[next] = existing + return byKey } -func sequenceHasPrefix(sequence []string, prefix []string) bool { - if len(prefix) > len(sequence) { - return false +func cloneLeaderChoices(in []shellLeaderChoice) []shellLeaderChoice { + if len(in) == 0 { + return nil } - for i := range prefix { - if normalizeLeaderToken(sequence[i]) != normalizeLeaderToken(prefix[i]) { - return false + out := make([]shellLeaderChoice, len(in)) + copy(out, in) + return out +} + +func normalizeLeaderSequence(sequence []string) []string { + out := make([]string, 0, len(sequence)) + for _, token := range sequence { + if normalized := normalizeLeaderToken(token); normalized != "" { + out = append(out, normalized) } } - return true + return out } -func sequenceEqual(a []string, b []string) bool { - if len(a) != len(b) { - return false +func leaderPrefixKey(prefix []string) string { + if len(prefix) == 0 { + return "" } - for i := range a { - if normalizeLeaderToken(a[i]) != normalizeLeaderToken(b[i]) { - return false + return strings.Join(prefix, leaderPrefixSeparator) +} + +func shellLeaderBindingsSignature(bindings []shellLeaderBinding) string { + if len(bindings) == 0 { + return "" + } + parts := make([]string, 0, len(bindings)) + for _, binding := range bindings { + parts = append(parts, strings.Join([]string{ + strings.Join(normalizeLeaderSequence(binding.Sequence), "/"), + strings.TrimSpace(binding.Group), + strings.TrimSpace(binding.Label), + strings.TrimSpace(binding.Description), + string(binding.Action.Verb), + binding.Action.Target.Kind, + string(binding.Action.Target.RouteID), + strings.TrimSpace(binding.Action.Target.CommandID), + strings.Join(binding.Action.Target.CommandArgs, "/"), + }, "|")) + } + return strings.Join(parts, leaderPrefixSeparator) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if text := strings.TrimSpace(value); text != "" { + return text } } - return true + return "" } func normalizeLeaderToken(token string) string { diff --git a/cmd/runecode-tui/shell_model.go b/cmd/runecode-tui/shell_model.go index 7c933d5d..6f199d72 100644 --- a/cmd/runecode-tui/shell_model.go +++ b/cmd/runecode-tui/shell_model.go @@ -1,8 +1,6 @@ package main import ( - "os" - "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -15,6 +13,8 @@ const ( emergencyQuitArmWindow = 1500 * time.Millisecond ) +var forceMemoryWorkbenchState bool + type shellOverlayID string const ( @@ -85,6 +85,7 @@ type shellModel struct { workbenchScope string toasts shellToastService objectIndex shellDiscoverabilityIndex + paletteCache []paletteEntry sidebarVisible bool inspectorOn bool @@ -116,12 +117,21 @@ type shellModel struct { selectionMode bool copyActionIndex int - leader shellLeaderState - leaderKeyConfig string - leaderKeyInvalid string - commandMode shellCommandModeState - emergencyQuit shellEmergencyQuitState - quitConfirm shellQuitConfirmState + leader shellLeaderState + leaderBindingsSignature string + leaderKeyConfig string + leaderKeyInvalid string + commandMode shellCommandModeState + emergencyQuit shellEmergencyQuitState + quitConfirm shellQuitConfirmState + overlayFrameCache *shellOverlayFrameCache +} + +type shellOverlayFrameCache struct { + overlay shellOverlayID + width int + height int + frame string } type shellEmergencyQuitState struct { @@ -134,72 +144,12 @@ type shellQuitConfirmState struct { reason string } -func newShellModel() shellModel { - routes := shellRoutes() - models := newRouteModels(routes) - defaultRoute := routeChat - commands := defaultShellCommandRegistry() - actions := newShellActionGraph(routes, commands) - workbench := newDefaultWorkbenchStateStore() - binaryPath := strings.ToLower(strings.TrimSpace(os.Args[0])) - if strings.HasSuffix(binaryPath, ".test") || strings.HasSuffix(binaryPath, ".test.exe") { - workbench = &memoryWorkbenchStateStore{} - } - scope := logicalBrokerTargetKey() - initialState := workbenchLocalState{SidebarVisible: true, InspectorVisible: true, InspectorMode: presentationRendered, ThemePreset: themePresetDark, LastRouteID: defaultRoute, ViewedActivity: map[string]string{}, LastSessionByWS: map[string]string{}, SidebarPaneRatio: 0.22, InspectorPaneRatio: 0.30} - if existing := workbench.Read(scope); isZeroWorkbenchState(existing) { - workbench.Write(scope, initialState) - } - appTheme = newTheme(themePresetDark) - m := shellModel{ - keys: defaultShellKeyMap(), - routes: routes, - nav: newPrimaryNavModel(routes), - palette: newPaletteModel(nil), - sessions: newSessionSwitcherModel(), - focus: focusNav, - client: newLocalBrokerClient(), - focusManager: newShellFocusManager(focusNav), - overlayManager: shellOverlayManager{}, - commands: commands, - actions: actions, - clipboard: newShellClipboardService(), - workbench: workbench, - workbenchScope: scope, - toasts: newShellToastService(), - routeModels: models, - location: shellWorkbenchLocation{ - Primary: shellObjectLocation{RouteID: defaultRoute, Object: workbenchObjectRef{Kind: "route", ID: string(defaultRoute)}}, - }, - sidebarVisible: true, - inspectorOn: true, - themePreset: themePresetDark, - preferredMode: presentationRendered, - sidebarRatio: 0.22, - inspectorRatio: 0.30, - sessionLoading: true, - pinnedSessions: map[string]struct{}{}, - lastSessionByWS: map[string]string{}, - recentObjects: nil, - sessionWorkspace: map[string]string{}, - viewedActivity: map[string]string{}, - watch: newShellWatchManager(), - objectIndex: newShellDiscoverabilityIndex(routes), - overlayReturn: focusContent, - leader: newShellLeaderState(actions.leaderBindings(shellModel{})), - leaderKeyConfig: "space", - } - _ = m.setLeaderKey(m.leaderKeyConfig) - m.restoreWorkbenchState() - m.syncSidebarCursorToLocation() - return m -} - func (m shellModel) Init() tea.Cmd { return tea.Batch(m.activateCurrentRouteCmd(), m.loadSessionWorkspaceCmd(), m.loadObjectIndexCmd(), m.startWatchPollCmd(), m.mouseCaptureCmd()) } func (m shellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.prepareOverlayFrameCache(msg) m = m.disarmEmergencyQuitOnNormalInteraction(msg) updated, cmd, handled := m.handleQuitMessage(msg) m = updated.(shellModel) @@ -265,15 +215,21 @@ func (m *shellModel) publishShellPreferencesToCurrentRoute() { } func (m shellModel) activeShellSurface() routeSurface { + surface, _ := m.activeShellSurfacePlan() + return surface +} + +func (m shellModel) activeShellSurfacePlan() (routeSurface, shellLayoutPlan) { active := m.routeModels[m.currentRouteID()] if active == nil { - return routeSurface{ + surface := routeSurface{ Regions: routeSurfaceRegions{ Main: routeSurfaceRegion{Body: "Route not available"}, }, Capabilities: routeSurfaceCapabilities{}, Chrome: routeSurfaceChrome{Breadcrumbs: []string{"Home", string(m.currentRouteID())}}, } + return surface, m.planShellLayout(surface) } baseCtx := routeShellContext{Width: m.width, Height: m.availableShellHeight(), Focus: m.focus, Focused: m.focusedRouteRegion(), Breakpoint: m.breakpoint(), Render: routeShellRenderPreferences{PreferredPresentation: normalizePresentationMode(m.preferredMode), ThemePreset: normalizeThemePreset(m.themePreset)}} surface := active.ShellSurface(baseCtx) @@ -281,8 +237,8 @@ func (m shellModel) activeShellSurface() routeSurface { ctx := baseCtx ctx.Regions = layout.Regions ctx.Breakpoint = layout.Breakpoint - surface = active.ShellSurface(ctx) - return m.withLocationChrome(surface) + surface = m.withLocationChrome(active.ShellSurface(ctx)) + return surface, layout } func (m shellModel) availableShellHeight() int { @@ -300,6 +256,84 @@ func (m shellModel) availableShellHeight() int { return viewportHeight } +func (m shellModel) focusTraversalLayout() shellLayoutPlan { + active := m.routeModels[m.currentRouteID()] + if active == nil { + return m.planShellLayout(routeSurface{}) + } + ctx := routeShellContext{Width: m.width, Height: m.height, Focus: m.focus, Focused: m.focusedRouteRegion(), Breakpoint: m.breakpoint(), Render: routeShellRenderPreferences{PreferredPresentation: normalizePresentationMode(m.preferredMode), ThemePreset: normalizeThemePreset(m.themePreset)}} + return m.planShellLayout(active.ShellSurface(ctx)) +} + +func (m shellModel) activeOverlayID() shellOverlayID { + switch { + case m.palette.IsOpen(): + return overlayIDQuickJump + case m.sessions.IsOpen(): + return overlayIDSessions + case m.leader.Active(): + return overlayIDLeader + case m.quitConfirm.active: + return overlayIDQuitConfirm + case m.narrowSidebarOn && m.breakpoint() == shellBreakpointNarrow: + return overlayIDSidebar + case m.narrowInspectOn && m.breakpoint() == shellBreakpointNarrow: + return overlayIDInspector + default: + return "" + } +} + +func (m shellModel) overlayFrameCacheable() bool { + switch m.activeOverlayID() { + case overlayIDQuickJump, overlayIDSessions, overlayIDLeader, overlayIDQuitConfirm: + return true + default: + return false + } +} + +func (m *shellModel) invalidateOverlayFrameCache() { + if m.overlayFrameCache == nil { + return + } + *m.overlayFrameCache = shellOverlayFrameCache{} +} + +func (m *shellModel) invalidatePaletteCache() { + m.paletteCache = nil +} + +func (m shellModel) paletteImmediateEntries() []paletteEntry { + return m.buildPaletteCommandEntries() +} + +func (m shellModel) loadPaletteEntriesCmd(request uint64) tea.Cmd { + commands := append([]paletteEntry(nil), m.buildPaletteCommandEntries()...) + indexSnapshot := m.objectIndex.clone() + activeSurfaceEntries := append([]paletteEntry(nil), m.buildActiveSurfacePaletteEntries()...) + actionCenterEntries := append([]paletteEntry(nil), m.buildActionCenterPaletteEntries()...) + return func() tea.Msg { + entries := make([]paletteEntry, 0, len(commands)+len(activeSurfaceEntries)+len(actionCenterEntries)+64) + entries = append(entries, commands...) + entries = append(entries, activeSurfaceEntries...) + entries = append(entries, actionCenterEntries...) + entries = append(entries, buildPaletteDiscoverabilityEntries(indexSnapshot, len(entries)+1)...) + return shellPaletteEntriesLoadedMsg{request: request, entries: entries} + } +} + +func (m shellModel) loadPaletteFilterCmd(request uint64) tea.Cmd { + entriesVersion := m.palette.entriesVersion + query := m.palette.query + normalizedEntries := append([]string(nil), m.palette.normalizedEntries...) + priorNeedle := m.palette.appliedNeedle + priorMatches := append([]int(nil), m.palette.matchIndexes...) + return func() tea.Msg { + return buildPaletteFilterResult(request, entriesVersion, query, normalizedEntries, priorNeedle, priorMatches) + } +} + func (m shellModel) focusedRouteRegion() routeRegionFocus { if m.palette.IsOpen() || m.sessions.IsOpen() || m.leader.Active() || m.quitConfirm.active { return routeRegionOverlay diff --git a/cmd/runecode-tui/shell_model_state.go b/cmd/runecode-tui/shell_model_state.go new file mode 100644 index 00000000..19eb990d --- /dev/null +++ b/cmd/runecode-tui/shell_model_state.go @@ -0,0 +1,90 @@ +package main + +import ( + "os" + "strings" +) + +func newShellModel() shellModel { + return newShellModelWithWorkbenchStore(nil) +} + +func newShellModelWithWorkbenchStore(store shellWorkbenchStateStore) shellModel { + routes := shellRoutes() + models := newRouteModels(routes) + defaultRoute := routeChat + commands := defaultShellCommandRegistry() + actions := newShellActionGraph(routes, commands) + leaderBindings := actions.leaderBindings(shellModel{}) + workbench := resolveShellWorkbenchStore(store) + scope := logicalBrokerTargetKey() + primeShellWorkbenchState(workbench, scope, defaultRoute) + appTheme = newTheme(themePresetDark) + m := newShellModelState(routes, models, commands, actions, leaderBindings, workbench, scope, defaultRoute) + _ = m.setLeaderKey(m.leaderKeyConfig) + m.restoreWorkbenchState() + m.syncSidebarCursorToLocation() + return m +} + +func resolveShellWorkbenchStore(store shellWorkbenchStateStore) shellWorkbenchStateStore { + if store != nil { + return store + } + binaryPath := strings.ToLower(strings.TrimSpace(os.Args[0])) + if forceMemoryWorkbenchState || strings.HasSuffix(binaryPath, ".test") || strings.HasSuffix(binaryPath, ".test.exe") { + return &memoryWorkbenchStateStore{} + } + return newDefaultWorkbenchStateStore() +} + +func primeShellWorkbenchState(workbench shellWorkbenchStateStore, scope string, defaultRoute routeID) { + initialState := workbenchLocalState{SidebarVisible: true, InspectorVisible: true, InspectorMode: presentationRendered, ThemePreset: themePresetDark, LastRouteID: defaultRoute, ViewedActivity: map[string]string{}, LastSessionByWS: map[string]string{}, SidebarPaneRatio: 0.22, InspectorPaneRatio: 0.30} + if existing := workbench.Read(scope); isZeroWorkbenchState(existing) { + workbench.Write(scope, initialState) + } +} + +func newShellModelState(routes []routeDefinition, models map[routeID]routeModel, commands shellCommandRegistry, actions shellActionGraph, leaderBindings []shellLeaderBinding, workbench shellWorkbenchStateStore, scope string, defaultRoute routeID) shellModel { + return shellModel{ + keys: defaultShellKeyMap(), + routes: routes, + nav: newPrimaryNavModel(routes), + palette: newPaletteModel(nil), + sessions: newSessionSwitcherModel(), + focus: focusNav, + client: newLocalBrokerClient(), + focusManager: newShellFocusManager(focusNav), + overlayManager: shellOverlayManager{}, + commands: commands, + actions: actions, + clipboard: newShellClipboardService(), + workbench: workbench, + workbenchScope: scope, + toasts: newShellToastService(), + routeModels: models, + location: shellWorkbenchLocation{ + Primary: shellObjectLocation{RouteID: defaultRoute, Object: workbenchObjectRef{Kind: "route", ID: string(defaultRoute)}}, + }, + sidebarVisible: true, + inspectorOn: true, + themePreset: themePresetDark, + preferredMode: presentationRendered, + sidebarRatio: 0.22, + inspectorRatio: 0.30, + sessionLoading: true, + pinnedSessions: map[string]struct{}{}, + lastSessionByWS: map[string]string{}, + recentObjects: nil, + sessionWorkspace: map[string]string{}, + viewedActivity: map[string]string{}, + watch: newShellWatchManager(), + objectIndex: newShellDiscoverabilityIndex(routes), + overlayReturn: focusContent, + leader: newShellLeaderState(leaderBindings), + leaderBindingsSignature: shellLeaderBindingsSignature(leaderBindings), + leaderKeyConfig: "space", + overlayFrameCache: &shellOverlayFrameCache{}, + paletteCache: nil, + } +} diff --git a/cmd/runecode-tui/shell_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index a9cce8ef..7265dc55 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -351,9 +351,12 @@ func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarHiddenNarrow(t *test } bottom := m.renderBottomStrip(m.activeShellSurface()) - if !strings.Contains(bottom, "Quick action: Quit RuneCode") || !strings.Contains(bottom, "(:quit)") { + if !strings.Contains(bottom, "Quit :quit") { t.Fatalf("expected bottom strip to expose beginner quit affordance when sidebar hidden, got %q", bottom) } + if !strings.Contains(bottom, "\n") { + t.Fatalf("expected two-line bottom strip with route keys above command line, got %q", bottom) + } } func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarToggledOff(t *testing.T) { @@ -390,14 +393,29 @@ func TestShellBottomStripCopyHintUsesActionEntryWording(t *testing.T) { m.location.Primary = shellObjectLocation{RouteID: routeRuns, Object: workbenchObjectRef{Kind: "route", ID: string(routeRuns)}} bottom := m.renderBottomStrip(m.activeShellSurface()) - if !strings.Contains(bottom, "Copy actions (use action entry):") { - t.Fatalf("expected updated copy hint wording, got %q", bottom) + if strings.Contains(bottom, "Copy via action entry (3)") { + t.Fatalf("expected copy hint removed from collapsed footer, got %q", bottom) } if strings.Contains(bottom, "Y cycles/copies") { t.Fatalf("expected retired Y wording removed from bottom strip, got %q", bottom) } } +func TestShellBottomStripSummarizesRouteActionsCalmly(t *testing.T) { + m := newShellModel() + m.width = 150 + bottom := m.renderBottomStrip(m.activeShellSurface()) + if strings.Contains(bottom, "Route actions:") { + t.Fatalf("expected route action summary removed from collapsed footer, got %q", bottom) + } + if !strings.Contains(bottom, "\n") { + t.Fatalf("expected separate route-keys and command lines, got %q", bottom) + } + if strings.Contains(bottom, "Next actions:") { + t.Fatalf("expected retired next-actions wording removed, got %q", bottom) + } +} + func TestShellSidebarQuitActionExecutesViaUnifiedActionGraph(t *testing.T) { m := newShellModel() m.width = 150 diff --git a/cmd/runecode-tui/shell_object_index.go b/cmd/runecode-tui/shell_object_index.go index 79a5d4ed..ac2b4411 100644 --- a/cmd/runecode-tui/shell_object_index.go +++ b/cmd/runecode-tui/shell_object_index.go @@ -43,6 +43,21 @@ type shellDiscoverabilityIndex struct { sessionWS map[string]string } +func (idx shellDiscoverabilityIndex) clone() shellDiscoverabilityIndex { + return shellDiscoverabilityIndex{ + routes: append([]routeDefinition(nil), idx.routes...), + sessions: cloneSummaryMap(idx.sessions), + runs: cloneRunSummaryMap(idx.runs), + approvals: cloneApprovalSummaryMap(idx.approvals), + artifacts: cloneArtifactSummaryMap(idx.artifacts), + auditRecords: cloneAuditTimelineMap(idx.auditRecords), + recentObjects: append([]workbenchObjectRef(nil), idx.recentObjects...), + recentSession: append([]string(nil), idx.recentSession...), + activeSession: idx.activeSession, + sessionWS: cloneSessionMap(idx.sessionWS), + } +} + func newShellDiscoverabilityIndex(routes []routeDefinition) shellDiscoverabilityIndex { idx := shellDiscoverabilityIndex{ routes: append([]routeDefinition(nil), routes...), @@ -83,6 +98,7 @@ func (m shellModel) loadObjectIndexCmd() tea.Cmd { } func (m *shellModel) applyObjectIndexLoaded(msg shellObjectIndexLoadedMsg) { + m.invalidateOverlayFrameCache() if msg.sessionErr == nil { m.objectIndex.ingestSessions(msg.sessions) } @@ -98,6 +114,7 @@ func (m *shellModel) applyObjectIndexLoaded(msg shellObjectIndexLoadedMsg) { if msg.auditErr == nil { m.objectIndex.ingestAuditRecords(msg.auditRecords) } + m.invalidatePaletteCache() m.refreshObjectIndexFromShellState() } @@ -110,9 +127,6 @@ func (m *shellModel) refreshObjectIndexFromShellState() { m.objectIndex.ingestRuns(shellRunSummariesFromWatch(m.watch.reduction.runs)) m.objectIndex.ingestApprovals(shellApprovalSummariesFromWatch(m.watch.reduction.approvals)) m.objectIndex.ingestSessions(shellSessionSummariesFromWatch(m.watch.reduction.sessions)) - if m.palette.IsOpen() { - m.palette = m.palette.UpdateEntries(m.buildPaletteEntries()) - } } func shellRunSummariesFromWatch(items map[string]brokerapi.RunSummary) []brokerapi.RunSummary { @@ -353,3 +367,58 @@ func sortedKeys[T any](items map[string]T) []string { sort.Strings(out) return out } + +func cloneSummaryMap(in map[string]brokerapi.SessionSummary) map[string]brokerapi.SessionSummary { + if in == nil { + return map[string]brokerapi.SessionSummary{} + } + out := make(map[string]brokerapi.SessionSummary, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneRunSummaryMap(in map[string]brokerapi.RunSummary) map[string]brokerapi.RunSummary { + if in == nil { + return map[string]brokerapi.RunSummary{} + } + out := make(map[string]brokerapi.RunSummary, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneApprovalSummaryMap(in map[string]brokerapi.ApprovalSummary) map[string]brokerapi.ApprovalSummary { + if in == nil { + return map[string]brokerapi.ApprovalSummary{} + } + out := make(map[string]brokerapi.ApprovalSummary, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneArtifactSummaryMap(in map[string]brokerapi.ArtifactSummary) map[string]brokerapi.ArtifactSummary { + if in == nil { + return map[string]brokerapi.ArtifactSummary{} + } + out := make(map[string]brokerapi.ArtifactSummary, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneAuditTimelineMap(in map[string]brokerapi.AuditTimelineViewEntry) map[string]brokerapi.AuditTimelineViewEntry { + if in == nil { + return map[string]brokerapi.AuditTimelineViewEntry{} + } + out := make(map[string]brokerapi.AuditTimelineViewEntry, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/cmd/runecode-tui/shell_overlay_behavior_test.go b/cmd/runecode-tui/shell_overlay_behavior_test.go index 053d1c4c..4f12742d 100644 --- a/cmd/runecode-tui/shell_overlay_behavior_test.go +++ b/cmd/runecode-tui/shell_overlay_behavior_test.go @@ -1,9 +1,11 @@ package main import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/runecode-ai/runecode/internal/brokerapi" ) func TestLeaderOverlayConsumesMouseWithoutClickThrough(t *testing.T) { @@ -35,3 +37,93 @@ func TestLeaderOverlayConsumesMouseWithoutClickThrough(t *testing.T) { t.Fatal("expected leader mode to remain active after consumed mouse event") } } + +func TestShellSessionOverlayViewReusesCachedFrameWhileTyping(t *testing.T) { + m := newShellModel() + m.width = 120 + m.height = 32 + m.sessionItems = []brokerapi.SessionSummary{ + {Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}}, + {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-2"}}, + } + route := &countingRouteModel{id: routeDashboard} + m.location.Primary = shellObjectLocation{RouteID: routeDashboard, Object: workbenchObjectRef{Kind: "route", ID: string(routeDashboard)}} + m.routeModels[routeDashboard] = route + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + shell := updated.(shellModel) + _ = shell.View() + initialCalls := route.shellSurfaceCalls + if initialCalls == 0 { + t.Fatal("expected initial session overlay render to compute background frame") + } + + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + shell = updated.(shellModel) + beforeView := route.shellSurfaceCalls + view := shell.View() + if !strings.Contains(view, "session-2") { + t.Fatalf("expected updated quick-switch overlay after typing, got %q", view) + } + if route.shellSurfaceCalls != beforeView { + t.Fatalf("expected cached background frame during session typing, calls before=%d after=%d", beforeView, route.shellSurfaceCalls) + } +} + +func TestShellPaletteOverlayViewRefreshesBodyWhileFrameCached(t *testing.T) { + m := newShellModel() + m.width = 120 + m.height = 32 + m.palette = newPaletteModel([]paletteEntry{ + {Index: 1, Label: "open chat", Search: "chat"}, + {Index: 2, Label: "open runs", Search: "runs"}, + }).Open() + m.syncOverlayStack() + route := &countingRouteModel{id: routeDashboard} + m.location.Primary = shellObjectLocation{RouteID: routeDashboard, Object: workbenchObjectRef{Kind: "route", ID: string(routeDashboard)}} + m.routeModels[routeDashboard] = route + + _ = m.View() + initialCalls := route.shellSurfaceCalls + if initialCalls == 0 { + t.Fatal("expected initial palette overlay render to compute background frame") + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + shell := updated.(shellModel) + beforeView := route.shellSurfaceCalls + view := shell.View() + if !strings.Contains(view, "r") { + t.Fatalf("expected updated palette query while reusing cached frame, got %q", view) + } + if route.shellSurfaceCalls != beforeView { + t.Fatalf("expected cached background frame during palette typing, calls before=%d after=%d", beforeView, route.shellSurfaceCalls) + } +} + +func TestShellBackgroundStateUpdatesInvalidateOverlayFrameCache(t *testing.T) { + m := newShellModel() + m.width = 120 + m.height = 32 + m.palette = newPaletteModel([]paletteEntry{{Index: 1, Label: "open chat", Search: "chat"}}).Open() + m.syncOverlayStack() + _ = m.View() + if m.overlayFrameCache == nil || m.overlayFrameCache.frame == "" { + t.Fatal("expected overlay frame cache populated before background update") + } + m.applyWatchTransport(shellWatchTransportLoadedMsg{}) + if m.overlayFrameCache.frame != "" { + t.Fatal("expected watch transport update to invalidate overlay frame cache") + } + + m.palette = newPaletteModel([]paletteEntry{{Index: 1, Label: "open chat", Search: "chat"}}).Open() + m.syncOverlayStack() + _ = m.View() + if m.overlayFrameCache == nil || m.overlayFrameCache.frame == "" { + t.Fatal("expected overlay frame cache repopulated before session update") + } + m.applySessionWorkspaceLoaded(sessionWorkspaceLoadedMsg{sessions: []brokerapi.SessionSummary{{Identity: brokerapi.SessionIdentity{SessionID: "session-1"}}}}) + if m.overlayFrameCache.frame != "" { + t.Fatal("expected session workspace update to invalidate overlay frame cache") + } +} diff --git a/cmd/runecode-tui/shell_overlay_render.go b/cmd/runecode-tui/shell_overlay_render.go index 205366d3..e490389e 100644 --- a/cmd/runecode-tui/shell_overlay_render.go +++ b/cmd/runecode-tui/shell_overlay_render.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "github.com/charmbracelet/lipgloss" @@ -16,44 +17,40 @@ func centeredOverlayBlockBounded(title shellOverlayID, body string, viewportWidt body = "(empty overlay)" } outerWidth := overlayBlockWidth(viewportWidth) - innerWidth := outerWidth - 2 + frameInnerWidth := outerWidth - 2 + innerWidth := frameInnerWidth - 4 if innerWidth < 1 { innerWidth = 1 } - contentWidth := innerWidth - 2 - if contentWidth < 1 { - contentWidth = 1 - } + contentWidth := innerWidth bodyHeight := lipgloss.Height(strings.TrimRight(body, "\n")) if bodyHeight < 1 { bodyHeight = 1 } if maxHeight > 0 { - maxBodyHeight := maxHeight - 3 // 1 overlay header + 2 frame border rows. + maxBodyHeight := maxHeight - 3 // 1 modal header + 2 frame border rows. if maxBodyHeight < 1 { maxBodyHeight = 1 } bodyHeight = maxBodyHeight } body = constrainShellBlock(body, contentWidth, bodyHeight) + titleLine := tableHeader(overlayDisplayTitle(title)) + dismissLine := muted(overlayDismissHint(title)) content := appTheme.SurfaceOverlay. Width(contentWidth). MaxWidth(contentWidth). - Padding(0, 1). - Render(body) + Padding(0, 2). + Render(compactLines(titleLine, dismissLine, body)) frame := appTheme.SurfaceOverlay. Border(lipgloss.NormalBorder()). BorderForeground(appTheme.BorderStrong.GetForeground()). - Width(innerWidth). - MaxWidth(innerWidth). + Padding(0, 0). Render(content) - rendered := compactLines( - tableHeader("Overlay")+" "+neutralBadge(strings.ToUpper(string(title))), - lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Left).Render(frame), - ) + rendered := lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(frame) if maxHeight <= 0 { return rendered } @@ -79,11 +76,40 @@ func centeredOverlayContentBounds(viewportWidth int) (int, int) { viewportWidth = 120 } outerWidth := overlayBlockWidth(viewportWidth) - startX := 0 + startX := (viewportWidth - outerWidth) / 2 + if startX < 0 { + startX = 0 + } contentStartX := startX + 2 // border + left padding - contentEndX := startX + outerWidth - 3 + contentEndX := startX + outerWidth - 5 if contentEndX < contentStartX { contentEndX = contentStartX } return contentStartX, contentEndX } + +func overlayDisplayTitle(id shellOverlayID) string { + switch id { + case overlayIDQuickJump: + return "Command Palette" + case overlayIDSessions: + return "Session Switcher" + case overlayIDSidebar: + return "Navigation" + case overlayIDInspector: + return "Inspector" + case overlayIDLeader: + return "Leader Help" + case overlayIDQuitConfirm: + return "Quit RuneCode" + default: + return strings.TrimSpace(string(id)) + } +} + +func overlayDismissHint(id shellOverlayID) string { + if id == overlayIDQuitConfirm { + return "esc keep editing" + } + return fmt.Sprintf("esc close") +} diff --git a/cmd/runecode-tui/shell_overlay_view.go b/cmd/runecode-tui/shell_overlay_view.go new file mode 100644 index 00000000..fa23b53f --- /dev/null +++ b/cmd/runecode-tui/shell_overlay_view.go @@ -0,0 +1,234 @@ +package main + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func (m shellModel) cachedOverlayWorkbench(viewportWidth int, viewportHeight int) (string, bool) { + if !m.overlayFrameCacheable() || m.overlayFrameCache == nil { + return "", false + } + cached := m.overlayFrameCache + overlayID := m.activeOverlayID() + if cached.overlay != overlayID || cached.width != viewportWidth || cached.height != viewportHeight || cached.frame == "" { + return "", false + } + overlayBody, ok := m.cacheableOverlayBodyWithHeight(viewportHeight) + if !ok { + return "", false + } + return applyModalOverlay(scrubShellFrameForOverlay(cached.frame, viewportWidth, viewportHeight), overlayBody, viewportWidth, viewportHeight), true +} + +func (m shellModel) cacheableOverlayBodyWithHeight(viewportHeight int) (string, bool) { + parts := m.overlayStaticParts() + maxOverlayHeight := overlayHeightBudget(viewportHeight) + remainingHeight := overlayRemainingHeight(parts, maxOverlayHeight) + overlayWidth := normalizedOverlayWidth(m.width) + overlay, ok := m.cacheableOverlayBody(overlayWidth, remainingHeight) + if !ok || strings.TrimSpace(overlay) == "" { + return "", false + } + parts = append(parts, overlay) + contentHeight := lipgloss.Height(strings.Join(parts, "\n")) + if contentHeight > maxOverlayHeight { + contentHeight = maxOverlayHeight + } + content := constrainShellBlock(strings.Join(parts, "\n"), overlayWidth, contentHeight) + return content, true +} + +func (m shellModel) cacheableOverlayBody(overlayWidth int, remainingHeight int) (string, bool) { + switch m.activeOverlayID() { + case overlayIDQuickJump: + return centeredOverlayBlockBounded(overlayIDQuickJump, m.renderPalette(), overlayWidth, remainingHeight), true + case overlayIDSessions: + return centeredOverlayBlockBounded(overlayIDSessions, m.renderSessionQuickSwitcher(), overlayWidth, remainingHeight), true + case overlayIDLeader: + return centeredOverlayBlockBounded(overlayIDLeader, m.renderLeaderWhichKey(), overlayWidth, remainingHeight), true + case overlayIDQuitConfirm: + return centeredOverlayBlockBounded(overlayIDQuitConfirm, m.renderQuitConfirmDialog(), overlayWidth, remainingHeight), true + default: + return "", false + } +} + +func (m shellModel) overlayBodyWithHeight(surface routeSurface, layout shellLayoutPlan, viewportHeight int) (string, int) { + parts := m.overlayStaticParts() + maxOverlayHeight := overlayHeightBudget(viewportHeight) + remainingHeight := overlayRemainingHeight(parts, maxOverlayHeight) + overlayWidth := normalizedOverlayWidth(m.width) + overlay := m.activeOverlayBody(surface, layout, overlayWidth, remainingHeight) + if strings.TrimSpace(overlay) != "" { + parts = append(parts, overlay) + } + if len(parts) == 0 { + return "", 0 + } + contentHeight := lipgloss.Height(strings.Join(parts, "\n")) + if contentHeight > maxOverlayHeight { + contentHeight = maxOverlayHeight + } + content := constrainShellBlock(strings.Join(parts, "\n"), overlayWidth, contentHeight) + return content, lipgloss.Height(content) +} + +func (m shellModel) overlayStaticParts() []string { + return nil +} + +func overlayHeightBudget(viewportHeight int) int { + maxOverlayHeight := viewportHeight - 8 + if maxOverlayHeight < 1 { + return 1 + } + return maxOverlayHeight +} + +func overlayRemainingHeight(parts []string, maxOverlayHeight int) int { + remainingHeight := maxOverlayHeight - lipgloss.Height(strings.Join(parts, "\n")) + if len(parts) > 0 { + remainingHeight-- + } + if remainingHeight < 1 { + return 1 + } + return remainingHeight +} + +func (m shellModel) activeOverlayBody(surface routeSurface, layout shellLayoutPlan, overlayWidth int, remainingHeight int) string { + switch { + case m.palette.IsOpen(): + return centeredOverlayBlockBounded(overlayIDQuickJump, m.renderPalette(), overlayWidth, remainingHeight) + case m.sessions.IsOpen(): + return centeredOverlayBlockBounded(overlayIDSessions, m.renderSessionQuickSwitcher(), overlayWidth, remainingHeight) + case m.leader.Active(): + return centeredOverlayBlockBounded(overlayIDLeader, m.renderLeaderWhichKey(), overlayWidth, remainingHeight) + case m.quitConfirm.active: + return centeredOverlayBlockBounded(overlayIDQuitConfirm, m.renderQuitConfirmDialog(), overlayWidth, remainingHeight) + case m.narrowSidebarOn && m.breakpoint() == shellBreakpointNarrow: + return centeredOverlayBlockBounded(overlayIDSidebar, m.renderSidebar(), overlayWidth, remainingHeight) + case m.narrowInspectOn && m.breakpoint() == shellBreakpointNarrow: + return m.narrowInspectorOverlayBody(surface, layout, overlayWidth, remainingHeight) + default: + return "" + } +} + +func (m shellModel) narrowInspectorOverlayBody(surface routeSurface, layout shellLayoutPlan, overlayWidth int, remainingHeight int) string { + inspector := strings.TrimSpace(surface.Regions.Inspector.Body) + title := strings.TrimSpace(surface.Regions.Inspector.Title) + if title == "" { + title = "Inspector" + } + if inspector != "" && routeInspectorAvailable(surface) && m.inspectorOn { + return centeredOverlayBlockBounded(overlayIDInspector, compactLines(title, inspector), overlayWidth, remainingHeight) + } + if !layout.InspectorVisible && !routeInspectorAvailable(surface) { + return centeredOverlayBlockBounded(overlayIDInspector, "Inspector unavailable for current route.", overlayWidth, remainingHeight) + } + return centeredOverlayBlockBounded(overlayIDInspector, compactLines(title, "No inspector item selected."), overlayWidth, remainingHeight) +} + +func (m shellModel) activeOverlayHeight(viewportHeight int) int { + surface := m.activeShellSurfaceWithoutOverlayHeight() + layout := m.planShellLayout(surface) + _, height := m.overlayBodyWithHeight(surface, layout, viewportHeight) + return height +} + +func (m shellModel) activeShellSurfaceWithoutOverlayHeight() routeSurface { + active := m.routeModels[m.currentRouteID()] + if active == nil { + return routeSurface{} + } + baseCtx := routeShellContext{Width: m.width, Height: m.height, Focus: m.focus, Focused: m.focusedRouteRegion(), Breakpoint: m.breakpoint(), Render: routeShellRenderPreferences{PreferredPresentation: normalizePresentationMode(m.preferredMode), ThemePreset: normalizeThemePreset(m.themePreset)}} + surface := active.ShellSurface(baseCtx) + layout := m.planShellLayout(surface) + ctx := baseCtx + ctx.Regions = layout.Regions + ctx.Breakpoint = layout.Breakpoint + return m.withLocationChrome(active.ShellSurface(ctx)) +} + +func normalizedOverlayWidth(width int) int { + if width <= 0 { + return 1 + } + return width +} + +func scrubShellFrameForOverlay(frame string, width int, height int) string { + if strings.TrimSpace(frame) == "" { + return frame + } + lines := strings.Split(padShellFrame(frame, width, height), "\n") + blank := lipgloss.NewStyle().Width(width).MaxWidth(width).Render("") + blankFrom := len(lines) - (shellStatusHeight + shellBottomStripHeight) + if blankFrom < 0 { + blankFrom = 0 + } + for i := range lines { + if i >= blankFrom { + lines[i] = blank + continue + } + stripped := strings.TrimSpace(ansiStripForOverlay(lines[i])) + if stripped == "" { + lines[i] = blank + continue + } + if containsBoxDrawing(stripped) { + lines[i] = blank + continue + } + lines[i] = appTheme.Muted.Width(width).MaxWidth(width).Render(ansiStripForOverlay(lines[i])) + } + return strings.Join(lines, "\n") +} + +func containsBoxDrawing(line string) bool { + for _, r := range line { + switch r { + case '┌', '┐', '└', '┘', '│', '─', '├', '┤', '┬', '┴', '┼': + return true + } + } + return false +} + +func ansiStripForOverlay(line string) string { + return sanitizeUIText(ansi.Strip(line)) +} + +func applyModalOverlay(frame string, overlay string, width int, height int) string { + if strings.TrimSpace(overlay) == "" { + return frame + } + if width <= 0 { + width = 1 + } + if height <= 0 { + return frame + } + frameLines := strings.Split(padShellBlock(frame, width, height), "\n") + overlayLines := strings.Split(strings.TrimRight(overlay, "\n"), "\n") + if len(overlayLines) > height { + overlayLines = overlayLines[:height] + } + start := (height - len(overlayLines)) / 2 + if start < 0 { + start = 0 + } + for i, line := range overlayLines { + row := start + i + if row < 0 || row >= len(frameLines) { + continue + } + frameLines[row] = lipgloss.NewStyle().Width(width).MaxWidth(width).Align(lipgloss.Center).Render(line) + } + return strings.Join(frameLines, "\n") +} diff --git a/cmd/runecode-tui/shell_perf_test.go b/cmd/runecode-tui/shell_perf_test.go index 8e286912..d31066ea 100644 --- a/cmd/runecode-tui/shell_perf_test.go +++ b/cmd/runecode-tui/shell_perf_test.go @@ -1,8 +1,14 @@ package main import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" + "time" + tea "github.com/charmbracelet/bubbletea" "github.com/runecode-ai/runecode/internal/brokerapi" ) @@ -62,3 +68,366 @@ func BenchmarkBuildPaletteEntries(b *testing.B) { _ = m.buildPaletteEntries() } } + +type blockingWorkbenchStateStore struct { + memoryWorkbenchStateStore + writeDelay time.Duration + writes int +} + +func (s *blockingWorkbenchStateStore) Write(targetKey string, next workbenchLocalState) { + time.Sleep(s.writeDelay) + s.writes++ + s.memoryWorkbenchStateStore.Write(targetKey, next) +} + +func TestShellSessionQuickSwitchTypingDoesNotPersistWorkbenchState(t *testing.T) { + store := &blockingWorkbenchStateStore{writeDelay: 20 * time.Millisecond} + m := newShellModelWithWorkbenchStore(store) + m.sessionItems = []brokerapi.SessionSummary{ + {Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}}, + {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-2"}}, + } + m.sessions = m.sessions.Open(m.sessionItems) + m.syncOverlayStack() + beforeWrites := store.writes + start := time.Now() + for _, r := range "session-1" { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(shellModel) + } + if elapsed := time.Since(start); elapsed >= store.writeDelay { + t.Fatalf("expected typing path to avoid synchronous persistence delay, elapsed=%v writes before=%d after=%d", elapsed, beforeWrites, store.writes) + } + if store.writes != beforeWrites { + t.Fatalf("expected no workbench persistence during quick-switch typing, writes before=%d after=%d", beforeWrites, store.writes) + } + if got := m.sessions.query; got != "session-1" { + t.Fatalf("expected full quick-switch query retained, got %q", got) + } +} + +func TestFileWorkbenchStateStoreWritesAsynchronously(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "workbench-state.json") + store := newFileWorkbenchStateStore(path) + store.writeDebounce = 0 + store.writeDelay = func() { time.Sleep(25 * time.Millisecond) } + state := workbenchLocalState{ThemePreset: themePresetHigh, LastRouteID: routeRuns} + start := time.Now() + store.Write("broker_local_api:test", state) + if elapsed := time.Since(start); elapsed >= 25*time.Millisecond { + t.Fatalf("expected asynchronous write to return before simulated write delay, took %v", elapsed) + } + store.Flush() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read persisted workbench state: %v", err) + } + if len(raw) == 0 { + t.Fatal("expected persisted workbench state file content") + } + got := store.Read("broker_local_api:test") + if got.LastRouteID != routeRuns || got.ThemePreset != themePresetHigh { + t.Fatalf("unexpected persisted state: %+v", got) + } +} + +func TestFileWorkbenchStateStoreRetainsDirtyStateOnWriteFailure(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "workbench-state.json") + store := newFileWorkbenchStateStore(tmpDir) + store.writeDebounce = 0 + store.Write("broker_local_api:test", workbenchLocalState{LastRouteID: routeRuns}) + store.Flush() + if store.LastError() == nil { + t.Fatal("expected write failure to be reported") + } + store.path = path + store.Flush() + if err := store.LastError(); err != nil { + t.Fatalf("expected successful retry to clear write error, got %v", err) + } + if got := store.Read("broker_local_api:test"); got.LastRouteID != routeRuns { + t.Fatalf("expected retry to persist state, got %+v", got) + } +} + +func BenchmarkShellSessionQuickSwitchTyping(b *testing.B) { + m := newShellModel() + m.sessionItems = []brokerapi.SessionSummary{ + {Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, LastActivityPreview: "draft review"}, + {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-2"}, LastActivityPreview: "plan sync"}, + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + shell := m + shell.sessions = shell.sessions.Open(shell.sessionItems) + shell.syncOverlayStack() + for _, r := range "session-1" { + updated, _ := shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + shell = updated.(shellModel) + } + } +} + +func BenchmarkPaletteFilterTyping(b *testing.B) { + base := newPaletteModel(makePaletteBenchmarkEntries(2000)).Open() + query := "session-1999" + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + state := base + state.query = query + state.appliedNeedle = "" + state.filterRequest = 1 + _ = buildPaletteFilterResult(state.filterRequest, state.entriesVersion, state.query, state.normalizedEntries, state.appliedNeedle, state.matchIndexes) + } +} + +func BenchmarkPaletteOpenAndFastCopyTyping(b *testing.B) { + base := newShellModel() + base.width = 120 + base.height = 32 + base.paletteCache = makePaletteBenchmarkEntries(4000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + shell := base + updated, cmd := shell.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + shell = updated.(shellModel) + if cmd != nil { + updated, follow := shell.Update(cmd()) + shell = updated.(shellModel) + if follow != nil { + updated, _ = shell.Update(follow()) + shell = updated.(shellModel) + } + } + for _, r := range "copy" { + updated, cmd = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + shell = updated.(shellModel) + if cmd != nil { + updated, _ = shell.Update(cmd()) + shell = updated.(shellModel) + } + } + } +} + +func BenchmarkSessionSwitcherFilterTyping(b *testing.B) { + sessions := makeSessionSwitcherBenchmarkSessions(2000) + base := newSessionSwitcherModel().UpdateSessions(sessions) + query := []rune("session-1999") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + state := base + state.query = "" + state.selectedIndex = 0 + state.rebuildMatches() + for _, r := range query { + state = state.AppendQuery(string(r)) + } + } +} + +func BenchmarkRenderPaletteLargeMatchWindow(b *testing.B) { + m := newShellModel() + m.width = 120 + m.palette = newPaletteModel(makePaletteBenchmarkEntries(2000)).Open() + m.palette.selectedIndex = 1000 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.renderPalette() + } +} + +func TestShellPaletteFastTypingRetainsCopyQuery(t *testing.T) { + m := newShellModel() + m.width = 120 + m.height = 32 + m.paletteCache = []paletteEntry{ + {Index: 1, Label: "copy current identity", Description: "copy the current route identity", Search: "copy identity route"}, + {Index: 2, Label: "copy next route action", Description: "copy the next route action", Search: "copy next route action"}, + {Index: 3, Label: "open runs", Description: "jump to runs", Search: "open runs"}, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + shell := updated.(shellModel) + if cmd != nil { + updated, follow := shell.Update(cmd()) + shell = updated.(shellModel) + if follow != nil { + updated, _ = shell.Update(follow()) + shell = updated.(shellModel) + } + } + for _, r := range "copy" { + updated, cmd = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + shell = updated.(shellModel) + if cmd != nil { + updated, _ = shell.Update(cmd()) + shell = updated.(shellModel) + } + } + if got := shell.palette.query; got != "copy" { + t.Fatalf("expected full palette query retained, got %q", got) + } + if shell.palette.MatchCount() == 0 { + t.Fatal("expected copy query to retain matching palette entries") + } + selected, ok := shell.palette.SelectedEntry() + if !ok || !strings.Contains(selected.Label, "copy") { + t.Fatalf("expected selected copy entry after fast typing, got %+v ok=%v", selected, ok) + } +} + +func BenchmarkRenderSessionQuickSwitcherLargeWindow(b *testing.B) { + m := newShellModel() + m.width = 120 + m.sessions = newSessionSwitcherModel().Open(makeSessionSwitcherBenchmarkSessions(2000)) + m.sessions.selectedIndex = 1000 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.renderSessionQuickSwitcher() + } +} + +func makePaletteBenchmarkEntries(count int) []paletteEntry { + entries := make([]paletteEntry, 0, count) + for i := 0; i < count; i++ { + entries = append(entries, paletteEntry{ + Index: i + 1, + Label: fmt.Sprintf("command %04d", i), + Description: fmt.Sprintf("open workspace ws-%d", i%250), + Search: fmt.Sprintf("session-%04d route-chat evidence-%04d", i, i), + }) + } + return entries +} + +func makeSessionSwitcherBenchmarkSessions(count int) []brokerapi.SessionSummary { + sessions := make([]brokerapi.SessionSummary, 0, count) + for i := 0; i < count; i++ { + sessions = append(sessions, brokerapi.SessionSummary{ + Identity: brokerapi.SessionIdentity{SessionID: fmt.Sprintf("session-%04d", i), WorkspaceID: fmt.Sprintf("ws-%03d", i%250)}, + LastActivityKind: "chat_message", + LastActivityPreview: fmt.Sprintf("preview for session-%04d", i), + Status: "active", + }) + } + return sessions +} + +type countingRouteModel struct { + id routeID + shellSurfaceCalls int +} + +func (m *countingRouteModel) ID() routeID { return m.id } + +func (m *countingRouteModel) Title() string { return "Counting" } + +func (m *countingRouteModel) Update(msg tea.Msg) (routeModel, tea.Cmd) { + _ = msg + return m, nil +} + +func (m *countingRouteModel) View(width, height int, focus focusArea) string { + _, _, _ = width, height, focus + return "counting" +} + +func (m *countingRouteModel) ShellSurface(ctx routeShellContext) routeSurface { + _ = ctx + m.shellSurfaceCalls++ + return routeSurface{ + Regions: routeSurfaceRegions{ + Main: routeSurfaceRegion{Title: "Counting", Body: "main"}, + Inspector: routeSurfaceRegion{Title: "Inspector", Body: "detail"}, + }, + Capabilities: routeSurfaceCapabilities{Inspector: routeInspectorCapability{Supported: true, Enabled: true}}, + } +} + +func TestShellLeaderOverlayViewReusesCachedFrameOnStep(t *testing.T) { + m := newShellModel() + m.width = 120 + m.height = 32 + route := &countingRouteModel{id: routeDashboard} + m.location.Primary = shellObjectLocation{RouteID: routeDashboard, Object: workbenchObjectRef{Kind: "route", ID: string(routeDashboard)}} + m.routeModels[routeDashboard] = route + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + shell := updated.(shellModel) + _ = shell.View() + firstCalls := route.shellSurfaceCalls + if firstCalls == 0 { + t.Fatal("expected initial overlay view to render route surface") + } + if shell.overlayFrameCache == nil || shell.overlayFrameCache.frame == "" { + t.Fatal("expected overlay frame cache populated after initial leader view") + } + + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + shell = updated.(shellModel) + beforeSecondView := route.shellSurfaceCalls + view := shell.View() + if !strings.Contains(view, "Sequence: w") { + t.Fatalf("expected updated leader sequence in cached view, got %q", view) + } + if route.shellSurfaceCalls != beforeSecondView { + t.Fatalf("expected cached overlay view to avoid shell surface rerender, calls before=%d after=%d", beforeSecondView, route.shellSurfaceCalls) + } +} + +func BenchmarkShellLeaderOpenAndStep(b *testing.B) { + base := newShellModel() + base.width = 120 + base.height = 32 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + shell := base + updated, _ := shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + shell = updated.(shellModel) + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + _ = updated.(shellModel) + } +} + +func BenchmarkShellViewLeaderOverlayCached(b *testing.B) { + m := newShellModel() + m.width = 120 + m.height = 32 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + shell := updated.(shellModel) + _ = shell.View() + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + shell = updated.(shellModel) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = shell.View() + } +} + +func BenchmarkShellFocusTraversalHotPath(b *testing.B) { + base := newShellModel() + base.width = 150 + base.height = 40 + base.location.Primary = shellObjectLocation{RouteID: routeRuns, Object: workbenchObjectRef{Kind: "route", ID: string(routeRuns)}} + base.setFocus(focusContent) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + shell := base + updated, _ := shell.Update(tea.KeyMsg{Type: tea.KeyTab}) + shell = updated.(shellModel) + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + _ = updated.(shellModel) + } +} diff --git a/cmd/runecode-tui/shell_persistence.go b/cmd/runecode-tui/shell_persistence.go index 4f542ffc..9bd54e33 100644 --- a/cmd/runecode-tui/shell_persistence.go +++ b/cmd/runecode-tui/shell_persistence.go @@ -1,12 +1,46 @@ package main -import "strings" +import ( + "sort" + "strings" +) + +const ( + maxPersistedPinnedSessions = 50 + maxPersistedRecentSessions = 8 + maxPersistedRecentObjects = 20 + maxPersistedLastSessionByWS = 50 +) func (m *shellModel) persistWorkbenchState() { if m.workbench == nil { return } - m.workbench.Write(m.workbenchScope, workbenchLocalState{ + m.workbench.Write(m.workbenchScope, m.currentWorkbenchState()) +} + +func (m *shellModel) flushWorkbenchState() { + if flusher, ok := m.workbench.(shellWorkbenchStateFlusher); ok { + flusher.Flush() + } +} + +func (m *shellModel) workbenchFlushError() error { + if flusher, ok := m.workbench.(interface{ LastError() error }); ok { + return flusher.LastError() + } + return nil +} + +func safeWorkbenchPersistenceErrorText(err error) string { + if err == nil { + return "" + } + return "workbench state persistence failed; local path redacted" +} + +func (m *shellModel) currentWorkbenchState() workbenchLocalState { + return workbenchLocalState{ SidebarVisible: m.sidebarVisible, InspectorVisible: m.inspectorOn, InspectorMode: normalizePresentationMode(m.preferredMode), @@ -14,16 +48,16 @@ func (m *shellModel) persistWorkbenchState() { LeaderKey: strings.TrimSpace(m.leaderKeyConfig), LastRouteID: m.currentRouteID(), LastSessionID: m.activeSessionID, - LastSessionByWS: cloneSessionMap(m.lastSessionByWS), + LastSessionByWS: cloneSessionMapWithLimit(m.lastSessionByWS, maxPersistedLastSessionByWS), PinnedSessions: m.persistedPinnedSessionRefs(), RecentSessions: m.persistedRecentSessionRefs(), - RecentObjects: append([]workbenchObjectRef(nil), m.recentObjects...), + RecentObjects: cloneRecentObjectsWithLimit(m.recentObjects, maxPersistedRecentObjects), ViewedActivity: cloneViewedActivity(m.viewedActivity), SidebarPaneRatio: clampPaneRatio(m.sidebarRatio), InspectorPaneRatio: clampPaneRatio(m.inspectorRatio), SidebarCollapsed: m.sidebarFolded, InspectorCollapsed: m.inspectorFolded, - }) + } } func (m *shellModel) restoreWorkbenchState() { @@ -99,6 +133,9 @@ func (m *shellModel) restoreWorkbenchSessionState(state workbenchLocalState) { m.activeSessionID = strings.TrimSpace(state.LastSessionID) m.pinnedSessions = map[string]struct{}{} for _, ref := range state.PinnedSessions { + if len(m.pinnedSessions) >= maxPersistedPinnedSessions { + break + } sid := strings.TrimSpace(ref.SessionID) if sid == "" { continue @@ -106,8 +143,11 @@ func (m *shellModel) restoreWorkbenchSessionState(state workbenchLocalState) { m.pinnedSessions[sid] = struct{}{} m.rememberSessionWorkspace(sid, ref.WorkspaceID) } - m.recentSessions = make([]string, 0, len(state.RecentSessions)) + m.recentSessions = make([]string, 0, min(len(state.RecentSessions), maxPersistedRecentSessions)) for _, ref := range state.RecentSessions { + if len(m.recentSessions) >= maxPersistedRecentSessions { + break + } sid := strings.TrimSpace(ref.SessionID) if sid == "" { continue @@ -125,13 +165,16 @@ func (m *shellModel) rememberSessionWorkspace(sessionID string, workspaceID stri } func (m *shellModel) restoreWorkbenchRecentState(state workbenchLocalState) { - m.recentObjects = append([]workbenchObjectRef(nil), state.RecentObjects...) - m.lastSessionByWS = cloneSessionMap(state.LastSessionByWS) + m.recentObjects = cloneRecentObjectsWithLimit(state.RecentObjects, maxPersistedRecentObjects) + m.lastSessionByWS = cloneSessionMapWithLimit(state.LastSessionByWS, maxPersistedLastSessionByWS) m.viewedActivity = cloneViewedActivity(state.ViewedActivity) } func (m *shellModel) persistedPinnedSessionRefs() []workbenchSessionRef { keys := sortedSessionKeys(m.pinnedSessions) + if len(keys) > maxPersistedPinnedSessions { + keys = keys[:maxPersistedPinnedSessions] + } out := make([]workbenchSessionRef, 0, len(keys)) for _, sid := range keys { out = append(out, workbenchSessionRef{WorkspaceID: strings.TrimSpace(m.sessionWorkspace[sid]), SessionID: sid}) @@ -140,8 +183,12 @@ func (m *shellModel) persistedPinnedSessionRefs() []workbenchSessionRef { } func (m *shellModel) persistedRecentSessionRefs() []workbenchSessionRef { - out := make([]workbenchSessionRef, 0, len(m.recentSessions)) + limit := min(len(m.recentSessions), maxPersistedRecentSessions) + out := make([]workbenchSessionRef, 0, limit) for _, sid := range m.recentSessions { + if len(out) >= maxPersistedRecentSessions { + break + } sid = strings.TrimSpace(sid) if sid == "" { continue @@ -152,16 +199,48 @@ func (m *shellModel) persistedRecentSessionRefs() []workbenchSessionRef { } func cloneSessionMap(in map[string]string) map[string]string { + return cloneSessionMapWithLimit(in, 0) +} + +func cloneSessionMapWithLimit(in map[string]string, limit int) map[string]string { if in == nil { return map[string]string{} } - out := make(map[string]string, len(in)) - for k, v := range in { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + if limit > 0 && len(keys) > limit { + keys = keys[:limit] + } + out := make(map[string]string, len(keys)) + for _, k := range keys { + v := in[k] out[k] = v } return out } +func cloneRecentObjectsWithLimit(in []workbenchObjectRef, limit int) []workbenchObjectRef { + if len(in) == 0 { + return nil + } + if limit > 0 && len(in) > limit { + in = in[:limit] + } + out := make([]workbenchObjectRef, 0, len(in)) + for _, ref := range in { + out = append(out, workbenchObjectRef{ + Kind: strings.TrimSpace(ref.Kind), + ID: strings.TrimSpace(ref.ID), + WorkspaceID: strings.TrimSpace(ref.WorkspaceID), + SessionID: strings.TrimSpace(ref.SessionID), + }) + } + return out +} + func clampPaneRatio(v float64) float64 { if v <= 0 { return 0 diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index fdfd6880..d15743e6 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -3,6 +3,8 @@ package main import ( "fmt" "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" ) func (m shellModel) renderOverlayStack() string { @@ -18,29 +20,35 @@ func (m shellModel) renderOverlayStack() string { func (m shellModel) renderPalette() string { b := strings.Builder{} - b.WriteString(tableHeader("Workbench Command Surface") + " " + neutralBadge("toggle=ctrl+p") + "\n") - b.WriteString("Verbs: " + strings.Join([]string{infoBadge("open"), infoBadge("inspect"), infoBadge("jump"), infoBadge("back")}, " ") + "\n") - b.WriteString(fmt.Sprintf("Query: %q\n", m.palette.query)) - if len(m.palette.matches) == 0 { - b.WriteString(muted("No matches. Press esc to close.")) + width := boundedOverlayListWidth(m.width) + b.WriteString(renderOverlaySearchPrompt(m.palette.query) + "\n") + if m.palette.MatchCount() == 0 { + if m.palette.entriesLoading || m.palette.filterLoading { + b.WriteString(muted("Updating matches...")) + b.WriteString("\n") + return b.String() + } + b.WriteString(muted("No matches. Keep typing or press esc to close.")) b.WriteString("\n") return b.String() } - b.WriteString(tableHeader("Matches")) + b.WriteString(tableHeader("Suggested actions")) b.WriteString("\n") - rows := make([]boundedListRow, 0, len(m.palette.matches)) - for _, entry := range m.palette.matches { - rows = append(rows, boundedListRow{Text: paletteMatchLine(entry, false), Selectable: true}) - } - b.WriteString(renderBoundedList(boundedListSpec{ - Rows: rows, - Selected: m.palette.selectedIndex, - Width: boundedOverlayListWidth(m.width), - Height: 8, + b.WriteString(renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: m.palette.MatchCount(), + SelectedRow: m.palette.selectedIndex, + Width: width, + Height: 10, GapMarker: "...", - PreserveGaps: true, ApplySelected: true, ActiveFill: true, + RenderRow: func(index int) boundedListRow { + entry, ok := m.palette.MatchEntry(index) + if !ok { + return boundedListRow{} + } + return boundedListRow{Text: paletteMatchLineBounded(entry, width), Selectable: true} + }, })) b.WriteString("\n") return b.String() @@ -48,58 +56,94 @@ func (m shellModel) renderPalette() string { func (m shellModel) renderSessionQuickSwitcher() string { b := strings.Builder{} - b.WriteString(tableHeader("Session Quick Switcher") + " " + neutralBadge("toggle=ctrl+j") + "\n") - b.WriteString(fmt.Sprintf("Query: %q\n", m.sessions.query)) + width := boundedOverlayListWidth(m.width) + b.WriteString(renderOverlaySearchPrompt(m.sessions.query) + "\n") if len(m.sessions.matches) == 0 { b.WriteString(muted("No matches. Press esc to close.")) b.WriteString("\n") return b.String() } - b.WriteString(tableHeader("Matches")) + b.WriteString(tableHeader("Recent sessions")) b.WriteString("\n") - rows := make([]boundedListRow, 0, len(m.sessions.matches)) - for i, s := range m.sessions.matches { - marker := " " - if i == m.sessions.selectedIndex { - marker = "▶" - } - sessionLabel := s.Identity.SessionID - if m.watch.projection.Activity.Active.Kind == "session" && strings.TrimSpace(m.watch.projection.Activity.Active.ID) != "" && m.watch.projection.Activity.Active.ID == s.Identity.SessionID { - sessionLabel = "● " + sessionLabel - } - line := fmt.Sprintf(" %s %s | ws=%s | activity=%s/%s | cue=%s | preview=%q | incomplete=%t | runs=%d approvals=%d", - marker, - sessionLabel, - s.Identity.WorkspaceID, - defaultPlaceholder(s.LastActivityAt, "n/a"), - defaultPlaceholder(s.LastActivityKind, "n/a"), - sessionHighLevelCue(s), - truncateText(s.LastActivityPreview, 50), - s.HasIncompleteTurn, - s.LinkedRunCount, - s.LinkedApprovalCount, - ) - rows = append(rows, boundedListRow{Text: line, Selectable: true}) - } - b.WriteString(renderBoundedList(boundedListSpec{ - Rows: rows, - Selected: m.sessions.selectedIndex, - Width: boundedOverlayListWidth(m.width), + b.WriteString(renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: len(m.sessions.matches), + SelectedRow: m.sessions.selectedIndex, + Width: width, Height: 8, GapMarker: "...", - PreserveGaps: true, ApplySelected: true, ActiveFill: true, + RenderRow: func(index int) boundedListRow { + return boundedListRow{Text: m.renderSessionQuickSwitcherRow(index, width), Selectable: true} + }, })) b.WriteString("\n") return b.String() } +func (m shellModel) renderSessionQuickSwitcherRow(index int, width int) string { + s := m.sessions.matches[index] + marker := " " + if index == m.sessions.selectedIndex { + marker = "▶" + } + rawSessionID := s.Identity.SessionID + sessionLabel := sanitizeUIText(rawSessionID) + if sessionLabel == "" { + sessionLabel = "session" + } + if m.watch.projection.Activity.Active.Kind == "session" && strings.TrimSpace(m.watch.projection.Activity.Active.ID) != "" && m.watch.projection.Activity.Active.ID == rawSessionID { + sessionLabel = "● " + sessionLabel + } + preview := truncateText(sanitizeUIText(s.LastActivityPreview), 58) + lineParts := []string{fmt.Sprintf("%s %s", marker, sessionLabel), sessionHighLevelCue(s)} + if counts := strings.TrimSpace(compactSessionSwitcherCounts(s)); counts != "" { + lineParts = append(lineParts, "["+counts+"]") + } + line := clipDisplayText(strings.Join(lineParts, " "), width) + detailParts := make([]string, 0, 3) + if workspace := sanitizeUIText(s.Identity.WorkspaceID); workspace != "" { + detailParts = append(detailParts, "Workspace "+workspace) + } + if activityKind := sanitizeUIText(s.LastActivityKind); activityKind != "" { + detailParts = append(detailParts, "Recent activity "+activityKind) + } + if preview != "" { + detailParts = append(detailParts, preview) + } + detail := " " + strings.Join(detailParts, " • ") + if strings.TrimSpace(detail) == "" { + detail = " No recent activity yet." + } + if s.HasIncompleteTurn { + detail += " • Needs follow-up" + } + return compactLines(line, muted(clipDisplayText(detail, width))) +} + +func compactSessionSwitcherCounts(summary brokerapi.SessionSummary) string { + parts := make([]string, 0, 2) + if summary.LinkedRunCount > 0 { + parts = append(parts, countNoun(summary.LinkedRunCount, "run", "runs")) + } + if summary.LinkedApprovalCount > 0 { + parts = append(parts, countNoun(summary.LinkedApprovalCount, "approval", "approvals")) + } + if len(parts) == 0 { + return "idle" + } + return strings.Join(parts, " • ") +} + func (m shellModel) renderLeaderWhichKey() string { b := strings.Builder{} b.WriteString(tableHeader("Leader Mode") + " " + neutralBadge("start="+m.keys.LeaderStart.label()) + "\n") b.WriteString("Sequence: " + m.leader.SequenceLabel() + "\n") b.WriteString("Press esc to abort.\n") + if len(m.leader.prefix) == 0 { + b.WriteString("Help: " + m.keys.LeaderStart.label() + " leader mode; tab next focus area; shift+tab previous focus area.\n") + b.WriteString(" ctrl+p opens quick jump palette; ctrl+j opens session quick switcher.\n") + } choices := m.leader.Choices() if len(choices) == 0 { b.WriteString(muted("No valid next keys.")) @@ -108,18 +152,27 @@ func (m shellModel) renderLeaderWhichKey() string { } b.WriteString(tableHeader("Valid next keys")) b.WriteString("\n") - for _, choice := range choices { - suffix := "" - if choice.Completes { - suffix = " " + infoBadge("exec") - } - line := " • " + choice.Key + " — " + defaultPlaceholder(choice.Label, "(group)") + " — " + defaultPlaceholder(choice.Description, "") + suffix - b.WriteString(line) - b.WriteString("\n") - } + b.WriteString(renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: len(choices), + Width: boundedOverlayListWidth(m.width), + Height: 10, + GapMarker: "...", + RenderRow: func(index int) boundedListRow { + return boundedListRow{Text: leaderChoiceLine(choices[index])} + }, + })) + b.WriteString("\n") return b.String() } +func leaderChoiceLine(choice shellLeaderChoice) string { + suffix := "" + if choice.Completes { + suffix = " " + infoBadge("exec") + } + return " • " + choice.Key + " — " + defaultPlaceholder(choice.Label, "(group)") + " — " + defaultPlaceholder(choice.Description, "") + suffix +} + func (m shellModel) renderQuitConfirmDialog() string { reason := strings.TrimSpace(m.quitConfirm.reason) if reason == "" { @@ -136,7 +189,7 @@ func (m shellModel) renderQuitConfirmDialog() string { func boundedOverlayListWidth(viewportWidth int) int { if viewportWidth <= 0 { - return 0 + return 80 } width := overlayBlockWidth(viewportWidth) - 4 if width < 1 { @@ -150,7 +203,7 @@ func (m shellModel) paletteStartY() int { } func (m shellModel) sidebarYRange() (startY int, endY int) { - startY = shellTopStatusHeight + shellSyncHealthHeight + shellBreadcrumbHeight + shellHistoryHeight + shellPaneSpacerHeight + 3 + startY = shellTopStatusHeight + shellSyncHealthHeight + shellPaneSpacerHeight + 3 endY = startY + m.sidebarMouseRowCount() - 1 return startY, endY } @@ -199,16 +252,10 @@ func (m shellModel) renderPaneActivityMarker() string { if m.watch.projection.Activity.State != shellActivityStateRunning && m.watch.projection.Activity.State != shellActivityStateWaiting { return "" } - if strings.TrimSpace(m.watch.projection.Activity.Active.Kind) == "" || strings.TrimSpace(m.watch.projection.Activity.Active.ID) == "" { - if m.watch.projection.Activity.State == shellActivityStateWaiting { - return warnBadge("WAITING") - } - return infoBadge("ACTIVE") - } if m.watch.projection.Activity.State == shellActivityStateWaiting { - return warnBadge(fmt.Sprintf("WAITING %s=%s", sanitizeUIText(m.watch.projection.Activity.Active.Kind), sanitizeUIText(m.watch.projection.Activity.Active.ID))) + return warnBadge("WAITING") } - return infoBadge(fmt.Sprintf("ACTIVE %s=%s", sanitizeUIText(m.watch.projection.Activity.Active.Kind), sanitizeUIText(m.watch.projection.Activity.Active.ID))) + return infoBadge("WORKING") } func (m shellModel) renderRunningIndicator() string { @@ -216,9 +263,34 @@ func (m shellModel) renderRunningIndicator() string { return "" } frames := []string{"⠁", "⠂", "⠄", "⠂", "⠁", "⠈", "⠐", "⠈"} - label := "running" - if strings.TrimSpace(m.watch.projection.Activity.Active.Kind) != "" && strings.TrimSpace(m.watch.projection.Activity.Active.ID) != "" { - label = fmt.Sprintf("running %s:%s", sanitizeUIText(m.watch.projection.Activity.Active.Kind), sanitizeUIText(m.watch.projection.Activity.Active.ID)) + label := "working" + if target := strings.TrimSpace(humanShellActivityTarget(m.watch.projection.Activity.Active)); target != "" { + label = "working on " + target } return infoBadge(frames[m.activityFrame%len(frames)] + " " + label) } + +func (m shellModel) renderPrimaryWorkbenchActions() string { + parts := make([]string, 0, 3) + if _, ok := m.actions.definitionByID("shell.open_action_center"); ok { + parts = append(parts, "Action Center") + } + if _, ok := m.actions.definitionByID("shell.open_approvals"); ok { + parts = append(parts, "Approvals") + } + if _, ok := m.actions.definitionByID("shell.open_palette"); ok { + if m.width > 0 && m.width < shellMediumMinWidth { + parts = append(parts, "Cmds ^P/:") + } else { + parts = append(parts, "Commands ctrl+p/:") + } + } + if m.width > 0 && m.width < shellMediumMinWidth && len(parts) > 2 { + parts = parts[len(parts)-2:] + } + return strings.Join(parts, " · ") +} + +func renderOverlaySearchPrompt(query string) string { + return appTheme.TextSecondary.Render("› Search") + " " + paletteSearchText(query) +} diff --git a/cmd/runecode-tui/shell_services.go b/cmd/runecode-tui/shell_services.go index 01520929..6893c8a5 100644 --- a/cmd/runecode-tui/shell_services.go +++ b/cmd/runecode-tui/shell_services.go @@ -2,10 +2,8 @@ package main import ( "encoding/base64" - "encoding/json" "io" "os" - "path/filepath" "sort" "strings" @@ -293,132 +291,3 @@ func osc52EnabledByEnv(value string) bool { return false } } - -type workbenchLocalState struct { - SidebarVisible bool - InspectorVisible bool - InspectorMode contentPresentationMode - ThemePreset themePreset - LeaderKey string - LastRouteID routeID - LastSessionID string - LastSessionByWS map[string]string - PinnedSessions []workbenchSessionRef - RecentSessions []workbenchSessionRef - RecentObjects []workbenchObjectRef - ViewedActivity map[string]string - SidebarPaneRatio float64 - InspectorPaneRatio float64 - SidebarCollapsed bool - InspectorCollapsed bool -} - -type shellWorkbenchStateStore interface { - Read(targetKey string) workbenchLocalState - Write(targetKey string, next workbenchLocalState) -} - -type memoryWorkbenchStateStore struct { - states map[string]workbenchLocalState -} - -func (s *memoryWorkbenchStateStore) Read(targetKey string) workbenchLocalState { - if s == nil || strings.TrimSpace(targetKey) == "" { - return workbenchLocalState{} - } - if s.states == nil { - return workbenchLocalState{} - } - return s.states[targetKey] -} - -func (s *memoryWorkbenchStateStore) Write(targetKey string, next workbenchLocalState) { - if s == nil || strings.TrimSpace(targetKey) == "" { - return - } - if s.states == nil { - s.states = map[string]workbenchLocalState{} - } - s.states[targetKey] = next -} - -type workbenchSessionRef struct { - WorkspaceID string `json:"workspace_id"` - SessionID string `json:"session_id"` -} - -type workbenchObjectRef struct { - Kind string `json:"kind"` - ID string `json:"id"` - WorkspaceID string `json:"workspace_id,omitempty"` - SessionID string `json:"session_id,omitempty"` -} - -type workbenchPersistenceEnvelope struct { - SchemaVersion int `json:"schema_version"` - Targets map[string]workbenchLocalState `json:"targets"` -} - -const workbenchPersistenceSchemaVersion = 1 - -type fileWorkbenchStateStore struct { - path string -} - -func newDefaultWorkbenchStateStore() shellWorkbenchStateStore { - configRoot, err := os.UserConfigDir() - if err != nil || strings.TrimSpace(configRoot) == "" { - return &memoryWorkbenchStateStore{} - } - return &fileWorkbenchStateStore{path: filepath.Join(configRoot, "runecode", "tui", "workbench-state.json")} -} - -func (s *fileWorkbenchStateStore) Read(targetKey string) workbenchLocalState { - env := s.readEnvelope() - if env.Targets == nil { - return workbenchLocalState{} - } - return env.Targets[strings.TrimSpace(targetKey)] -} - -func (s *fileWorkbenchStateStore) Write(targetKey string, next workbenchLocalState) { - targetKey = strings.TrimSpace(targetKey) - if targetKey == "" || strings.TrimSpace(s.path) == "" { - return - } - env := s.readEnvelope() - if env.Targets == nil { - env.Targets = map[string]workbenchLocalState{} - } - env.SchemaVersion = workbenchPersistenceSchemaVersion - env.Targets[targetKey] = next - if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { - return - } - raw, err := json.MarshalIndent(env, "", " ") - if err != nil { - return - } - _ = os.WriteFile(s.path, raw, 0o600) -} - -func (s *fileWorkbenchStateStore) readEnvelope() workbenchPersistenceEnvelope { - if strings.TrimSpace(s.path) == "" { - return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} - } - raw, err := os.ReadFile(s.path) - if err != nil || len(raw) == 0 { - return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} - } - var env workbenchPersistenceEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} - } - if env.Targets == nil { - env.Targets = map[string]workbenchLocalState{} - } - if env.SchemaVersion <= 0 { - env.SchemaVersion = workbenchPersistenceSchemaVersion - } - return env -} diff --git a/cmd/runecode-tui/shell_services_test.go b/cmd/runecode-tui/shell_services_test.go index 43b61f18..4882cc9a 100644 --- a/cmd/runecode-tui/shell_services_test.go +++ b/cmd/runecode-tui/shell_services_test.go @@ -175,11 +175,26 @@ func TestCenteredOverlayBlockBoundedClipsToHeight(t *testing.T) { if got := lipgloss.Width(rendered); got != 80 { t.Fatalf("expected bounded overlay width=80, got %d", got) } + lines := strings.Split(rendered, "\n") + if len(lines) == 0 || !strings.Contains(lines[0], "┌") || !strings.Contains(lines[0], "┐") { + t.Fatalf("expected top frame row to include both overlay corners, got %q", rendered) + } + hasRightEdge := false + for _, line := range lines[1:] { + trimmed := strings.TrimRight(line, " ") + if strings.HasSuffix(trimmed, "│") { + hasRightEdge = true + break + } + } + if !hasRightEdge { + t.Fatalf("expected at least one overlay body row to retain the right border, got %q", rendered) + } } func TestCenteredOverlayContentBoundsUseInnerContentArea(t *testing.T) { start, end := centeredOverlayContentBounds(80) - if start != 2 || end != 69 { - t.Fatalf("expected content bounds [2,69], got [%d,%d]", start, end) + if start != 6 || end != 71 { + t.Fatalf("expected centered content bounds [6,71], got [%d,%d]", start, end) } } diff --git a/cmd/runecode-tui/shell_shortcuts_test.go b/cmd/runecode-tui/shell_shortcuts_test.go index dbee4ddf..edcdec4f 100644 --- a/cmd/runecode-tui/shell_shortcuts_test.go +++ b/cmd/runecode-tui/shell_shortcuts_test.go @@ -9,15 +9,23 @@ import ( func TestHelpRenderedFromRealKeyBindings(t *testing.T) { m := newShellModel() + m.width = 150 + m.height = 40 help := renderHelp(defaultShellKeyMap(), false, m.actions) - for _, want := range []string{"ctrl+c", "space", "tab", "shift+tab", "ctrl+p", "ctrl+j"} { - if !strings.Contains(help, want) { - t.Fatalf("expected %q in help, got %q", want, help) + mustContainAll(t, help, "space leader", "ctrl+p commands", "ctrl+j sessions", "tab focus") + if strings.Contains(help, "ctrl+n") || strings.Contains(help, "Open selected match") { + t.Fatalf("expected compact footer help without overlay-specific bindings, got %q", help) + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + v := updated.(shellModel).View() + for _, want := range []string{"space", "tab", "shift+tab", "ctrl+p", "ctrl+j"} { + if !strings.Contains(v, want) { + t.Fatalf("expected %q in leader overlay help, got %q", want, v) } } for _, retired := range []string{"q/ctrl+c", "b/alt+left", "0-9", "pgup", "pgdown"} { - if strings.Contains(help, retired) { - t.Fatalf("did not expect retired shortcut %q in help, got %q", retired, help) + if strings.Contains(v, retired) { + t.Fatalf("did not expect retired shortcut %q in leader overlay help, got %q", retired, v) } } } @@ -172,13 +180,18 @@ func TestShellFormerPlainLetterGlobalsFlowToActiveRouteTyping(t *testing.T) { func TestShellCtrlPRemainsExplicitPaletteEntry(t *testing.T) { m := newShellModel() updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) - if cmd != nil { - t.Fatal("did not expect separate command while opening palette") - } shell := updated.(shellModel) if !shell.palette.IsOpen() { t.Fatal("expected ctrl+p to open command palette") } + if cmd == nil { + return + } + updated, _ = shell.Update(cmd()) + shell = updated.(shellModel) + if !shell.palette.IsOpen() { + t.Fatal("expected palette to remain open after background refresh") + } } func TestShellTextEntryTypingDoesNotOpenLeaderOrPalette(t *testing.T) { @@ -774,3 +787,44 @@ func TestLeaderStartKeyValidationRejectsUnsafeOptions(t *testing.T) { } } } + +func TestShellLeaderBindingsRefreshWhenAvailabilityChanges(t *testing.T) { + m := newShellModel() + m.width = 150 + cmd := m.commands.commands["shell.copy_identity"] + cmd.ID = "shell.custom_sensitive" + cmd.Title = "Custom Sensitive" + cmd.Aliases = []string{"custom sensitive"} + cmd.LeaderPath = []string{"z", "s"} + cmd.LeaderGroup = "Custom" + m.commands.Register(cmd) + m.actions = newShellActionGraph(m.routes, m.commands) + m.location.Primary = shellObjectLocation{RouteID: routeProviders, Object: workbenchObjectRef{Kind: "route", ID: string(routeProviders)}} + m.routeModels[routeProviders] = providerSetupRouteModel{def: routeDefinition{ID: routeProviders, Label: "Model Providers"}, entryActive: true} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + shell := updated.(shellModel) + for _, choice := range shell.leader.Choices() { + if choice.Key == "z" { + t.Fatalf("expected custom family unavailable during exclusive capture, got %+v", shell.leader.Choices()) + } + } + + provider := shell.routeModels[routeProviders].(providerSetupRouteModel) + provider.entryActive = false + shell.routeModels[routeProviders] = provider + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyEsc}) + shell = updated.(shellModel) + updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + shell = updated.(shellModel) + foundCustom := false + for _, choice := range shell.leader.Choices() { + if choice.Key == "z" { + foundCustom = true + break + } + } + if !foundCustom { + t.Fatalf("expected leader bindings refreshed after availability change, got %+v", shell.leader.Choices()) + } +} diff --git a/cmd/runecode-tui/shell_sidebar.go b/cmd/runecode-tui/shell_sidebar.go index e93b33e4..1469a916 100644 --- a/cmd/runecode-tui/shell_sidebar.go +++ b/cmd/runecode-tui/shell_sidebar.go @@ -205,7 +205,7 @@ func (m shellModel) appendSidebarSessionLines(lines []string, entries []sidebarE continue } selected := i == cursor - item := sessionDirectoryLine(entry.Session, m.activeSessionID, m.pinnedSessions, recentOrder, m.viewedActivity, m.watch.projection.Activity.Active) + item := sessionSidebarLine(entry.Session, m.activeSessionID, m.pinnedSessions, recentOrder, m.viewedActivity, m.watch.projection.Activity.Active) marker := " " if selected { marker = ">" diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 0c9d7c07..cd5bf034 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -165,8 +165,8 @@ func TestShellViewCompositorPlacesPanesHorizontally(t *testing.T) { if !strings.Contains(v, "┌") || !strings.Contains(v, "┐") { t.Fatalf("expected lipgloss pane borders in compositor output, got %q", v) } - if strings.Contains(v, "││") { - t.Fatalf("expected single-width shared pane separators without doubled borders, got %q", v) + if !strings.Contains(v, "Sidebar (22%)") || !strings.Contains(v, "Main pane —") { + t.Fatalf("expected sidebar and main pane headings in compositor output, got %q", v) } if !strings.Contains(v, "Main pane") || !strings.Contains(v, "Sidebar") { t.Fatalf("expected main+sidebar pane titles in compositor output, got %q", v) @@ -196,8 +196,8 @@ func TestRenderShellPanesDoesNotDoubleConstrainRenderedRow(t *testing.T) { surface := m.activeShellSurface() layout := m.planShellLayout(surface) row := m.renderShellPanes(surface, layout) - if strings.Contains(row, "││") { - t.Fatalf("expected rendered pane row not to be re-split into double separators, got %q", row) + if !strings.Contains(row, "Sidebar (22%)") || !strings.Contains(row, "Main pane —") { + t.Fatalf("expected rendered pane row to preserve sidebar and main pane headings, got %q", row) } if got := lipgloss.Height(row); got < layout.Regions.Main.Height { t.Fatalf("expected pane row height at least %d, got %d", layout.Regions.Main.Height, got) @@ -349,11 +349,16 @@ func TestShellViewRendersShellSurfaces(t *testing.T) { m := newShellModel() m.width = 150 v := m.View() - for _, want := range []string{"Runecode TUI α shell", "Path:", "History:", "Main pane", "Sidebar", "Bottom strip", "Status:"} { + for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "ctrl+p commands", "r reload"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in view, got %q", want, v) } } + for _, retired := range []string{"Path:", "History:", "clipboard=", localBrokerBoundaryPosture(), "inspector on", "copy actions 3 via action entry", "Workbench actions"} { + if strings.Contains(v, retired) { + t.Fatalf("did not expect retired dense chrome text %q in view, got %q", retired, v) + } + } for _, want := range []string{"┌────────────────", "FOCUS"} { if !strings.Contains(v, want) { t.Fatalf("expected pane framing affordance %q in view, got %q", want, v) @@ -386,10 +391,10 @@ func TestShellOverlayRemainsVisibleWithinViewport(t *testing.T) { if got := lipgloss.Height(v); got != 28 { t.Fatalf("expected full-frame height=28 with overlay open, got %d", got) } - if !strings.Contains(v, "Workbench Command Surface") { + if !strings.Contains(v, "Command Palette") { t.Fatalf("expected palette overlay content in viewport, got %q", v) } - for _, want := range []string{"Overlay", "Matches"} { + for _, want := range []string{"Suggested actions", "esc", "close"} { if !strings.Contains(v, want) { t.Fatalf("expected styled overlay affordance %q in viewport, got %q", want, v) } @@ -416,7 +421,7 @@ func TestShellOverlayNarrowViewportKeepsFrameBounds(t *testing.T) { t.Fatalf("expected overlay/frame line width <= 42, got %d in %q", lipgloss.Width(line), line) } } - if !strings.Contains(v, "Workbench Command Surface") { + if !strings.Contains(v, "Command Palette") { t.Fatalf("expected palette overlay content in narrow viewport, got %q", v) } } @@ -432,11 +437,11 @@ func TestShellOverlayBodyHeightClampsToViewportBudget(t *testing.T) { layout := shell.planShellLayout(surface) overlay, overlayHeight := shell.overlayBodyWithHeight(surface, layout, shell.height) - if overlayHeight != 4 { - t.Fatalf("expected overlay height=4 from viewport budget (12-8), got %d", overlayHeight) + if overlayHeight != 3 { + t.Fatalf("expected overlay height=3 from current viewport budget, got %d", overlayHeight) } - if got := lipgloss.Height(overlay); got != 4 { - t.Fatalf("expected rendered overlay block height=4, got %d", got) + if got := lipgloss.Height(overlay); got != 3 { + t.Fatalf("expected rendered overlay block height=3, got %d", got) } for _, line := range strings.Split(overlay, "\n") { if lipgloss.Width(line) > 52 { @@ -515,6 +520,12 @@ func TestShellToastRemainsVisibleWithinViewport(t *testing.T) { if !strings.Contains(v, "Toast: INFO: Sidebar visibility changed.") { t.Fatalf("expected toast content in viewport, got %q", v) } + if strings.Contains(v, "Overlay stack") { + t.Fatalf("expected toast not to render as centered overlay, got %q", v) + } + if strings.HasSuffix(v, "\n") { + t.Fatalf("expected shell frame not to leave trailing blank line below footer, got %q", v) + } } func TestShellClipboardCopiesCurrentBreadcrumbIdentity(t *testing.T) { @@ -559,7 +570,7 @@ func TestShellSelectionModeToggleReflectsInView(t *testing.T) { t.Fatal("expected selection mode enabled") } v := shell.View() - if !strings.Contains(v, "selection=on") { + if !strings.Contains(v, "Selection mode on") { t.Fatalf("expected selection mode state in view, got %q", v) } } @@ -609,15 +620,6 @@ func TestShellOverlayDoesNotBlockWatchUpdates(t *testing.T) { } } -func TestShellBottomStripSelectionHintUsesCtrlT(t *testing.T) { - m := newShellModel() - m.width = 150 - v := m.View() - if !strings.Contains(v, "Selection mode off") { - t.Fatalf("expected updated selection hint in bottom strip, got %q", v) - } -} - func TestShellScrollDispatchTargetsRouteViewportState(t *testing.T) { m := newShellModel() m.width = 150 @@ -718,19 +720,6 @@ func TestShellPaletteCommandEntriesComeFromActionDefinitions(t *testing.T) { } } -func TestHelpIncludesActionMetadataFromUnifiedDefinitions(t *testing.T) { - m := newShellModel() - cmd := m.commands.commands["shell.focus_main"] - cmd.HelpText = "focus main pane — custom help text" - m.commands.Register(cmd) - m.actions = newShellActionGraph(m.routes, m.commands) - - help := renderHelp(m.keys, false, m.actions) - if !strings.Contains(help, "focus main pane — custom help text") { - t.Fatalf("expected help to include action-metadata help text, got %q", help) - } -} - func TestShellPaletteNavigationFromFreshLaunchUsesShellIndex(t *testing.T) { m := newShellModel() m.client = &fakeBrokerClient{} @@ -746,11 +735,23 @@ func TestShellPaletteNavigationFromFreshLaunchUsesShellIndex(t *testing.T) { updated, _ := m.Update(loadedMsg) shell := updated.(shellModel) - updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + updated, cmd := shell.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) shell = updated.(shellModel) + if cmd != nil { + updated, follow := shell.Update(cmd()) + shell = updated.(shellModel) + if follow != nil { + updated, _ = shell.Update(follow()) + shell = updated.(shellModel) + } + } for _, r := range "run-1" { - updated, _ = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, cmd = shell.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) shell = updated.(shellModel) + if cmd != nil { + updated, _ = shell.Update(cmd()) + shell = updated.(shellModel) + } } selected, ok := shell.palette.SelectedEntry() if !ok { @@ -760,7 +761,7 @@ func TestShellPaletteNavigationFromFreshLaunchUsesShellIndex(t *testing.T) { t.Fatalf("expected selected run entry after query, got %q", selected.Label) } - updated, cmd := shell.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd = shell.Update(tea.KeyMsg{Type: tea.KeyEnter}) if cmd == nil { t.Fatal("expected palette pick command") } @@ -855,10 +856,10 @@ func TestShellWatchManagerUpdatesRoutesAndSyncHealth(t *testing.T) { } view := shell.View() mustContainAll(t, view, - "Sync health:", - "sync=healthy", - "last_event=run_watch_terminal subject=run-1 status=completed", - "event=session_watch_terminal subject=session-1 status=completed", + "Product truth:", + "Sync healthy", + "Open Action Center for the follow-up, then inspect Audit or Runs", + "Evidence: Runs, Audit, and Status keep proof details.", ) } @@ -873,7 +874,7 @@ func TestShellWatchManagerRendersDisconnectedHealth(t *testing.T) { if shell.watch.projection.Health.State != shellSyncStateDisconnected { t.Fatalf("expected disconnected sync, got %s", shell.watch.projection.Health.State) } - if !strings.Contains(shell.View(), "sync=disconnected") { + if !strings.Contains(shell.View(), "Sync disconnected") { t.Fatalf("expected disconnected indicator in view, got %q", shell.View()) } } diff --git a/cmd/runecode-tui/shell_update.go b/cmd/runecode-tui/shell_update.go index 6b2e211f..06f68e5d 100644 --- a/cmd/runecode-tui/shell_update.go +++ b/cmd/runecode-tui/shell_update.go @@ -7,6 +7,30 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +func (m *shellModel) prepareOverlayFrameCache(msg tea.Msg) { + if m.shouldPreserveOverlayFrameCache(msg) { + return + } + m.invalidateOverlayFrameCache() +} + +func (m shellModel) shouldPreserveOverlayFrameCache(msg tea.Msg) bool { + if !m.overlayFrameCacheable() { + return false + } + switch m.activeOverlayID() { + case overlayIDQuickJump, overlayIDSessions, overlayIDLeader, overlayIDQuitConfirm: + switch msg.(type) { + case tea.KeyMsg, tea.MouseMsg: + return true + default: + return false + } + default: + return false + } +} + func (m shellModel) disarmEmergencyQuitOnNormalInteraction(msg tea.Msg) shellModel { if !m.emergencyQuit.pending { return m @@ -21,6 +45,7 @@ func (m shellModel) disarmEmergencyQuitOnNormalInteraction(msg tea.Msg) shellMod switch msg.(type) { case tea.KeyMsg, tea.MouseMsg: m.emergencyQuit.pending = false + m.invalidateOverlayFrameCache() } return m } @@ -35,9 +60,11 @@ func (m shellModel) handleQuitMessage(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.quitConfirm = shellQuitConfirmState{} m.emergencyQuit.pending = false m.quitting = true + m.invalidateOverlayFrameCache() return m, tea.Quit, true } m.emergencyQuit.pending = true + m.invalidateOverlayFrameCache() m.emergencyQuit.token++ token := m.emergencyQuit.token tick := tea.Tick(emergencyQuitArmWindow, func(time.Time) tea.Msg { @@ -48,6 +75,7 @@ func (m shellModel) handleQuitMessage(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } if m.emergencyQuit.pending { m.emergencyQuit.pending = false + m.invalidateOverlayFrameCache() } return m, nil, false } @@ -64,6 +92,7 @@ func (m shellModel) handleEmergencyQuitTimeoutMessage(msg tea.Msg) (tea.Model, t return m, nil, true } m.emergencyQuit.pending = false + m.invalidateOverlayFrameCache() return m, nil, true } @@ -142,11 +171,13 @@ func (m shellModel) handleQuitConfirmMessage(msg tea.Msg) (tea.Model, tea.Cmd, b case key.Type == tea.KeyEnter || key.String() == "y": m.quitConfirm = shellQuitConfirmState{} m.quitting = true + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() return m, tea.Quit, true case key.Type == tea.KeyEsc || key.String() == "n": m.quitConfirm = shellQuitConfirmState{} + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() return m, nil, true @@ -168,6 +199,20 @@ func (m shellModel) handleShellMessage(msg tea.Msg) (tea.Model, tea.Cmd, bool) { case shellObjectIndexLoadedMsg: m.applyObjectIndexLoaded(typed) return m, nil, true + case shellPaletteEntriesLoadedMsg: + if updated, applied := m.palette.ApplyEntriesRefresh(typed); applied { + m.palette = updated + if next, request, ok := m.palette.BeginFilterRefresh(); ok { + m.palette = next + return m, m.loadPaletteFilterCmd(request), true + } + } + return m, nil, true + case shellPaletteFilterLoadedMsg: + if updated, applied := m.palette.ApplyFilterRefresh(typed); applied { + m.palette = updated + } + return m, nil, true case shellWatchPollMsg: return m, m.loadWatchPollCmd(), true case shellWatchTransportLoadedMsg: @@ -211,6 +256,7 @@ func (m shellModel) handleWindowSize(msg tea.Msg) (tea.Model, tea.Cmd, bool) { prev := m.breakpoint() m.width = typed.Width m.height = typed.Height + m.invalidateOverlayFrameCache() if prev != shellBreakpointNarrow && m.breakpoint() == shellBreakpointNarrow { m.narrowSidebarOn = false m.narrowInspectOn = false @@ -257,12 +303,14 @@ func (m shellModel) handleSessionQuickSwitchMessage(msg tea.Msg) (tea.Model, tea switch { case m.keys.SessionQuickSwitchClose.matches(key): m.sessions = m.sessions.Close() + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() return m, nil, true case m.keys.SessionQuickSwitchPick.matches(key): sid := strings.TrimSpace(m.sessions.SelectedSessionID()) m.sessions = m.sessions.Close() + m.invalidateOverlayFrameCache() m.syncOverlayStack() m.restoreFocusAfterOverlayClose() if sid == "" { @@ -290,6 +338,9 @@ func (m shellModel) handleSessionQuickSwitchMessage(msg tea.Msg) (tea.Model, tea func (m shellModel) handlePaletteMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd, bool) { updatedPalette, routeMsg, changed := m.palette.UpdateMouse(mouse, m.paletteStartY(), m.width) m.palette = updatedPalette + if !m.palette.IsOpen() { + m.invalidateOverlayFrameCache() + } m.syncOverlayStack() m.restoreFocusAfterOverlayClose() if changed { @@ -301,12 +352,22 @@ func (m shellModel) handlePaletteMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd, func (m shellModel) handlePaletteKey(key tea.KeyMsg) (tea.Model, tea.Cmd, bool) { updatedPalette, routeMsg, changed := m.palette.Update(key, m.keys) m.palette = updatedPalette + var cmd tea.Cmd + if changed == false { + if next, request, ok := m.palette.BeginFilterRefresh(); ok { + m.palette = next + cmd = m.loadPaletteFilterCmd(request) + } + } + if !m.palette.IsOpen() { + m.invalidateOverlayFrameCache() + } m.syncOverlayStack() m.restoreFocusAfterOverlayClose() if changed { return m, func() tea.Msg { return routeMsg }, true } - return m, nil, true + return m, cmd, true } func (m *shellModel) syncOverlayStack() { diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index f2a53f45..6cebacb1 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -12,10 +12,12 @@ func (m shellModel) View() string { return "Goodbye from runecode-tui.\n" } - surface := m.activeShellSurface() - layout := m.planShellLayout(surface) viewportWidth, viewportHeight := normalizedShellViewport(m.width, m.height) - workbench := m.renderShellWorkbench(surface, layout, viewportWidth, viewportHeight) + workbench, ok := m.cachedOverlayWorkbench(viewportWidth, viewportHeight) + if !ok { + surface, layout := m.activeShellSurfacePlan() + workbench = m.renderShellWorkbench(surface, layout, viewportWidth, viewportHeight) + } root := appTheme.SurfaceBase. Width(viewportWidth). MaxWidth(viewportWidth). @@ -26,151 +28,52 @@ func (m shellModel) View() string { func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayoutPlan, viewportWidth int, viewportHeight int) string { overlayBody, _ := m.overlayBodyWithHeight(surface, layout, viewportHeight) - b := strings.Builder{} - m.writeShellFrame(&b, surface, layout) - m.writeShellFooter(&b) - frame := padShellBlock(strings.TrimRight(b.String(), "\n"), viewportWidth, m.availableShellHeight()) + frame := m.renderShellFrame(surface, layout, viewportWidth, viewportHeight) if strings.TrimSpace(overlayBody) == "" { + if m.overlayFrameCache != nil { + *m.overlayFrameCache = shellOverlayFrameCache{} + } return lipgloss.JoinVertical(lipgloss.Left, frame) } - return lipgloss.JoinVertical(lipgloss.Left, frame, overlayBody) + return applyModalOverlay(scrubShellFrameForOverlay(frame, viewportWidth, viewportHeight), overlayBody, viewportWidth, viewportHeight) +} + +func (m shellModel) renderShellFrame(surface routeSurface, layout shellLayoutPlan, viewportWidth int, viewportHeight int) string { + if m.overlayFrameCacheable() && m.overlayFrameCache != nil { + if cached := m.overlayFrameCache; cached.overlay == m.activeOverlayID() && cached.width == viewportWidth && cached.height == viewportHeight && cached.frame != "" { + return cached.frame + } + } + b := strings.Builder{} + m.writeShellFrame(&b, surface, layout, viewportWidth) + m.writeShellFooter(&b, viewportWidth) + frame := padShellFrame(strings.TrimRight(b.String(), "\n"), viewportWidth, viewportHeight) + if m.overlayFrameCacheable() && m.overlayFrameCache != nil { + *m.overlayFrameCache = shellOverlayFrameCache{overlay: m.activeOverlayID(), width: viewportWidth, height: viewportHeight, frame: frame} + } + return frame } -func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, layout shellLayoutPlan) { - viewportWidth, _ := normalizedShellViewport(m.width, m.height) +func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, layout shellLayoutPlan, viewportWidth int) { b.WriteString(constrainShellBlock(m.renderTopStatus(surface, layout), viewportWidth, shellTopStatusHeight)) b.WriteString("\n") b.WriteString(constrainShellBlock(m.renderSyncHealth(), viewportWidth, shellSyncHealthHeight)) b.WriteString("\n") - b.WriteString(constrainShellBlock(m.renderBreadcrumbs(surface), viewportWidth, shellBreadcrumbHeight)) - b.WriteString("\n") - b.WriteString(constrainShellBlock(m.renderHistory(), viewportWidth, shellHistoryHeight)) - b.WriteString("\n") b.WriteString(constrainShellBlock("", viewportWidth, shellPaneSpacerHeight)) b.WriteString("\n") b.WriteString(m.renderShellPanes(surface, layout)) b.WriteString("\n") - b.WriteString(constrainShellBlock(m.renderBottomStrip(surface), viewportWidth, layout.Regions.Bottom.Height)) - b.WriteString("\n") b.WriteString(constrainShellBlock(m.renderStatusSurface(surface), viewportWidth, layout.Regions.Status.Height)) b.WriteString("\n") + b.WriteString(constrainShellBlock(m.renderBottomStrip(surface), viewportWidth, layout.Regions.Bottom.Height)) } -func (m shellModel) writeShellFooter(b *strings.Builder) { - viewportWidth, _ := normalizedShellViewport(m.width, m.height) - b.WriteString(constrainShellBlock(renderHelp(m.keys, m.palette.IsOpen() || m.sessions.IsOpen(), m.actions), viewportWidth, 1)) - b.WriteString("\n") - b.WriteString(constrainShellBlock(muted(localBrokerBoundaryPosture()), viewportWidth, 1)) - b.WriteString("\n") - b.WriteString(constrainShellBlock(muted("Trust boundary: typed broker contracts only; no CLI scraping or daemon-private path modeling."), viewportWidth, 1)) - b.WriteString("\n") -} - -func (m shellModel) overlayBodyWithHeight(surface routeSurface, layout shellLayoutPlan, viewportHeight int) (string, int) { - parts := m.overlayStaticParts() - maxOverlayHeight := overlayHeightBudget(viewportHeight) - remainingHeight := overlayRemainingHeight(parts, maxOverlayHeight) - overlayWidth := normalizedOverlayWidth(m.width) - overlay := m.activeOverlayBody(surface, layout, overlayWidth, remainingHeight) - if strings.TrimSpace(overlay) != "" { - parts = append(parts, overlay) - } - if len(parts) == 0 { - return "", 0 - } - contentHeight := lipgloss.Height(strings.Join(parts, "\n")) - if contentHeight > maxOverlayHeight { - contentHeight = maxOverlayHeight - } - content := constrainShellBlock(strings.Join(parts, "\n"), overlayWidth, contentHeight) - return content, lipgloss.Height(content) -} - -func (m shellModel) overlayStaticParts() []string { - parts := []string{} - if toast := strings.TrimSpace(m.toasts.Latest()); toast != "" { - parts = append(parts, "Toast: "+sanitizeUIText(toast)) - } - return parts -} - -func overlayHeightBudget(viewportHeight int) int { - maxOverlayHeight := viewportHeight - 8 - if maxOverlayHeight < 1 { - return 1 - } - return maxOverlayHeight -} - -func overlayRemainingHeight(parts []string, maxOverlayHeight int) int { - remainingHeight := maxOverlayHeight - lipgloss.Height(strings.Join(parts, "\n")) - if len(parts) > 0 { - remainingHeight-- - } - if remainingHeight < 1 { - return 1 - } - return remainingHeight -} - -func (m shellModel) activeOverlayBody(surface routeSurface, layout shellLayoutPlan, overlayWidth int, remainingHeight int) string { - switch { - case m.palette.IsOpen(): - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDQuickJump, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderPalette()), overlayWidth, remainingHeight)) - case m.sessions.IsOpen(): - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDSessions, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderSessionQuickSwitcher()), overlayWidth, remainingHeight)) - case m.leader.Active(): - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDLeader, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderLeaderWhichKey()), overlayWidth, remainingHeight)) - case m.quitConfirm.active: - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDQuitConfirm, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderQuitConfirmDialog()), overlayWidth, remainingHeight)) - case m.narrowSidebarOn && m.breakpoint() == shellBreakpointNarrow: - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDSidebar, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderSidebar()), overlayWidth, remainingHeight)) - case m.narrowInspectOn && m.breakpoint() == shellBreakpointNarrow: - return m.narrowInspectorOverlayBody(surface, layout, overlayWidth, remainingHeight) - default: - return "" - } -} - -func (m shellModel) narrowInspectorOverlayBody(surface routeSurface, layout shellLayoutPlan, overlayWidth int, remainingHeight int) string { - inspector := strings.TrimSpace(surface.Regions.Inspector.Body) - title := strings.TrimSpace(surface.Regions.Inspector.Title) - if title == "" { - title = "Inspector" - } - if inspector != "" && routeInspectorAvailable(surface) && m.inspectorOn { - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDInspector, compactLines("Return focus: "+m.overlayReturn.Label(), title, inspector), overlayWidth, remainingHeight)) - } - if !layout.InspectorVisible && !routeInspectorAvailable(surface) { - return centeredOverlayBlockBounded(overlayIDInspector, compactLines("Return focus: "+m.overlayReturn.Label(), "Inspector unavailable for current route."), overlayWidth, remainingHeight) - } - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDInspector, compactLines("Return focus: "+m.overlayReturn.Label(), title, "No inspector item selected."), overlayWidth, remainingHeight)) -} - -func (m shellModel) activeOverlayHeight(viewportHeight int) int { - _, height := m.overlayBodyWithHeight(m.activeShellSurfaceWithoutOverlayHeight(), m.planShellLayout(m.activeShellSurfaceWithoutOverlayHeight()), viewportHeight) - return height -} - -func (m shellModel) activeShellSurfaceWithoutOverlayHeight() routeSurface { - active := m.routeModels[m.currentRouteID()] - if active == nil { - return routeSurface{} - } - baseCtx := routeShellContext{Width: m.width, Height: m.height, Focus: m.focus, Focused: m.focusedRouteRegion(), Breakpoint: m.breakpoint(), Render: routeShellRenderPreferences{PreferredPresentation: normalizePresentationMode(m.preferredMode), ThemePreset: normalizeThemePreset(m.themePreset)}} - surface := active.ShellSurface(baseCtx) - layout := m.planShellLayout(surface) - ctx := baseCtx - ctx.Regions = layout.Regions - ctx.Breakpoint = layout.Breakpoint - return m.withLocationChrome(active.ShellSurface(ctx)) -} - -func normalizedOverlayWidth(width int) int { - if width <= 0 { - return 1 +func (m shellModel) writeShellFooter(b *strings.Builder, viewportWidth int) { + if shellFooterHeight <= 0 { + return } - return width + b.WriteString("\n") + b.WriteString(constrainShellBlock(renderHelp(m.keys, m.palette.IsOpen() || m.sessions.IsOpen(), m.actions), viewportWidth, shellFooterHeight)) } func constrainShellBlock(block string, width int, height int) string { @@ -200,27 +103,22 @@ func constrainShellBlock(block string, width int, height int) string { } func (m shellModel) renderTopStatus(surface routeSurface, layout shellLayoutPlan) string { - selection := "off" - activity := renderShellActivityState(m.watch.projection.Activity.State) - if m.selectionMode { - selection = "on" + routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("RuneCode Workbench"), neutralBadge("ROUTE "+strings.ToUpper(sanitizeUIText(m.routeLabel(m.currentRouteID()))))) + workbenchSummary := []string{humanFocusLabel(m.focus) + " focus"} + if activity := strings.TrimSpace(humanShellActivitySummary(m.watch.projection.Activity)); activity != "" { + workbenchSummary = append(workbenchSummary, activity) + } else if strings.TrimSpace(m.activeSessionID) != "" { + workbenchSummary = append(workbenchSummary, fmt.Sprintf("Session %s", sanitizeUIText(m.activeSessionID))) } - routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("Runecode TUI α shell"), neutralBadge("THEME "+string(m.themePreset))) - workbenchSummary := []string{ - fmt.Sprintf("route=%s", m.routeLabel(m.currentRouteID())), - fmt.Sprintf("focus=%s", strings.ToUpper(m.focus.Label())), - fmt.Sprintf("layout=%s", layout.Breakpoint), - fmt.Sprintf("selection=%s", selection), - } - if routeInspectorAvailable(surface) { - workbenchSummary = append(workbenchSummary, fmt.Sprintf("inspector=%t", layout.InspectorVisible)) + if m.selectionMode { + workbenchSummary = append(workbenchSummary, "Selection mode on") } - if strings.TrimSpace(m.activeSessionID) != "" { - workbenchSummary = append(workbenchSummary, fmt.Sprintf("session=%s", sanitizeUIText(m.activeSessionID))) + if routeInspectorAvailable(surface) && layout.InspectorVisible { + workbenchSummary = append(workbenchSummary, "Inspector ready") } return compactLines( appTheme.SurfaceChrome.Padding(0, 1).Render(routeSummary), - appTheme.SurfaceChrome.Padding(0, 1).Render(strings.Join(append(workbenchSummary, activity), " • ")+renderRunningSuffix(m.renderRunningIndicator())), + appTheme.SurfaceChrome.Padding(0, 1).Render(strings.Join(workbenchSummary, " • ")), ) } @@ -233,12 +131,16 @@ func renderRunningSuffix(indicator string) string { } func (m shellModel) renderSyncHealth() string { - text := "Sync health: " + renderShellSyncState(m.watch.projection.Health.State) - if strings.TrimSpace(m.watch.projection.Activity.Active.ID) != "" { - text += " " + infoBadge(fmt.Sprintf("active_%s=%s", sanitizeUIText(m.watch.projection.Activity.Active.Kind), sanitizeUIText(m.watch.projection.Activity.Active.ID))) + prefix := "Product truth: " + if m.width > 0 && m.width < shellMediumMinWidth { + prefix = "Truth: " } + text := prefix + renderShellSyncState(m.watch.projection.Health.State) if strings.TrimSpace(m.watch.projection.Health.ErrorText) != "" { - text += " " + muted("("+sanitizeUIText(m.watch.projection.Health.ErrorText)+")") + text += " • " + muted("Status has detail: "+sanitizeUIText(m.watch.projection.Health.ErrorText)) + } + if actions := strings.TrimSpace(m.renderPrimaryWorkbenchActions()); actions != "" { + text += " • " + actions } return text } @@ -285,6 +187,39 @@ func (m shellModel) renderHistory() string { return muted("History: " + strings.Join(items, " <- ")) } +func (m shellModel) renderActiveWorkSummary() string { + active := m.watch.projection.Activity.Active + if strings.TrimSpace(active.ID) == "" { + return "" + } + label := sanitizeUIText(active.Kind) + if label == "" { + label = "work" + } + return fmt.Sprintf("Active %s %s", label, sanitizeUIText(active.ID)) +} + +func (m shellModel) renderChromeDiagnosticHint(surface routeSurface, layout shellLayoutPlan) string { + parts := []string{} + if routeInspectorAvailable(surface) { + parts = append(parts, fmt.Sprintf("inspector %s", boolOnOff(layout.InspectorVisible))) + } + if m.selectionMode { + parts = append(parts, "selection text") + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " • ") +} + +func boolOnOff(v bool) string { + if v { + return "on" + } + return "off" +} + func (m shellModel) renderShellPanes(surface routeSurface, layout shellLayoutPlan) string { viewportWidth, viewportHeight := normalizedShellViewport(m.width, m.height) mainTitle := "Main pane" @@ -294,7 +229,7 @@ func (m shellModel) renderShellPanes(surface routeSurface, layout shellLayoutPla if activity := strings.TrimSpace(m.renderPaneActivityMarker()); activity != "" { mainTitle += " " + activity } - mainBody := strings.TrimSpace(surface.Regions.Main.Body) + mainBody := strings.Trim(surface.Regions.Main.Body, "\n") if modes := renderModeSwitchTabs(surface.Actions.ModeTabs, surface.Actions.ActiveTab); strings.TrimSpace(modes) != "" { mainBody = compactLines(mainBody, modes) } @@ -317,7 +252,7 @@ func (m shellModel) renderShellPanes(surface routeSurface, layout shellLayoutPla if layout.Breakpoint == shellBreakpointWide { inspectorTitle += fmt.Sprintf(" (%.0f%%)", clampPaneRatio(m.inspectorRatio)*100) } - inspectorPane := renderShellPane(shellPaneSpec{Title: inspectorTitle, Body: strings.TrimSpace(surface.Regions.Inspector.Body), Width: routeRegionWidth(layout.Regions.Inspector, 0), Height: routeRegionHeight(layout.Regions.Inspector, viewportHeight), Focused: m.focus == focusInspector, Border: shellPaneBorder{Top: true, Bottom: true, Left: false, Right: true}}) + inspectorPane := renderShellPane(shellPaneSpec{Title: inspectorTitle, Body: strings.Trim(surface.Regions.Inspector.Body, "\n"), Width: routeRegionWidth(layout.Regions.Inspector, 0), Height: routeRegionHeight(layout.Regions.Inspector, viewportHeight), Focused: m.focus == focusInspector, Border: shellPaneBorder{Top: true, Bottom: true, Left: false, Right: true}}) row = joinPanesHorizontal(row, inspectorPane) } row = lipgloss.NewStyle().Width(viewportWidth).MaxWidth(viewportWidth).Render(row) @@ -354,7 +289,13 @@ func (m shellModel) renderSidebar() string { } entries := m.sidebarEntries() cursor := m.normalizedSidebarCursor(entries) - width := m.planShellLayout(m.activeShellSurface()).Regions.Sidebar.Width - 2 + var surface routeSurface + if m.breakpoint() == shellBreakpointNarrow && m.narrowSidebarOn { + surface = m.activeShellSurfaceWithoutOverlayHeight() + } else { + surface = m.activeShellSurface() + } + width := m.planShellLayout(surface).Regions.Sidebar.Width - 2 if width < 12 { width = 12 } @@ -368,3 +309,28 @@ func (m shellModel) renderSidebar() string { } return strings.Join(lines, "\n") } + +func padShellFrame(block string, width int, height int) string { + if height <= 0 { + return "" + } + if width <= 0 { + width = 1 + } + trimmed := strings.TrimRight(block, "\n") + if trimmed == "" { + return strings.TrimRight(lipgloss.NewStyle().Width(width).Height(height).Render(""), "\n") + } + rawLines := strings.Split(trimmed, "\n") + if len(rawLines) >= height { + return strings.Join(rawLines[:height], "\n") + } + padCount := height - len(rawLines) + lineStyle := lipgloss.NewStyle().Width(width).MaxWidth(width).Height(1).MaxHeight(1) + padded := make([]string, 0, height) + for i := 0; i < padCount; i++ { + padded = append(padded, lineStyle.Render("")) + } + padded = append(padded, rawLines...) + return strings.Join(padded, "\n") +} diff --git a/cmd/runecode-tui/shell_watch_projection.go b/cmd/runecode-tui/shell_watch_projection.go index 51348754..7dc3af84 100644 --- a/cmd/runecode-tui/shell_watch_projection.go +++ b/cmd/runecode-tui/shell_watch_projection.go @@ -105,30 +105,30 @@ func (m *shellModel) publishWatchStateToRoutes() { func renderShellSyncState(state shellSyncState) string { switch state { case shellSyncStateHealthy: - return successBadge("sync=healthy") + return successBadge("Sync healthy") case shellSyncStateDegraded: - return warnBadge("sync=degraded") + return warnBadge("Sync degraded") case shellSyncStateReconnecting: - return warnBadge("sync=reconnecting") + return warnBadge("Sync reconnecting") case shellSyncStateDisconnected: - return dangerBadge("sync=disconnected") + return dangerBadge("Sync disconnected") default: - return neutralBadge("sync=loading") + return neutralBadge("Sync loading") } } func renderShellActivityState(state shellActivityState) string { switch state { case shellActivityStateLoading: - return neutralBadge("activity=loading") + return neutralBadge("Activity loading") case shellActivityStateWaiting: - return warnBadge("activity=waiting") + return warnBadge("Activity waiting") case shellActivityStateRunning: - return infoBadge("activity=running") + return infoBadge("Activity running") case shellActivityStateDegradedSync: - return warnBadge("activity=degraded_sync") + return warnBadge("Activity degraded sync") default: - return neutralBadge("activity=idle") + return neutralBadge("Activity idle") } } diff --git a/cmd/runecode-tui/shell_watch_transport.go b/cmd/runecode-tui/shell_watch_transport.go index 251b5852..f16830c6 100644 --- a/cmd/runecode-tui/shell_watch_transport.go +++ b/cmd/runecode-tui/shell_watch_transport.go @@ -83,6 +83,8 @@ func (m shellModel) loadWatchPollCmd() tea.Cmd { func (m *shellModel) applyWatchTransport(msg shellWatchTransportLoadedMsg) { m.watch.applyTransport(msg) m.publishWatchStateToRoutes() + m.invalidatePaletteCache() + m.invalidateOverlayFrameCache() m.refreshObjectIndexFromShellState() } diff --git a/cmd/runecode-tui/shell_workbench_store.go b/cmd/runecode-tui/shell_workbench_store.go new file mode 100644 index 00000000..59807e41 --- /dev/null +++ b/cmd/runecode-tui/shell_workbench_store.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type workbenchLocalState struct { + SidebarVisible bool + InspectorVisible bool + InspectorMode contentPresentationMode + ThemePreset themePreset + LeaderKey string + LastRouteID routeID + LastSessionID string + LastSessionByWS map[string]string + PinnedSessions []workbenchSessionRef + RecentSessions []workbenchSessionRef + RecentObjects []workbenchObjectRef + ViewedActivity map[string]string + SidebarPaneRatio float64 + InspectorPaneRatio float64 + SidebarCollapsed bool + InspectorCollapsed bool +} + +type shellWorkbenchStateStore interface { + Read(targetKey string) workbenchLocalState + Write(targetKey string, next workbenchLocalState) +} + +type shellWorkbenchStateFlusher interface { + Flush() +} + +type shellWorkbenchStateErrorReporter interface { + LastError() error +} + +type memoryWorkbenchStateStore struct { + states map[string]workbenchLocalState +} + +func (s *memoryWorkbenchStateStore) Read(targetKey string) workbenchLocalState { + if s == nil || strings.TrimSpace(targetKey) == "" { + return workbenchLocalState{} + } + if s.states == nil { + return workbenchLocalState{} + } + return cloneWorkbenchLocalState(s.states[targetKey]) +} + +func (s *memoryWorkbenchStateStore) Write(targetKey string, next workbenchLocalState) { + if s == nil || strings.TrimSpace(targetKey) == "" { + return + } + if s.states == nil { + s.states = map[string]workbenchLocalState{} + } + s.states[targetKey] = cloneWorkbenchLocalState(next) +} + +func (s *memoryWorkbenchStateStore) Flush() {} + +type workbenchSessionRef struct { + WorkspaceID string `json:"workspace_id"` + SessionID string `json:"session_id"` +} + +type workbenchObjectRef struct { + Kind string `json:"kind"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +type workbenchPersistenceEnvelope struct { + SchemaVersion int `json:"schema_version"` + Targets map[string]workbenchLocalState `json:"targets"` +} + +const ( + workbenchPersistenceSchemaVersion = 1 + workbenchPersistenceDebounce = 25 * time.Millisecond +) + +type fileWorkbenchStateStore struct { + path string + + mu sync.Mutex + cond *sync.Cond + loaded bool + env workbenchPersistenceEnvelope + dirty bool + flushScheduled bool + flushing bool + lastErr error + + writeDebounce time.Duration + writeDelay func() +} + +func newDefaultWorkbenchStateStore() shellWorkbenchStateStore { + configRoot, err := os.UserConfigDir() + if err != nil || strings.TrimSpace(configRoot) == "" { + return &memoryWorkbenchStateStore{} + } + return newFileWorkbenchStateStore(filepath.Join(configRoot, "runecode", "tui", "workbench-state.json")) +} + +func newFileWorkbenchStateStore(path string) *fileWorkbenchStateStore { + store := &fileWorkbenchStateStore{path: path, writeDebounce: workbenchPersistenceDebounce} + store.cond = sync.NewCond(&store.mu) + return store +} + +func (s *fileWorkbenchStateStore) Read(targetKey string) workbenchLocalState { + targetKey = strings.TrimSpace(targetKey) + if s == nil || targetKey == "" { + return workbenchLocalState{} + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureLoadedLocked() + if s.env.Targets == nil { + return workbenchLocalState{} + } + return cloneWorkbenchLocalState(s.env.Targets[targetKey]) +} + +func (s *fileWorkbenchStateStore) Write(targetKey string, next workbenchLocalState) { + targetKey = strings.TrimSpace(targetKey) + if s == nil || targetKey == "" || strings.TrimSpace(s.path) == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureLoadedLocked() + if s.env.Targets == nil { + s.env.Targets = map[string]workbenchLocalState{} + } + s.env.SchemaVersion = workbenchPersistenceSchemaVersion + s.env.Targets[targetKey] = cloneWorkbenchLocalState(next) + s.dirty = true + if !s.flushScheduled && !s.flushing { + s.flushScheduled = true + go s.flushLoop() + } + s.cond.Broadcast() +} + +func (s *fileWorkbenchStateStore) Flush() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureLoadedLocked() + if s.dirty && !s.flushScheduled && !s.flushing { + s.flushScheduled = true + go s.flushLoop() + } + for s.flushScheduled || s.flushing { + s.cond.Wait() + } +} + +func (s *fileWorkbenchStateStore) LastError() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.lastErr +} + +func (s *fileWorkbenchStateStore) ensureLoadedLocked() { + if s.cond == nil { + s.cond = sync.NewCond(&s.mu) + } + if s.loaded { + if s.env.Targets == nil { + s.env = workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} + } + return + } + s.env = s.readEnvelopeFromDisk() + s.loaded = true +} + +func (s *fileWorkbenchStateStore) flushLoop() { + debounce := s.writeDebounce + if debounce > 0 { + time.Sleep(debounce) + } + for { + env, delay, ok := s.beginFlushCycle() + if !ok { + return + } + + if delay != nil { + delay() + } + err := s.persistEnvelope(env) + + pending := s.finishFlushCycle(err) + if !pending { + return + } + if debounce > 0 { + time.Sleep(debounce) + } + } +} + +func (s *fileWorkbenchStateStore) beginFlushCycle() (workbenchPersistenceEnvelope, func(), bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.ensureLoadedLocked() + if !s.dirty { + s.flushScheduled = false + s.cond.Broadcast() + return workbenchPersistenceEnvelope{}, nil, false + } + env := cloneWorkbenchPersistenceEnvelope(s.env) + s.dirty = false + s.flushScheduled = false + s.flushing = true + return env, s.writeDelay, true +} + +func (s *fileWorkbenchStateStore) finishFlushCycle(err error) bool { + s.mu.Lock() + defer s.mu.Unlock() + if err != nil { + s.dirty = true + s.lastErr = err + } else { + s.lastErr = nil + } + s.flushing = false + pending := s.dirty + if pending && err == nil { + s.flushScheduled = true + } + s.cond.Broadcast() + return pending +} + +func (s *fileWorkbenchStateStore) persistEnvelope(env workbenchPersistenceEnvelope) error { + if strings.TrimSpace(s.path) == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + raw, err := json.MarshalIndent(env, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, raw, 0o600) +} + +func (s *fileWorkbenchStateStore) readEnvelopeFromDisk() workbenchPersistenceEnvelope { + if strings.TrimSpace(s.path) == "" { + return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} + } + raw, err := os.ReadFile(s.path) + if err != nil || len(raw) == 0 { + return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} + } + var env workbenchPersistenceEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} + } + if env.Targets == nil { + env.Targets = map[string]workbenchLocalState{} + } + if env.SchemaVersion <= 0 { + env.SchemaVersion = workbenchPersistenceSchemaVersion + } + if env.SchemaVersion > workbenchPersistenceSchemaVersion { + return workbenchPersistenceEnvelope{SchemaVersion: workbenchPersistenceSchemaVersion, Targets: map[string]workbenchLocalState{}} + } + return cloneWorkbenchPersistenceEnvelope(env) +} + +func cloneWorkbenchPersistenceEnvelope(env workbenchPersistenceEnvelope) workbenchPersistenceEnvelope { + out := workbenchPersistenceEnvelope{ + SchemaVersion: env.SchemaVersion, + Targets: make(map[string]workbenchLocalState, len(env.Targets)), + } + if out.SchemaVersion <= 0 { + out.SchemaVersion = workbenchPersistenceSchemaVersion + } + for key, state := range env.Targets { + out.Targets[key] = cloneWorkbenchLocalState(state) + } + return out +} + +func cloneWorkbenchLocalState(state workbenchLocalState) workbenchLocalState { + state.LastSessionByWS = cloneSessionMap(state.LastSessionByWS) + state.PinnedSessions = append([]workbenchSessionRef(nil), state.PinnedSessions...) + state.RecentSessions = append([]workbenchSessionRef(nil), state.RecentSessions...) + state.RecentObjects = append([]workbenchObjectRef(nil), state.RecentObjects...) + state.ViewedActivity = cloneViewedActivity(state.ViewedActivity) + return state +} diff --git a/cmd/runecode-tui/shell_workspace.go b/cmd/runecode-tui/shell_workspace.go index 19be5c1a..7c541e87 100644 --- a/cmd/runecode-tui/shell_workspace.go +++ b/cmd/runecode-tui/shell_workspace.go @@ -22,6 +22,7 @@ func (m shellModel) loadSessionWorkspaceCmd() tea.Cmd { func (m *shellModel) applySessionWorkspaceLoaded(msg sessionWorkspaceLoadedMsg) { m.sessionLoading = false + m.invalidateOverlayFrameCache() if msg.err != nil { m.sessionLoadError = safeUIErrorText(msg.err) m.refreshObjectIndexFromShellState() @@ -31,6 +32,7 @@ func (m *shellModel) applySessionWorkspaceLoaded(msg sessionWorkspaceLoadedMsg) m.sessionItems = append([]brokerapi.SessionSummary(nil), msg.sessions...) m.rememberSessionWorkspaces(m.sessionItems) m.sessions = m.sessions.UpdateSessions(m.sessionItems) + m.invalidatePaletteCache() m.ensureActiveSessionSelection() m.sessionSelected = selectedSessionIndex(m.sessionItems, m.activeSessionID) m.syncSidebarCursorToLocation() diff --git a/cmd/runecode-tui/snapshot_bundle_specs.go b/cmd/runecode-tui/snapshot_bundle_specs.go new file mode 100644 index 00000000..b522c5ea --- /dev/null +++ b/cmd/runecode-tui/snapshot_bundle_specs.go @@ -0,0 +1,60 @@ +//go:build runecode_tui_snapshot + +package main + +func snapshotBundleSpecs() map[snapshotAuditBundle]snapshotBundleSpec { + return map[snapshotAuditBundle]snapshotBundleSpec{ + snapshotBundleFullAudit: { + Name: snapshotBundleFullAudit, + Scenarios: []string{ + "dashboard-healthy-empty", + "dashboard-approval-waiting", + "dashboard-blocked", + "dashboard-degraded", + "dashboard-toast-info", + "command-mode-draft", + "command-mode-error", + "overlay-command-palette", + "overlay-session-switcher", + "quit-confirm-chat-compose", + "quit-confirm-command-entry", + "quit-confirm-provider-secret", + "dashboard-focus-nav", + "leader-help-root", + "chat-active-session", + "chat-compose-open", + "runs-active-detail", + "runs-focus-inspector", + "runs-raw-detail", + "approvals-pending-detail", + "approvals-structured-detail", + "action-center-triage", + "artifacts-evidence-detail", + "artifacts-structured-detail", + "audit-degraded-detail", + "audit-raw-detail", + "status-ready-overview", + "status-degraded-overview", + "model-providers-credential-needed", + "model-providers-secret-entry", + "git-setup-identity-needed", + "git-setup-provider-linked", + "git-remote-approval-ready", + "git-remote-executed", + "git-remote-fail-closed", + }, + Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeArtifacts, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, + }, + snapshotBundleDashboard: {Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "dashboard-toast-info", "dashboard-focus-nav"}, Routes: []routeID{routeDashboard}}, + snapshotBundleActionCenter: {Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, + snapshotBundleRuns: {Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail", "runs-focus-inspector", "runs-raw-detail", "artifacts-evidence-detail", "artifacts-structured-detail"}, Routes: []routeID{routeRuns, routeArtifacts}}, + snapshotBundleApprovals: {Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail", "approvals-structured-detail"}, Routes: []routeID{routeApprovals}}, + snapshotBundleAudit: {Name: snapshotBundleAudit, Scenarios: []string{"audit-degraded-detail", "audit-raw-detail"}, Routes: []routeID{routeAudit}}, + snapshotBundleStatus: {Name: snapshotBundleStatus, Scenarios: []string{"status-ready-overview", "status-degraded-overview"}, Routes: []routeID{routeStatus}}, + snapshotBundleSetup: {Name: snapshotBundleSetup, Scenarios: []string{"model-providers-credential-needed", "model-providers-secret-entry", "git-setup-identity-needed", "git-setup-provider-linked", "git-remote-approval-ready", "git-remote-executed", "git-remote-fail-closed"}, Routes: []routeID{routeProviders, routeGitSetup, routeGitRemote}}, + snapshotBundleChat: {Name: snapshotBundleChat, Scenarios: []string{"chat-active-session", "chat-compose-open"}, Routes: []routeID{routeChat}}, + snapshotBundleShell: {Name: snapshotBundleShell, Scenarios: []string{"command-mode-draft", "command-mode-error", "overlay-command-palette", "overlay-session-switcher", "quit-confirm-chat-compose", "quit-confirm-command-entry", "quit-confirm-provider-secret", "leader-help-root"}, Routes: []routeID{routeDashboard, routeChat, routeProviders}}, + snapshotBundleNarrowMobile: {Name: snapshotBundleNarrowMobile, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, + snapshotBundleNarrowCompact: {Name: snapshotBundleNarrowCompact, Scenarios: []string{"narrow-sidebar-overlay-compact", "narrow-inspector-overlay-compact"}, Routes: []routeID{routeDashboard, routeRuns}}, + } +} diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go new file mode 100644 index 00000000..053cc30f --- /dev/null +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -0,0 +1,74 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "slices" + "strings" +) + +type snapshotAuditBundle string + +const ( + snapshotBundleNone snapshotAuditBundle = "" + snapshotBundleFullAudit snapshotAuditBundle = "full-audit" + snapshotBundleDashboard snapshotAuditBundle = "dashboard-audit" + snapshotBundleActionCenter snapshotAuditBundle = "action-center-audit" + snapshotBundleRuns snapshotAuditBundle = "runs-audit" + snapshotBundleApprovals snapshotAuditBundle = "approvals-audit" + snapshotBundleAudit snapshotAuditBundle = "audit-route-audit" + snapshotBundleStatus snapshotAuditBundle = "status-audit" + snapshotBundleSetup snapshotAuditBundle = "setup-audit" + snapshotBundleChat snapshotAuditBundle = "chat-audit" + snapshotBundleShell snapshotAuditBundle = "shell-overlays-audit" + snapshotBundleNarrowMobile snapshotAuditBundle = "shell-narrow-mobile-audit" + snapshotBundleNarrowCompact snapshotAuditBundle = "shell-narrow-compact-audit" +) + +type snapshotCoverage struct { + Bundle string `json:"bundle,omitempty"` + Routes []string `json:"routes,omitempty"` + Viewports []string `json:"viewports,omitempty"` + Scenarios []string `json:"scenarios,omitempty"` +} + +type snapshotBundleSpec struct { + Name snapshotAuditBundle + Scenarios []string + Routes []routeID +} + +func normalizeSnapshotBundle(name string) snapshotAuditBundle { + return snapshotAuditBundle(strings.TrimSpace(name)) +} + +func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { + bundle, ok := snapshotBundleSpecs()[normalizeSnapshotBundle(name)] + if !ok { + return snapshotBundleSpec{}, usageErrorf("unknown snapshot bundle %q", name) + } + return bundle, nil +} + +func snapshotCoverageForBundle(bundle snapshotAuditBundle, scenarios []snapshotScenarioState, cfg tuiSnapshotConfig) snapshotCoverage { + routeSet := make(map[string]struct{}, len(scenarios)) + viewportSet := make(map[string]struct{}, len(scenarios)) + scenarioNames := make([]string, 0, len(scenarios)) + for _, scenario := range scenarios { + routeSet[string(scenario.RouteID)] = struct{}{} + viewportSet[string(snapshotScenarioViewport(scenario, cfg))] = struct{}{} + scenarioNames = append(scenarioNames, scenario.Name) + } + routes := make([]string, 0, len(routeSet)) + for route := range routeSet { + routes = append(routes, route) + } + viewports := make([]string, 0, len(viewportSet)) + for viewport := range viewportSet { + viewports = append(viewports, viewport) + } + slices.Sort(routes) + slices.Sort(viewports) + slices.Sort(scenarioNames) + return snapshotCoverage{Bundle: string(bundle), Routes: routes, Viewports: viewports, Scenarios: scenarioNames} +} diff --git a/cmd/runecode-tui/snapshot_disabled.go b/cmd/runecode-tui/snapshot_disabled.go new file mode 100644 index 00000000..4894e04e --- /dev/null +++ b/cmd/runecode-tui/snapshot_disabled.go @@ -0,0 +1,27 @@ +//go:build !runecode_tui_snapshot + +package main + +import ( + "flag" + "fmt" +) + +type tuiSnapshotConfig struct { + enabled bool + viewport snapshotViewportPreset +} + +type snapshotViewportPreset string + +type tuiSnapshotFlags struct{} + +func registerSnapshotFlags(*flag.FlagSet) tuiSnapshotFlags { + return tuiSnapshotFlags{} +} + +func applySnapshotCLIConfig(*tuiCLIConfig, tuiSnapshotFlags) {} + +func writeSnapshotArtifacts(tuiSnapshotConfig) error { + return fmt.Errorf("snapshot support requires the runecode_tui_snapshot build tag") +} diff --git a/cmd/runecode-tui/snapshot_disabled_test.go b/cmd/runecode-tui/snapshot_disabled_test.go new file mode 100644 index 00000000..012bef3b --- /dev/null +++ b/cmd/runecode-tui/snapshot_disabled_test.go @@ -0,0 +1,52 @@ +//go:build !runecode_tui_snapshot + +package main + +import ( + "strings" + "testing" +) + +func TestParseCLIConfigRejectsSnapshotFlagsWithoutBuildTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + }{ + {name: "scenario", args: []string{"--snapshot-scenario", "all"}}, + {name: "output-dir", args: []string{"--snapshot-output-dir", "/tmp/snaps"}}, + {name: "viewport", args: []string{"--snapshot-viewport", "mobile"}}, + {name: "width", args: []string{"--snapshot-width", "140"}}, + {name: "height", args: []string{"--snapshot-height", "40"}}, + {name: "theme", args: []string{"--snapshot-theme", "dusk"}}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, err := parseCLIConfig(tc.args) + if err == nil { + t.Fatal("parseCLIConfig expected error") + } + if _, ok := err.(*usageError); !ok { + t.Fatalf("parseCLIConfig error type = %T, want *usageError", err) + } + if got := err.Error(); got != "runecode-tui usage: runecode-tui [--runtime-dir dir] [--socket-name broker.sock] [--help]" { + t.Fatalf("parseCLIConfig error = %q", got) + } + }) + } +} + +func TestWriteSnapshotArtifactsReturnsGracefulErrorWithoutBuildTag(t *testing.T) { + t.Parallel() + + err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true}) + if err == nil { + t.Fatal("writeSnapshotArtifacts returned nil error, want graceful failure") + } + if !strings.Contains(err.Error(), "runecode_tui_snapshot") { + t.Fatalf("writeSnapshotArtifacts error = %q, want build-tag guidance", err) + } +} diff --git a/cmd/runecode-tui/snapshot_enabled.go b/cmd/runecode-tui/snapshot_enabled.go new file mode 100644 index 00000000..888f92be --- /dev/null +++ b/cmd/runecode-tui/snapshot_enabled.go @@ -0,0 +1,86 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "flag" + "strings" +) + +type snapshotViewportPreset string + +const ( + snapshotViewportDesktop snapshotViewportPreset = "desktop" + snapshotViewportCompact snapshotViewportPreset = "compact" + snapshotViewportMobile snapshotViewportPreset = "mobile" +) + +type snapshotViewportSpec struct { + Name snapshotViewportPreset + Width int + Height int +} + +type tuiSnapshotConfig struct { + enabled bool + scenario string + bundle string + outputDir string + viewport snapshotViewportPreset + width int + height int + widthFromViewport bool + heightFromViewport bool + theme themePreset +} + +type tuiSnapshotFlags struct { + scenario *string + bundle *string + outputDir *string + viewport *string + width *int + height *int + theme *string +} + +func registerSnapshotFlags(fs *flag.FlagSet) tuiSnapshotFlags { + return tuiSnapshotFlags{ + scenario: fs.String("snapshot-scenario", "", "hidden dev option: deterministic TUI snapshot scenario"), + bundle: fs.String("snapshot-bundle", "", "hidden dev option: snapshot audit bundle"), + outputDir: fs.String("snapshot-output-dir", snapshotDefaultOutputDir(), "hidden dev option: snapshot output directory"), + viewport: fs.String("snapshot-viewport", string(snapshotViewportDesktop), "hidden dev option: snapshot viewport preset"), + width: fs.Int("snapshot-width", 0, "hidden dev option: snapshot terminal width override"), + height: fs.Int("snapshot-height", 0, "hidden dev option: snapshot terminal height override"), + theme: fs.String("snapshot-theme", string(themePresetDark), "hidden dev option: snapshot theme preset"), + } +} + +func applySnapshotCLIConfig(cfg *tuiCLIConfig, flags tuiSnapshotFlags) { + if *flags.scenario == "" && *flags.bundle == "" { + return + } + cfg.snapshot = tuiSnapshotConfig{ + enabled: true, + scenario: *flags.scenario, + bundle: *flags.bundle, + outputDir: *flags.outputDir, + viewport: snapshotViewportPreset(strings.TrimSpace(*flags.viewport)), + width: *flags.width, + height: *flags.height, + theme: normalizeThemePreset(themePreset(*flags.theme)), + } +} + +func resolveSnapshotViewportPreset(name snapshotViewportPreset) (snapshotViewportSpec, error) { + switch snapshotViewportPreset(strings.TrimSpace(string(name))) { + case "", snapshotViewportDesktop: + return snapshotViewportSpec{Name: snapshotViewportDesktop, Width: 160, Height: 48}, nil + case snapshotViewportCompact: + return snapshotViewportSpec{Name: snapshotViewportCompact, Width: 120, Height: 36}, nil + case snapshotViewportMobile: + return snapshotViewportSpec{Name: snapshotViewportMobile, Width: 80, Height: 32}, nil + default: + return snapshotViewportSpec{}, usageErrorf("unknown snapshot viewport %q", name) + } +} diff --git a/cmd/runecode-tui/snapshot_enabled_test.go b/cmd/runecode-tui/snapshot_enabled_test.go new file mode 100644 index 00000000..8f517bfd --- /dev/null +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -0,0 +1,350 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var snapshotSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + +func TestParseCLIConfigParsesSnapshotOptionsWithBuildTag(t *testing.T) { + cfg, err := parseCLIConfig([]string{"--snapshot-scenario", "dashboard", "--snapshot-output-dir", "/tmp/snaps", "--snapshot-width", "140", "--snapshot-height", "40", "--snapshot-theme", "dusk", "--snapshot-viewport", "mobile"}) + if err != nil { + t.Fatalf("parseCLIConfig returned error: %v", err) + } + if !cfg.snapshot.enabled || cfg.snapshot.scenario != "dashboard" || cfg.snapshot.outputDir != "/tmp/snaps" || cfg.snapshot.viewport != snapshotViewportMobile || cfg.snapshot.width != 140 || cfg.snapshot.height != 40 || cfg.snapshot.theme != themePresetDusk { + t.Fatalf("parseCLIConfig snapshot cfg = %+v, want snapshot options populated", cfg.snapshot) + } +} + +func TestParseCLIConfigParsesSnapshotBundleWithBuildTag(t *testing.T) { + cfg, err := parseCLIConfig([]string{"--snapshot-bundle", "full-audit", "--snapshot-output-dir", "/tmp/snaps"}) + if err != nil { + t.Fatalf("parseCLIConfig returned error: %v", err) + } + if !cfg.snapshot.enabled || cfg.snapshot.bundle != "full-audit" || cfg.snapshot.outputDir != "/tmp/snaps" { + t.Fatalf("parseCLIConfig snapshot cfg = %+v, want bundle populated", cfg.snapshot) + } +} + +func TestNormalizeSnapshotConfigUsesViewportPresetDefaults(t *testing.T) { + cfg, err := normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", viewport: snapshotViewportCompact}) + if err != nil { + t.Fatalf("normalizeSnapshotConfig returned error: %v", err) + } + if cfg.viewport != snapshotViewportCompact || cfg.width != 120 || cfg.height != 36 { + t.Fatalf("normalizeSnapshotConfig cfg = %+v, want compact preset dimensions", cfg) + } +} + +func TestNormalizeSnapshotConfigKeepsExplicitDimensionOverrides(t *testing.T) { + cfg, err := normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", viewport: snapshotViewportMobile, width: 90, height: 28}) + if err != nil { + t.Fatalf("normalizeSnapshotConfig returned error: %v", err) + } + if cfg.viewport != snapshotViewportMobile || cfg.width != 90 || cfg.height != 28 { + t.Fatalf("normalizeSnapshotConfig cfg = %+v, want explicit overrides preserved", cfg) + } +} + +func TestNormalizeSnapshotConfigAllowsRealTempPathWhenTempDirIsSymlink(t *testing.T) { + baseDir := t.TempDir() + realTempDir := filepath.Join(baseDir, "real-temp") + if err := os.MkdirAll(realTempDir, 0o755); err != nil { + t.Fatalf("MkdirAll(realTempDir) returned error: %v", err) + } + linkedTempDir := filepath.Join(baseDir, "temp-link") + if err := os.Symlink(realTempDir, linkedTempDir); err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("Symlink(linkedTempDir) returned error: %v", err) + } + t.Setenv("TMPDIR", linkedTempDir) + + outputDir := filepath.Join(realTempDir, "snapshots") + cfg, err := normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", outputDir: outputDir, viewport: snapshotViewportCompact}) + if err != nil { + t.Fatalf("normalizeSnapshotConfig returned error: %v", err) + } + if cfg.outputDir != outputDir { + t.Fatalf("normalizeSnapshotConfig outputDir = %q, want %q", cfg.outputDir, outputDir) + } +} + +func TestNormalizeSnapshotConfigAllowsCanonicalTmpWhenTempDirDiffers(t *testing.T) { + if _, err := os.Stat("/tmp"); err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skip("/tmp is not present on this platform") + } + t.Fatalf("Stat(/tmp) returned error: %v", err) + } + + customTempDir := filepath.Join(t.TempDir(), "custom-temp") + if err := os.MkdirAll(customTempDir, 0o755); err != nil { + t.Fatalf("MkdirAll(customTempDir) returned error: %v", err) + } + t.Setenv("TMPDIR", customTempDir) + + outputDir := filepath.Join("/tmp", ".tui-snapshots", t.Name()) + cfg, err := normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", outputDir: outputDir, viewport: snapshotViewportCompact}) + if err != nil { + t.Fatalf("normalizeSnapshotConfig returned error: %v", err) + } + wantOutputDir, err := snapshotCanonicalPath(outputDir) + if err != nil { + t.Fatalf("snapshotCanonicalPath returned error: %v", err) + } + if cfg.outputDir != wantOutputDir { + t.Fatalf("normalizeSnapshotConfig outputDir = %q, want %q", cfg.outputDir, wantOutputDir) + } +} + +func TestNormalizeSnapshotConfigDefaultsToRepoSnapshotDir(t *testing.T) { + cfg, err := normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", viewport: snapshotViewportCompact}) + if err != nil { + t.Fatalf("normalizeSnapshotConfig returned error: %v", err) + } + wantOutputDir, err := snapshotCanonicalPath(snapshotDefaultOutputDir()) + if err != nil { + t.Fatalf("snapshotCanonicalPath(snapshotDefaultOutputDir()) returned error: %v", err) + } + if cfg.outputDir != wantOutputDir { + t.Fatalf("normalizeSnapshotConfig outputDir = %q, want %q", cfg.outputDir, wantOutputDir) + } +} + +func TestNormalizeSnapshotConfigRejectsOutputDirEscapingTempViaSymlink(t *testing.T) { + baseDir := t.TempDir() + realTempDir := filepath.Join(baseDir, "real-temp") + if err := os.MkdirAll(realTempDir, 0o755); err != nil { + t.Fatalf("MkdirAll(realTempDir) returned error: %v", err) + } + linkedTempDir := filepath.Join(baseDir, "temp-link") + if err := os.Symlink(realTempDir, linkedTempDir); err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("Symlink(linkedTempDir) returned error: %v", err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd returned error: %v", err) + } + outsideDir, err := os.MkdirTemp(wd, "snapshot-outside-") + if err != nil { + t.Fatalf("MkdirTemp(outsideDir) returned error: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(outsideDir) + }) + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatalf("MkdirAll(outsideDir) returned error: %v", err) + } + escapeLink := filepath.Join(realTempDir, "escape") + if err := os.Symlink(outsideDir, escapeLink); err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skipf("symlink creation not permitted: %v", err) + } + t.Fatalf("Symlink(escapeLink) returned error: %v", err) + } + t.Setenv("TMPDIR", linkedTempDir) + + _, err = normalizeSnapshotConfig(tuiSnapshotConfig{enabled: true, scenario: "dashboard", outputDir: filepath.Join(escapeLink, "snapshots"), viewport: snapshotViewportCompact}) + if err == nil { + t.Fatalf("normalizeSnapshotConfig returned nil error, want rejection for symlink escape") + } +} + +func TestWriteSnapshotArtifactsIncludesPerScenarioViewportAndDimensions(t *testing.T) { + outputDir := t.TempDir() + if err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true, scenario: "dashboard-healthy-empty", outputDir: outputDir, viewport: snapshotViewportMobile}); err != nil { + t.Fatalf("writeSnapshotArtifacts returned error: %v", err) + } + raw, err := os.ReadFile(filepath.Join(outputDir, "manifest.json")) + if err != nil { + t.Fatalf("ReadFile(manifest.json) returned error: %v", err) + } + var manifest snapshotManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("json.Unmarshal(manifest.json) returned error: %v", err) + } + if manifest.Viewport != string(snapshotViewportMobile) || manifest.Width != 80 || manifest.Height != 32 { + t.Fatalf("manifest = %+v, want mobile top-level metadata", manifest) + } + if manifest.Version != 2 { + t.Fatalf("manifest version = %d, want 2", manifest.Version) + } + if len(manifest.Scenarios) != 1 { + t.Fatalf("manifest scenarios len = %d, want 1", len(manifest.Scenarios)) + } + entry := manifest.Scenarios[0] + if entry.Viewport != string(snapshotViewportMobile) || entry.Width != 80 || entry.Height != 32 { + t.Fatalf("manifest entry = %+v, want mobile per-scenario dimensions", entry) + } + if entry.Artifacts.SVG.Path != "dashboard-healthy-empty.mobile.svg" || entry.Artifacts.Text.Path != "dashboard-healthy-empty.mobile.txt" || entry.Artifacts.ANSI.Path != "dashboard-healthy-empty.mobile.ansi" || entry.Artifacts.PNG.Path != "dashboard-healthy-empty.mobile.png" { + t.Fatalf("manifest entry paths = %+v, want relative viewport-qualified artifact names", entry) + } + if !snapshotSHA256Pattern.MatchString(entry.Artifacts.ANSI.SHA256) || !snapshotSHA256Pattern.MatchString(entry.Artifacts.Text.SHA256) || !snapshotSHA256Pattern.MatchString(entry.Artifacts.SVG.SHA256) { + t.Fatalf("manifest artifact hashes = %+v, want sha256 hex digests", entry.Artifacts) + } + if entry.Artifacts.PNG.SHA256 != "" { + t.Fatalf("manifest png hash = %q, want empty when png is not generated by Go tool", entry.Artifacts.PNG.SHA256) + } +} + +func TestWriteSnapshotArtifactsUsesScenarioViewportOverrideForArtifactMetadata(t *testing.T) { + outputDir := t.TempDir() + if err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true, scenario: "narrow-sidebar-overlay", outputDir: outputDir, viewport: snapshotViewportDesktop}); err != nil { + t.Fatalf("writeSnapshotArtifacts returned error: %v", err) + } + raw, err := os.ReadFile(filepath.Join(outputDir, "manifest.json")) + if err != nil { + t.Fatalf("ReadFile(manifest.json) returned error: %v", err) + } + var manifest snapshotManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("json.Unmarshal(manifest.json) returned error: %v", err) + } + if len(manifest.Scenarios) != 1 { + t.Fatalf("manifest scenarios len = %d, want 1", len(manifest.Scenarios)) + } + entry := manifest.Scenarios[0] + if entry.Viewport != string(snapshotViewportMobile) || entry.Width != 80 || entry.Height != 32 { + t.Fatalf("manifest entry = %+v, want mobile override dimensions", entry) + } + if entry.Artifacts.SVG.Path != "narrow-sidebar-overlay.mobile.svg" || entry.Artifacts.Text.Path != "narrow-sidebar-overlay.mobile.txt" || entry.Artifacts.ANSI.Path != "narrow-sidebar-overlay.mobile.ansi" || entry.Artifacts.PNG.Path != "narrow-sidebar-overlay.mobile.png" { + t.Fatalf("manifest entry paths = %+v, want mobile-qualified artifact names", entry) + } +} + +func TestWriteSnapshotArtifactsBundleCoverageTracksScenarioViewportOverrides(t *testing.T) { + outputDir := t.TempDir() + if err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true, bundle: string(snapshotBundleNarrowMobile), outputDir: outputDir, viewport: snapshotViewportDesktop}); err != nil { + t.Fatalf("writeSnapshotArtifacts returned error: %v", err) + } + raw, err := os.ReadFile(filepath.Join(outputDir, "manifest.json")) + if err != nil { + t.Fatalf("ReadFile(manifest.json) returned error: %v", err) + } + var manifest snapshotManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("json.Unmarshal(manifest.json) returned error: %v", err) + } + if len(manifest.Coverage.Viewports) != 1 || manifest.Coverage.Viewports[0] != string(snapshotViewportMobile) { + t.Fatalf("manifest coverage viewports = %+v, want only mobile", manifest.Coverage.Viewports) + } +} + +func TestWriteSnapshotArtifactsIncludesBundleCoverageMetadata(t *testing.T) { + outputDir := t.TempDir() + if err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true, bundle: "dashboard-audit", outputDir: outputDir, viewport: snapshotViewportDesktop}); err != nil { + t.Fatalf("writeSnapshotArtifacts returned error: %v", err) + } + raw, err := os.ReadFile(filepath.Join(outputDir, "manifest.json")) + if err != nil { + t.Fatalf("ReadFile(manifest.json) returned error: %v", err) + } + var manifest snapshotManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("json.Unmarshal(manifest.json) returned error: %v", err) + } + if manifest.Bundle != "dashboard-audit" || manifest.Coverage.Bundle != "dashboard-audit" { + t.Fatalf("manifest bundle coverage = %+v, want dashboard-audit", manifest) + } + if len(manifest.Coverage.Routes) != 1 || manifest.Coverage.Routes[0] != "dashboard" { + t.Fatalf("manifest coverage routes = %+v, want dashboard only", manifest.Coverage) + } + if len(manifest.Coverage.Scenarios) != 6 { + t.Fatalf("manifest coverage scenarios = %+v, want 6 dashboard scenarios", manifest.Coverage) + } +} + +func TestWriteSnapshotArtifactsRemovesStalePNG(t *testing.T) { + outputDir := t.TempDir() + stalePNG := filepath.Join(outputDir, "dashboard-healthy-empty.mobile.png") + if err := os.WriteFile(stalePNG, []byte("stale"), 0o644); err != nil { + t.Fatalf("WriteFile(stalePNG) returned error: %v", err) + } + + if err := writeSnapshotArtifacts(tuiSnapshotConfig{enabled: true, scenario: "dashboard-healthy-empty", outputDir: outputDir, viewport: snapshotViewportMobile}); err != nil { + t.Fatalf("writeSnapshotArtifacts returned error: %v", err) + } + if _, err := os.Stat(stalePNG); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(stalePNG) error = %v, want not exist after regeneration", err) + } +} + +func TestRunMainSnapshotUsageErrorsExitCodeTwo(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "mutually exclusive config", + args: []string{"--snapshot-scenario", "dashboard-healthy-empty", "--snapshot-bundle", "dashboard-audit"}, + wantErr: "snapshot bundle and snapshot scenario are mutually exclusive", + }, + { + name: "invalid viewport", + args: []string{"--snapshot-scenario", "dashboard-healthy-empty", "--snapshot-viewport", "tablet"}, + wantErr: "unknown snapshot viewport \"tablet\"", + }, + { + name: "negative width", + args: []string{"--snapshot-scenario", "dashboard-healthy-empty", "--snapshot-width", "-1"}, + wantErr: "snapshot dimensions must be positive", + }, + { + name: "unknown scenario", + args: []string{"--snapshot-scenario", "missing-scenario"}, + wantErr: "unknown snapshot scenario \"missing-scenario\"", + }, + { + name: "unknown bundle", + args: []string{"--snapshot-bundle", "missing-bundle"}, + wantErr: "unknown snapshot bundle \"missing-bundle\"", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var stderr bytes.Buffer + code := runMain(tc.args, os.Stdin, os.Stdout, &stderr) + if code != 2 { + t.Fatalf("runMain(%v) exit code = %d stderr=%q, want 2", tc.args, code, stderr.String()) + } + if got := stderr.String(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("runMain(%v) stderr = %q, want substring %q", tc.args, got, tc.wantErr) + } else if strings.Contains(got, "runecode-tui snapshot failed:") { + t.Fatalf("runMain(%v) stderr = %q, want plain usage error", tc.args, got) + } + }) + } +} + +func TestRunMainSnapshotRuntimeFailuresExitCodeOne(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "snapshot-output") + if err := os.WriteFile(outputDir, []byte("occupied"), 0o644); err != nil { + t.Fatalf("WriteFile(outputDir) returned error: %v", err) + } + + var stderr bytes.Buffer + code := runMain([]string{"--snapshot-scenario", "dashboard-healthy-empty", "--snapshot-output-dir", outputDir}, os.Stdin, os.Stdout, &stderr) + if code != 1 { + t.Fatalf("runMain(snapshot runtime failure) exit code = %d stderr=%q, want 1", code, stderr.String()) + } + if got := stderr.String(); !strings.Contains(got, "runecode-tui snapshot failed:") { + t.Fatalf("runMain(snapshot runtime failure) stderr = %q, want runtime failure prefix", got) + } +} diff --git a/cmd/runecode-tui/snapshot_paths.go b/cmd/runecode-tui/snapshot_paths.go new file mode 100644 index 00000000..232b25b4 --- /dev/null +++ b/cmd/runecode-tui/snapshot_paths.go @@ -0,0 +1,162 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const snapshotDefaultDirName = ".tui-snapshots" + +func snapshotDefaultOutputDir() string { + workspaceRoot, err := snapshotWorkspaceRoot() + if err == nil { + return filepath.Join(workspaceRoot, snapshotDefaultDirName) + } + cwd, err := os.Getwd() + if err == nil { + return filepath.Join(cwd, snapshotDefaultDirName) + } + return filepath.Join(os.TempDir(), snapshotDefaultDirName) +} + +func normalizeSnapshotOutputDir(outputDir string) (string, error) { + resolvedOutputDir, err := snapshotCanonicalPath(outputDir) + if err != nil { + return "", fmt.Errorf("resolve snapshot output directory: %w", err) + } + trustedRoots, err := snapshotTrustedOutputRoots() + if err != nil { + return "", err + } + for _, trustedRoot := range trustedRoots { + if snapshotPathWithinBase(trustedRoot, resolvedOutputDir) { + return resolvedOutputDir, nil + } + } + return "", fmt.Errorf("snapshot output directory must stay under one of %s", strings.Join(trustedRoots, ", ")) +} + +func snapshotTrustedOutputRoots() ([]string, error) { + roots := make([]string, 0, 2) + seen := make(map[string]struct{}, 2) + + addRoot := func(path string) error { + if path == "" { + return nil + } + resolvedPath, err := snapshotCanonicalPath(path) + if err != nil { + return err + } + if _, ok := seen[resolvedPath]; ok { + return nil + } + seen[resolvedPath] = struct{}{} + roots = append(roots, resolvedPath) + return nil + } + + if err := addRoot(os.TempDir()); err != nil { + return nil, fmt.Errorf("resolve temporary directory: %w", err) + } + if _, err := os.Stat("/tmp"); err == nil { + if err := addRoot("/tmp"); err != nil { + return nil, fmt.Errorf("resolve /tmp temporary directory: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("stat /tmp: %w", err) + } + if err := addRoot(snapshotDefaultOutputDir()); err != nil { + return nil, fmt.Errorf("resolve default snapshot directory: %w", err) + } + + return roots, nil +} + +func snapshotWorkspaceRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + resolvedCWD, err := snapshotCanonicalPath(cwd) + if err != nil { + return "", err + } + dir := resolvedCWD + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(dir) + if parent == dir { + return resolvedCWD, nil + } + dir = parent + } +} + +func snapshotCanonicalPath(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolvedPath, err := snapshotEvalSymlinksAllowMissing(filepath.Clean(absPath)) + if err != nil { + return "", err + } + return filepath.Clean(resolvedPath), nil +} + +func snapshotEvalSymlinksAllowMissing(path string) (string, error) { + resolvedPath, err := filepath.EvalSymlinks(path) + if err == nil { + return resolvedPath, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + missingParts := []string{} + existingPath := path + for { + if _, statErr := os.Stat(existingPath); statErr == nil { + break + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", statErr + } + + parent := filepath.Dir(existingPath) + if parent == existingPath { + return "", err + } + missingParts = append(missingParts, filepath.Base(existingPath)) + existingPath = parent + } + + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + for i := len(missingParts) - 1; i >= 0; i-- { + resolvedExistingPath = filepath.Join(resolvedExistingPath, missingParts[i]) + } + return resolvedExistingPath, nil +} + +func snapshotPathWithinBase(base string, candidate string) bool { + rel, err := filepath.Rel(base, candidate) + if err != nil { + return false + } + if rel == "." { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/cmd/runecode-tui/snapshot_scenario_catalog.go b/cmd/runecode-tui/snapshot_scenario_catalog.go new file mode 100644 index 00000000..c064ab12 --- /dev/null +++ b/cmd/runecode-tui/snapshot_scenario_catalog.go @@ -0,0 +1,59 @@ +//go:build runecode_tui_snapshot + +package main + +func allSnapshotScenarios() []snapshotScenarioState { + return []snapshotScenarioState{ + buildHealthyEmptySnapshot(), + buildApprovalWaitingSnapshot(), + buildBlockedSnapshot(), + buildDegradedSnapshot(), + buildToastSnapshot(), + buildCommandModeDraftSnapshot(), + buildCommandModeErrorSnapshot(), + buildCommandPaletteSnapshot(), + buildSessionSwitcherSnapshot(), + buildQuitConfirmChatComposeSnapshot(), + buildQuitConfirmCommandEntrySnapshot(), + buildQuitConfirmProviderSecretSnapshot(), + buildNarrowSidebarOverlaySnapshot(), + buildNarrowInspectorOverlaySnapshot(), + buildNarrowSidebarOverlayCompactSnapshot(), + buildNarrowInspectorOverlayCompactSnapshot(), + buildDashboardFocusNavSnapshot(), + buildLeaderHelpSnapshot(), + buildChatSnapshot(), + buildChatComposeOpenSnapshot(), + buildRunsSnapshot(), + buildRunsFocusInspectorSnapshot(), + buildRunsRawSnapshot(), + buildApprovalsSnapshot(), + buildApprovalsStructuredSnapshot(), + buildActionCenterSnapshot(), + buildArtifactsSnapshot(), + buildArtifactsStructuredSnapshot(), + buildAuditSnapshot(), + buildAuditRawSnapshot(), + buildStatusSnapshot(), + buildStatusDegradedSnapshot(), + buildProviderSetupSnapshot(), + buildProviderSecretEntrySnapshot(), + buildGitSetupSnapshot(), + buildGitSetupProviderLinkedSnapshot(), + buildGitRemoteSnapshot(), + buildGitRemoteExecutedSnapshot(), + buildGitRemoteFailClosedSnapshot(), + } +} + +func dashboardSnapshotScenarios() []snapshotScenarioState { + return []snapshotScenarioState{ + buildHealthyEmptySnapshot(), + buildApprovalWaitingSnapshot(), + buildBlockedSnapshot(), + buildDegradedSnapshot(), + buildToastSnapshot(), + buildDashboardFocusNavSnapshot(), + buildLeaderHelpSnapshot(), + } +} diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go new file mode 100644 index 00000000..95613b9d --- /dev/null +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -0,0 +1,388 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +type snapshotScenarioState struct { + Name string + RouteID routeID + Surface routeModel + Sessions []brokerapi.SessionSummary + Watch shellWatchTransportLoadedMsg + Theme themePreset + Focus focusArea + Toast string + Viewport snapshotViewportPreset + Width int + Height int + Prepare func(*shellModel) + PostRender func(*shellModel) +} + +func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { + scenarios := allSnapshotScenarios() + switch strings.TrimSpace(name) { + case "", "all": + return scenarios, nil + case "dashboard": + return dashboardSnapshotScenarios(), nil + case "action-center": + return []snapshotScenarioState{buildActionCenterSnapshot()}, nil + default: + for _, scenario := range scenarios { + if scenario.Name == name { + return []snapshotScenarioState{scenario}, nil + } + } + return nil, usageErrorf("unknown snapshot scenario %q", name) + } +} + +func resolveSnapshotBundleScenarios(name string) ([]snapshotScenarioState, error) { + bundle, err := resolveSnapshotBundle(name) + if err != nil { + return nil, err + } + out := make([]snapshotScenarioState, 0, len(bundle.Scenarios)) + for _, name := range bundle.Scenarios { + scenarios, err := resolveSnapshotScenarios(name) + if err != nil { + return nil, err + } + out = append(out, scenarios...) + } + return out, nil +} + +func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) (string, routeID, error) { + snapshotRenderMu.Lock() + defer snapshotRenderMu.Unlock() + + return withSnapshotColorProfile(func() (string, routeID, error) { + forceMemoryWorkbenchState = true + defer func() { forceMemoryWorkbenchState = false }() + theme := cfg.theme + if theme == "" { + theme = state.Theme + } + width, height := snapshotScenarioDimensions(state, cfg) + appTheme = newTheme(theme) + m := newShellModel() + m.width = width + m.height = height + m.themePreset = theme + m.preferredMode = presentationRendered + m.location.Primary = shellObjectLocation{RouteID: state.RouteID, Object: workbenchObjectRef{Kind: "route", ID: string(state.RouteID)}} + m.nav.SelectByRouteID(state.RouteID) + m.routeModels[state.RouteID] = state.Surface + m.applySessionWorkspaceLoaded(sessionWorkspaceLoadedMsg{sessions: append([]brokerapi.SessionSummary(nil), state.Sessions...)}) + m.applyWatchTransport(state.Watch) + m.publishWatchStateToRoutes() + if strings.TrimSpace(state.Toast) != "" { + m.toasts.Push(toastInfo, state.Toast) + } + if state.Prepare != nil { + state.Prepare(&m) + } + m.setFocus(state.Focus) + m.syncSidebarCursorToLocation() + if state.PostRender != nil { + state.PostRender(&m) + } + return m.View(), state.RouteID, nil + }) +} + +func snapshotScenarioViewport(state snapshotScenarioState, cfg tuiSnapshotConfig) snapshotViewportPreset { + if state.Viewport != "" { + return state.Viewport + } + return cfg.viewport +} + +func snapshotScenarioDimensions(state snapshotScenarioState, cfg tuiSnapshotConfig) (int, int) { + width := cfg.width + height := cfg.height + if state.Viewport != "" { + viewport, err := resolveSnapshotViewportPreset(state.Viewport) + if err == nil { + if cfg.widthFromViewport { + width = viewport.Width + } + if cfg.heightFromViewport { + height = viewport.Height + } + } + } + if state.Width > 0 { + width = state.Width + } + if state.Height > 0 { + height = state.Height + } + return width, height +} + +func defaultSnapshotSessions() []brokerapi.SessionSummary { + return []brokerapi.SessionSummary{ + {Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Draft change proposal", LinkedRunCount: 1, LinkedApprovalCount: 1, TurnCount: 2}, + {Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-1"}, Status: "waiting", LastActivityPreview: "Review blocked work", LinkedRunCount: 1, LinkedApprovalCount: 0, TurnCount: 3}, + } +} + +func buildHealthyEmptySnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: true, RecoveryComplete: true}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{ + SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", + ValidationState: "valid", + CompatibilityPosture: "supported", + NormalOperationAllowed: true, + }}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "ok", CurrentlyDegraded: false}}, + }, + } + return snapshotScenarioState{Name: "dashboard-healthy-empty", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildApprovalWaitingSnapshot() snapshotScenarioState { + watch := snapshotWatchApprovalWaiting() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: true, RecoveryComplete: true}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported_with_upgrade_available", NormalOperationAllowed: true}}, + runs: []brokerapi.RunSummary{{RunID: "run-1", WorkspaceID: "ws-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1, ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "ok"}}, + approvals: []brokerapi.ApprovalSummary{{ApprovalID: "ap-1", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-1", StageID: "stage-2", ActionKind: "promotion"}}}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "ok", CurrentlyDegraded: false}}, + }, + } + return snapshotScenarioState{Name: "dashboard-approval-waiting", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildBlockedSnapshot() snapshotScenarioState { + watch := snapshotWatchBlocked() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: true, RecoveryComplete: true}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported", NormalOperationAllowed: true}}, + runs: []brokerapi.RunSummary{{RunID: "run-blocked", WorkspaceID: "ws-1", LifecycleState: "blocked", BlockingReasonCode: "approval_wait", PendingApprovalCount: 1, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "ok"}}, + approvals: []brokerapi.ApprovalSummary{{ApprovalID: "ap-blocked", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-blocked", StageID: "stage-1", ActionKind: "promotion"}}}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "ok", CurrentlyDegraded: false}}, + }, + } + return snapshotScenarioState{Name: "dashboard-blocked", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildDegradedSnapshot() snapshotScenarioState { + watch := snapshotWatchDegraded() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: false, RecoveryComplete: false}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported_with_upgrade_available", NormalOperationAllowed: true}}, + runs: []brokerapi.RunSummary{{RunID: "run-degraded", WorkspaceID: "ws-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "unavailable", RuntimePostureDegraded: true, ProvisioningPosture: "tofu", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "degraded", AuditCurrentlyDegraded: true}}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "degraded", CurrentlyDegraded: true, FindingCount: 2}}, + }, + } + return snapshotScenarioState{Name: "dashboard-degraded", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildToastSnapshot() snapshotScenarioState { + watch := snapshotWatchBlocked() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: true, RecoveryComplete: true}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported", NormalOperationAllowed: true}}, + runs: []brokerapi.RunSummary{{RunID: "run-blocked", WorkspaceID: "ws-1", LifecycleState: "blocked", BlockingReasonCode: "approval_wait", PendingApprovalCount: 1, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "ok"}}, + approvals: []brokerapi.ApprovalSummary{{ApprovalID: "ap-blocked", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-blocked", StageID: "stage-1", ActionKind: "promotion"}}}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "ok", CurrentlyDegraded: false}}, + }, + } + return snapshotScenarioState{Name: "dashboard-toast-info", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent, Toast: "Sidebar visibility changed."} +} + +func buildCommandModeDraftSnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "command-mode-draft", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusContent, Prepare: func(m *shellModel) { + m.commandMode = m.commandMode.Open().Append("open status") + }} +} + +func buildCommandModeErrorSnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "command-mode-error", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusContent, Prepare: func(m *shellModel) { + m.commandMode = m.commandMode.SetError("unknown command shell.unknown") + }} +} + +func buildCommandPaletteSnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "overlay-command-palette", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + openPaletteOverlay(m) + m.palette.query = "focus" + if updated, request, ok := m.palette.BeginFilterRefresh(); ok { + if applied, ok := updated.ApplyFilterRefresh(buildPaletteFilterResult(request, updated.entriesVersion, updated.query, updated.normalizedEntries, updated.appliedNeedle, updated.matchIndexes)); ok { + m.palette = applied + } + } + m.syncOverlayStack() + }} +} + +func buildSessionSwitcherSnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "overlay-session-switcher", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + openSessionSwitcherOverlay(m) + m.sessions.query = "session" + m.sessions.rebuildMatches() + m.syncOverlayStack() + }} +} + +func buildQuitConfirmChatComposeSnapshot() snapshotScenarioState { + chatState := buildChatSnapshot() + return snapshotScenarioState{Name: "quit-confirm-chat-compose", RouteID: routeChat, Surface: chatState.Surface, Sessions: chatState.Sessions, Watch: chatState.Watch, Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + chat := m.routeModels[routeChat].(chatRouteModel) + chat.composeOn = true + chat.composer.Focus() + m.routeModels[routeChat] = chat + m.beginOverlaySession() + m.quitConfirm = shellQuitConfirmState{active: true, reason: "chat compose"} + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} + +func buildQuitConfirmCommandEntrySnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "quit-confirm-command-entry", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + m.commandMode = m.commandMode.Open().Append("open runs") + m.beginOverlaySession() + m.quitConfirm = shellQuitConfirmState{active: true, reason: "command entry"} + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} + +func buildQuitConfirmProviderSecretSnapshot() snapshotScenarioState { + providerState := buildProviderSetupSnapshot() + return snapshotScenarioState{Name: "quit-confirm-provider-secret", RouteID: routeProviders, Surface: providerState.Surface, Sessions: providerState.Sessions, Watch: providerState.Watch, Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + provider := m.routeModels[routeProviders].(providerSetupRouteModel) + provider.entryActive = true + provider.secretRunes = []rune("secret-token") + m.routeModels[routeProviders] = provider + m.beginOverlaySession() + m.quitConfirm = shellQuitConfirmState{active: true, reason: "provider secret entry"} + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} + +func buildNarrowSidebarOverlaySnapshot() snapshotScenarioState { + return snapshotScenarioState{Name: "narrow-sidebar-overlay", RouteID: routeDashboard, Surface: buildBlockedSnapshot().Surface, Sessions: defaultSnapshotSessions(), Watch: snapshotWatchBlocked(), Theme: themePresetDark, Focus: focusPalette, Viewport: snapshotViewportMobile, Prepare: func(m *shellModel) { + m.narrowSidebarOn = true + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} + +func buildNarrowInspectorOverlaySnapshot() snapshotScenarioState { + runsState := buildRunsSnapshot() + return snapshotScenarioState{Name: "narrow-inspector-overlay", RouteID: routeRuns, Surface: runsState.Surface, Sessions: runsState.Sessions, Watch: runsState.Watch, Theme: themePresetDark, Focus: focusPalette, Viewport: snapshotViewportMobile, Prepare: func(m *shellModel) { + m.narrowInspectOn = true + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} + +func buildNarrowSidebarOverlayCompactSnapshot() snapshotScenarioState { + state := buildNarrowSidebarOverlaySnapshot() + state.Name = "narrow-sidebar-overlay-compact" + state.Width = shellMediumMinWidth - 2 + state.Height = 36 + return state +} + +func buildNarrowInspectorOverlayCompactSnapshot() snapshotScenarioState { + state := buildNarrowInspectorOverlaySnapshot() + state.Name = "narrow-inspector-overlay-compact" + state.Width = shellMediumMinWidth - 2 + state.Height = 36 + return state +} + +func buildDashboardFocusNavSnapshot() snapshotScenarioState { + blocked := buildBlockedSnapshot() + blocked.Name = "dashboard-focus-nav" + blocked.Focus = focusNav + return blocked +} + +func buildActionCenterSnapshot() snapshotScenarioState { + watch := snapshotWatchActionCenter() + now := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) + audit := brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "failed", AnchoringStatus: "degraded", CurrentlyDegraded: true, FindingCount: 3, HardFailures: []string{"anchor_receipt_invalid"}}} + action := actionCenterRouteModel{ + def: routeDefinition{ID: routeAction, Label: "Action Center"}, + now: func() time.Time { return now }, + inspectorOn: true, + family: actionCenterFamilyApprovals, + selected: map[actionCenterFamily]int{ + actionCenterFamilyApprovals: 0, + actionCenterFamilyOps: 0, + actionCenterFamilyBlocked: 0, + }, + watch: watchProjection(watch).Live, + watchHealth: watchProjection(watch).Health, + runs: []brokerapi.RunSummary{ + {RunID: "run-approval", WorkspaceID: "ws-1", LifecycleState: "waiting", PendingApprovalCount: 1, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed"}, + {RunID: "run-degraded", WorkspaceID: "ws-1", LifecycleState: "active", RuntimePostureDegraded: true, BackendKind: "workspace", IsolationAssuranceLevel: "reduced", AuditAnchoringStatus: "degraded", AuditCurrentlyDegraded: true}, + {RunID: "run-blocked", WorkspaceID: "ws-1", LifecycleState: "blocked", BlockingReasonCode: "project_blocked", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed"}, + }, + approvals: []brokerapi.ApprovalSummary{{ApprovalID: "ap-urgent", Status: "pending", ApprovalTriggerCode: "policy_gate", ExpiresAt: now.Add(45 * time.Minute).Format(time.RFC3339), BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-approval", StageID: "stage-2", ActionKind: "promotion"}}}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "incompatible", CompatibilityPosture: "unsupported", NormalOperationAllowed: false}, BlockedExplanation: "project setup blocks normal operation", RemediationGuidance: []string{"review project setup", "apply supported substrate"}}, + audit: &audit, + } + return snapshotScenarioState{Name: "action-center-triage", RouteID: routeAction, Surface: action, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildLeaderHelpSnapshot() snapshotScenarioState { + watch := snapshotWatchBlocked() + dashboard := dashboardRouteModel{ + def: routeDefinition{ID: routeDashboard, Label: "Dashboard"}, + data: dashboardData{ + readiness: brokerapi.BrokerReadiness{Ready: true, RecoveryComplete: true}, + version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0"}, + project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported", NormalOperationAllowed: true}}, + runs: []brokerapi.RunSummary{{RunID: "run-blocked", WorkspaceID: "ws-1", LifecycleState: "blocked", BlockingReasonCode: "approval_wait", PendingApprovalCount: 1, BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "ok"}}, + approvals: []brokerapi.ApprovalSummary{{ApprovalID: "ap-blocked", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{RunID: "run-blocked", StageID: "stage-1", ActionKind: "promotion"}}}, + live: watchProjection(watch).Live, + audit: brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "ok", AnchoringStatus: "ok", CurrentlyDegraded: false}}, + }, + } + return snapshotScenarioState{Name: "leader-help-root", RouteID: routeDashboard, Surface: dashboard, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusPalette, Prepare: func(m *shellModel) { + m.beginOverlaySession() + m.leader.Rebind(m.actions.leaderBindings(*m)) + m.leader.Start() + m.setFocus(focusPalette) + m.syncOverlayStack() + }} +} diff --git a/cmd/runecode-tui/snapshot_scenarios_routes.go b/cmd/runecode-tui/snapshot_scenarios_routes.go new file mode 100644 index 00000000..5f78e0ea --- /dev/null +++ b/cmd/runecode-tui/snapshot_scenarios_routes.go @@ -0,0 +1,212 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +func buildChatSnapshot() snapshotScenarioState { + watch := snapshotWatchApprovalWaiting() + active := &brokerapi.SessionDetail{ + Summary: brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Draft change proposal", LinkedRunCount: 1, LinkedArtifactCount: 1, LinkedApprovalCount: 1}, + CurrentTurnExecution: &brokerapi.SessionTurnExecution{TurnID: "turn-2", SessionID: "session-1", TriggerSource: "operator", RequestedOperation: "change_draft", ExecutionState: "waiting", PrimaryRunID: "run-1", PendingApprovalID: "ap-1"}, + LinkedRunIDs: []string{"run-1"}, + LinkedApprovalIDs: []string{"ap-1"}, + } + run := &brokerapi.RunDetail{Summary: brokerapi.RunSummary{RunID: "run-1", WorkspaceID: "ws-1", WorkflowKind: "change_draft", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed"}, Coordination: brokerapi.RunCoordinationSummary{Blocked: true, WaitReasonCode: "approval_wait"}, PendingApprovalIDs: []string{"ap-1"}, ArtifactCountsByClass: map[string]int{"diffs": 1}} + chat := chatRouteModel{def: routeDefinition{ID: routeChat, Label: "Chat"}, sessions: defaultSnapshotSessions(), selected: 0, active: active, activeID: "session-1", inspectorOn: true, presentation: presentationRendered, posture: &brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported", NormalOperationAllowed: true}}, runDetail: run, detailDoc: newLongFormDocumentState(), detailDocSession: active} + return snapshotScenarioState{Name: "chat-active-session", RouteID: routeChat, Surface: chat, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildChatComposeOpenSnapshot() snapshotScenarioState { + chatState := buildChatSnapshot() + chat := chatState.Surface.(chatRouteModel) + chat.composeOn = true + chat.composer.SetValue("Summarize blocked work and next approval.") + chat.composer.Focus() + chatState.Name = "chat-compose-open" + chatState.Surface = chat + return chatState +} + +func buildRunsSnapshot() snapshotScenarioState { + watch := snapshotWatchApprovalWaiting() + active := &brokerapi.RunDetail{Summary: brokerapi.RunSummary{RunID: "run-1", WorkspaceID: "ws-1", WorkflowKind: "change_draft", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", ProvisioningPosture: "attested", AuditIntegrityStatus: "ok", AuditAnchoringStatus: "ok"}, Coordination: brokerapi.RunCoordinationSummary{Blocked: true, WaitReasonCode: "approval_wait", CoordinationMode: "stage_gate"}, StageSummaries: []brokerapi.RunStageSummary{{StageID: "stage-1", PendingApprovalCount: 1, ArtifactCount: 2}}, PendingApprovalIDs: []string{"ap-1"}, ArtifactCountsByClass: map[string]int{"diffs": 2}, AuthoritativeState: map[string]any{"workflow_projection_reason": "plan_authoritative"}} + runs := runsRouteModel{def: routeDefinition{ID: routeRuns, Label: "Runs"}, runs: []brokerapi.RunSummary{active.Summary}, selected: 0, active: active, inspectorOn: true, presentation: presentationRendered, detailDoc: newLongFormDocumentState()} + return snapshotScenarioState{Name: "runs-active-detail", RouteID: routeRuns, Surface: runs, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildRunsFocusInspectorSnapshot() snapshotScenarioState { + runsState := buildRunsSnapshot() + runsState.Name = "runs-focus-inspector" + runsState.Focus = focusInspector + return runsState +} + +func buildRunsRawSnapshot() snapshotScenarioState { + runsState := buildRunsSnapshot() + runs := runsState.Surface.(runsRouteModel) + runs.presentation = presentationRaw + runs.syncDetailDocument() + runsState.Name = "runs-raw-detail" + runsState.Surface = runs + return runsState +} + +func buildApprovalsSnapshot() snapshotScenarioState { + watch := snapshotWatchApprovalWaiting() + active := &brokerapi.ApprovalGetResponse{Approval: brokerapi.ApprovalSummary{ApprovalID: "ap-1", Status: "pending", ApprovalTriggerCode: "policy_gate", BoundScope: brokerapi.ApprovalBoundScope{WorkspaceID: "ws-1", RunID: "run-1", StageID: "stage-1", ActionKind: "promotion"}}, ApprovalDetail: brokerapi.ApprovalDetail{BindingKind: "exact_action", PolicyReasonCode: "requires_human_review", LifecycleDetail: brokerapi.ApprovalLifecycleDetail{LifecycleState: "pending", LifecycleReasonCode: "awaiting_decision"}, WhatChangesIfApproved: brokerapi.ApprovalWhatChangesIfApproved{Summary: "Promotion continues", EffectKind: "unblock_next_stage"}, BlockedWorkScope: brokerapi.ApprovalBlockedWorkScope{ScopeKind: "stage", RunID: "run-1", StageID: "stage-1", ActionKind: "promotion"}, BoundIdentity: brokerapi.ApprovalBoundIdentity{ApprovalRequestDigest: "sha256:req", ManifestHash: "sha256:manifest", PolicyDecisionHash: "sha256:policy"}}} + approvals := approvalsRouteModel{def: routeDefinition{ID: routeApprovals, Label: "Approvals"}, items: []brokerapi.ApprovalSummary{active.Approval}, selected: 0, active: active, inspectorOn: true, presentation: presentationRendered, detailDoc: newLongFormDocumentState()} + return snapshotScenarioState{Name: "approvals-pending-detail", RouteID: routeApprovals, Surface: approvals, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildApprovalsStructuredSnapshot() snapshotScenarioState { + approvalState := buildApprovalsSnapshot() + approvals := approvalState.Surface.(approvalsRouteModel) + approvals.presentation = presentationStructured + approvals.syncDetailDocument() + approvalState.Name = "approvals-structured-detail" + approvalState.Surface = approvals + return approvalState +} + +func buildArtifactsSnapshot() snapshotScenarioState { + watch := snapshotWatchApprovalWaiting() + selected := brokerapi.ArtifactSummary{RunID: "run-1"} + selected.Reference.Digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + selected.Reference.ContentType = "text/plain" + selected.Reference.DataClass = "diffs" + selected.Reference.SizeBytes = 128 + selected.Reference.ProvenanceReceiptHash = "sha256:receipt" + other := brokerapi.ArtifactSummary{RunID: "run-1"} + other.Reference.Digest = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + other.Reference.ContentType = "text/plain" + other.Reference.DataClass = "build_logs" + other.Reference.SizeBytes = 256 + other.Reference.ProvenanceReceiptHash = "sha256:receipt-2" + artifacts := artifactsRouteModel{ + def: routeDefinition{ID: routeArtifacts, Label: "Artifacts"}, + items: []brokerapi.ArtifactSummary{selected, other}, + selected: 0, + active: &brokerapi.LocalArtifactHeadResponse{Artifact: selected}, + content: "diff --git a/docs/plan.md b/docs/plan.md\n+Evidence trail copy now points reviewers to Audit\n+Anchoring receipt attached: sha256:receipt\n-result: pending approval\n", + mode: artifactModeDiff, + presentation: presentationRendered, + inspectorOn: true, + detailDoc: newLongFormDocumentState(), + } + artifacts.syncDetailDocument() + return snapshotScenarioState{Name: "artifacts-evidence-detail", RouteID: routeArtifacts, Surface: artifacts, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildArtifactsStructuredSnapshot() snapshotScenarioState { + artifactState := buildArtifactsSnapshot() + artifacts := artifactState.Surface.(artifactsRouteModel) + artifacts.presentation = presentationStructured + artifacts.syncDetailDocument() + artifactState.Name = "artifacts-structured-detail" + artifactState.Surface = artifacts + return artifactState +} + +func buildAuditSnapshot() snapshotScenarioState { + watch := snapshotWatchActionCenter() + verify := &brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "failed", AnchoringStatus: "degraded", CurrentlyDegraded: true, HardFailures: []string{"anchor_receipt_invalid"}}} + recordDigest := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("c", 64)} + record := &brokerapi.AuditRecordGetResponse{Record: brokerapi.AuditRecordDetail{RecordDigest: recordDigest, RecordFamily: "audit_event", EventType: "run_state", Summary: "Run state changed", OccurredAt: "2026-05-09T12:20:00Z", LinkedReferences: []brokerapi.AuditRecordLinkedReference{{ReferenceKind: "run", ReferenceID: "run-1"}}, VerificationPosture: &brokerapi.AuditRecordVerificationPosture{Status: "degraded", ReasonCodes: []string{"anchor_delayed"}}}} + audit := auditRouteModel{def: routeDefinition{ID: routeAudit, Label: "Audit"}, verify: verify, active: record, timeline: []brokerapi.AuditTimelineViewEntry{{RecordDigest: recordDigest, EventType: "run_state", Summary: "Run state changed"}}, inspectorOn: true, presentation: presentationRendered, detailDoc: newLongFormDocumentState()} + return snapshotScenarioState{Name: "audit-degraded-detail", RouteID: routeAudit, Surface: audit, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildAuditRawSnapshot() snapshotScenarioState { + auditState := buildAuditSnapshot() + audit := auditState.Surface.(auditRouteModel) + audit.presentation = presentationRaw + audit.syncDetailDocument() + auditState.Name = "audit-raw-detail" + auditState.Surface = audit + return auditState +} + +func buildStatusSnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + status := statusRouteModel{def: routeDefinition{ID: routeStatus, Label: "Status"}, data: statusLoadedMsg{readiness: brokerapi.BrokerReadiness{Ready: true, LocalOnly: true, RecoveryComplete: true, AppendPositionStable: true, CurrentSegmentWritable: true, VerifierMaterialAvailable: true, DerivedIndexCaughtUp: true}, version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0", BuildRevision: "deadbeef", BuildTime: "2026-05-09T00:00:00Z", ProtocolBundleVersion: "v0", ProtocolBundleManifestHash: "sha256:bundle", APIFamily: "local", APIVersion: "0.1.0"}, lifecycle: brokerapi.BrokerProductLifecyclePosture{SchemaID: "runecode.protocol.v0.BrokerProductLifecyclePosture", LifecyclePosture: "normal", AttachMode: "managed", Attachable: true, NormalOperationAllowed: true}, project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "valid", CompatibilityPosture: "supported", NormalOperationAllowed: true}, RemediationGuidance: []string{"No remediation required"}}, posture: brokerapi.BackendPostureState{InstanceID: "instance-1", BackendKind: "workspace"}}} + return snapshotScenarioState{Name: "status-ready-overview", RouteID: routeStatus, Surface: status, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildStatusDegradedSnapshot() snapshotScenarioState { + watch := snapshotWatchDegraded() + status := statusRouteModel{def: routeDefinition{ID: routeStatus, Label: "Status"}, status: "Project setup validation could not be refreshed. Review the broker guidance below.", data: statusLoadedMsg{readiness: brokerapi.BrokerReadiness{Ready: false, LocalOnly: true, RecoveryComplete: false, AppendPositionStable: true, CurrentSegmentWritable: false, VerifierMaterialAvailable: true, DerivedIndexCaughtUp: false}, version: brokerapi.BrokerVersionInfo{ProductVersion: "0.1.0", BuildRevision: "deadbeef", BuildTime: "2026-05-09T00:00:00Z", ProtocolBundleVersion: "v0", ProtocolBundleManifestHash: "sha256:bundle", APIFamily: "local", APIVersion: "0.1.0"}, lifecycle: brokerapi.BrokerProductLifecyclePosture{SchemaID: "runecode.protocol.v0.BrokerProductLifecyclePosture", SchemaVersion: "0.1.0", ProductInstanceID: "repo-test", LifecycleGeneration: "gen-degraded", LifecyclePosture: "degraded", AttachMode: "managed", Attachable: true, NormalOperationAllowed: false, DegradedReasonCodes: []string{"verifier_lagging"}}, project: brokerapi.ProjectSubstratePostureGetResponse{PostureSummary: brokerapi.ProjectSubstratePostureSummary{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", ValidationState: "degraded", CompatibilityPosture: "supported_with_upgrade_available", NormalOperationAllowed: true}, RemediationGuidance: []string{"Refresh managed setup validation"}}, posture: brokerapi.BackendPostureState{InstanceID: "instance-1", BackendKind: "workspace"}}} + return snapshotScenarioState{Name: "status-degraded-overview", RouteID: routeStatus, Surface: status, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildProviderSetupSnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + profile := brokerapi.ProviderProfile{ProviderProfileID: "provider-profile-test", DisplayLabel: "OpenAI Compatible", ProviderFamily: "openai_compatible", AdapterKind: "chat_completions_v0", CurrentAuthMode: "direct_credential", SupportedAuthModes: []string{"direct_credential"}, ReadinessPosture: brokerapi.ProviderReadinessPosture{CredentialState: "missing", EffectiveReadiness: "not_ready"}} + provider := providerSetupRouteModel{def: routeDefinition{ID: routeProviders, Label: "Model Providers"}, selected: providerSetupDefaultsFor("openai_compatible"), status: "Press s to start direct-credential setup. Press f to switch provider family.", profiles: []brokerapi.ProviderProfile{profile}} + return snapshotScenarioState{Name: "model-providers-credential-needed", RouteID: routeProviders, Surface: provider, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildProviderSecretEntrySnapshot() snapshotScenarioState { + providerState := buildProviderSetupSnapshot() + provider := providerState.Surface.(providerSetupRouteModel) + provider.entryActive = true + provider.secretRunes = []rune("sk-live-123456") + provider.status = "Credential entry is active. Press Enter to submit or Esc to cancel." + providerState.Name = "model-providers-secret-entry" + providerState.Surface = provider + return providerState +} + +func buildGitSetupSnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + git := gitSetupRouteModel{def: routeDefinition{ID: routeGitSetup, Label: "Git Setup"}, provider: "github", data: brokerapi.GitSetupGetResponse{ProviderAccount: brokerapi.GitProviderAccountState{Provider: "github", Linked: true, AccountUsername: "runecode-dev"}, IdentityProfiles: []brokerapi.GitCommitIdentityProfile{{ProfileID: "default", DisplayName: "RuneCode Dev", AuthorName: "RuneCode Dev", AuthorEmail: "dev@example.com", CommitterName: "RuneCode Dev", CommitterEmail: "dev@example.com", DefaultProfile: true}}, AuthPosture: brokerapi.GitAuthPostureState{AuthStatus: "linked", BootstrapMode: "browser", HeadlessBootstrapSupported: true}, ControlPlaneState: brokerapi.GitControlPlaneState{DefaultIdentityProfileID: "default", LastSetupView: "overview"}, PolicySurface: brokerapi.GitPolicySurfaceState{ArtifactManagedOnly: true, DirectMutationSupport: false}}} + return snapshotScenarioState{Name: "git-setup-identity-needed", RouteID: routeGitSetup, Surface: git, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildGitSetupProviderLinkedSnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + git := gitSetupRouteModel{def: routeDefinition{ID: routeGitSetup, Label: "Git Setup"}, provider: "github", status: "The provider account is linked, but commit identity still needs setup.", data: brokerapi.GitSetupGetResponse{ProviderAccount: brokerapi.GitProviderAccountState{Provider: "github", Linked: true, AccountUsername: "runecode-dev"}, AuthPosture: brokerapi.GitAuthPostureState{AuthStatus: "linked", BootstrapMode: "browser", HeadlessBootstrapSupported: true}, ControlPlaneState: brokerapi.GitControlPlaneState{DefaultIdentityProfileID: "", LastSetupView: "overview"}, PolicySurface: brokerapi.GitPolicySurfaceState{ArtifactManagedOnly: true, DirectMutationSupport: false}}} + return snapshotScenarioState{Name: "git-setup-provider-linked", RouteID: routeGitSetup, Surface: git, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildGitRemoteSnapshot() snapshotScenarioState { + watch := snapshotWatchHealthyEmpty() + requestHash := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("1", 64)} + actionHash := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("2", 64)} + decisionHash := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("3", 64)} + approvalRequest := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("4", 64)} + approvalDecision := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("5", 64)} + patchDigest := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("6", 64)} + expectedTree := trustpolicy.Digest{HashAlg: "sha256", Hash: strings.Repeat("7", 64)} + prepared := brokerapi.GitRemoteMutationPreparedState{PreparedMutationID: "sha256:" + strings.Repeat("9", 64), RunID: "run-1", Provider: "github", DestinationRef: "github.com/runecode-ai/runecode", RequestKind: "git_ref_update", TypedRequestHash: requestHash, ActionRequestHash: actionHash, PolicyDecisionHash: decisionHash, RequiredApprovalID: "sha256:" + strings.Repeat("a", 64), RequiredApprovalRequestHash: &approvalRequest, RequiredApprovalDecisionHash: &approvalDecision, LifecycleState: "prepared", ExecutionState: "not_started", DerivedSummary: brokerapi.GitRemoteMutationDerivedSummary{RepositoryIdentity: "github.com/runecode-ai/runecode", TargetRefs: []string{"refs/heads/main"}, ReferencedPatchArtifactHashes: []trustpolicy.Digest{patchDigest}, ExpectedResultTreeHash: expectedTree, CommitSubject: "Apply reviewed patch"}} + git := gitRemoteMutationRouteModel{def: routeDefinition{ID: routeGitRemote, Label: "Git Remote"}, prepared: prepared} + return snapshotScenarioState{Name: "git-remote-approval-ready", RouteID: routeGitRemote, Surface: git, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} +} + +func buildGitRemoteExecutedSnapshot() snapshotScenarioState { + gitState := buildGitRemoteSnapshot() + git := gitState.Surface.(gitRemoteMutationRouteModel) + git.prepared.ExecutionState = "completed" + git.status = "Remote execution completed. Review the refreshed state to confirm the remote change landed as expected." + gitState.Name = "git-remote-executed" + gitState.Surface = git + return gitState +} + +func buildGitRemoteFailClosedSnapshot() snapshotScenarioState { + gitState := buildGitRemoteSnapshot() + git := gitState.Surface.(gitRemoteMutationRouteModel) + git.prepared.RequiredApprovalID = "" + git.prepared.RequiredApprovalRequestHash = nil + git.prepared.RequiredApprovalDecisionHash = nil + git.errText = "required approval binding is incomplete in prepared state; execute remains fail-closed" + git.status = "" + gitState.Name = "git-remote-fail-closed" + gitState.Surface = git + return gitState +} diff --git a/cmd/runecode-tui/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go new file mode 100644 index 00000000..6e5d68cc --- /dev/null +++ b/cmd/runecode-tui/snapshot_svg.go @@ -0,0 +1,360 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image/color" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/muesli/termenv" +) + +var snapshotRenderMu sync.Mutex + +type snapshotManifest struct { + Version int `json:"version"` + Bundle string `json:"bundle,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Viewport string `json:"viewport,omitempty"` + Theme string `json:"theme"` + Coverage snapshotCoverage `json:"coverage,omitempty"` + Scenarios []snapshotManifestEntry `json:"scenarios"` +} + +type snapshotManifestArtifact struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` +} + +type snapshotManifestArtifacts struct { + ANSI snapshotManifestArtifact `json:"ansi"` + Text snapshotManifestArtifact `json:"text"` + SVG snapshotManifestArtifact `json:"svg"` + PNG snapshotManifestArtifact `json:"png"` +} + +type snapshotManifestEntry struct { + Name string `json:"name"` + Viewport string `json:"viewport,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Route string `json:"route"` + Artifacts snapshotManifestArtifacts `json:"artifacts"` +} + +type snapshotArtifactPaths struct { + ANSI string + Text string + SVG string + PNG string +} + +type snapshotSVGLayout struct { + Cols int + Rows int + CellWidth int + CellHeight int + FontSize int + Padding int + Width int + Height int +} + +func writeSnapshotArtifacts(cfg tuiSnapshotConfig) error { + cfg, err := normalizeSnapshotConfig(cfg) + if err != nil { + return err + } + scenarios, err := resolveSnapshotScenarios(cfg.scenario) + if err != nil { + return err + } + if cfg.bundle != "" { + scenarios, err = resolveSnapshotBundleScenarios(cfg.bundle) + if err != nil { + return err + } + } + if err := os.MkdirAll(cfg.outputDir, 0o755); err != nil { + return err + } + manifest := snapshotManifest{Version: 2, Bundle: cfg.bundle, Width: cfg.width, Height: cfg.height, Viewport: string(cfg.viewport), Theme: string(snapshotManifestTheme(cfg, scenarios)), Coverage: snapshotCoverageForBundle(normalizeSnapshotBundle(cfg.bundle), scenarios, cfg)} + entries, err := writeSnapshotScenarioArtifacts(cfg, scenarios) + if err != nil { + return err + } + manifest.Scenarios = entries + return writeSnapshotManifest(cfg.outputDir, manifest) +} + +func normalizeSnapshotConfig(cfg tuiSnapshotConfig) (tuiSnapshotConfig, error) { + if cfg.outputDir == "" { + cfg.outputDir = snapshotDefaultOutputDir() + } + if strings.TrimSpace(cfg.bundle) != "" && strings.TrimSpace(cfg.scenario) != "" { + return tuiSnapshotConfig{}, usageErrorf("snapshot bundle and snapshot scenario are mutually exclusive") + } + normalizedOutputDir, err := normalizeSnapshotOutputDir(cfg.outputDir) + if err != nil { + return tuiSnapshotConfig{}, usageErrorf("%v", err) + } + cfg.outputDir = normalizedOutputDir + viewport, err := resolveSnapshotViewportPreset(cfg.viewport) + if err != nil { + return tuiSnapshotConfig{}, err + } + if cfg.viewport == "" { + cfg.viewport = viewport.Name + } + if cfg.width == 0 { + cfg.width = viewport.Width + cfg.widthFromViewport = true + } + if cfg.height == 0 { + cfg.height = viewport.Height + cfg.heightFromViewport = true + } + if cfg.width <= 0 || cfg.height <= 0 { + return tuiSnapshotConfig{}, usageErrorf("snapshot dimensions must be positive") + } + return cfg, nil +} + +func snapshotManifestTheme(cfg tuiSnapshotConfig, scenarios []snapshotScenarioState) themePreset { + if cfg.theme != "" { + return cfg.theme + } + if len(scenarios) > 0 { + return scenarios[0].Theme + } + return themePresetDark +} + +func writeSnapshotScenarioArtifacts(cfg tuiSnapshotConfig, scenarios []snapshotScenarioState) ([]snapshotManifestEntry, error) { + entries := make([]snapshotManifestEntry, 0, len(scenarios)) + for _, scenario := range scenarios { + entry, err := writeSnapshotScenarioArtifact(cfg, scenario) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + return entries, nil +} + +func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScenarioState) (snapshotManifestEntry, error) { + viewport := snapshotScenarioViewport(scenario, cfg) + width, height := snapshotScenarioDimensions(scenario, cfg) + paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name, viewport) + ansiManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.ANSI) + if err != nil { + return snapshotManifestEntry{}, err + } + textManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.Text) + if err != nil { + return snapshotManifestEntry{}, err + } + svgManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.SVG) + if err != nil { + return snapshotManifestEntry{}, err + } + pngManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.PNG) + if err != nil { + return snapshotManifestEntry{}, err + } + if err := removeSnapshotArtifactIfPresent(paths.PNG); err != nil { + return snapshotManifestEntry{}, err + } + view, routeID, err := renderSnapshotScenario(scenario, cfg) + if err != nil { + return snapshotManifestEntry{}, err + } + ansiArtifact, err := writeSnapshotHashedArtifact(paths.ANSI, []byte(view)) + if err != nil { + return snapshotManifestEntry{}, err + } + textArtifact, err := writeSnapshotHashedArtifact(paths.Text, []byte(ansi.Strip(view))) + if err != nil { + return snapshotManifestEntry{}, err + } + svgArtifact, err := writeSnapshotHashedArtifact(paths.SVG, []byte(renderSnapshotSVG(view, width, height))) + if err != nil { + return snapshotManifestEntry{}, err + } + ansiArtifact.Path = ansiManifestPath + textArtifact.Path = textManifestPath + svgArtifact.Path = svgManifestPath + return snapshotManifestEntry{ + Name: scenario.Name, + Viewport: string(viewport), + Width: width, + Height: height, + Route: string(routeID), + Artifacts: snapshotManifestArtifacts{ + ANSI: ansiArtifact, + Text: textArtifact, + SVG: svgArtifact, + PNG: snapshotManifestArtifact{Path: pngManifestPath}, + }, + }, nil +} + +func snapshotManifestArtifactPath(outputDir string, artifactPath string) (string, error) { + relPath, err := filepath.Rel(outputDir, artifactPath) + if err != nil { + return "", fmt.Errorf("resolve snapshot artifact path %q relative to %q: %w", artifactPath, outputDir, err) + } + relPath = filepath.Clean(relPath) + if relPath == "." || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("snapshot artifact path %q must stay under %s", artifactPath, outputDir) + } + return relPath, nil +} + +func removeSnapshotArtifactIfPresent(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func writeSnapshotHashedArtifact(path string, content []byte) (snapshotManifestArtifact, error) { + if err := os.WriteFile(path, content, 0o644); err != nil { + return snapshotManifestArtifact{}, err + } + sum := sha256.Sum256(content) + return snapshotManifestArtifact{Path: path, SHA256: hex.EncodeToString(sum[:])}, nil +} + +func snapshotPathsForScenario(outputDir string, scenario string, viewport snapshotViewportPreset) snapshotArtifactPaths { + base := filepath.Join(outputDir, scenario+"."+string(viewport)) + return snapshotArtifactPaths{ANSI: base + ".ansi", Text: base + ".txt", SVG: base + ".svg", PNG: base + ".png"} +} + +func writeSnapshotManifest(outputDir string, manifest snapshotManifest) error { + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(outputDir, "manifest.json"), append(encoded, '\n'), 0o644) +} + +func renderSnapshotSVG(ansiView string, cols int, rows int) string { + layout := newSnapshotSVGLayout(cols, rows) + buf := cellbuf.NewBuffer(cols, rows) + cellbuf.SetContent(buf, ansiView) + var out bytes.Buffer + writeSnapshotSVGHeader(&out, layout) + writeSnapshotSVGCells(&out, buf, layout) + writeSnapshotSVGFooter(&out) + return out.String() +} + +func newSnapshotSVGLayout(cols int, rows int) snapshotSVGLayout { + layout := snapshotSVGLayout{Cols: cols, Rows: rows, CellWidth: 9, CellHeight: 18, FontSize: 14, Padding: 16} + layout.Width = cols*layout.CellWidth + layout.Padding*2 + layout.Height = rows*layout.CellHeight + layout.Padding*2 + return layout +} + +func writeSnapshotSVGHeader(out *bytes.Buffer, layout snapshotSVGLayout) { + fmt.Fprintf(out, ``, layout.Width, layout.Height, layout.Width, layout.Height) + out.WriteString("\n") + fmt.Fprintf(out, ``, snapshotDefaultBG()) + out.WriteString("\n") + fmt.Fprintf(out, ``, layout.FontSize) + out.WriteString("\n") +} + +func writeSnapshotSVGCells(out *bytes.Buffer, buf *cellbuf.Buffer, layout snapshotSVGLayout) { + for y := 0; y < layout.Rows; y++ { + for x := 0; x < layout.Cols; x++ { + writeSnapshotSVGCell(out, buf.Cell(x, y), x, y, layout) + } + } +} + +func writeSnapshotSVGCell(out *bytes.Buffer, cell *cellbuf.Cell, x int, y int, layout snapshotSVGLayout) { + if cell == nil || cell.Width == 0 { + return + } + fg, bg := snapshotCellColors(cell) + px, py := layout.Padding+x*layout.CellWidth, layout.Padding+y*layout.CellHeight + writeSnapshotSVGCellBackground(out, bg, px, py, cell.Width*layout.CellWidth, layout.CellHeight) + writeSnapshotSVGCellText(out, cell, fg, px, py+layout.FontSize) +} + +func writeSnapshotSVGCellBackground(out *bytes.Buffer, bg string, x int, y int, width int, height int) { + if bg == snapshotDefaultBG() { + return + } + fmt.Fprintf(out, ``, x, y, width, height, bg) + out.WriteString("\n") +} + +func writeSnapshotSVGCellText(out *bytes.Buffer, cell *cellbuf.Cell, fg string, x int, y int) { + text := cell.String() + if strings.TrimSpace(text) == "" { + return + } + weight := "400" + if cell.Style.Attrs&cellbuf.BoldAttr != 0 { + weight = "700" + } + fmt.Fprintf(out, `%s`, x, y, fg, weight, escapeSVGText(text)) + out.WriteString("\n") +} + +func writeSnapshotSVGFooter(out *bytes.Buffer) { + out.WriteString("\n\n") +} + +func snapshotCellColors(cell *cellbuf.Cell) (string, string) { + fg := colorToHex(cell.Style.Fg, snapshotDefaultFG()) + bg := colorToHex(cell.Style.Bg, snapshotDefaultBG()) + if cell.Style.Attrs&cellbuf.ReverseAttr != 0 { + return bg, fg + } + return fg, bg +} + +func colorToHex(c color.Color, fallback string) string { + if c == nil { + return fallback + } + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) +} + +func escapeSVGText(text string) string { + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, "'", "'") + text = strings.ReplaceAll(text, `"`, """) + return text +} + +func snapshotDefaultFG() string { return "#d0d0d0" } + +func snapshotDefaultBG() string { return "#1c1c1c" } + +func withSnapshotColorProfile(fn func() (string, routeID, error)) (string, routeID, error) { + original := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(original) + return fn() +} diff --git a/cmd/runecode-tui/snapshot_watch_helpers.go b/cmd/runecode-tui/snapshot_watch_helpers.go new file mode 100644 index 00000000..a9346ecb --- /dev/null +++ b/cmd/runecode-tui/snapshot_watch_helpers.go @@ -0,0 +1,51 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "time" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func watchProjection(msg shellWatchTransportLoadedMsg) shellWatchProjectionState { + manager := newShellWatchManager() + manager.applyTransport(msg) + return manager.projection +} + +func snapshotWatchHealthyEmpty() shellWatchTransportLoadedMsg { + return shellWatchTransportLoadedMsg{ObservedAt: time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)} +} + +func snapshotWatchApprovalWaiting() shellWatchTransportLoadedMsg { + now := time.Date(2026, 5, 9, 12, 5, 0, 0, time.UTC) + run := brokerapi.RunSummary{RunID: "run-1", LifecycleState: "active", BackendKind: "workspace", IsolationAssuranceLevel: "sandboxed", PendingApprovalCount: 1} + approval := brokerapi.ApprovalSummary{ApprovalID: "ap-1", Status: "pending"} + session := brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Awaiting approval"} + return shellWatchTransportLoadedMsg{ObservedAt: now, Run: shellWatchRunTransportResult{Events: []brokerapi.RunWatchEvent{{EventType: "run_watch_snapshot", Seq: 1, Run: &run}}}, Approval: shellWatchApprovalTransportResult{Events: []brokerapi.ApprovalWatchEvent{{EventType: "approval_watch_snapshot", Seq: 1, Approval: &approval}}}, Session: shellWatchSessionTransportResult{Events: []brokerapi.SessionWatchEvent{{EventType: "session_watch_snapshot", Seq: 1, Session: &session}}}} +} + +func snapshotWatchBlocked() shellWatchTransportLoadedMsg { + now := time.Date(2026, 5, 9, 12, 10, 0, 0, time.UTC) + run := brokerapi.RunSummary{RunID: "run-blocked", LifecycleState: "blocked", BlockingReasonCode: "approval_wait", PendingApprovalCount: 1} + approval := brokerapi.ApprovalSummary{ApprovalID: "ap-blocked", Status: "pending"} + session := brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-1"}, Status: "blocked", LastActivityPreview: "Blocked on approval"} + return shellWatchTransportLoadedMsg{ObservedAt: now, Run: shellWatchRunTransportResult{Events: []brokerapi.RunWatchEvent{{EventType: "run_watch_snapshot", Seq: 1, Run: &run}}}, Approval: shellWatchApprovalTransportResult{Events: []brokerapi.ApprovalWatchEvent{{EventType: "approval_watch_snapshot", Seq: 1, Approval: &approval}}}, Session: shellWatchSessionTransportResult{Events: []brokerapi.SessionWatchEvent{{EventType: "session_watch_snapshot", Seq: 1, Session: &session}}}} +} + +func snapshotWatchDegraded() shellWatchTransportLoadedMsg { + now := time.Date(2026, 5, 9, 12, 15, 0, 0, time.UTC) + run := brokerapi.RunSummary{RunID: "run-degraded", LifecycleState: "active", RuntimePostureDegraded: true, AuditCurrentlyDegraded: true} + approval := brokerapi.ApprovalSummary{ApprovalID: "ap-quiet", Status: "consumed"} + session := brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-1", WorkspaceID: "ws-1"}, Status: "degraded", LastActivityPreview: "Runtime posture degraded"} + return shellWatchTransportLoadedMsg{ObservedAt: now, Run: shellWatchRunTransportResult{Events: []brokerapi.RunWatchEvent{{EventType: "run_watch_snapshot", Seq: 1, Run: &run}, {EventType: "run_watch_terminal", Seq: 2, Terminal: true, TerminalStatus: "completed"}}}, Approval: shellWatchApprovalTransportResult{Events: []brokerapi.ApprovalWatchEvent{{EventType: "approval_watch_snapshot", Seq: 1, Approval: &approval}}}, Session: shellWatchSessionTransportResult{Events: []brokerapi.SessionWatchEvent{{EventType: "session_watch_snapshot", Seq: 1, Session: &session}, {EventType: "session_watch_terminal", Seq: 2, Terminal: true, TerminalStatus: "completed"}}}} +} + +func snapshotWatchActionCenter() shellWatchTransportLoadedMsg { + now := time.Date(2026, 5, 9, 12, 20, 0, 0, time.UTC) + run := brokerapi.RunSummary{RunID: "run-degraded", LifecycleState: "active", RuntimePostureDegraded: true} + approval := brokerapi.ApprovalSummary{ApprovalID: "ap-urgent", Status: "pending"} + session := brokerapi.SessionSummary{Identity: brokerapi.SessionIdentity{SessionID: "session-2", WorkspaceID: "ws-1"}, Status: "active", LastActivityPreview: "Triage action center"} + return shellWatchTransportLoadedMsg{ObservedAt: now, Run: shellWatchRunTransportResult{Events: []brokerapi.RunWatchEvent{{EventType: "run_watch_snapshot", Seq: 1, Run: &run}}}, Approval: shellWatchApprovalTransportResult{Events: []brokerapi.ApprovalWatchEvent{{EventType: "approval_watch_snapshot", Seq: 1, Approval: &approval}}}, Session: shellWatchSessionTransportResult{Events: []brokerapi.SessionWatchEvent{{EventType: "session_watch_snapshot", Seq: 1, Session: &session}}}} +} diff --git a/cmd/runecode-tui/theme.go b/cmd/runecode-tui/theme.go index 0344a0a5..b3d0d088 100644 --- a/cmd/runecode-tui/theme.go +++ b/cmd/runecode-tui/theme.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "strings" "github.com/charmbracelet/lipgloss" @@ -237,6 +236,13 @@ func muted(label string) string { } func keyHint(label string) string { + label = strings.TrimSpace(label) + for _, prefix := range []string{"Route keys:", "Keys:"} { + if strings.HasPrefix(label, prefix) { + label = strings.TrimSpace(strings.TrimPrefix(label, prefix)) + break + } + } return appTheme.KeyHint.Render(label) } @@ -269,14 +275,14 @@ func renderSelectableRow(line string, width int, selected bool, active bool) str } func focusBadge(focus focusArea) string { - return infoBadge(fmt.Sprintf("focus=%s", strings.ToUpper(focus.Label()))) + return infoBadge(humanFocusLabel(focus) + " focus") } func navStateBadge(active bool) string { if active { - return successBadge("ACTIVE") + return successBadge("Focused") } - return neutralBadge("IDLE") + return neutralBadge("Overview") } func boolBadge(label string, value bool) string { diff --git a/justfile b/justfile index 46db8e0d..fb1a6c90 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,8 @@ golangci_lint := "github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8" +dev_bin_dir := "/tmp/runecode-current/bin" +dev_gocache_dir := "/tmp/runecode-ci-cache/gocache" +dev_gotmp_dir := "/tmp/runecode-ci-cache/gotmp" +tui_snapshot_tag := "runecode_tui_snapshot" default: @just --list @@ -19,6 +23,9 @@ test: go test ./... cd runner && npm test +tui-perf: + go test ./cmd/runecode-tui -run "TestShellSessionQuickSwitchTypingDoesNotPersistWorkbenchState|TestFileWorkbenchStateStoreWritesAsynchronously" -bench "Benchmark(PaletteFilterTyping|SessionSwitcherFilterTyping|RenderPaletteLargeMatchWindow|RenderSessionQuickSwitcherLargeWindow|ShellLeaderOpenAndStep|ShellViewLeaderOverlayCached|ShellFocusTraversalHotPath|ShellSessionQuickSwitchTyping|ShellViewEmpty|ShellViewWaitingSession|ShellWatchApply|BuildPaletteEntries)$" -benchtime=1x -count=1 + model-check: go run ./tools/tlccheck --mode all @@ -35,6 +42,7 @@ ci-fast: go run ./tools/checksourcequality cd runner && npm ci go test ./... + just tui-perf go build ./cmd/... cd runner && npm run lint cd runner && npm test @@ -44,14 +52,21 @@ ci: # Canonical local check entrypoint. # Required shared-Linux performance contracts run in CI via ci-required-shared-linux. just ci-fast + just tui-snapshot-ci + just tui-release-safety just model-check ci-required-shared-linux: - just ci-fast + just ci tmpdir="$(mktemp -d)" && trap 'rm -rf "$tmpdir"' EXIT && \ go run ./tools/perfgatesharedlinux --output "$tmpdir/perf-check.json" && \ go run ./tools/perfcontracts --check-output "$tmpdir/perf-check.json" --lane required_shared_linux +ci-portability-unix: + just ci-fast + just tui-snapshot-ci + just tui-release-safety + ci-portability: go run ./tools/gofmtcheck go run {{golangci_lint}} run @@ -69,3 +84,141 @@ refresh-release-vendor-hash: dev: @just --list + +tui-dev-build: + mkdir -p {{dev_bin_dir}} {{dev_gocache_dir}} {{dev_gotmp_dir}} + GOCACHE={{dev_gocache_dir}} GOTMPDIR={{dev_gotmp_dir}} TMPDIR={{dev_gotmp_dir}} go build -o {{dev_bin_dir}}/runecode ./cmd/runecode + GOCACHE={{dev_gocache_dir}} GOTMPDIR={{dev_gotmp_dir}} TMPDIR={{dev_gotmp_dir}} go build -o {{dev_bin_dir}}/runecode-broker ./cmd/runecode-broker + GOCACHE={{dev_gocache_dir}} GOTMPDIR={{dev_gotmp_dir}} TMPDIR={{dev_gotmp_dir}} go build -o {{dev_bin_dir}}/runecode-tui ./cmd/runecode-tui + +tui-dev: tui-dev-build + PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode attach + +tui-dev-restart: tui-dev-build + PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode restart + PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode attach + +tui-dev-status: tui-dev-build + PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode status + +tui-dev-stop: tui-dev-build + PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode stop + +_tui-snapshot scenario: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario {{scenario}} + +_tui-snapshot-bundle bundle: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle {{bundle}} + +tui-dev-snapshot-build: + mkdir -p {{dev_bin_dir}} {{dev_gocache_dir}} {{dev_gotmp_dir}} + GOCACHE={{dev_gocache_dir}} GOTMPDIR={{dev_gotmp_dir}} TMPDIR={{dev_gotmp_dir}} go build -tags {{tui_snapshot_tag}} -o {{dev_bin_dir}}/runecode-tui ./cmd/runecode-tui + +_tui-snapshot-viewport scenario viewport: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario {{scenario}} --viewport {{viewport}} + +tui-snapshot-dashboard-multi: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario dashboard --viewport desktop + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario dashboard --viewport mobile + +tui-snapshot-dashboard: + just _tui-snapshot dashboard + +tui-snapshot-action-center: + just _tui-snapshot action-center + +tui-snapshot-all: + just _tui-snapshot all + +tui-snapshot-audit-full: + just _tui-snapshot-bundle full-audit + +tui-snapshot-audit-dashboard: + just _tui-snapshot-bundle dashboard-audit + +tui-snapshot-audit-action-center: + just _tui-snapshot-bundle action-center-audit + +tui-snapshot-audit-runs: + just _tui-snapshot-bundle runs-audit + +tui-snapshot-audit-approvals: + just _tui-snapshot-bundle approvals-audit + +tui-snapshot-audit-audit: + just _tui-snapshot-bundle audit-route-audit + +tui-snapshot-audit-status: + just _tui-snapshot-bundle status-audit + +tui-snapshot-audit-setup: + just _tui-snapshot-bundle setup-audit + +tui-snapshot-audit-chat: + just _tui-snapshot-bundle chat-audit + +tui-snapshot-audit-shell: + just _tui-snapshot-bundle shell-overlays-audit + +tui-snapshot-audit-shell-narrow-mobile: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-mobile-audit --viewport mobile + +tui-snapshot-audit-shell-narrow-compact: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-compact-audit --viewport compact + +tui-snapshot-review: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle full-audit --review --review-mode summary + +tui-snapshot-review-full: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle full-audit --review --review-mode summary + +tui-snapshot-review-dashboard: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle dashboard-audit --review --review-mode summary + +tui-snapshot-review-action-center: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle action-center-audit --review --review-mode summary + +tui-snapshot-review-shell: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-overlays-audit --review --review-mode summary + +tui-snapshot-review-shell-list: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-overlays-audit --review --review-mode list + +tui-snapshot-review-shell-narrow-mobile: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-mobile-audit --viewport mobile --review --review-mode summary + +tui-snapshot-review-shell-narrow-mobile-list: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-mobile-audit --viewport mobile --review --review-mode list + +tui-snapshot-review-shell-narrow-mobile-open: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-mobile-audit --viewport mobile --review --review-mode open + +tui-snapshot-review-shell-narrow-compact: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-compact-audit --viewport compact --review --review-mode summary + +tui-snapshot-review-shell-narrow-compact-list: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-compact-audit --viewport compact --review --review-mode list + +tui-snapshot-review-shell-narrow-compact-open: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle shell-narrow-compact-audit --viewport compact --review --review-mode open + +tui-snapshot-review-summary: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle full-audit --review --review-mode summary + +tui-snapshot-review-list: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle full-audit --review --review-mode list + +tui-snapshot-review-open: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --bundle full-audit --review --review-mode open + +tui-snapshot-ci: + go run ./tools/tuisnapshotci + +tui-release-safety: + mkdir -p {{dev_gocache_dir}} {{dev_gotmp_dir}} + tmpdir="$$(mktemp -d)" && trap 'rm -rf "$$tmpdir"' EXIT && \ + GOCACHE={{dev_gocache_dir}} GOTMPDIR={{dev_gotmp_dir}} TMPDIR={{dev_gotmp_dir}} go build -o "$$tmpdir/runecode-tui" ./cmd/runecode-tui && \ + if "$$tmpdir/runecode-tui" --snapshot-scenario all >/dev/null 2>/dev/null; then \ + printf '%s\n' 'runecode-tui default build unexpectedly accepted --snapshot-scenario all' >&2; \ + exit 1; \ + fi diff --git a/nix/packages/release-artifacts.nix b/nix/packages/release-artifacts.nix index f50947a2..a834bfeb 100644 --- a/nix/packages/release-artifacts.nix +++ b/nix/packages/release-artifacts.nix @@ -75,7 +75,7 @@ pkgs.buildGoModule { src = releaseSource; go = goToolchain; # Refresh explicitly with `just refresh-release-vendor-hash`. - vendorHash = "sha256-I+WLce2YQRAVkMovlaWcbocashO88gyF9P4/y4dHJho="; + vendorHash = "sha256-9GFHTHDhmqFPgv68JYtv8TygKwDuVXwcIIuP8AtRYzM="; # The workflow runs `just ci` before building this packaging-focused derivation. doCheck = false; strictDeps = true; diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md index 0d5ef264..7114cbab 100644 --- a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md @@ -151,6 +151,10 @@ The positive foundation is also clear: trusted `RunPlan` compile/persist, active ## Product Polish Goal Dogfooding should be part of the plan, not an afterthought. +The TUI polish goal for this lane is to move the current interface from a dense development/debug console toward a polished, professional, production-feeling operator product. The existing TUI foundations are strong: a unified shell, route workbenches, inspectors, command mode, palette discovery, themes, live watch projection, and broker-owned state surfaces. The remaining product gap is presentation hierarchy and operator confidence. Primary screens should explain what is happening, whether the operator needs to act, and what to do next before exposing protocol, schema, or debug-level detail. + +The preserved full snapshot audit reinforced that goal. A desktop `full-audit` bundle covering Dashboard, Chat, Runs, Approvals, Action Center, Audit, Status, Model Providers, Git Setup, and Git Remote confirmed that the product now has real route structure and operator intent, but it also showed that the default shell still spends too much space on chrome and footer surfaces, many rendered panes still read like styled control-plane views, and the current deterministic audit coverage still omits Artifacts, overlay surfaces, and compact/mobile layouts. + This lane should capture polish work discovered while testing the real workflow path, especially in: - run and session state clarity @@ -163,6 +167,170 @@ This lane should capture polish work discovered while testing the real workflow The TUI is the highest-priority polish surface because it is the normal user-facing shell for the local product. +The first once-over identified these concrete polish directions: + +- Treat `Action Center` as the operator home for attention, blocked work, approvals, failed or waiting workflows, substrate remediation, audit/runtime degradation, and follow-up cues. +- Keep `Dashboard` as a calm executive overview that summarizes system health, current workflow posture, and the most important next action rather than a dense dump of readiness fields. +- Make every route answer the same operator questions: what is happening, is it healthy, am I blocked, what should I do next, and where is the evidence? +- Prefer product language in primary panes and move protocol names, raw contract terms, and debug-level identifiers into inspectors, detail modes, or copy actions. +- Rework global chrome so the top bar and footer feel calm: show route, product posture, sync truth, and a small set of primary actions without repeating long trust-boundary or shortcut text on every screen. +- Make loading, empty, waiting, blocked, degraded, failed, resumed, completed, and approval-required states use one state-card pattern with a state label, reason, next action, and shortcut or route cue. +- Make project-substrate setup and remediation feel like a guided broker-owned flow: inspect current posture, preview init or upgrade, review the planned mutation, apply intentionally, then validate/status again. +- Make Chat and run progress feel alive but honest by surfacing broker-owned execution stages such as waiting, plan compiled, runner active, approval required, artifact ready, failed, and completed without inventing optimistic progress. +- Make evidence trails obvious across runs, sessions, approvals, artifacts, audit records, and verification actions so users can follow a workflow result to the artifacts and audit proof behind it. +- Shorten primary keyboard help and keep exhaustive shortcuts discoverable through command discovery, leader help, or route detail surfaces so the footer stays readable. + +### TUI Once-Over Findings +The focused source review of the current TUI implementation found that the product is partway through this polish goal, but not yet at the professional operator-product bar required for beta. The code already uses a real Bubble Tea shell, route models, route workbenches, inspectors, command and leader surfaces, live watch projection, themes, Lip Gloss-styled panes, Bubbles textarea/viewport/help/spinner primitives, and broker-owned state. The gap is no longer whether the implementation has TUI foundations; the gap is whether the default presentation feels calm, coherent, and designed rather than like a dense control-plane console. + +### TUI Interaction Performance Findings +The dogfooding review also found that the TUI currently feels sluggish during common interaction loops. This is beta-blocking product polish because delayed or dropped input makes the product feel unproductive even when the underlying broker state is correct. + +The source review identified these concrete causes: + +- overlay search currently performs synchronous per-keystroke filtering on the Bubble Tea event loop for the command palette and session switcher, while repeatedly lowercasing and scanning large entry sets in hot input paths +- overlay list rendering formats every match before the bounded list window trims to the visible rows, so short overlays still pay formatting cost for the full result set +- overlay typing still re-renders the full shell workbench behind the modal surface, including route surface generation, pane layout, sidebar, inspector, and frame scrubbing work that does not change for most overlay keystrokes +- shell surface generation is duplicated in hot paths: layout planning, overlay-height calculation, focus traversal, and final `View` rendering can all trigger repeated route `ShellSurface` work for a single key press +- leader navigation recomputes bindings and next-choice sets more often than necessary, including repeated scans and sorting during open/step flows +- some interactive navigation and workbench actions still perform synchronous persistence writes from key-driven paths, which can introduce visible hitches when the local filesystem is slow + +These findings align with Bubble Tea's execution model: `Update` and render are serialized on the main event loop, so any synchronous filter, render, layout, or file-write cost directly increases key-to-render latency and can make rapid input feel dropped. + +### TUI Interaction Performance Recommendations +The TUI polish plan should therefore treat interaction latency as a first-class product-quality requirement, not as a later optimization pass. + +Recommended closure order: + +1. Make hot key paths cheap. + Keep text-entry, leader, focus-traversal, and navigation updates close to state mutation only. Avoid repeated route-surface generation, repeated layout planning, and any synchronous file I/O from those key paths. + +2. Precompute and reuse searchable text. + Palette and quick-switch entries should carry normalized search text so typing does not repeatedly lowercase and concatenate fields. Match buffers should reuse backing storage rather than forcing unnecessary allocation churn. + +3. Render only visible rows for overlay lists. + Command palette, session switcher, and leader-help lists should format only the bounded visible window plus gap markers rather than building rows for the full match set. + +4. Cache unchanged shell frame work while overlays are active. + When the user is typing into command palette, session switcher, or leader help, the background workbench should be reused until route state, watch state, window size, theme, or focus requires invalidation. + +5. Remove unnecessary leader recomputation. + Leader mode should avoid repeated binding scans and sorting for unchanged prefixes; open/start flows should not rebuild the same choice set multiple times. + +6. Move persistence off the critical input path. + Workbench-state persistence should be queued or coalesced so route/layout actions remain responsive without weakening durability expectations. + +7. Add deterministic latency-focused tests and benchmarks. + TUI polish is not complete until the repository has repeatable benchmarks and regression tests for keypress-heavy interactions and a CI job that makes regressions visible. + +The current implementation partially satisfies the CHG-060 intent, but the full snapshot audit clarifies the remaining gaps: + +- `Dashboard` is the best-aligned route. It now has an executive state card, high-value counts, and next-action language, but it still shares the screen with too much shell chrome and still repeats state/badge language that should feel calmer and more intentional. +- `Action Center` is the strongest triage surface and should become the operator home for follow-up, but selected cards are still too dense and the inspector largely repeats the same reason, impact, owner/action, and target text as raw key/value detail. +- `Chat` has the strongest route-level alignment around active session, composer state, broker-owned execution state, and linked evidence counts, but the route still feels crowded because active-session state, workflow state, approval state, session directory, mode tabs, inspector, route keys, and shell footer all compete at once. +- `Runs` keeps outcomes, coordination, approvals, artifacts, and audit evidence together, but rendered content still leaks implementation vocabulary such as projection or plan-authority terms, backend labels, approval profile detail, and raw posture taxonomy before the route has cleanly answered the operator's first questions. +- `Approvals` distinguishes approval-required, pending, resolved, expired, unsupported, lifecycle, policy, and trigger cues, but the rendered view still surfaces policy codes, trigger codes, gate bindings, and bound-scope detail too early for a non-debug operator. +- `Artifacts`, `Audit`, and verification still need to read as one evidence trail. The product has useful continuity and copyable raw identifiers, but digest labels, event names, verification codes, anchoring posture terms, and full raw identities still dominate too much of the primary visual hierarchy. +- `Status` and project-substrate remediation have the right broker-owned lifecycle concepts, but the main route still reads too much like a diagnostic dump because subsystem booleans, protocol/version details, compatibility tokens, preview handles, and normal-operation flags remain in the primary pane. +- `Model Providers`, `Git Setup`, and `Git Remote` remain the least polished product surfaces. They still read like broker state dumps because rendered views expose provider family, endpoint, profile IDs, auth modes, bootstrap modes, policy booleans, prepared mutation IDs, long request or approval hashes, credential leases, lifecycle reason codes, and derived git metadata before the operator story is clear. +- The shell chrome is improved from a raw debug frame, but the snapshot audit shows that top status, sync strip, pane spacer, bottom strip, status row, footer help, and the repeated broker-truth sentence still consume too much vertical space. The workbench feels dense before route content begins. +- Overlay behavior is directionally right in code, but the deterministic route bundle did not yet prove command palette, session switcher, leader help, inspector sheet, sidebar drawer, or quit confirmation as polished modal surfaces. Those surfaces remain an explicit TUI acceptance gap until they are captured and reviewed. +- The implementation uses Lip Gloss styles and tokens, but the styling is still mostly utility-level: bordered panes, badges, muted text, and surface colors. It does not yet deliver a strong visual system with accent rails, differentiated message blocks, sparse typography, focused cards, subdued secondary text, and consistent color semantics across all routes and responsive layouts. + +The result is a TUI that is operationally useful and honest, but still short of the desired polished/professional experience. The remaining work should be treated as beta-blocking product polish, not optional beautification, because the TUI is the normal local product shell and the main place where a new Linux user will decide whether RuneCode feels understandable and trustworthy. + +### Professional TUI Target +The target experience should feel closer to a high-quality terminal product than a protocol inspector. The reference direction is a dark, spacious workbench with strong text hierarchy, restraint, and a few high-confidence color accents. Screens should look intentionally composed even when the underlying state is complex. + +The target visual language is: + +- dark base surface with elevated panels for prompts, summaries, command palettes, and long-form detail +- a restrained accent palette: green for verified/successful/ready, amber for attention/waiting, red or magenta for blocked/failed/danger, purple for active planning/commands, cyan or blue for links/evidence/navigation, gray for secondary metadata +- high-contrast but low-noise typography: primary status in clear text, secondary facts dimmed, raw IDs shortened unless selected or copied +- one prominent operator card per route that answers: what is happening, is it healthy, am I blocked, what should I do next, and where is the evidence +- visual accent rails or sidebars for active message blocks, selected work items, approval-required cards, and command input rather than wrapping every object in heavy borders +- fewer always-visible words in chrome; route-specific detail should live in the route body, inspector, command palette, or copy actions, and the footer should not repeat trust-boundary or shortcut prose that overwhelms the route +- focused overlays that appear as centered, elevated panels with optional background dimming instead of appended blocks +- compact command discovery similar to the inspiration: title, escape hint, search field, grouped suggestions, selected row with strong contrast, and right-aligned shortcuts or command IDs +- diff/log/evidence views that use colored line blocks and columns intentionally, while keeping trust-sensitive raw content redacted and secondary unless selected + +The target interaction model is: + +- start at `Action Center` when there is any operator follow-up; otherwise start at `Dashboard` or show a Dashboard card whose primary call-to-action is Action Center +- make `Action Center` the triage home, not just another route: all blocked, waiting, degraded, approval, stale, setup, and evidence-health issues should be visible there first +- keep `Dashboard` calm and non-action-heavy: a single executive state, a small count strip, the current workflow posture, and the next best action +- keep `Chat` as the active work loop: session, composer, current broker-owned execution state, and evidence links +- keep `Runs`, `Approvals`, `Artifacts`, and `Audit` as drill-down evidence workbenches with rendered, structured, and raw modes +- keep `Status` as the product lifecycle and project-substrate remediation route +- keep `Model Providers`, `Git Setup`, and `Git Remote` behind product-language setup/review cards, with raw broker fields moved to structured/raw detail modes + +### Bubble Tea, Bubbles, And Lip Gloss Standards +The TUI already implements Bubble Tea and Lip Gloss, but the polish closure should raise the implementation to a stricter product-quality standard. + +Bubble Tea expectations: + +- preserve the `tea.Model`/`Init`/`Update`/`View` architecture and keep all blocking broker, file, or network work inside `tea.Cmd` +- keep route models focused on state transitions and route-specific rendering; avoid hidden global side effects during `View` +- keep `View` deterministic and side-effect free so performance and snapshot-style tests remain meaningful +- keep async polling honest: use broker watch state, route reload commands, and timed ticks only to reflect real broker-owned state, never to create client-local optimistic completion +- keep keyboard ownership explicit for text entry, secret entry, overlays, command mode, and normal route navigation +- keep window-size handling and route viewport resizing centralized so narrow, medium, and wide layouts remain predictable +- keep mouse capture optional and reversible for text selection workflows +- keep route activation, palette actions, and reference jumps typed and non-destructive + +Bubbles expectations: + +- use `textarea` for multiline composer behavior instead of ad-hoc line editing +- use `viewport` or equivalent bounded long-form primitives for transcripts, diffs, logs, artifacts, and audit records rather than clipping raw strings manually +- use `help`/`key` for generated concise help where possible, and keep exhaustive key discovery in command/leader/help surfaces +- use `spinner` or small activity indicators only for real in-flight commands or watch activity, not to imply progress that the broker has not reported + +Lip Gloss expectations: + +- build a small design-token system before adding more one-off styles: surfaces, foregrounds, accents, state colors, borders, selected rows, dimmed text, code/digest text, links, and destructive actions +- prefer composable card, rail, row, pill, command-palette, and evidence-trail components over per-route string concatenation +- use `lipgloss.Width`, ANSI-aware truncation, and display-cell-aware clipping for styled and wide-character content; do not truncate styled rows by raw rune count when alignment matters +- use `JoinHorizontal` and `JoinVertical` for real composition, but budget widths and heights before render so panes and overlays do not overflow or collapse unpredictably +- keep borders intentional: use fewer full boxes, more padding, accent rails, muted dividers, and selected-row backgrounds +- make overlays genuinely overlay-like within terminal constraints: centered panel, bounded height, clear escape hint, optional dimmed underlying frame, and no duplicate footer noise +- maintain accessibility in high-contrast theme: selected rows, warnings, and dangerous actions must remain legible without relying on hue alone + +### Gap Closure Plan +To reach a polished and professional TUI experience, close the gaps in this order: + +1. Establish a visual system and shared components. + Define product-level primitives for hero state card, compact count strip, action item, evidence trail, setup stepper, command palette, modal panel, detail viewport, and selected directory row. These should be Lip Gloss components that routes compose instead of bespoke line dumps. + +2. Reframe the shell. + Reduce always-visible chrome to product title/route, sync truth, active work, and the smallest viable action hint. Move diagnostics, path/history, raw focus/layout state, clipboard/copy counts, repeated broker-truth reminders, and exhaustive shortcuts into command discovery, inspector, or Status. Wide layout can keep sidebar/main/inspector, but medium and narrow layouts should feel like focused single-pane work with overlays for nav and inspector. + +3. Make overlays professional. + Replace appended overlay blocks with centered modal panels. Command palette should have a search row, grouped suggestions, high-contrast selected row, right-aligned command IDs/shortcuts, and dimmed background where terminal rendering allows. Session switcher and leader help should follow the same modal grammar. + +4. Promote Action Center. + Make Action Center the first place to look when anything needs attention. The shell should either start there when follow-up exists or make the Dashboard hero card route there. Rename primary bucket labels into product language: `Approvals`, `Operational Attention`, and `Blocked Work` rather than internal token names. Every item should include state, reason, impact, owner/action, target, and evidence source, with raw watch-family facts moved to inspector/detail. The main pane and inspector should complement each other rather than repeating the same triage card verbatim. + +5. Calm Dashboard. + Dashboard should show one executive state card, one count strip, workflow posture, current active work, and the next best action. Readiness fields, version/build data, watch-family summaries, protocol bundle detail, low-level audit facts, and raw evidence posture fields should move to Status, Action Center detail, or inspector. + +6. Normalize state cards. + All loading, empty, ready, waiting, blocked, degraded, failed, completed, resumed, and approval-required states should use a rich state-card spec with state label, message, reason, next action, route cue, shortcut cue, and optional evidence/source cue. The basic three-argument state-card helper should be retired from primary route states or wrapped so missing reason/next-action cannot regress. + +7. Split rendered, structured, and raw modes more strictly. + Rendered mode should be product language. Structured mode can show stable fields and counts. Raw mode can show protocol, schema, hashes, contract method names, binding kinds, policy codes, request/decision identities, and debug details. Runs, Approvals, Artifacts, Audit, Status, Model Providers, Git Setup, and Git Remote need this separation most. + +8. Improve evidence trail navigation. + Every workflow result should have an obvious path: Chat or Runs -> Artifacts -> Audit -> verification posture -> export/offline verification -> anchoring where available. Use short stable labels in primary views and keep full digests copyable through copy actions or raw mode. + +9. Polish setup/review routes. + Model Providers should read like credential setup with safe secret ingress, current readiness, next action, and evidence that no secret leaked. Git Setup should read like account and identity setup, not a broker state dump. Git Remote should read like a guarded review-and-execute flow with approval and lease prerequisites, not a hash inventory. + +10. Verify with real product walkthroughs. + Capture terminal frames and deterministic bundle coverage for Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status/project substrate, Model Providers, Git Setup, Git Remote, command palette, session switcher, leader help, inspector sheet, sidebar drawer, quit confirmation, blocked/degraded/disconnected states, and desktop plus compact/mobile layouts. Review those frames against CHG-060 before marking the TUI acceptance criterion complete; desktop-only route snapshots are not enough. + +11. Close interaction-latency gaps with measured verification. + Optimize palette/session filtering, overlay rendering, leader-step handling, and focus/navigation hot paths using the smallest correct changes. Add targeted benchmarks for keypress update latency, search/filter latency, and overlay view cost, plus deterministic tests that confirm rapid scripted input is fully consumed without dropped keys. + TUI acceptance is intentionally dogfooding-gated rather than fully preplanned. Issues found during walkthroughs of the required paths should be captured, blockers and misleading product-truth issues should be fixed before closure, and non-blocking polish can be recorded as follow-up. That polish should stay aligned with the reviewed performance-contract discipline in `CHG-053`, especially: @@ -170,6 +338,7 @@ That polish should stay aligned with the reviewed performance-contract disciplin - attach, reconnect, and resume surfaces should continue to reflect broker-owned lifecycle truth rather than client-local optimistic shortcuts - waiting, blocked, degraded, and failed states should remain operator-visible without reintroducing misleading high-activity rendering paths or synthetic progress cues - dogfooding fixes should preserve the same authoritative surfaces that the MVP performance gates measure rather than optimizing around those gates with less truthful UI behavior +- polished wording must not imply a stronger attestation, execution, publication, or collaboration posture than the current broker-owned state can prove ## Verification Smoke Path This lane should require that the real workflow path also exercises the verification surfaces already present in the repository. @@ -186,6 +355,12 @@ The alpha.11 smoke path should include: - export a bundle and verify it offline - exercise external anchoring when appropriate and available +The TUI polish close path should also include interaction-performance verification: + +- benchmark palette typing, session-switcher typing, leader open/step, and overlay-heavy shell view paths against realistic deterministic datasets +- verify rapid scripted key input is fully consumed for overlay text-entry flows without lost characters +- keep the benchmark gate deterministic by fixing term size, data shape, and benchmark OS in CI rather than relying on PTY timing variance across platforms + The goal is not to finish every planned verification-plane feature here. The goal is to prove that beta ships with real evidence continuity from a real workflow path. ## Release-Surface Alignment @@ -207,7 +382,7 @@ This alpha lane is complete when: - the path uses authoritative trusted `RunPlan` adoption in production - run and session progress surfaces reflect real execution rather than only synthetic projection - verification artifacts are generated and exercised from that real workflow path -- TUI and surrounding operator surfaces are polished enough that a new user can test the product coherently on Linux +- TUI and surrounding operator surfaces are polished enough that a new user can test the product coherently on Linux, as demonstrated by captured route, overlay, and responsive-layout audit frames - the beta story is honest about assurance and execution behavior - workflow-path and TUI polish remain compatible with the authoritative surfaces and honest measurement boundaries frozen by `CHG-053` - git remote publication is either explicitly out of beta messaging or covered by a separate reviewed smoke path before any publishing claim is made diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md index 772cd04e..37a08ca3 100644 --- a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md @@ -33,6 +33,9 @@ Without a dedicated alpha hardening lane, RuneCode risks declaring beta too earl - Fold in the truthful attestation-posture correction from `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` as a required pre-beta hardening companion. - Keep workflow-path dogfooding, TUI polish, and operator-truth improvements aligned with the reviewed performance-contract discipline in `CHG-2026-053-9d2b-performance-baselines-verification-gates-v0`, especially around attach/resume truth, waiting-state communication, and avoidance of client-local shortcuts that would make measured product surfaces less honest. - Explicitly capture TUI and operator polish discovered while dogfooding the real workflow path, especially around run state clarity, attach or reconnect behavior, remediation cues, approval and audit discoverability, and overall usability. +- Treat TUI interaction latency as a beta-blocking polish concern alongside wording and visual hierarchy: rapid typing in search surfaces, arrow-key traversal, and leader-key navigation must remain responsive under realistic datasets without dropped input caused by synchronous filtering, repeated route-surface rendering, or key-path persistence work. +- Treat the remaining TUI presentation work as a beta-blocking product gate rather than optional visual cleanup: the current TUI has strong Bubble Tea, Bubbles, Lip Gloss, route, inspector, command, and broker-state foundations, but the full snapshot audit still shows heavy shell chrome and footer pressure, rendered-mode control-plane/debug leakage in primary panes, Action Center and Dashboard hierarchy gaps, setup/evidence routes that still read like state dumps, and missing overlay plus responsive audit proof. Closure therefore requires a calmer shell, professional modal overlays, stricter rendered/structured/raw separation, route-by-route product-language cleanup, display-width-safe styling, and captured desktop plus compact/mobile walkthrough evidence before the TUI can be called polished. +- Require deterministic TUI interaction-performance coverage in CI before this lane can close: benchmark keypress-heavy update/filter/view paths, add no-dropped-keys tests for fast scripted overlay input, and gate regressions with a stable Linux benchmark job rather than leaving interaction responsiveness to manual dogfooding alone. - Align roadmap and product-surface messaging with the real shipped state once the honest workflow path exists. - Require the alpha lane to exercise verification artifacts on the real workflow path so beta ships with strong evidence continuity instead of a later degraded verification posture. - Keep git remote publication out of the required beta close gate unless beta messaging explicitly claims prompt-to-PR, push, or team collaboration. Local canonical lifecycle and implementation are required; remote publication remains adjacent follow-on scope. @@ -56,6 +59,8 @@ That split is easier to reason about than continuing to leave integration and po - `approved_change_implementation` may include required RuneContext lifecycle metadata updates, such as `tasks.md`, `status.yaml`, verification status, roadmap, or release-note updates, when those updates are part of the approved implementation input set; a separate fifth workflow operation is not required for this lane. - `approved_change_implementation` must preserve two digest domains: the routing-bound artifact digest identifies the exact stored payload bytes, while `input_set_digest` identifies the canonical input-set body with `input_set_digest` omitted. - TUI and product polish discovered while dogfooding are legitimate alpha hardening work and should be planned explicitly rather than treated as incidental cleanup. +- TUI polish means more than using Bubble Tea and Lip Gloss primitives. The beta bar requires a professional operator experience: calm hierarchy, reduced shell chrome and footer noise, product language in primary panes, debug detail moved to structured/raw modes, clear state cards, intentional command discovery, responsive desktop/compact/mobile layouts, and honest broker-owned progress without synthetic optimism. +- TUI polish also requires strong interaction performance. The TUI should feel immediate under normal operator input, which means keeping Bubble Tea `Update` and render hot paths small, precomputing searchable text, rendering only visible overlay rows, avoiding duplicated route-surface work, and moving persistence off critical key paths where needed. - Verification artifacts generated from the real workflow path must remain first-class deliverables of this lane so later verification work strengthens rather than backfills the beta story. - Product polish in this lane must improve operator clarity without undermining the authoritative broker-owned and persisted surfaces that `CHG-053` measures and protects. @@ -75,7 +80,7 @@ If completed, this change gives RuneCode one explicit alpha lane to finish the w - reviewed draft promote/apply into canonical RuneContext files - approved implementation through the honest workflow path - truthful runtime assurance posture -- dogfooded and more coherent TUI and operator surfaces +- dogfooded, visually reviewed, and professionally polished TUI and operator surfaces - release messaging that matches reality - real workflow-generated verification artifacts that can anchor later trust improvements diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml index d3dd75e9..04abe3a0 100644 --- a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml @@ -4,7 +4,7 @@ title: Beta Readiness Hardening + Product Polish status: implemented type: project size: large -verification_status: passed +verification_status: pending context_bundles: - product-planning - go-control-plane diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md index 43883998..8aa2f7c8 100644 --- a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md @@ -41,14 +41,48 @@ ## Phase 5: TUI And Product Polish During Dogfooding -- [ ] Capture TUI polish items discovered while testing the real workflow path. -- [ ] Improve clarity for waiting, blocked, degraded, failed, resumed, and completed states. -- [ ] Improve attach, reconnect, and resume ergonomics where dogfooding reveals rough edges. -- [ ] Improve project-substrate remediation and workflow follow-up guidance where operator confusion appears. -- [ ] Improve discoverability for artifacts, audit evidence, approvals, and verification actions. -- [ ] Tighten wording, route labels, and status cues so the product reads like one coherent local system. -- [ ] Fix blockers and misleading product-truth issues found during TUI walkthroughs before closure. -- [ ] Record non-blocking polish follow-ups when they do not block the supported beta proof. +- [x] Run a focused TUI once-over against the supported beta workflow path and capture implementation findings before broad polish changes: launch through the canonical `runecode` path, inspect Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status, Model Providers, Git Setup, Git Remote, setup/remediation routes, command palette, session switcher, leader help, overlays, shell chrome, footer/status surfaces, wide layout, and narrow layout; record where the UI reads like a dense debug console, where primary actions are unclear, where route terminology leaks protocol internals, and where product-truth cues are missing or misleading. +- [ ] Resolve the CHG-060 lifecycle mismatch before closure: keep TUI polish and the TUI acceptance criterion open until the remaining presentation and best-practice gaps are implemented, verified, and reviewed through captured terminal frames and deterministic snapshot bundles; do not rely on the broader workflow integration implementation as proof that TUI polish is complete. +- [ ] Establish a shared professional TUI visual system before doing more per-route string edits: define Lip Gloss components for hero state cards, compact count strips, action-center items, evidence trails, setup steppers, modal panels, command-palette rows, selected directory rows, long-form detail viewports, copy-action summaries, and subdued metadata; use those components across routes instead of bespoke line dumps. +- [ ] Rework the color and style tokens into a deliberate product palette: dark base surface, elevated panel surface, overlay surface, dimmed secondary text, link/evidence color, code/digest color, selected-row foreground/background, success/ready green, waiting/attention amber, blocked/danger red or magenta, active/command purple, and high-contrast variants; verify each state remains readable without relying on hue alone. +- [ ] Reframe the global shell chrome into a calm product workbench rather than a constant debug/status dump: keep product title, route, product posture, sync truth, active work, and one or two primary actions visible; remove raw layout/focus diagnostics, history/path noise, repeated trust-boundary text, clipboard/copy counts, and exhaustive shortcuts from the primary frame; preserve those details through command discovery, inspectors, Status/detail routes, or copy actions. +- [ ] Reduce vertical chrome pressure: review the combined top status, sync strip, pane spacer, bottom strip, status row, footer help, and repeated broker-truth sentence budget; collapse or hide non-essential rows when the route body is constrained; make medium and narrow layouts feel like focused single-pane product screens rather than a cramped wide workbench. +- [ ] Make overlays professional modal surfaces: render command palette, session switcher, leader help, inspector sheet, sidebar drawer, and quit confirmation as centered elevated panels with bounded height, clear escape hints, grouped content, strong selected-row styling, and optional dimming of the underlying frame; do not append overlays as ordinary blocks below the workbench. +- [ ] Redesign command discovery around the reference command-palette pattern: title and `esc` hint, search field, grouped suggestions, high-contrast selected row, right-aligned command IDs or shortcuts, low-noise descriptions, and stable keyboard behavior; route commands and copy actions should be discoverable without filling every footer with exhaustive key text. +- [ ] Remove input-latency regressions from overlay search and navigation hot paths: make command-palette and session-switcher filtering cheap enough for rapid typing, avoid dropped characters under scripted fast input, and keep arrow-key/leader-key traversal responsive without event-loop stalls. +- [ ] Precompute normalized search text and reuse match buffers for palette/session filtering so per-keystroke update work does not repeatedly lowercase, concatenate, or reallocate large entry sets. +- [ ] Render only visible overlay rows for palette/session/leader surfaces rather than formatting the full match set before bounded-window trimming. +- [ ] Reduce repeated route-surface and layout work during overlay and focus interactions: avoid recomputing unchanged shell background/frame state for every overlay keypress, and avoid duplicate surface/layout generation in focus-traversal hot paths where the background has not changed. +- [ ] Remove synchronous workbench persistence from critical interactive key paths by queuing or coalescing writes without weakening the persisted workbench behavior. +- [ ] Reduce unnecessary leader binding/choice recomputation so opening leader mode and stepping through sequences stays immediate under repeated use. +- [ ] Promote `Action Center` as the operator home for attention and follow-up: make it the clearest place to see pending approvals, blocked workflow impact, failed or degraded runs, project-substrate remediation needs, audit/runtime degradation, waiting work, stale queues, and next actions; every item should state reason, impact, owner/action required, target route/object, and evidence link source without inferring state outside broker-owned surfaces. +- [ ] Make `Action Center` first in the operator journey when follow-up exists: start there when broker-owned state reports attention, or make Dashboard's top card route there as the primary next action; rename primary buckets from implementation tokens such as `operational_attention` and `blocked_work_impact` to product labels such as `Operational Attention` and `Blocked Work`; move watch-family/raw sync facts to inspector/detail. +- [ ] Reduce `Action Center` duplication and text density: selected triage cards should be scannable in the main pane, while the inspector should add linked evidence, drill-down actions, and structured/raw detail rather than restating the same reason, impact, owner/action, and target fields verbatim. +- [ ] Polish `Dashboard` into a calm executive overview that points operators to `Action Center`: show the most important current state first (`healthy`, `needs attention`, `blocked`, `degraded`, `active work`, or `no work yet`), summarize product readiness and workflow posture in product language, show a small number of high-value counts and next actions, and move raw readiness fields, version/build data, protocol bundle details, watch-family terminology, and low-level audit facts into Status, Action Center detail, inspectors, or raw/structured modes. +- [ ] Remove duplicate badge/state noise from primary route headers and hero cards: avoid patterns where the same state is repeated as a banner, badge, and inline text unless the repetition adds a distinct operator meaning. +- [ ] Normalize a rich state-card UX pattern for loading, empty, ready, waiting, blocked, degraded, failed, resumed, completed, and approval-required states: each card must include a short state label, plain-English message, plain-English reason, authoritative source/object identity when useful, next recommended action, route cue, shortcut cue, and optional evidence/source cue; the pattern must avoid fake progress and must distinguish broker-known state from unknown or unavailable state. +- [ ] Retire or wrap underspecified state cards in primary routes: replace basic `renderStateCard(state, title, message)` usages for route loading/error/empty states with richer specs in Chat, Runs, Approvals, Artifacts, Audit, Model Providers, Git Setup, Git Remote, and any other primary route state that lacks reason/next-action/route cue/evidence cue. +- [ ] Enforce rendered/structured/raw separation: rendered mode uses product language and short stable identities, structured mode shows stable fields and counts, and raw mode contains protocol names, schema names, hashes, contract method names, binding kinds, policy codes, request/decision identities, API details, and debug posture taxonomy; Runs, Approvals, Artifacts, Audit, Status, Model Providers, Git Setup, and Git Remote are the priority routes for this split. +- [ ] Make project-substrate setup and remediation feel like a guided broker-owned flow in the TUI: expose inspect/current posture, compatible adoption, init preview, init apply, upgrade preview, upgrade apply, and post-apply validation/status as an understandable sequence; preview/apply screens should explain whether mutation will occur, what handle or digest was acquired without leaking sensitive paths, what blocks normal operation, and what the operator should do next if preview/apply is unavailable or fails. +- [ ] Improve Chat route workflow clarity so execution feels alive, useful, and honest: show the active canonical session, composer state, current or latest execution state, waiting reason, approval requirement, linked run/artifact/audit counts, and follow-up action in product language; surface real broker/runner stages such as plan compiled, runner active, checkpoint received, approval required, artifact ready, failed, and completed only when broker-owned state supports them. +- [ ] Improve Runs route clarity around execution outcomes and evidence: make the primary route show outcome, workflow operation, plan authority, runner/reporting posture, failure or blocked reason, and high-value linked evidence in product language; move `backend_kind`, projection or plan-authority implementation terms, full workflow hashes, authoritative/advisory key counts, coordination locks/conflicts, approval profile, and raw posture taxonomy to structured/raw detail unless the selected state makes one of those facts the primary operator decision. +- [ ] Improve Approvals route confidence and safety: distinguish pending, resolved, expired, unsupported, stale, superseded, and approval-required workflow states; show why the approval exists, what exact object/action it gates, what will happen after approval, what evidence or artifact should be reviewed first, and why an approval cannot be resolved when validation blocks the action; move binding kinds, policy reason codes, trigger codes, canonical bound identity fields, and exact bound-scope dumps out of rendered primary text unless they are directly needed for the decision. +- [ ] Improve Artifacts, Audit, and verification discoverability as one evidence trail: make it obvious how to move from workflow result to artifacts, from artifacts to audit records, from audit records to verification posture, and from verification posture to export/offline verification or anchoring actions where available; keep raw digests copyable but do not make long digests, event tokens, or verification codes the primary visual content when a shorter stable identity or label is available. +- [ ] Polish Model Providers into a safe setup screen: primary view should show provider readiness, selected provider label, credential setup state, whether secret ingress is ready, whether a credential is stored, current next action, and assurance that raw secrets are never displayed; move endpoint/path, profile IDs, auth mode internals, compatibility probe detail, and broker profile fields into structured/raw detail or inspector. +- [ ] Polish Git Setup into an account and identity setup screen: primary view should show provider connection state, auth method next action, default identity profile state, policy safety summary, and what the operator can do next; move bootstrap modes, policy booleans, recent repository counts, and control-plane convenience fields into structured/raw detail. +- [ ] Polish Git Remote into a guarded review-and-execute screen: primary view should show prepared change summary, repository/ref target, required approval status, credential lease readiness, fail-closed execution status, and the exact next action; move typed request hashes, action hashes, policy hashes, approval request/decision hashes, provider auth lease IDs, lifecycle reason codes, and derived internals into structured/raw detail unless they are selected for copy/review. +- [ ] Shorten route key hints and footer wording so the TUI feels calm while remaining discoverable: primary route footers should show only the most useful keys for the current state, while exhaustive route commands remain available through command mode, fuzzy discovery, leader help, inspectors, or a dedicated help surface generated from the action graph. +- [ ] Tighten route labels, headings, badges, and status messages into consistent product language: prefer terms like `needs attention`, `blocked`, `waiting`, `ready`, `evidence available`, `approval required`, `setup required`, `review required`, `safe to continue`, and `not ready` in primary panes; reserve protocol names, schema names, raw contract method names, and implementation terms for detail modes where they help debugging or verification. +- [ ] Apply Bubble Tea best practices during polish: keep all blocking broker/network/file work inside `tea.Cmd`, keep `View` deterministic and side-effect free, keep route state transitions in `Update`, keep text-entry and secret-entry keyboard ownership explicit, keep resize handling centralized, keep mouse capture optional/reversible, and do not use timers/spinners to imply progress that broker-owned state has not reported. +- [ ] Apply Bubbles best practices during polish: use `textarea` for multiline composer behavior, use `viewport` or bounded long-form primitives for transcripts/diffs/logs/artifacts/audit records, use `help`/`key` for generated concise help where practical, and use `spinner` or activity indicators only for real in-flight commands or watch activity. +- [ ] Apply Lip Gloss best practices during polish: compose screens from shared styles and components, budget widths/heights before rendering, use display-cell-aware and ANSI-aware truncation for styled rows, avoid raw rune-count clipping where alignment matters, prefer intentional accent rails/padding/selected-row backgrounds over excessive full borders, and verify dark/dusk/high-contrast themes. +- [ ] Add deterministic TUI interaction-performance tests and benchmarks: cover palette typing, session-switcher typing, leader open/step flows, overlay-heavy shell view cost, and no-dropped-keys behavior for fast scripted overlay input. +- [ ] Add a CI performance gate for TUI interaction latency on a deterministic Linux job: run focused Go benchmarks with fixed datasets/terminal sizes, compare against a checked-in baseline or conservative threshold, and fail on meaningful regressions instead of relying only on manual dogfooding. +- [ ] Preserve trust, attach, and performance-contract truth while polishing: do not create client-local optimistic progress, synthetic completion, local-only lifecycle inference, fake attestation confidence, fake remote-publication confidence, or hidden degraded states; attach/reconnect/resume, waiting, blocked, degraded, and failed states must continue to reflect broker-owned lifecycle, run/session, watch, audit, and project-substrate surfaces. +- [ ] Capture visual dogfooding evidence before closure: save terminal frames or deterministic route render snapshots for Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status/project substrate, Model Providers, Git Setup, Git Remote, command palette, session switcher, leader help, inspector sheet, sidebar drawer, quit confirmation, desktop/compact/mobile layouts, disconnected/degraded watch state, blocked project setup, approval required, failed run, and empty/no-work states. +- [ ] Expand deterministic audit-bundle coverage so the preserved full-audit story actually proves closure: add Artifacts plus overlay surfaces to the deterministic review set, and require compact/mobile or equivalent responsive bundles rather than relying on desktop-only route captures. +- [ ] Fix TUI blockers and misleading product-truth issues discovered during walkthroughs before marking this phase complete: misleading wording, unavailable next actions, hidden blocked reasons, unclear approval consequences, missing evidence links, confusing setup remediation, raw debug dominance in primary panes, cramped chrome, unprofessional overlays, inaccessible color states, or UI states that imply stronger execution/attestation/publication behavior than the implementation provides are blockers for this polish phase. +- [ ] Record non-blocking TUI polish follow-ups separately when they do not block the beta proof: optional theme refinements, secondary keyboard customization, advanced filtering, additional inspector modes, animation refinements, future collaboration/publishing affordances, and nonessential visual flourish should be tracked as follow-up unless required for a new Linux user to coherently test the supported local workflow path. ## Phase 6: Verification Smoke Path @@ -79,6 +113,7 @@ - [x] Runner progress shown to operators comes from real reporting integration for the supported path. - [x] The supported path is inspectable through session, run, artifact, approval, and audit surfaces. - [x] Verification artifacts are generated and exercised from the same real workflow path. -- [ ] TUI and surrounding operator surfaces are polished enough that a new Linux user can test the product coherently. +- [ ] TUI and surrounding operator surfaces are polished enough that a new Linux user can test the product coherently, with captured route, overlay, and responsive-layout audit evidence to prove it. +- [ ] TUI interaction latency remains within reviewed expectations under deterministic benchmarked typing/navigation scenarios, with CI coverage that catches regressions before merge. - [x] Git remote publication is not implied unless explicitly verified by a separate publishing smoke path. - [x] The beta story is more truthful and less scaffold-heavy after this alpha lane completes. diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md index c45677d6..8381fee8 100644 --- a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md @@ -15,7 +15,20 @@ - Verified the supported workflow slice now compiles and persists authoritative `RunPlan` state, launches/proxies the runner path, accepts runner checkpoint/result reports, and keeps run/session projections plan-authoritative. - Added/updated smoke coverage that exercises `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`, then inspects runs, artifacts, approvals, audit records, evidence snapshot, inclusion, and offline bundle verification. - External audit anchoring remains environment-conditional and is not claimed as a completed always-on smoke in this closure pass. -- TUI polish remains explicitly pending and is not marked complete here. +- TUI polish remains explicitly pending and is not marked complete here. The preserved full snapshot audit confirmed that the TUI has real route structure and product intent, but it also showed heavy chrome/footer pressure, rendered-mode debug leakage, route-specific product-language gaps, and missing overlay/responsive audit proof that must be closed before the TUI acceptance criterion can pass. + +## TUI Polish Review Findings +- Current TUI implementation does use Bubble Tea, Bubbles, and Lip Gloss foundations: `tea.NewProgram`, `tea.Model`/`Init`/`Update`/`View`, async `tea.Cmd` route loading, `tea.Tick` for broker-watch polling/activity, Bubbles textarea/viewport/help/spinner primitives, and Lip Gloss theme/style/composition primitives. +- Bubble Tea architecture is broadly sound, but polish work must preserve deterministic `View` rendering, route-local `Update` state transitions, explicit keyboard ownership for text/secret entry and overlays, command-based broker operations, centralized resize behavior, and honest watch-driven progress. +- Lip Gloss usage is competent but not yet product-polished. Current styling provides panes, borders, badges, muted text, and theme tokens, but the professional bar requires stronger shared components, accent rails, focused cards, modal overlays, deliberate state colors, display-width-safe truncation, and high-contrast verification. +- Interaction performance is currently below the desired product bar. The source review found synchronous per-keystroke overlay filtering, full-match row formatting before visible-window trimming, repeated shell-background render work while overlays are active, repeated route-surface/layout generation in hot paths, unnecessary leader binding/choice recomputation, and some synchronous persistence writes from interactive paths. +- These latency findings match Bubble Tea's serialized `Update` plus render model: any synchronous filter, layout, render, or file-write cost directly increases key-to-render latency and can make rapid input feel dropped. TUI polish therefore includes interaction responsiveness, not only wording and visual hierarchy. +- The highest-value implementation fixes are to precompute normalized search text, reuse match buffers, render only visible overlay rows, cache/reuse unchanged shell background work while overlays are active, reduce duplicate route-surface work in focus/overlay flows, remove synchronous persistence from critical key paths, and add deterministic benchmarks/tests for keypress-heavy scenarios. +- The preserved `full-audit` desktop bundle covered 10 routes and 13 scenarios: Dashboard, Chat, Runs, Approvals, Action Center, Audit, Status, Model Providers, Git Setup, and Git Remote. That bundle is useful proof of route direction, but it is not enough to close TUI polish because it does not yet cover Artifacts, overlay surfaces, or compact/mobile behavior. +- The TUI partially satisfies CHG-060: Dashboard now has an executive-summary shape, Action Center is a credible triage route, Chat communicates active-work state honestly, Status/project substrate has understandable lifecycle concepts, and the product has real inspectors, copy actions, and plan-authoritative workflow surfaces. +- The TUI does not fully satisfy CHG-060 yet: shell chrome and footer rows still dominate too much vertical space, Action Center cards and inspectors are too dense, and Runs, Approvals, Audit, Status, Model Providers, Git Setup, and Git Remote still expose too much control-plane/debug detail in rendered primary surfaces. +- Dashboard is the closest route to the target, but it still needs calmer chrome and less duplicated state/badge language; Git Remote, Model Providers, Git Setup, Audit, and Runs remain the clearest rendered-mode cleanup priorities. +- The clean layout target should follow the provided inspiration: dark spacious base, elevated panels, subdued secondary text, strong selected rows, compact command palette, left accent rails for active blocks, careful color semantics, and low-noise bottom help. ## Required Product Smokes - Project-substrate lifecycle smoke: inspect/posture, adopt compatible existing substrate, init preview/apply for missing substrate, upgrade preview/apply for supported older substrate, and validate/status after apply. @@ -27,6 +40,31 @@ - External anchoring smoke: exercise external audit anchoring on the real workflow path where environment and policy allow. - Git publication posture: confirm beta messaging does not claim push, pull request, prompt-to-PR, or team-collaboration publishing unless a separate reviewed git remote smoke path is added. +## Required TUI Polish Smokes +- Launch through the canonical product path, not only `go run ./cmd/runecode-tui`, so the TUI is reviewed with broker-owned lifecycle and sibling-binary resolution behavior. +- Capture terminal frames or deterministic render snapshots for Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status/project substrate, Model Providers, Git Setup, Git Remote, command palette, session switcher, leader help, inspector sheet, sidebar drawer, quit confirmation, desktop, compact, and mobile layouts. +- Require the preserved deterministic audit bundle used for closure to cover Artifacts and overlay surfaces in addition to the current desktop route set; desktop-only route captures are insufficient proof of TUI polish. +- Verify rapid typing in command palette and session switcher remains responsive and deterministic under realistic fixture sizes, without dropped characters or visible event-loop stalls. +- Verify arrow-key focus/navigation and leader-key start/step flows remain responsive under normal route and overlay state, including when the active route has populated inspector/detail content. +- For every primary route, verify the first visible card answers: what is happening, whether it is healthy, whether the operator is blocked, what the operator should do next, and where the evidence or source of truth lives. +- Verify Dashboard is a calm executive overview and does not surface readiness fields, watch-family details, protocol bundle data, version/build detail, or low-level audit facts as primary content. +- Verify Action Center is the clearest operator home for attention, with product-language buckets for approvals, operational attention, and blocked work, and with raw watch-family/debug facts moved to inspector/detail. +- Verify Action Center selected cards remain scannable and the inspector adds drill-down value instead of restating the same reason, impact, owner/action, and target fields verbatim. +- Verify Chat only shows broker-owned execution stages when broker/session/run state supports them and does not invent optimistic progress. +- Verify Runs, Approvals, Artifacts, Audit, Status, Model Providers, Git Setup, and Git Remote separate rendered/product language from structured and raw modes. +- Verify Model Providers, Git Setup, and Git Remote no longer read like broker API dumps in their primary rendered views. +- Verify command palette and overlays render as professional centered/elevated modal surfaces with bounded height, selected-row styling, grouped content, escape hints, and no duplicate footer noise. +- Verify route footers and global chrome remain calm while exhaustive commands stay discoverable through command palette, leader help, inspectors, or generated help; repeated trust-boundary prose and verbose shortcut lists should not dominate every screen. +- Verify all state-card variants include state label, message, reason, next action, route cue, shortcut cue, and evidence/source cue where applicable. +- Verify display-width-sensitive rows remain aligned with styled text, wide characters, and ANSI sequences; raw rune-count truncation should not break selected rows, directories, overlays, or inspector summaries. +- Verify dark, dusk, and high-contrast themes preserve legibility for ready/success, waiting/attention, blocked/danger, active/command, link/evidence, selected, muted, and raw/digest text. + +## Required TUI Interaction Performance Checks +- Add focused Go benchmarks for palette typing/filtering, session-switcher typing/filtering, leader open/step, and overlay-heavy shell view rendering with deterministic fixed datasets. +- Add a deterministic no-dropped-keys test that scripts rapid overlay text-entry input and verifies all characters are consumed in order. +- Keep benchmark runs deterministic by fixing dataset size, terminal dimensions, and benchmark environment; do not rely on PTY wall-clock timing variance across the full CI matrix. +- Run the TUI performance gate on a stable Linux CI job and fail on meaningful regressions rather than treating interaction responsiveness as manual-only QA. + ## Verification Notes - Confirm `runecontext/project/roadmap.md` places this change under `v0.1.0-alpha.11` and keeps `v0.1.0-beta.1` as the milestone framing. - Confirm `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` is reflected consistently wherever beta assurance wording depends on its verified integration. @@ -39,9 +77,10 @@ - Confirm approved implementation can carry required RuneContext lifecycle metadata updates without creating a separate fifth workflow operation for this lane. - Confirm approved implementation keeps `input_set_artifact_digest` and semantic `input_set_digest` distinct and fail-closed on embedded semantic digest drift. - Confirm the tasks explicitly capture TUI and operator polish discovered while testing. +- Confirm the TUI polish task list includes the current once-over findings, professional target experience, route-by-route gap closure, Bubble Tea/Bubbles/Lip Gloss best-practice expectations, and visual dogfooding evidence requirements. - Confirm the change requires exercising evidence snapshot, record inclusion, bundle export, and offline verification on the real workflow path. - Confirm the design keeps product messaging and assurance wording aligned with actual implementation state. - Confirm git remote publication remains out of required beta messaging unless a separate reviewed publishing smoke path is added. ## Close Gate -Use the repository's standard verification flow before closing this change, with `just ci` as the parity gate after targeted workflow and product smokes pass. +Use the repository's standard verification flow before closing this change, with `just ci` as the parity gate after targeted workflow and product smokes pass. The TUI acceptance criterion must remain open until the required TUI polish smokes pass and captured frames show a professional, calm, product-language TUI rather than a dense debug console. diff --git a/runecontext/project/standards-inventory.md b/runecontext/project/standards-inventory.md index e476fa34..b196120c 100644 --- a/runecontext/project/standards-inventory.md +++ b/runecontext/project/standards-inventory.md @@ -27,6 +27,8 @@ Recent notable standards for this branch: - `runecontext/standards/security/runner-durable-state-and-replay.md` - `runecontext/standards/security/runtime-image-signing-admission-and-verified-cache.md` - `runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md` +- `runecontext/standards/product/tui-bubbletea-lipgloss-performance.md` +- `runecontext/standards/testing/tui-snapshot-and-interaction-performance-gates.md` - `runecontext/standards/testing/performance-contract-governance.md` Recent additions should be reflected here when they become durable cross-cutting guidance rather than change-local design notes. diff --git a/runecontext/standards/product/tui-bubbletea-lipgloss-performance.md b/runecontext/standards/product/tui-bubbletea-lipgloss-performance.md new file mode 100644 index 00000000..4ba015e1 --- /dev/null +++ b/runecontext/standards/product/tui-bubbletea-lipgloss-performance.md @@ -0,0 +1,59 @@ +--- +schema_version: 1 +id: product/tui-bubbletea-lipgloss-performance +title: TUI Bubble Tea And Lip Gloss Performance +status: active +suggested_context_bundles: + - product-planning + - go-control-plane +--- + +# TUI Bubble Tea And Lip Gloss Performance + +Use this standard when changing `cmd/runecode-tui` shell, route, overlay, input, rendering, or style code. + +## Bubble Tea Event Loop + +- Treat `Update` and `View` as serialized hot paths: any synchronous filtering, route-surface generation, layout, render, broker call, file write, or network call can directly increase key-to-render latency. +- Keep all blocking broker, file, and network work in `tea.Cmd`; route `Update` methods should update state and return commands, not perform blocking work inline. +- Keep `View` deterministic and side-effect free so snapshots, benchmarks, and no-dropped-key tests remain meaningful. +- Keep route activation, palette actions, reference jumps, and mutation actions typed and non-destructive until the user executes an explicit action. +- Keep async watch/polling honest: ticks, spinners, and reload commands may reflect real in-flight commands or broker-owned watch state, but must not imply client-local optimistic completion. +- Keep keyboard ownership explicit for text entry, secret entry, overlays, command mode, normal route navigation, selection mode, quit confirmation, and emergency quit. +- Keep resize handling centralized so route viewport sizing stays predictable across desktop, compact, and mobile terminal widths. +- Keep mouse capture optional and reversible so terminal text selection remains possible. + +## Input-Path Performance + +- Precompute normalized searchable text for command palette, session switcher, and object indexes before hot typing paths. +- Reuse match buffers and avoid repeated lowercase/concatenate/allocate scans on every keypress. +- Version asynchronous search/filter requests and reject stale results when query, entry version, or normalized search needle no longer matches current state. +- Render only the visible window of overlay rows plus gap markers; do not format every match before bounded-window trimming. +- Avoid repeated route `ShellSurface`, layout, overlay-height, and final `View` work for one keypress unless the underlying state changed. +- Reuse cached overlay background frames while typing in command palette, session switcher, or leader help, and explicitly invalidate the cache when route state, watch/session/object-index state, theme, focus, overlay identity, or viewport size changes. +- Keep local convenience persistence off critical input paths; debounce or asynchronously write layout, theme, recent-session, and leader-key preferences. +- Add regression coverage for fast scripted overlay input to prove characters are consumed in order without dropped input. + +## Bubbles Usage + +- Use `textarea` or equivalent maintained primitives for multiline composer behavior instead of ad-hoc line editing. +- Use `viewport` or bounded long-form primitives for transcripts, diffs, logs, artifact content, and audit records rather than clipping raw strings manually. +- Use `help`/`key` or one generated action graph for concise help where practical; keep exhaustive discovery in command palette, leader help, or dedicated help surfaces. +- Use `spinner` or small activity indicators only for real in-flight commands or watch activity. + +## Lip Gloss Rendering + +- Build from shared design tokens for base surface, elevated panel surface, overlay surface, foreground, dimmed text, links/evidence, code/digest text, selected rows, borders, and state colors. +- Prefer composable card, rail, row, pill, command-palette, modal-panel, detail-viewport, setup-stepper, and evidence-trail components over per-route string dumps. +- Budget widths and heights before rendering; prevent panes and overlays from overflowing or collapsing unpredictably. +- Use `lipgloss.Width`, ANSI-aware truncation, and display-cell-aware clipping for styled, wide-character, and colored content; do not use raw rune-count clipping where alignment matters. +- Prefer padding, accent rails, muted dividers, and selected-row backgrounds over excessive full borders. +- Make modal surfaces visibly modal within terminal constraints: centered panel, bounded height, clear escape hint, strong selected row, and no duplicate footer noise. +- Verify dark, dusk, and high-contrast themes; selected rows, warnings, destructive actions, and reduced-assurance states must remain legible without relying on hue alone. + +## Verification + +- Keep focused benchmarks for palette typing, session-switcher typing, leader open/step flows, overlay rendering, shell focus traversal, watch projection, and shell `View` costs. +- Keep no-dropped-key tests for fast scripted overlay entry. +- Keep perf harness startup and key-response markers aligned with stable current shell chrome. +- Treat interaction responsiveness as product correctness, not optional cleanup. diff --git a/runecontext/standards/product/tui-shell-input-and-command-surfaces.md b/runecontext/standards/product/tui-shell-input-and-command-surfaces.md index 25f20f3d..6168facb 100644 --- a/runecontext/standards/product/tui-shell-input-and-command-surfaces.md +++ b/runecontext/standards/product/tui-shell-input-and-command-surfaces.md @@ -22,3 +22,5 @@ When extending `runecode-tui` shell interaction behavior: - Treat emergency quit as distinct from normal quit: `ctrl+c` may remain an escape hatch, but normal quit confirmation and beginner discoverability should flow through the real shell action surfaces. - Clear pending emergency-quit state as soon as ordinary interaction resumes; emergency arming must not linger across unrelated normal use. - Keep client-local leader-key preferences and similar shell convenience state explicitly local-only and non-authoritative. + +For Bubble Tea event-loop discipline, overlay filtering performance, and Lip Gloss rendering guidance, also apply `runecontext/standards/product/tui-bubbletea-lipgloss-performance.md`. diff --git a/runecontext/standards/testing/tui-snapshot-and-interaction-performance-gates.md b/runecontext/standards/testing/tui-snapshot-and-interaction-performance-gates.md new file mode 100644 index 00000000..591179a9 --- /dev/null +++ b/runecontext/standards/testing/tui-snapshot-and-interaction-performance-gates.md @@ -0,0 +1,51 @@ +--- +schema_version: 1 +id: testing/tui-snapshot-and-interaction-performance-gates +title: TUI Snapshot And Interaction Performance Gates +status: active +suggested_context_bundles: + - ci-tooling + - product-planning +--- + +# TUI Snapshot And Interaction Performance Gates + +Use this standard when changing deterministic TUI snapshots, TUI visual review tooling, or TUI interaction-performance gates. + +## Snapshot Build Surface + +- Keep snapshot-only flags and scenario catalogs behind the `runecode_tui_snapshot` build tag. +- Keep default/release `runecode-tui` builds fail-closed for snapshot-only flags; `just tui-release-safety` must catch accidental exposure. +- Keep snapshot artifact generation deterministic for a fixed scenario, viewport, theme, and terminal size. +- Keep checked scenarios and bundles named explicitly; do not infer review scope from filenames when a manifest can carry the scope. + +## Snapshot Artifacts + +- Treat `manifest.json` as the authoritative inventory for generated scenarios, bundle, viewport, dimensions, route, artifact paths, and hashes. +- Emit `.ansi`, `.txt`, and `.svg` artifacts for each scenario; emit `.png` only when local ImageMagick `magick` or `convert` is available. +- Canonicalize output paths and constrain local snapshot output to trusted local roots before writing artifacts. +- Clean stale artifacts before each deterministic run unless the caller explicitly preserves them for inspection. +- Keep generated artifacts out of normal tracked source unless a later reviewed baseline policy explicitly introduces checked-in visual baselines. + +## Review Workflow + +- Keep `just tui-snapshot-review` non-GUI by default and suitable for terminal-only CI/dev environments. +- Keep GUI opening explicit through an `open` review mode or dedicated recipe. +- Keep route, overlay, compact, and mobile review bundles available so visual dogfooding does not cover only the default desktop route set. +- Preserve snapshot coverage for command palette, session switcher, leader help, quit confirmation, sidebar/focus states, rendered/structured/raw route modes, degraded states, setup flows, and evidence routes. + +## Interaction Performance Gates + +- Keep `just tui-perf` focused on deterministic, fixed-dataset TUI hot paths rather than broad end-to-end workflows. +- Keep `just ci-fast` running the focused TUI interaction benchmark lane so obvious hot-path regressions fail before merge. +- Keep required shared-Linux TUI perf contracts in `just ci-required-shared-linux` and `tools/perfcontracts/`; do not silently loosen thresholds or rewrite baselines from CI. +- Keep timing boundaries explicit: distinguish fresh-process attach latency, key-response latency, update/filter latency, render cost, idle CPU, and benchmark parser outputs. +- Keep perf diagnostics sanitized; do not leak sensitive local paths, tokens, raw broker startup output, or secret-like values in failures. +- Distinguish local noisy perf runs from deterministic CI failures, but fix stale markers, stale fixtures, and broken harness assumptions immediately. + +## Maintenance + +- Update snapshot expected scenario inventories when adding, renaming, or removing scenario names. +- Update perf harness markers when stable shell chrome changes. +- Update README command summaries when adding or retiring public `just tui-*` recipes. +- Cross-reference `runecontext/standards/product/tui-bubbletea-lipgloss-performance.md` for Bubble Tea/Lip Gloss implementation guidance. diff --git a/runner/package-lock.json b/runner/package-lock.json index 9f1d910d..050d9a9f 100644 --- a/runner/package-lock.json +++ b/runner/package-lock.json @@ -70,9 +70,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py new file mode 100644 index 00000000..ea1109e7 --- /dev/null +++ b/tools/test_tui_snapshot_review.py @@ -0,0 +1,343 @@ +import importlib.util +import io +import json +import tempfile +import unittest +from unittest import mock +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("tui_snapshot_review.py") +SPEC = importlib.util.spec_from_file_location("tui_snapshot_review", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class SelectArtifactPathsTest(unittest.TestCase): + def require_symlink(self, path: Path, target: Path, *, target_is_directory: bool = False) -> None: + try: + path.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError): + self.skipTest("symlinks unsupported on this platform") + + def test_confines_paths_and_falls_back_to_confined_svg(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + output_dir = temp_path / "snapshots" + output_dir.mkdir() + + outside_png = temp_path / "outside.png" + outside_png.write_bytes(b"png") + escaped_png = output_dir / "escaped.png" + self.require_symlink(escaped_png, outside_png) + + fallback_svg = output_dir / "fallback.svg" + fallback_svg.write_text("", encoding="utf-8") + + inside_png = output_dir / "inside.png" + inside_png.write_bytes(b"png") + + manifest_path = output_dir / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "scenarios": [ + { + "artifacts": { + "png": {"path": "escaped.png"}, + "svg": {"path": "fallback.svg"}, + } + }, + { + "artifacts": { + "png": {"path": "file:///etc/passwd"}, + "svg": {"path": "../outside.svg"}, + } + }, + { + "artifacts": { + "png": {"path": str(inside_png)}, + "svg": {"path": "../outside.svg"}, + } + }, + { + "artifacts": { + "png": {"path": "inside.png"}, + "svg": {"path": "../outside.svg"}, + } + }, + ] + } + ), + encoding="utf-8", + ) + + paths = MODULE.select_artifact_paths(str(output_dir), str(manifest_path)) + + self.assertEqual(paths, [str(fallback_svg.resolve()), str(inside_png.resolve())]) + self.assertEqual(MODULE.resolve_artifact_path(str(output_dir), "inside.png"), str(inside_png.resolve())) + self.assertIsNone(MODULE.resolve_artifact_path(str(output_dir), str(inside_png))) + + def test_resolve_output_dir_rejects_symlink_outside_temp(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + outside_dir = Path(__file__).resolve().parent + linked_output_dir = temp_path / "linked-output" + self.require_symlink(linked_output_dir, outside_dir, target_is_directory=True) + + self.assertIsNone(MODULE.resolve_output_dir(str(linked_output_dir))) + + def test_resolve_output_dir_accepts_real_tmp_when_tempdir_differs(self) -> None: + tmp_root = Path("/tmp") + if not tmp_root.is_dir(): + self.skipTest("/tmp unavailable on this platform") + + requested = str(tmp_root / ".tui-snapshots") + with mock.patch.object(MODULE.tempfile, "gettempdir", return_value=str(Path(tempfile.gettempdir()) / "alternate-temp-root")): + self.assertEqual(MODULE.resolve_output_dir(requested), str(Path(requested).resolve())) + + def test_resolve_output_dir_accepts_repo_snapshot_root(self) -> None: + requested = MODULE.repo_snapshot_root() + + self.assertEqual(MODULE.resolve_output_dir(requested), str(Path(requested).resolve())) + + def test_windows_absolute_paths_are_not_treated_as_uris(self) -> None: + self.assertFalse(MODULE.is_uri_like_path(r"C:\temp\snapshot.png")) + self.assertTrue(MODULE.is_uri_like_path("file:///tmp/snapshot.png")) + + def test_select_artifact_paths_returns_empty_for_invalid_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + manifest_path = output_dir / "manifest.json" + manifest_path.write_text("{not-json", encoding="utf-8") + + self.assertEqual(MODULE.select_artifact_paths(str(output_dir), str(manifest_path)), []) + + def test_select_artifact_paths_skips_malformed_manifest_entries(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + svg_path = output_dir / "fallback.svg" + svg_path.write_text("", encoding="utf-8") + manifest = { + "scenarios": [ + "not-an-object", + {"artifacts": "bad"}, + {"artifacts": {"png": "bad", "svg": {"path": "fallback.svg"}}}, + ] + } + + paths = MODULE.select_artifact_paths_from_manifest(str(output_dir), manifest) + + self.assertEqual(paths, [str(svg_path.resolve())]) + + +class ReviewModesTest(unittest.TestCase): + def create_snapshot_dir(self) -> tuple[Path, Path, Path]: + temp_dir = Path(tempfile.mkdtemp()) + png_path = temp_dir / "dashboard.desktop.png" + png_path.write_bytes(b"png") + svg_path = temp_dir / "action-center.desktop.svg" + svg_path.write_text("", encoding="utf-8") + manifest_path = temp_dir / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "version": 2, + "bundle": "full-audit", + "viewport": "desktop", + "theme": "dark", + "coverage": { + "bundle": "full-audit", + "routes": ["dashboard", "action-center"], + "viewports": ["desktop"], + "scenarios": ["dashboard", "action-center"], + }, + "scenarios": [ + { + "name": "dashboard", + "viewport": "desktop", + "route": "dashboard", + "artifacts": { + "png": {"path": png_path.name}, + "svg": {"path": "dashboard.desktop.svg"}, + }, + }, + { + "name": "action-center", + "viewport": "desktop", + "route": "approvals", + "artifacts": { + "png": {"path": "missing.png"}, + "svg": {"path": svg_path.name}, + }, + }, + ], + } + ), + encoding="utf-8", + ) + self.addCleanup(lambda: __import__("shutil").rmtree(temp_dir, ignore_errors=True)) + return temp_dir, png_path, svg_path + + def run_main(self, *argv: str) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with mock.patch.object(MODULE.sys, "argv", [str(MODULE_PATH), *argv]), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + return MODULE.main(), stdout.getvalue(), stderr.getvalue() + + def test_default_mode_prints_summary_without_opening_gui(self) -> None: + output_dir, png_path, svg_path = self.create_snapshot_dir() + with mock.patch.object(MODULE, "detect_opener", return_value="xdg-open"), mock.patch.object(MODULE.subprocess, "run") as run_mock: + exit_code, stdout, stderr = self.run_main(str(output_dir)) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertIn(f"Output dir: {output_dir.resolve()}", stdout) + self.assertIn("Bundle: full-audit", stdout) + self.assertIn("Coverage routes: dashboard, action-center", stdout) + self.assertIn("Coverage summary: bundle=full-audit routes=2 viewports=1 scenarios=2", stdout) + self.assertIn("Review artifacts: 2", stdout) + self.assertIn(f"- dashboard | viewport=desktop | route=dashboard | artifact=png | {png_path.resolve()}", stdout) + self.assertIn(f"- action-center | viewport=desktop | route=approvals | artifact=svg | {svg_path.resolve()}", stdout) + run_mock.assert_not_called() + + def test_list_mode_prints_selected_artifact_paths(self) -> None: + output_dir, png_path, svg_path = self.create_snapshot_dir() + with mock.patch.object(MODULE.subprocess, "run") as run_mock: + exit_code, stdout, stderr = self.run_main("--mode", "list", str(output_dir)) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(stdout.splitlines(), [str(png_path.resolve()), str(svg_path.resolve())]) + run_mock.assert_not_called() + + def test_open_mode_uses_desktop_opener(self) -> None: + output_dir, png_path, svg_path = self.create_snapshot_dir() + with mock.patch.object(MODULE, "detect_opener", return_value="xdg-open"), mock.patch.object(MODULE.subprocess, "run") as run_mock: + exit_code, stdout, stderr = self.run_main("--mode", "open", str(output_dir)) + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertEqual(len(run_mock.call_args_list), 2) + opened_paths = [call.args[0][1] for call in run_mock.call_args_list] + for opened_path in opened_paths: + self.assertTrue(Path(opened_path).name in {png_path.name, svg_path.name}) + self.assertNotEqual(Path(opened_path).resolve(), png_path.resolve()) + self.assertNotEqual(Path(opened_path).resolve(), svg_path.resolve()) + + def test_open_mode_stages_artifacts_before_opening(self) -> None: + output_dir, png_path, _ = self.create_snapshot_dir() + with mock.patch.object(MODULE, "detect_opener", return_value="xdg-open"), mock.patch.object(MODULE.subprocess, "run") as run_mock: + exit_code, stdout, stderr = self.run_main("--mode", "open", str(output_dir)) + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + staged_path = Path(run_mock.call_args_list[0].args[0][1]) + self.assertTrue(staged_path.is_file()) + self.assertEqual(staged_path.read_bytes(), png_path.read_bytes()) + + def test_requirement_flags_accept_matching_bundle_routes_and_viewport(self) -> None: + output_dir, _, _ = self.create_snapshot_dir() + + exit_code, stdout, stderr = self.run_main( + "--require-bundle", + "full-audit", + "--require-route", + "dashboard", + "--require-route", + "action-center", + "--require-viewport", + "desktop", + str(output_dir), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertIn("Bundle: full-audit", stdout) + + def test_require_bundle_rejects_wrong_bundle(self) -> None: + output_dir, _, _ = self.create_snapshot_dir() + + exit_code, stdout, stderr = self.run_main("--require-bundle", "dashboard-audit", str(output_dir)) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertIn("Required bundle 'dashboard-audit' was not found in manifest coverage (found 'full-audit').", stderr) + self.assertIn("This blocks preserved-artifact review; no audit coverage can be claimed for the requested scope.", stderr) + + def test_require_viewport_rejects_missing_viewport(self) -> None: + output_dir, _, _ = self.create_snapshot_dir() + + exit_code, stdout, stderr = self.run_main("--require-viewport", "mobile", str(output_dir)) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertIn("Required viewport 'mobile' was not found in manifest coverage (found desktop).", stderr) + + def test_require_route_rejects_missing_route(self) -> None: + output_dir, _, _ = self.create_snapshot_dir() + + exit_code, stdout, stderr = self.run_main("--require-route", "runs", str(output_dir)) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertIn("Required routes missing from manifest coverage: runs (found dashboard, action-center).", stderr) + + def test_missing_manifest_prints_read_only_guidance(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + + exit_code, stdout, stderr = self.run_main(str(output_dir)) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertIn(f"Failed to read snapshot manifest {output_dir / 'manifest.json'}", stderr) + self.assertIn("tui_snapshot_review.py only reviews existing artifacts; it does not generate snapshots.", stderr) + self.assertIn(f"TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR={output_dir.resolve()} just tui-snapshot-audit-full", stderr) + self.assertIn("If you are in plan/read-only mode, provide an existing TUI_SNAPSHOT_DIR containing manifest.json.", stderr) + + def test_manifest_without_reviewable_artifacts_prints_blocker_guidance(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + manifest_path = output_dir / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "version": 2, + "bundle": "full-audit", + "coverage": { + "bundle": "full-audit", + "routes": ["dashboard"], + "viewports": ["desktop"], + "scenarios": ["dashboard"], + }, + "scenarios": [ + { + "name": "dashboard", + "viewport": "desktop", + "route": "dashboard", + "artifacts": { + "png": {"path": "missing.png"}, + "svg": {"path": "missing.svg"}, + }, + } + ], + } + ), + encoding="utf-8", + ) + + exit_code, stdout, stderr = self.run_main(str(output_dir)) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertIn(f"No snapshot review artifacts found in {manifest_path}", stderr) + self.assertIn("This blocks snapshot review; do not treat missing artifacts as a TUI audit finding.", stderr) + self.assertIn(f"TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR={output_dir.resolve()} just tui-snapshot-audit-full", stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tui_snapshot_local.sh b/tools/tui_snapshot_local.sh new file mode 100644 index 00000000..75d456cb --- /dev/null +++ b/tools/tui_snapshot_local.sh @@ -0,0 +1,126 @@ +#!/bin/sh + +set -eu + +usage() { + printf '%s\n' 'usage: sh ./tools/tui_snapshot_local.sh --binary (--scenario | --bundle ) [--viewport ] [--review] [--review-mode ]' >&2 +} + +binary_path='' +scenario='' +bundle='' +viewport='' +review='0' +review_mode='summary' + +while [ "$#" -gt 0 ]; do + case "$1" in + --binary) + [ "$#" -ge 2 ] || { usage; exit 2; } + binary_path=$2 + shift 2 + ;; + --scenario) + [ "$#" -ge 2 ] || { usage; exit 2; } + scenario=$2 + shift 2 + ;; + --bundle) + [ "$#" -ge 2 ] || { usage; exit 2; } + bundle=$2 + shift 2 + ;; + --viewport) + [ "$#" -ge 2 ] || { usage; exit 2; } + viewport=$2 + shift 2 + ;; + --review) + review='1' + shift + ;; + --review-mode) + [ "$#" -ge 2 ] || { usage; exit 2; } + review_mode=$2 + shift 2 + ;; + *) + usage + exit 2 + ;; + esac +done + +[ -n "$binary_path" ] || { usage; exit 2; } +[ -n "$scenario" ] || [ -n "$bundle" ] || { usage; exit 2; } +[ -z "$scenario" ] || [ -z "$bundle" ] || { usage; exit 2; } + +case "$review_mode" in + summary|list|open) ;; + *) + usage + exit 2 + ;; +esac + +requested_output_dir=${TUI_SNAPSHOT_DIR:-} +if [ -n "$requested_output_dir" ]; then + output_dir=$(go run ./tools/tuisnapshotci local-output-dir --output-dir "$requested_output_dir") +else + output_dir=$(go run ./tools/tuisnapshotci local-output-dir) +fi + +cleanup_before() { + go run ./tools/tuisnapshotci cleanup-local-dir --output-dir "$output_dir" +} + +cleanup_after() { + if [ "${TUI_SNAPSHOT_KEEP:-}" = '1' ]; then + return 0 + fi + cleanup_before +} + +finish() { + status=$1 + trap - EXIT HUP INT TERM + if ! cleanup_after; then + if [ "$status" -eq 0 ]; then + exit 1 + fi + fi + exit "$status" +} + +cleanup_before +trap 'finish $?' EXIT HUP INT TERM + +mkdir -p "$output_dir" + +set -- "$binary_path" --snapshot-output-dir "$output_dir" +if [ -n "$scenario" ]; then + set -- "$@" --snapshot-scenario "$scenario" +fi +if [ -n "$bundle" ]; then + set -- "$@" --snapshot-bundle "$bundle" +fi +if [ -n "$viewport" ]; then + set -- "$@" --snapshot-viewport "$viewport" +fi +"$@" + +if command -v magick >/dev/null 2>&1; then + for svg in "$output_dir"/*.svg; do + [ -e "$svg" ] || continue + magick "$svg" "${svg%.svg}.png" + done +elif command -v convert >/dev/null 2>&1; then + for svg in "$output_dir"/*.svg; do + [ -e "$svg" ] || continue + convert "$svg" "${svg%.svg}.png" + done +fi + +if [ "$review" = '1' ]; then + python3 ./tools/tui_snapshot_review.py --mode "$review_mode" "$output_dir" +fi diff --git a/tools/tui_snapshot_review.py b/tools/tui_snapshot_review.py new file mode 100644 index 00000000..1be7298e --- /dev/null +++ b/tools/tui_snapshot_review.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 + +import argparse +import json +import atexit +import ntpath +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from typing import Dict, List, Optional +from urllib.parse import urlparse + + +REVIEW_MODE_LIST = "list" +REVIEW_MODE_SUMMARY = "summary" +REVIEW_MODE_OPEN = "open" +REVIEW_MODES = (REVIEW_MODE_LIST, REVIEW_MODE_SUMMARY, REVIEW_MODE_OPEN) +REPO_SNAPSHOT_DIR_NAME = ".tui-snapshots" +_OPEN_STAGE_DIRS: List[str] = [] + + +def register_open_stage_dir(path: str) -> None: + if path not in _OPEN_STAGE_DIRS: + _OPEN_STAGE_DIRS.append(path) + + +def cleanup_open_stage_dirs() -> None: + for path in _OPEN_STAGE_DIRS[:]: + shutil.rmtree(path, ignore_errors=True) + _OPEN_STAGE_DIRS.remove(path) + + +atexit.register(cleanup_open_stage_dirs) + + +def normalize_string_list(value: object) -> List[str]: + if not isinstance(value, list): + return [] + normalized: List[str] = [] + for item in value: + if isinstance(item, str) and item: + normalized.append(item) + return normalized + + +def is_windows_absolute_path(path: str) -> bool: + return ntpath.isabs(path) + + +def is_uri_like_path(path: str) -> bool: + if is_windows_absolute_path(path): + return False + + parsed = urlparse(path) + return bool(parsed.scheme or parsed.netloc) + + +def resolve_output_dir(output_dir: object) -> Optional[str]: + if not isinstance(output_dir, str) or not output_dir or is_uri_like_path(output_dir): + return None + + output_dir_real = os.path.realpath(output_dir) + for trusted_root in trusted_output_roots(): + try: + if os.path.commonpath([trusted_root, output_dir_real]) == trusted_root: + return output_dir_real + except ValueError: + continue + + return None + + +def trusted_temp_roots() -> List[str]: + roots = [os.path.realpath(tempfile.gettempdir())] + if os.path.isdir("/tmp"): + roots.append(os.path.realpath("/tmp")) + + unique_roots: List[str] = [] + for root in roots: + if root not in unique_roots: + unique_roots.append(root) + return unique_roots + + +def repo_snapshot_root() -> str: + script_dir = os.path.dirname(os.path.realpath(__file__)) + repo_root = os.path.realpath(os.path.join(script_dir, os.pardir)) + return os.path.join(repo_root, REPO_SNAPSHOT_DIR_NAME) + + +def trusted_output_roots() -> List[str]: + roots = trusted_temp_roots() + repo_root = os.path.realpath(repo_snapshot_root()) + if repo_root not in roots: + roots.append(repo_root) + return roots + + +def resolve_artifact_path(output_dir: str, artifact_path: object) -> Optional[str]: + if not isinstance(artifact_path, str) or not artifact_path: + return None + + if is_uri_like_path(artifact_path): + return None + + if os.path.isabs(artifact_path) or is_windows_absolute_path(artifact_path): + return None + + output_dir_real = resolve_output_dir(output_dir) + if output_dir_real is None: + return None + + normalized_artifact_path = os.path.normpath(artifact_path) + candidate_path = os.path.join(output_dir_real, normalized_artifact_path) + + candidate_real = os.path.realpath(candidate_path) + try: + if os.path.commonpath([output_dir_real, candidate_real]) != output_dir_real: + return None + except ValueError: + return None + + return candidate_real + + +def select_artifact_paths_from_manifest(output_dir: str, manifest: object) -> List[str]: + output_dir_real = resolve_output_dir(output_dir) + if output_dir_real is None: + return [] + + paths: List[str] = [] + if not isinstance(manifest, dict): + return paths + for entry in manifest.get("scenarios", []): + if not isinstance(entry, dict): + continue + artifacts = entry.get("artifacts", {}) + if not isinstance(artifacts, dict): + continue + png_artifact = artifacts.get("png", {}) + if not isinstance(png_artifact, dict): + png_artifact = {} + svg_artifact = artifacts.get("svg", {}) + if not isinstance(svg_artifact, dict): + svg_artifact = {} + png_path = resolve_artifact_path(output_dir_real, png_artifact.get("path")) + svg_path = resolve_artifact_path(output_dir_real, svg_artifact.get("path")) + chosen_path = png_path if png_path and os.path.exists(png_path) else svg_path + if chosen_path: + paths.append(chosen_path) + return paths + + +def select_artifact_paths(output_dir: str, manifest_path: str) -> List[str]: + try: + with open(manifest_path, encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) + except OSError: + return [] + except json.JSONDecodeError: + return [] + return select_artifact_paths_from_manifest(output_dir, manifest) + + +def select_review_entries_from_manifest(output_dir: str, manifest: object) -> List[Dict[str, str]]: + output_dir_real = resolve_output_dir(output_dir) + if output_dir_real is None or not isinstance(manifest, dict): + return [] + + entries: List[Dict[str, str]] = [] + for entry in manifest.get("scenarios", []): + if not isinstance(entry, dict): + continue + artifacts = entry.get("artifacts", {}) + if not isinstance(artifacts, dict): + continue + + png_artifact = artifacts.get("png", {}) + if not isinstance(png_artifact, dict): + png_artifact = {} + svg_artifact = artifacts.get("svg", {}) + if not isinstance(svg_artifact, dict): + svg_artifact = {} + + png_path = resolve_artifact_path(output_dir_real, png_artifact.get("path")) + svg_path = resolve_artifact_path(output_dir_real, svg_artifact.get("path")) + if png_path and os.path.exists(png_path): + entries.append( + { + "name": str(entry.get("name", "unnamed")), + "viewport": str(entry.get("viewport", manifest.get("viewport", "")) or ""), + "route": str(entry.get("route", "")), + "artifact_kind": "png", + "artifact_path": png_path, + } + ) + continue + if svg_path and os.path.exists(svg_path): + entries.append( + { + "name": str(entry.get("name", "unnamed")), + "viewport": str(entry.get("viewport", manifest.get("viewport", "")) or ""), + "route": str(entry.get("route", "")), + "artifact_kind": "svg", + "artifact_path": svg_path, + } + ) + + return entries + + +def detect_opener() -> Optional[str]: + system = platform.system() + if system == "Darwin": + return shutil.which("open") + if system == "Linux": + return shutil.which("xdg-open") + return None + + +def print_review_list(entries: List[Dict[str, str]]) -> None: + for entry in entries: + print(entry["artifact_path"]) + + +def print_review_summary(output_dir: str, manifest_path: str, manifest: object, entries: List[Dict[str, str]]) -> None: + scenario_count = len(manifest.get("scenarios", [])) if isinstance(manifest, dict) and isinstance(manifest.get("scenarios", []), list) else 0 + print(f"Output dir: {output_dir}") + print(f"Manifest: {manifest_path}") + if isinstance(manifest, dict): + if manifest.get("bundle"): + print(f"Bundle: {manifest['bundle']}") + if manifest.get("viewport"): + print(f"Viewport: {manifest['viewport']}") + if manifest.get("theme"): + print(f"Theme: {manifest['theme']}") + coverage = manifest.get("coverage", {}) + if isinstance(coverage, dict): + if coverage.get("routes"): + print(f"Coverage routes: {', '.join(coverage['routes'])}") + if coverage.get("viewports"): + print(f"Coverage viewports: {', '.join(coverage['viewports'])}") + if coverage.get("scenarios"): + print(f"Coverage scenarios: {', '.join(coverage['scenarios'])}") + print(f"Scenarios: {scenario_count}") + print(f"Review artifacts: {len(entries)}") + coverage_summary = bundle_coverage_summary(manifest) + if coverage_summary: + print(f"Coverage summary: {coverage_summary}") + manifest_scenarios = set() + selected_scenarios = set() + if isinstance(manifest, dict): + coverage = manifest.get("coverage", {}) + if isinstance(coverage, dict): + manifest_scenarios = {str(name) for name in coverage.get("scenarios", []) if isinstance(name, str)} + for entry in entries: + selected_scenarios.add(entry["name"]) + details = [entry["name"]] + if entry.get("viewport"): + details.append(f"viewport={entry['viewport']}") + if entry.get("route"): + details.append(f"route={entry['route']}") + details.append(f"artifact={entry['artifact_kind']}") + details.append(entry["artifact_path"]) + print("- " + " | ".join(details)) + missing = sorted(manifest_scenarios - selected_scenarios) + if missing: + print("Missing review artifacts: " + ", ".join(missing)) + + +def suggested_preserved_bundle_command(output_dir: str) -> str: + return f"TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR={output_dir} just tui-snapshot-audit-full" + + +def print_missing_manifest_guidance(output_dir: str, manifest_path: str) -> None: + print(f"No preserved snapshot manifest was found at {manifest_path}.", file=sys.stderr) + print("tui_snapshot_review.py only reviews existing artifacts; it does not generate snapshots.", file=sys.stderr) + print("If write-producing snapshot commands are allowed, generate preserved artifacts with:", file=sys.stderr) + print(f" {suggested_preserved_bundle_command(output_dir)}", file=sys.stderr) + print("If you are in plan/read-only mode, provide an existing TUI_SNAPSHOT_DIR containing manifest.json.", file=sys.stderr) + + +def print_missing_artifact_guidance(output_dir: str, manifest_path: str) -> None: + print(f"No snapshot review artifacts found in {manifest_path}", file=sys.stderr) + print("The manifest does not currently reference any readable PNG or SVG review artifacts.", file=sys.stderr) + print("This blocks snapshot review; do not treat missing artifacts as a TUI audit finding.", file=sys.stderr) + print("If write-producing snapshot commands are allowed, regenerate preserved artifacts with the matching audit recipe, for example:", file=sys.stderr) + print(f" {suggested_preserved_bundle_command(output_dir)}", file=sys.stderr) + print(f"Otherwise, point the review tool at a different preserved temp directory than {output_dir}.", file=sys.stderr) + + +def manifest_coverage(manifest: object) -> Dict[str, object]: + if not isinstance(manifest, dict): + return {} + coverage = manifest.get("coverage", {}) + if not isinstance(coverage, dict): + return {} + return coverage + + +def manifest_bundle_name(manifest: object) -> str: + coverage = manifest_coverage(manifest) + bundle = coverage.get("bundle") + if isinstance(bundle, str) and bundle: + return bundle + if isinstance(manifest, dict): + top_level_bundle = manifest.get("bundle") + if isinstance(top_level_bundle, str): + return top_level_bundle + return "" + + +def manifest_viewports(manifest: object) -> List[str]: + coverage = manifest_coverage(manifest) + viewports = normalize_string_list(coverage.get("viewports")) + if viewports: + return viewports + if isinstance(manifest, dict): + viewport = manifest.get("viewport") + if isinstance(viewport, str) and viewport: + return [viewport] + return [] + + +def manifest_routes(manifest: object) -> List[str]: + coverage = manifest_coverage(manifest) + routes = normalize_string_list(coverage.get("routes")) + if routes: + return routes + if not isinstance(manifest, dict): + return [] + scenario_routes: List[str] = [] + for entry in manifest.get("scenarios", []): + if not isinstance(entry, dict): + continue + route = entry.get("route") + if isinstance(route, str) and route and route not in scenario_routes: + scenario_routes.append(route) + return scenario_routes + + +def validate_manifest_requirements(manifest: object, require_bundle: str, require_routes: List[str], require_viewport: str) -> List[str]: + errors: List[str] = [] + bundle = manifest_bundle_name(manifest) + if require_bundle and bundle != require_bundle: + found_bundle = bundle or "" + errors.append(f"Required bundle {require_bundle!r} was not found in manifest coverage (found {found_bundle!r}).") + + available_viewports = manifest_viewports(manifest) + if require_viewport and require_viewport not in available_viewports: + found_viewports = ", ".join(available_viewports) if available_viewports else "" + errors.append(f"Required viewport {require_viewport!r} was not found in manifest coverage (found {found_viewports}).") + + available_routes = manifest_routes(manifest) + missing_routes = [route for route in require_routes if route not in available_routes] + if missing_routes: + found_routes = ", ".join(available_routes) if available_routes else "" + errors.append(f"Required routes missing from manifest coverage: {', '.join(missing_routes)} (found {found_routes}).") + + return errors + + +def print_requirement_failure(output_dir: str, errors: List[str]) -> None: + for err in errors: + print(err, file=sys.stderr) + print("This blocks preserved-artifact review; no audit coverage can be claimed for the requested scope.", file=sys.stderr) + print("Point the review tool at a preserved temp directory containing the requested scope, or regenerate it in write-capable mode with the matching audit recipe.", file=sys.stderr) + print(f"Example write-capable command: {suggested_preserved_bundle_command(output_dir)}", file=sys.stderr) + + +def bundle_coverage_summary(manifest: object) -> str: + if not isinstance(manifest, dict): + return "" + coverage = manifest.get("coverage", {}) + if not isinstance(coverage, dict): + return "" + routes = coverage.get("routes", []) + scenarios = coverage.get("scenarios", []) + viewports = coverage.get("viewports", []) + if not isinstance(routes, list) or not isinstance(scenarios, list) or not isinstance(viewports, list): + return "" + return f"bundle={coverage.get('bundle', '')} routes={len(routes)} viewports={len(viewports)} scenarios={len(scenarios)}" + + +def open_review_artifacts(entries: List[Dict[str, str]]) -> int: + opener = detect_opener() + if opener is None: + print("No supported desktop opener found for snapshot review; selected artifact paths:", file=sys.stderr) + print_review_list(entries) + return 1 + + failed_paths: List[str] = [] + stage_dir = tempfile.mkdtemp(prefix="runecode-tui-review-open-") + register_open_stage_dir(stage_dir) + for entry in entries: + artifact_path = entry["artifact_path"] + _, ext = os.path.splitext(artifact_path) + if ext.lower() not in {".png", ".svg"} or not os.path.isfile(artifact_path): + failed_paths.append(artifact_path) + continue + staged_path = os.path.join(stage_dir, os.path.basename(artifact_path)) + try: + shutil.copy2(artifact_path, staged_path) + subprocess.run([opener, staged_path], check=True) + except OSError: + failed_paths.append(artifact_path) + except subprocess.CalledProcessError: + failed_paths.append(artifact_path) + + for path in failed_paths: + print(path) + if failed_paths: + return 1 + return 0 + + +def parse_args(argv: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Review TUI snapshot artifacts without changing release behavior") + parser.add_argument( + "output_dir", + nargs="?", + default=repo_snapshot_root(), + help="snapshot output directory under a trusted snapshot root", + ) + parser.add_argument( + "--mode", + choices=REVIEW_MODES, + default=REVIEW_MODE_SUMMARY, + help="review mode: summary (default), list, or open", + ) + parser.add_argument( + "--require-bundle", + default="", + help="require a specific manifest bundle before review succeeds", + ) + parser.add_argument( + "--require-route", + action="append", + default=[], + help="require a specific covered route before review succeeds; repeat for multiple routes", + ) + parser.add_argument( + "--require-viewport", + default="", + help="require a specific covered viewport before review succeeds", + ) + return parser.parse_args(argv) + + +def main() -> int: + args = parse_args(sys.argv[1:]) + requested_output_dir = args.output_dir + output_dir = resolve_output_dir(requested_output_dir) + if output_dir is None: + print("Requested output directory must resolve inside a trusted snapshot root", file=sys.stderr) + return 1 + + manifest_path = os.path.join(output_dir, "manifest.json") + try: + with open(manifest_path, encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) + except FileNotFoundError as err: + print(f"Failed to read snapshot manifest {manifest_path}: {err}", file=sys.stderr) + print_missing_manifest_guidance(output_dir, manifest_path) + return 1 + except OSError as err: + print(f"Failed to read snapshot manifest {manifest_path}: {err}", file=sys.stderr) + return 1 + except json.JSONDecodeError as err: + print(f"Failed to parse snapshot manifest {manifest_path}: {err}", file=sys.stderr) + return 1 + + requirement_errors = validate_manifest_requirements(manifest, args.require_bundle, args.require_route, args.require_viewport) + if requirement_errors: + print_requirement_failure(output_dir, requirement_errors) + return 1 + + entries = select_review_entries_from_manifest(output_dir, manifest) + if not entries: + print_missing_artifact_guidance(output_dir, manifest_path) + return 1 + + if args.mode == REVIEW_MODE_LIST: + print_review_list(entries) + return 0 + if args.mode == REVIEW_MODE_SUMMARY: + print_review_summary(output_dir, manifest_path, manifest, entries) + return 0 + return open_review_artifacts(entries) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tuiperf/modes.go b/tools/tuiperf/modes.go index 5adbdaa6..45699b5f 100644 --- a/tools/tuiperf/modes.go +++ b/tools/tuiperf/modes.go @@ -101,13 +101,17 @@ func cpuMeasurementsForFixture(fixtureID string, result tuiperf.CPUSampleResult) return []perfcontracts.MeasurementRecord{{MetricID: "metric.tui.idle_cpu.waiting.avg_pct", Value: result.AverageCPUPercent, Unit: "percent"}, {MetricID: "metric.tui.idle_cpu.waiting.max_pct", Value: result.MaxCPUPercent, Unit: "percent"}} } +const ( + tuiStartupMarker = "RuneCode Workbench" + tuiKeyResponseMarker = "Main focus" +) + func collectLatencySamplesFromFreshSpawn( trials int, startHarness func() (runningHarness, error), stopHarnessFn func(runningHarness), collectSample func(runningHarness, string, time.Time) (float64, float64, error), ) ([]float64, []float64, error) { - marker := "Runecode TUI α shell" attachDurations := make([]float64, 0, trials) keyDurations := make([]float64, 0, trials) for i := 0; i < trials; i++ { @@ -118,7 +122,7 @@ func collectLatencySamplesFromFreshSpawn( } attachMS, keyMS, err := func() (float64, float64, error) { defer stopHarnessFn(h) - return collectSample(h, marker, start) + return collectSample(h, tuiStartupMarker, start) }() if err != nil { return nil, nil, err @@ -131,8 +135,7 @@ func collectLatencySamplesFromFreshSpawn( func collectLatencySampleFromHarness(h runningHarness, marker string, start time.Time) (float64, float64, error) { events := make(chan tuiperf.MarkerEvent, 64) - const keyResponseMarker = "focus=MAIN" - go tuiperf.WatchMarkers(h.ctx, h.tuiOut, []string{marker, keyResponseMarker}, events) + go tuiperf.WatchMarkers(h.ctx, h.tuiOut, []string{marker, tuiKeyResponseMarker}, events) attachAt, err := waitForMarker(events, marker, latencyMarkerTimeout) if err != nil { return 0, 0, err @@ -141,7 +144,7 @@ func collectLatencySampleFromHarness(h runningHarness, marker string, start time if _, err := io.WriteString(h.tuiIn, "\t"); err != nil { return 0, 0, err } - keyAt, err := waitForMarkerAfter(events, keyResponseMarker, keyStart, latencyMarkerTimeout) + keyAt, err := waitForMarkerAfter(events, tuiKeyResponseMarker, keyStart, latencyMarkerTimeout) if err != nil { return 0, 0, err } diff --git a/tools/tuiperf/modes_test.go b/tools/tuiperf/modes_test.go index 54ab2a9a..aad41a31 100644 --- a/tools/tuiperf/modes_test.go +++ b/tools/tuiperf/modes_test.go @@ -66,8 +66,8 @@ func collectTrialLifecycleSamples(t *testing.T) ([]string, []string, []float64, func(h runningHarness) { stopped = append(stopped, h.tuiCmd.Path) }, func(_ runningHarness, marker string, _ time.Time) (float64, float64, error) { sampleCalls++ - if marker != "Runecode TUI α shell" { - t.Fatalf("marker = %q, want %q", marker, "Runecode TUI α shell") + if marker != tuiStartupMarker { + t.Fatalf("marker = %q, want %q", marker, tuiStartupMarker) } return float64(sampleCalls), float64(sampleCalls + 10), nil }, @@ -176,13 +176,23 @@ func TestCollectLatencySamplesFromFreshSpawnMeasuresFromPreSpawnStart(t *testing } } +func TestLatencyMarkersMatchCurrentShellChrome(t *testing.T) { + t.Parallel() + if tuiStartupMarker != "RuneCode Workbench" { + t.Fatalf("tuiStartupMarker = %q, want current shell title", tuiStartupMarker) + } + if tuiKeyResponseMarker != "Main focus" { + t.Fatalf("tuiKeyResponseMarker = %q, want current tab-focus response", tuiKeyResponseMarker) + } +} + func TestWaitForMarkerAfterSkipsStaleEvents(t *testing.T) { t.Parallel() events := make(chan tuiperf.MarkerEvent, 3) start := time.Now() - events <- tuiperf.MarkerEvent{Marker: "Runecode TUI α shell", At: start.Add(-time.Millisecond)} - events <- tuiperf.MarkerEvent{Marker: "Runecode TUI α shell", At: start.Add(time.Millisecond)} - got, err := waitForMarkerAfter(events, "Runecode TUI α shell", start, time.Second) + events <- tuiperf.MarkerEvent{Marker: tuiStartupMarker, At: start.Add(-time.Millisecond)} + events <- tuiperf.MarkerEvent{Marker: tuiStartupMarker, At: start.Add(time.Millisecond)} + got, err := waitForMarkerAfter(events, tuiStartupMarker, start, time.Second) if err != nil { t.Fatalf("waitForMarkerAfter error = %v", err) } diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go new file mode 100644 index 00000000..99bf6d89 --- /dev/null +++ b/tools/tuisnapshotci/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + snapshotBuildTag = "runecode_tui_snapshot" + snapshotBundle = "full-audit" + snapshotViewport = "desktop" + manifestVersion = 2 +) + +var expectedScenarioNames = []string{ + "dashboard-healthy-empty", + "dashboard-approval-waiting", + "dashboard-blocked", + "dashboard-degraded", + "dashboard-toast-info", + "command-mode-draft", + "command-mode-error", + "overlay-command-palette", + "overlay-session-switcher", + "quit-confirm-chat-compose", + "quit-confirm-command-entry", + "quit-confirm-provider-secret", + "dashboard-focus-nav", + "leader-help-root", + "chat-active-session", + "chat-compose-open", + "runs-active-detail", + "runs-focus-inspector", + "runs-raw-detail", + "approvals-pending-detail", + "approvals-structured-detail", + "action-center-triage", + "artifacts-evidence-detail", + "artifacts-structured-detail", + "audit-degraded-detail", + "audit-raw-detail", + "status-ready-overview", + "status-degraded-overview", + "model-providers-credential-needed", + "model-providers-secret-entry", + "git-setup-identity-needed", + "git-setup-provider-linked", + "git-remote-approval-ready", + "git-remote-executed", + "git-remote-fail-closed", +} + +type snapshotManifest struct { + Version int `json:"version"` + Bundle string `json:"bundle,omitempty"` + Viewport string `json:"viewport,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Coverage snapshotCoverage `json:"coverage,omitempty"` + Scenarios []snapshotManifestEntry `json:"scenarios"` +} + +type snapshotCoverage struct { + Bundle string `json:"bundle,omitempty"` + Routes []string `json:"routes,omitempty"` + Viewports []string `json:"viewports,omitempty"` + Scenarios []string `json:"scenarios,omitempty"` +} + +type snapshotManifestEntry struct { + Name string `json:"name"` + Viewport string `json:"viewport,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Route string `json:"route"` + Artifacts snapshotManifestArtifacts `json:"artifacts"` +} + +type snapshotManifestArtifacts struct { + ANSI snapshotManifestArtifact `json:"ansi"` + Text snapshotManifestArtifact `json:"text"` + SVG snapshotManifestArtifact `json:"svg"` + PNG snapshotManifestArtifact `json:"png"` +} + +type snapshotManifestArtifact struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` +} + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "tuisnapshotci failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout io.Writer, stderr io.Writer) error { + if len(args) > 0 { + switch args[0] { + case "local-output-dir": + return runLocalOutputDir(args[1:], stdout, stderr) + case "cleanup-local-dir": + return runCleanupLocalDir(args[1:], stderr) + } + } + + outputDir, err := parseOutputDirArg(args, stderr) + if err != nil { + return err + } + return runSnapshotCI(outputDir, stdout) +} + +func runSnapshotCI(outputDir string, stdout io.Writer) error { + workspaceDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve workspace: %w", err) + } + + workDir, cleanup, err := prepareWorkDir(outputDir) + if err != nil { + return err + } + defer cleanup() + + binaryPath := filepath.Join(workDir, snapshotBinaryName()) + artifactsDir, err := prepareArtifactsDir(workDir, outputDir) + if err != nil { + return err + } + + if err := buildSnapshotBinary(workspaceDir, binaryPath); err != nil { + return err + } + if err := generateSnapshotArtifacts(workspaceDir, binaryPath, artifactsDir); err != nil { + return err + } + if err := validateSnapshotArtifacts(artifactsDir); err != nil { + return err + } + + _, _ = fmt.Fprintf(stdout, "validated TUI snapshot artifacts in %s\n", artifactsDir) + return nil +} + +func runLocalOutputDir(args []string, stdout io.Writer, stderr io.Writer) error { + outputDir, err := parseOutputDirArg(args, stderr) + if err != nil { + return err + } + resolvedOutputDir, err := resolveLocalSnapshotDir(outputDir) + if err != nil { + return err + } + _, _ = fmt.Fprintln(stdout, resolvedOutputDir) + return nil +} + +func runCleanupLocalDir(args []string, stderr io.Writer) error { + outputDir, err := parseOutputDirArg(args, stderr) + if err != nil { + return err + } + if err := cleanupLocalSnapshotDir(outputDir); err != nil { + return err + } + return nil +} + +func parseOutputDirArg(args []string, stderr io.Writer) (string, error) { + fs := flag.NewFlagSet("tuisnapshotci", flag.ContinueOnError) + fs.SetOutput(stderr) + outputDir := fs.String("output-dir", "", "directory for generated snapshot artifacts") + if err := fs.Parse(args); err != nil { + return "", err + } + if len(fs.Args()) > 0 { + return "", fmt.Errorf("tuisnapshotci accepts no positional arguments") + } + return *outputDir, nil +} + +func prepareWorkDir(outputDir string) (string, func(), error) { + if strings.TrimSpace(outputDir) != "" { + resolved, err := normalizeTrustedOutputDir(outputDir) + if err != nil { + return "", nil, err + } + return resolved, func() {}, nil + } + tmpDir, err := os.MkdirTemp("", "runecode-tui-snapshot-ci-*") + if err != nil { + return "", nil, fmt.Errorf("create temp directory: %w", err) + } + return tmpDir, func() { _ = os.RemoveAll(tmpDir) }, nil +} + +func snapshotBinaryName() string { + if runtime.GOOS == "windows" { + return "runecode-tui.exe" + } + return "runecode-tui" +} + +func buildSnapshotBinary(workspaceDir string, binaryPath string) error { + if err := runCommand(workspaceDir, "go", "build", "-tags", snapshotBuildTag, "-o", binaryPath, "./cmd/runecode-tui"); err != nil { + return fmt.Errorf("build snapshot-enabled TUI binary: %w", err) + } + return nil +} + +func generateSnapshotArtifacts(workspaceDir string, binaryPath string, outputDir string) error { + if err := runCommand(workspaceDir, binaryPath, "--snapshot-bundle", snapshotBundle, "--snapshot-viewport", snapshotViewport, "--snapshot-output-dir", outputDir); err != nil { + return fmt.Errorf("generate snapshot artifacts: %w", err) + } + return nil +} + +func runCommand(workspaceDir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = workspaceDir + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return err + } + return fmt.Errorf("%w: %s", err, trimmed) + } + return nil +} diff --git a/tools/tuisnapshotci/main_test.go b/tools/tuisnapshotci/main_test.go new file mode 100644 index 00000000..d38d4aec --- /dev/null +++ b/tools/tuisnapshotci/main_test.go @@ -0,0 +1,465 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunLocalOutputDirDefaultsToRepoSnapshotDir(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + if err := run([]string{"local-output-dir"}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + want, err := canonicalPath(defaultLocalSnapshotDir()) + if err != nil { + t.Fatalf("canonicalPath(defaultLocalSnapshotDir) returned error: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("local-output-dir = %q, want %q", got, want) + } +} + +func TestRunCleanupLocalDirRemovesTrustedSnapshotDir(t *testing.T) { + outputDir := filepath.Join(os.TempDir(), "runecode-custom-cleanup", t.Name()) + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + if err := os.WriteFile(filepath.Join(outputDir, "artifact.txt"), []byte("ok\n"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + var stdout strings.Builder + var stderr strings.Builder + if err := run([]string{"cleanup-local-dir", "--output-dir", outputDir}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + if _, err := os.Stat(outputDir); !os.IsNotExist(err) { + t.Fatalf("outputDir still exists after cleanup, stat err = %v", err) + } +} + +func TestRunCleanupLocalDirRejectsTrustedTempRoot(t *testing.T) { + outputDir := os.TempDir() + var stdout strings.Builder + var stderr strings.Builder + err := run([]string{"cleanup-local-dir", "--output-dir", outputDir}, &stdout, &stderr) + if err == nil { + t.Fatal("run returned nil error, want cleanup trust failure") + } + if !strings.Contains(err.Error(), "must not be the trusted temporary root") { + t.Fatalf("run error = %v, want cleanup trust failure", err) + } +} + +func TestValidateSnapshotArtifactsAcceptsExpectedManifestWithoutPNGFiles(t *testing.T) { + outputDir := t.TempDir() + manifest := snapshotManifest{ + Version: manifestVersion, + Bundle: snapshotBundle, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Coverage: testCoverage(), + } + for _, scenario := range expectedScenarioNames { + base := scenario + "." + snapshotViewport + manifest.Scenarios = append(manifest.Scenarios, snapshotManifestEntry{ + Name: scenario, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: "dashboard", + Artifacts: snapshotManifestArtifacts{ + ANSI: writeTestArtifact(t, outputDir, base+".ansi", scenario+" ansi\n"), + Text: writeTestArtifact(t, outputDir, base+".txt", scenario+" text\n"), + SVG: writeTestArtifact(t, outputDir, base+".svg", ""+scenario+"\n"), + PNG: snapshotManifestArtifact{Path: base + ".png"}, + }, + }) + } + writeManifest(t, outputDir, manifest) + + if err := validateSnapshotArtifacts(outputDir); err != nil { + t.Fatalf("validateSnapshotArtifacts returned error: %v", err) + } +} + +func TestRunRejectsPositionalArguments(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + err := run([]string{"unexpected"}, &stdout, &stderr) + if err == nil { + t.Fatal("run returned nil error, want positional argument failure") + } + if !strings.Contains(err.Error(), "accepts no positional arguments") { + t.Fatalf("run error = %v, want positional argument failure", err) + } +} + +func TestValidateSnapshotArtifactsAcceptsSymlinkedOutputDir(t *testing.T) { + realOutputDir := t.TempDir() + linkRoot := t.TempDir() + symlinkPath := filepath.Join(linkRoot, "snapshots-link") + if err := os.Symlink(realOutputDir, symlinkPath); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + manifest := snapshotManifest{ + Version: manifestVersion, + Bundle: snapshotBundle, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Coverage: testCoverage(), + } + for _, scenario := range expectedScenarioNames { + base := scenario + "." + snapshotViewport + manifest.Scenarios = append(manifest.Scenarios, snapshotManifestEntry{ + Name: scenario, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: "dashboard", + Artifacts: snapshotManifestArtifacts{ + ANSI: writeTestArtifact(t, realOutputDir, base+".ansi", scenario+" ansi\n"), + Text: writeTestArtifact(t, realOutputDir, base+".txt", scenario+" text\n"), + SVG: writeTestArtifact(t, realOutputDir, base+".svg", ""+scenario+"\n"), + PNG: snapshotManifestArtifact{Path: base + ".png"}, + }, + }) + } + writeManifest(t, realOutputDir, manifest) + + if err := validateSnapshotArtifacts(symlinkPath); err != nil { + t.Fatalf("validateSnapshotArtifacts returned error for symlink path: %v", err) + } +} + +func TestValidateSnapshotArtifactsRejectsMissingScenarioEntries(t *testing.T) { + outputDir := t.TempDir() + base := expectedScenarioNames[0] + "." + snapshotViewport + manifest := snapshotManifest{ + Version: manifestVersion, + Bundle: snapshotBundle, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Coverage: testCoverage(), + Scenarios: []snapshotManifestEntry{{ + Name: expectedScenarioNames[0], + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: "dashboard", + Artifacts: snapshotManifestArtifacts{ + ANSI: writeTestArtifact(t, outputDir, base+".ansi", "ansi\n"), + Text: writeTestArtifact(t, outputDir, base+".txt", "text\n"), + SVG: writeTestArtifact(t, outputDir, base+".svg", "\n"), + PNG: snapshotManifestArtifact{Path: base + ".png"}, + }, + }}, + } + writeManifest(t, outputDir, manifest) + + err := validateSnapshotArtifacts(outputDir) + if err == nil { + t.Fatal("validateSnapshotArtifacts returned nil error, want missing scenario failure") + } + if !strings.Contains(err.Error(), "missing expected scenarios") { + t.Fatalf("validateSnapshotArtifacts error = %v, want missing scenario failure", err) + } +} + +func TestValidateSnapshotArtifactsRejectsDuplicateScenarioEntries(t *testing.T) { + outputDir := t.TempDir() + manifest := testManifestForAllScenarios(t, outputDir) + manifest.Scenarios = append(manifest.Scenarios, duplicateScenarioEntry(t, outputDir, expectedScenarioNames[0])) + writeManifest(t, outputDir, manifest) + + err := validateSnapshotArtifacts(outputDir) + if err == nil { + t.Fatal("validateSnapshotArtifacts returned nil error, want duplicate scenario failure") + } + if !strings.Contains(err.Error(), "duplicate scenario entries") { + t.Fatalf("validateSnapshotArtifacts error = %v, want duplicate scenario failure", err) + } +} + +func TestValidateSnapshotArtifactsRejectsAbsoluteArtifactPaths(t *testing.T) { + outputDir := t.TempDir() + manifest := testManifestForAllScenarios(t, outputDir) + manifest.Scenarios[0].Artifacts.SVG.Path = filepath.Join(outputDir, expectedScenarioNames[0]+"."+snapshotViewport+".svg") + writeManifest(t, outputDir, manifest) + + err := validateSnapshotArtifacts(outputDir) + if err == nil { + t.Fatal("validateSnapshotArtifacts returned nil error, want absolute path failure") + } + if !strings.Contains(err.Error(), "want \""+expectedScenarioNames[0]+"."+snapshotViewport+".svg\"") { + t.Fatalf("validateSnapshotArtifacts error = %v, want relative path expectation", err) + } +} + +func TestValidateSnapshotArtifactsRejectsScenarioNamesWithSeparatorsOrTraversal(t *testing.T) { + tests := []struct { + name string + scenarioName string + want string + }{ + {name: "slash separator", scenarioName: "nested/name", want: "must not contain path separators"}, + {name: "backslash separator", scenarioName: `nested\\name`, want: "must not contain path separators"}, + {name: "dot traversal", scenarioName: "..", want: "must not be a path traversal component"}, + {name: "separator and traversal", scenarioName: "../escape", want: "must not contain path separators"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outputDir := t.TempDir() + manifest := testManifestForAllScenarios(t, outputDir) + manifest.Scenarios[0].Name = tt.scenarioName + writeManifest(t, outputDir, manifest) + + err := validateSnapshotArtifacts(outputDir) + if err == nil { + t.Fatal("validateSnapshotArtifacts returned nil error, want invalid scenario name failure") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("validateSnapshotArtifacts error = %v, want %q", err, tt.want) + } + }) + } +} + +func TestValidateScenarioEntryRejectsInvalidScenarioNameBeforeArtifactValidation(t *testing.T) { + err := validateScenarioEntry(t.TempDir(), snapshotManifestEntry{ + Name: "../escape", + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: "dashboard", + }) + if err == nil { + t.Fatal("validateScenarioEntry returned nil error, want invalid scenario name failure") + } + if !strings.Contains(err.Error(), "scenario name") || !strings.Contains(err.Error(), "must not contain path separators") { + t.Fatalf("validateScenarioEntry error = %v, want explicit scenario name validation failure", err) + } +} + +func TestValidateSnapshotArtifactsRejectsPNGDirectory(t *testing.T) { + outputDir := t.TempDir() + manifest := testManifestForAllScenarios(t, outputDir) + pngPath := filepath.Join(outputDir, manifest.Scenarios[0].Artifacts.PNG.Path) + if err := os.Mkdir(pngPath, 0o755); err != nil { + t.Fatalf("Mkdir(%s) returned error: %v", pngPath, err) + } + writeManifest(t, outputDir, manifest) + + err := validateSnapshotArtifacts(outputDir) + if err == nil { + t.Fatal("validateSnapshotArtifacts returned nil error, want PNG regular file failure") + } + if !strings.Contains(err.Error(), "optional PNG must be a regular file when present") { + t.Fatalf("validateSnapshotArtifacts error = %v, want PNG regular file failure", err) + } +} + +func TestPrepareWorkDirCanonicalizesSymlinkedOutputDir(t *testing.T) { + realRoot := t.TempDir() + linkRoot := t.TempDir() + symlinkPath := filepath.Join(linkRoot, "workspace-link") + if err := os.Symlink(realRoot, symlinkPath); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + requestedOutputDir := filepath.Join(symlinkPath, "nested", "artifacts") + got, cleanup, err := prepareWorkDir(requestedOutputDir) + if err != nil { + t.Fatalf("prepareWorkDir returned error: %v", err) + } + defer cleanup() + + want, err := canonicalPath(filepath.Join(realRoot, "nested", "artifacts")) + if err != nil { + t.Fatalf("canonicalPath(want) returned error: %v", err) + } + if got != want { + t.Fatalf("prepareWorkDir returned %q, want %q", got, want) + } +} + +func TestPrepareWorkDirRejectsOutputDirOutsideTrustedRoot(t *testing.T) { + workspaceDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd returned error: %v", err) + } + outsideDir := filepath.Join(workspaceDir, ".tmp-tuisnapshotci-outside-workdir") + defer os.RemoveAll(outsideDir) + outsideDir, err = filepath.Abs(outsideDir) + if err != nil { + t.Fatalf("Abs returned error: %v", err) + } + got, cleanup, err := prepareWorkDir(outsideDir) + if cleanup != nil { + defer cleanup() + } + if err == nil { + t.Fatalf("prepareWorkDir returned path %q, want trusted root failure", got) + } + if !strings.Contains(err.Error(), "output directory must stay under") { + t.Fatalf("prepareWorkDir error = %v, want trusted root failure", err) + } +} + +func TestPrepareArtifactsDirRejectsOutputDirOutsideTrustedRoot(t *testing.T) { + workspaceDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd returned error: %v", err) + } + outsideDir := filepath.Join(workspaceDir, ".tmp-tuisnapshotci-outside-artifacts") + defer os.RemoveAll(outsideDir) + outsideDir, err = filepath.Abs(outsideDir) + if err != nil { + t.Fatalf("Abs returned error: %v", err) + } + got, err := prepareArtifactsDir(outsideDir, outsideDir) + if err == nil { + t.Fatalf("prepareArtifactsDir returned path %q, want trusted root failure", got) + } + if !strings.Contains(err.Error(), "artifact directory must stay under") { + t.Fatalf("prepareArtifactsDir error = %v, want trusted root failure", err) + } +} + +func TestRequirePathWithinTrustedRootAcceptsCanonicalTmp(t *testing.T) { + if _, err := os.Stat("/tmp"); err != nil { + t.Skip("/tmp unavailable on this platform") + } + candidate, err := canonicalPath(filepath.Join("/tmp", ".tui-snapshots")) + if err != nil { + t.Fatalf("canonicalPath(/tmp candidate) returned error: %v", err) + } + if err := requirePathWithinTrustedRoot("artifact directory", candidate); err != nil { + t.Fatalf("requirePathWithinTrustedRoot returned error for /tmp path: %v", err) + } +} + +func TestRequirePathWithinTrustedRootAcceptsRepoSnapshotDir(t *testing.T) { + candidate, err := canonicalPath(defaultLocalSnapshotDir()) + if err != nil { + t.Fatalf("canonicalPath(defaultLocalSnapshotDir) returned error: %v", err) + } + if err := requirePathWithinTrustedRoot("artifact directory", candidate); err != nil { + t.Fatalf("requirePathWithinTrustedRoot returned error for repo snapshot dir: %v", err) + } +} + +func testManifestForAllScenarios(t *testing.T, outputDir string) snapshotManifest { + t.Helper() + manifest := snapshotManifest{ + Version: manifestVersion, + Bundle: snapshotBundle, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Coverage: testCoverage(), + } + for _, scenario := range expectedScenarioNames { + manifest.Scenarios = append(manifest.Scenarios, scenarioEntryForDir(t, outputDir, scenario)) + } + return manifest +} + +func scenarioEntryForDir(t *testing.T, outputDir string, scenario string) snapshotManifestEntry { + t.Helper() + base := scenario + "." + snapshotViewport + return snapshotManifestEntry{ + Name: scenario, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: routeForScenario(scenario), + Artifacts: snapshotManifestArtifacts{ + ANSI: writeTestArtifact(t, outputDir, base+".ansi", scenario+" ansi\n"), + Text: writeTestArtifact(t, outputDir, base+".txt", scenario+" text\n"), + SVG: writeTestArtifact(t, outputDir, base+".svg", ""+scenario+"\n"), + PNG: snapshotManifestArtifact{Path: base + ".png"}, + }, + } +} + +func testCoverage() snapshotCoverage { + return snapshotCoverage{Bundle: snapshotBundle, Routes: expectedCoverageRoutes(), Viewports: []string{snapshotViewport}, Scenarios: append([]string(nil), expectedScenarioNames...)} +} + +func routeForScenario(scenario string) string { + switch scenario { + case "dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded": + return "dashboard" + case "chat-active-session": + return "chat" + case "runs-active-detail": + return "runs" + case "approvals-pending-detail": + return "approvals" + case "action-center-triage": + return "action-center" + case "audit-degraded-detail": + return "audit" + case "status-ready-overview": + return "status" + case "model-providers-credential-needed": + return "model-providers" + case "git-setup-identity-needed": + return "git-setup" + case "git-remote-approval-ready": + return "git-remote-mutation" + default: + return "dashboard" + } +} + +func duplicateScenarioEntry(t *testing.T, outputDir string, scenario string) snapshotManifestEntry { + t.Helper() + base := scenario + "-duplicate." + snapshotViewport + return snapshotManifestEntry{ + Name: scenario, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + Route: "dashboard-duplicate", + Artifacts: snapshotManifestArtifacts{ + ANSI: writeTestArtifact(t, outputDir, base+".ansi", scenario+" duplicate ansi\n"), + Text: writeTestArtifact(t, outputDir, base+".txt", scenario+" duplicate text\n"), + SVG: writeTestArtifact(t, outputDir, base+".svg", ""+scenario+" duplicate\n"), + PNG: snapshotManifestArtifact{Path: base + ".png"}, + }, + } +} + +func writeManifest(t *testing.T, outputDir string, manifest snapshotManifest) { + t.Helper() + raw, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if err := os.WriteFile(filepath.Join(outputDir, "manifest.json"), raw, 0o644); err != nil { + t.Fatalf("WriteFile(manifest.json) returned error: %v", err) + } +} + +func writeTestArtifact(t *testing.T, outputDir string, relativePath string, content string) snapshotManifestArtifact { + t.Helper() + path := filepath.Join(outputDir, relativePath) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s) returned error: %v", path, err) + } + sum := sha256.Sum256([]byte(content)) + return snapshotManifestArtifact{Path: relativePath, SHA256: hex.EncodeToString(sum[:])} +} diff --git a/tools/tuisnapshotci/path.go b/tools/tuisnapshotci/path.go new file mode 100644 index 00000000..e88e07e5 --- /dev/null +++ b/tools/tuisnapshotci/path.go @@ -0,0 +1,220 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const localSnapshotDirName = ".tui-snapshots" + +func canonicalPath(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolvedPath, err := evalSymlinksAllowMissing(filepath.Clean(absPath)) + if err != nil { + return "", err + } + return filepath.Clean(resolvedPath), nil +} + +func evalSymlinksAllowMissing(path string) (string, error) { + resolvedPath, err := filepath.EvalSymlinks(path) + if err == nil { + return resolvedPath, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + missingParts := []string{} + existingPath := path + for { + if _, statErr := os.Stat(existingPath); statErr == nil { + break + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", statErr + } + + parent := filepath.Dir(existingPath) + if parent == existingPath { + return "", err + } + missingParts = append(missingParts, filepath.Base(existingPath)) + existingPath = parent + } + + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + for i := len(missingParts) - 1; i >= 0; i-- { + resolvedExistingPath = filepath.Join(resolvedExistingPath, missingParts[i]) + } + return resolvedExistingPath, nil +} + +func prepareArtifactsDir(workDir string, outputDir string) (string, error) { + artifactsDir := workDir + if outputDir == "" { + artifactsDir = filepath.Join(workDir, "artifacts") + } + resolvedArtifactsDir, err := canonicalPath(artifactsDir) + if err != nil { + return "", fmt.Errorf("resolve artifact directory: %w", err) + } + if err := requirePathWithinTrustedRoot("artifact directory", resolvedArtifactsDir); err != nil { + return "", err + } + if err := os.MkdirAll(resolvedArtifactsDir, 0o755); err != nil { + return "", fmt.Errorf("create artifact directory: %w", err) + } + return resolvedArtifactsDir, nil +} + +func resolveLocalSnapshotDir(outputDir string) (string, error) { + requestedDir := strings.TrimSpace(outputDir) + if requestedDir == "" { + requestedDir = defaultLocalSnapshotDir() + } + resolvedOutputDir, err := canonicalPath(requestedDir) + if err != nil { + return "", fmt.Errorf("resolve local snapshot directory: %w", err) + } + if err := requirePathWithinTrustedRoot("local snapshot directory", resolvedOutputDir); err != nil { + return "", err + } + return resolvedOutputDir, nil +} + +func cleanupLocalSnapshotDir(outputDir string) error { + resolvedOutputDir, err := resolveLocalSnapshotDir(outputDir) + if err != nil { + return err + } + if err := requirePathWithinTrustedRoot("local snapshot cleanup directory", resolvedOutputDir); err != nil { + return err + } + trustedRoots, err := trustedTempRoots() + if err != nil { + return err + } + for _, trustedRoot := range trustedRoots { + if resolvedOutputDir == trustedRoot { + return fmt.Errorf("local snapshot cleanup directory must not be the trusted temporary root %q", trustedRoot) + } + } + if err := os.RemoveAll(resolvedOutputDir); err != nil { + return fmt.Errorf("remove local snapshot directory %q: %w", resolvedOutputDir, err) + } + return nil +} + +func normalizeTrustedOutputDir(outputDir string) (string, error) { + resolvedOutputDir, err := canonicalPath(outputDir) + if err != nil { + return "", fmt.Errorf("resolve output directory: %w", err) + } + if err := requirePathWithinTrustedRoot("output directory", resolvedOutputDir); err != nil { + return "", err + } + return resolvedOutputDir, nil +} + +func requirePathWithinTrustedRoot(label string, candidate string) error { + trustedRoots, err := trustedSnapshotRoots() + if err != nil { + return err + } + for _, trustedRoot := range trustedRoots { + if pathWithinBase(trustedRoot, candidate) { + return nil + } + } + return fmt.Errorf("%s must stay under %s", label, trustedRoots[0]) +} + +func trustedSnapshotRoots() ([]string, error) { + roots, err := trustedTempRoots() + if err != nil { + return nil, err + } + defaultRoot, err := canonicalPath(defaultLocalSnapshotDir()) + if err != nil { + return nil, fmt.Errorf("resolve default local snapshot directory: %w", err) + } + if !slices.Contains(roots, defaultRoot) { + roots = append(roots, defaultRoot) + } + return roots, nil +} + +func defaultLocalSnapshotDir() string { + workspaceRoot, err := workspaceRoot() + if err == nil { + return filepath.Join(workspaceRoot, localSnapshotDirName) + } + if cwd, err := os.Getwd(); err == nil { + return filepath.Join(cwd, localSnapshotDirName) + } + return filepath.Join(os.TempDir(), localSnapshotDirName) +} + +func workspaceRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + resolvedCWD, err := canonicalPath(cwd) + if err != nil { + return "", err + } + dir := resolvedCWD + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(dir) + if parent == dir { + return resolvedCWD, nil + } + dir = parent + } +} + +func trustedTempRoots() ([]string, error) { + roots := []string{} + primaryRoot, err := canonicalPath(os.TempDir()) + if err != nil { + return nil, fmt.Errorf("resolve temporary directory: %w", err) + } + roots = append(roots, primaryRoot) + if _, err := os.Stat("/tmp"); err == nil { + tmpRoot, err := canonicalPath("/tmp") + if err != nil { + return nil, fmt.Errorf("resolve /tmp: %w", err) + } + if !slices.Contains(roots, tmpRoot) { + roots = append(roots, tmpRoot) + } + } + return roots, nil +} + +func pathWithinBase(base string, candidate string) bool { + rel, err := filepath.Rel(base, candidate) + if err != nil { + return false + } + if rel == "." { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/tools/tuisnapshotci/validate.go b/tools/tuisnapshotci/validate.go new file mode 100644 index 00000000..d2dc0ba3 --- /dev/null +++ b/tools/tuisnapshotci/validate.go @@ -0,0 +1,228 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +func validateSnapshotArtifacts(outputDir string) error { + resolvedOutputDir, err := canonicalPath(outputDir) + if err != nil { + return fmt.Errorf("resolve output directory: %w", err) + } + if err := requirePathWithinTrustedRoot("output directory", resolvedOutputDir); err != nil { + return err + } + outputDir = resolvedOutputDir + + manifest, err := readSnapshotManifest(outputDir) + if err != nil { + return err + } + entriesByName, err := validateManifestStructure(manifest) + if err != nil { + return err + } + return validateScenarioArtifacts(outputDir, entriesByName) +} + +func readSnapshotManifest(outputDir string) (snapshotManifest, error) { + manifestPath := filepath.Join(outputDir, "manifest.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + return snapshotManifest{}, fmt.Errorf("read manifest %q: %w", manifestPath, err) + } + var manifest snapshotManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return snapshotManifest{}, fmt.Errorf("decode manifest %q: %w", manifestPath, err) + } + return manifest, nil +} + +func validateManifestStructure(manifest snapshotManifest) (map[string]snapshotManifestEntry, error) { + if manifest.Version != manifestVersion { + return nil, fmt.Errorf("manifest version = %d, want %d", manifest.Version, manifestVersion) + } + if manifest.Bundle != snapshotBundle { + return nil, fmt.Errorf("manifest bundle = %q, want %q", manifest.Bundle, snapshotBundle) + } + if manifest.Viewport != snapshotViewport { + return nil, fmt.Errorf("manifest viewport = %q, want %q", manifest.Viewport, snapshotViewport) + } + if manifest.Width <= 0 || manifest.Height <= 0 { + return nil, fmt.Errorf("manifest dimensions must be positive, got %dx%d", manifest.Width, manifest.Height) + } + if err := validateCoverage(manifest.Coverage); err != nil { + return nil, err + } + entriesByName, err := manifestEntriesByName(manifest.Scenarios) + if err != nil { + return nil, err + } + if err := validateScenarioSet(entriesByName); err != nil { + return nil, err + } + return entriesByName, nil +} + +func validateCoverage(coverage snapshotCoverage) error { + if coverage.Bundle != snapshotBundle { + return fmt.Errorf("coverage bundle = %q, want %q", coverage.Bundle, snapshotBundle) + } + if len(coverage.Viewports) != 1 || coverage.Viewports[0] != snapshotViewport { + return fmt.Errorf("coverage viewports = %v, want [%s]", coverage.Viewports, snapshotViewport) + } + routes := append([]string(nil), coverage.Routes...) + slices.Sort(routes) + if !slices.Equal(routes, expectedCoverageRoutes()) { + return fmt.Errorf("coverage routes = %v, want %v", routes, expectedCoverageRoutes()) + } + scenarios := append([]string(nil), coverage.Scenarios...) + slices.Sort(scenarios) + wantScenarios := append([]string(nil), expectedScenarioNames...) + slices.Sort(wantScenarios) + if !slices.Equal(scenarios, wantScenarios) { + return fmt.Errorf("coverage scenarios = %v, want %v", scenarios, wantScenarios) + } + return nil +} + +func expectedCoverageRoutes() []string { + routes := []string{"action-center", "approvals", "artifacts", "audit", "chat", "dashboard", "git-remote-mutation", "git-setup", "model-providers", "runs", "status"} + slices.Sort(routes) + return routes +} + +func manifestEntriesByName(entries []snapshotManifestEntry) (map[string]snapshotManifestEntry, error) { + entriesByName := make(map[string]snapshotManifestEntry, len(entries)) + for _, entry := range entries { + if err := validateScenarioName(entry.Name); err != nil { + return nil, err + } + if _, exists := entriesByName[entry.Name]; exists { + return nil, fmt.Errorf("manifest contains duplicate scenario entries: %s", entry.Name) + } + entriesByName[entry.Name] = entry + } + return entriesByName, nil +} + +func validateScenarioName(name string) error { + if strings.ContainsAny(name, `/\\`) { + return fmt.Errorf("manifest scenario name %q must not contain path separators", name) + } + if name == "." || name == ".." { + return fmt.Errorf("manifest scenario name %q must not be a path traversal component", name) + } + return nil +} + +func validateScenarioSet(entriesByName map[string]snapshotManifestEntry) error { + var missing []string + for _, expected := range expectedScenarioNames { + if _, ok := entriesByName[expected]; !ok { + missing = append(missing, expected) + } + } + if len(missing) > 0 { + return fmt.Errorf("manifest missing expected scenarios: %s", strings.Join(missing, ", ")) + } + + var unexpected []string + for name := range entriesByName { + if !slices.Contains(expectedScenarioNames, name) { + unexpected = append(unexpected, name) + } + } + if len(unexpected) > 0 { + slices.Sort(unexpected) + return fmt.Errorf("manifest contains unexpected scenarios: %s", strings.Join(unexpected, ", ")) + } + return nil +} + +func validateScenarioArtifacts(outputDir string, entriesByName map[string]snapshotManifestEntry) error { + for _, scenario := range expectedScenarioNames { + entry := entriesByName[scenario] + if err := validateScenarioEntry(outputDir, entry); err != nil { + return fmt.Errorf("scenario %q: %w", scenario, err) + } + } + return nil +} + +func validateScenarioEntry(outputDir string, entry snapshotManifestEntry) error { + if err := validateScenarioName(entry.Name); err != nil { + return fmt.Errorf("scenario name: %w", err) + } + if entry.Viewport != snapshotViewport { + return fmt.Errorf("viewport = %q, want %q", entry.Viewport, snapshotViewport) + } + if entry.Width <= 0 || entry.Height <= 0 { + return fmt.Errorf("dimensions must be positive, got %dx%d", entry.Width, entry.Height) + } + if strings.TrimSpace(entry.Route) == "" { + return errors.New("route must be present") + } + + base := entry.Name + "." + snapshotViewport + if err := validateHashedArtifact(outputDir, entry.Artifacts.ANSI, base+".ansi"); err != nil { + return fmt.Errorf("ansi artifact: %w", err) + } + if err := validateHashedArtifact(outputDir, entry.Artifacts.Text, base+".txt"); err != nil { + return fmt.Errorf("text artifact: %w", err) + } + if err := validateHashedArtifact(outputDir, entry.Artifacts.SVG, base+".svg"); err != nil { + return fmt.Errorf("svg artifact: %w", err) + } + if err := validateOptionalPNGArtifact(outputDir, entry.Artifacts.PNG, base+".png"); err != nil { + return fmt.Errorf("png artifact: %w", err) + } + return nil +} + +func validateHashedArtifact(outputDir string, artifact snapshotManifestArtifact, expectedPath string) error { + if artifact.Path != expectedPath { + return fmt.Errorf("path = %q, want %q", artifact.Path, expectedPath) + } + if strings.TrimSpace(artifact.SHA256) == "" { + return errors.New("sha256 must be present") + } + content, err := os.ReadFile(filepath.Join(outputDir, expectedPath)) + if err != nil { + return err + } + sum := sha256.Sum256(content) + if artifact.SHA256 != hex.EncodeToString(sum[:]) { + return fmt.Errorf("sha256 = %q, want %q", artifact.SHA256, hex.EncodeToString(sum[:])) + } + return nil +} + +func validateOptionalPNGArtifact(outputDir string, artifact snapshotManifestArtifact, expectedPath string) error { + if artifact.Path != expectedPath { + return fmt.Errorf("path = %q, want %q", artifact.Path, expectedPath) + } + if artifact.SHA256 != "" { + return fmt.Errorf("sha256 = %q, want empty for optional PNG", artifact.SHA256) + } + info, err := os.Lstat(filepath.Join(outputDir, expectedPath)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("optional PNG must be a regular file when present") + } + return nil +}