From 94122a577b61f2047f515de72329dc5a2bf0ccd7 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Fri, 8 May 2026 17:13:01 -0500 Subject: [PATCH 01/39] This enhances the TUI enhancement details and ideas. Signed-off-by: Zebulon Rouse-Frantzich --- .../design.md | 16 +++++++++++++ .../tasks.md | 23 ++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) 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..c94b0d56 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,8 @@ 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. + This lane should capture polish work discovered while testing the real workflow path, especially in: - run and session state clarity @@ -163,6 +165,19 @@ 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 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 +185,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. 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..46888f1b 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,21 @@ ## 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. +- [ ] 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, and setup/remediation routes, and 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. +- [ ] Reframe the global shell chrome into a calm product workbench rather than a constant debug/status dump: keep route, focus, product posture, sync truth, and active work visible, but reduce repeated long trust-boundary text, raw layout/focus diagnostics, history noise, clipboard noise, and exhaustive shortcuts in the primary frame; preserve detailed truth through command discovery, inspectors, status/detail surfaces, or copy actions instead of deleting it. +- [ ] 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 the reason, impact, owner/action required, target route/object, and evidence link source without inferring state outside broker-owned surfaces. +- [ ] 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, protocol bundle details, watch-family terminology, and low-level audit facts into secondary/detail views. +- [ ] Create a unified state-card UX pattern for loading, empty, ready, waiting, blocked, degraded, failed, resumed, completed, and approval-required states: each card should include a short state label, a plain-English reason, the authoritative source or object identity when useful, the next recommended action, and the shortcut/route to take that action; the pattern must avoid fake progress and must distinguish broker-known state from unknown or unavailable state. +- [ ] 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 run directory and inspector clearly show status, workflow operation, plan authority, runner/reporting posture, failure or blocked reason, linked approvals, artifacts, and audit records; provide obvious navigation/copy cues from a selected run to its session, artifacts, approvals, and audit evidence. +- [ ] Improve Approvals route confidence and safety: distinguish pending, resolved, expired, unsupported, 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. +- [ ] Improve Artifacts, Audit, and verification discoverability as one evidence trail: make it obvious how to move from a 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 the primary visual content when a shorter stable identity or label is available. +- [ ] 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`, and `setup required` in primary panes; reserve protocol names, schema names, raw contract method names, and implementation terms for detail modes where they help debugging or verification. +- [ ] 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, 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. +- [ ] 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, 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: visual refinements, optional theme changes, secondary keyboard customization, advanced filtering, additional inspector modes, or future collaboration/publishing affordances should be tracked as follow-up unless they are required for a new Linux user to coherently test the supported local workflow path. ## Phase 6: Verification Smoke Path From a2cf8a1e4610d5c1a0197115b3f219f8efeeea97 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Fri, 8 May 2026 18:53:32 -0500 Subject: [PATCH 02/39] Implements some TUI polish from initial thoughts of how to improve things. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/command_surface.go | 4 +- .../local_rpc_integration_linux_test.go | 36 +- cmd/runecode-tui/route_action_center.go | 110 ++++-- .../route_action_center_helpers.go | 200 ++++++---- .../route_action_center_render.go | 94 +++++ cmd/runecode-tui/route_action_center_test.go | 22 +- .../route_approval_fixture_test.go | 37 ++ cmd/runecode-tui/route_approvals.go | 16 +- .../route_approvals_followup_helpers.go | 125 ++++++ cmd/runecode-tui/route_approvals_helpers.go | 374 +++++++++--------- .../route_approvals_resolve_helpers.go | 164 ++++++++ .../route_approvals_validation_test.go | 47 ++- cmd/runecode-tui/route_artifacts.go | 24 +- cmd/runecode-tui/route_artifacts_detail.go | 52 ++- cmd/runecode-tui/route_audit.go | 10 +- cmd/runecode-tui/route_audit_anchor_action.go | 12 +- cmd/runecode-tui/route_audit_render.go | 71 +++- cmd/runecode-tui/route_audit_status_test.go | 11 +- cmd/runecode-tui/route_chat.go | 36 +- .../route_chat_execution_helpers.go | 354 ++++++++++++++++- .../route_chat_execution_helpers_test.go | 41 ++ cmd/runecode-tui/route_chat_inspector.go | 7 +- cmd/runecode-tui/route_chat_rendering.go | 34 +- cmd/runecode-tui/route_chat_state.go | 37 +- cmd/runecode-tui/route_chat_test.go | 19 +- cmd/runecode-tui/route_contract_usage_test.go | 6 +- cmd/runecode-tui/route_dashboard.go | 50 ++- cmd/runecode-tui/route_dashboard_helpers.go | 323 ++++++++++----- cmd/runecode-tui/route_dashboard_test.go | 26 +- .../route_dashboard_watch_helpers.go | 111 ++++++ cmd/runecode-tui/route_runs.go | 52 ++- .../route_runs_approvals_artifacts_test.go | 82 ++-- cmd/runecode-tui/route_runs_detail.go | 183 ++++++++- cmd/runecode-tui/route_status.go | 188 +++------ .../route_status_project_substrate.go | 32 +- cmd/runecode-tui/route_status_render.go | 219 ++++++++++ cmd/runecode-tui/route_status_test.go | 54 +-- cmd/runecode-tui/route_tests_helpers_test.go | 74 ++-- cmd/runecode-tui/routes.go | 4 +- cmd/runecode-tui/shared_components.go | 27 -- cmd/runecode-tui/shared_components_test.go | 32 ++ cmd/runecode-tui/shared_state_card.go | 83 ++++ cmd/runecode-tui/shell_contracts.go | 13 +- cmd/runecode-tui/shell_footer_view.go | 58 ++- cmd/runecode-tui/shell_layout.go | 8 +- cmd/runecode-tui/shell_modes_quit_test.go | 2 +- cmd/runecode-tui/shell_render_helpers.go | 2 +- cmd/runecode-tui/shell_test.go | 15 +- cmd/runecode-tui/shell_view.go | 68 +++- 49 files changed, 2738 insertions(+), 911 deletions(-) create mode 100644 cmd/runecode-tui/route_action_center_render.go create mode 100644 cmd/runecode-tui/route_approval_fixture_test.go create mode 100644 cmd/runecode-tui/route_approvals_followup_helpers.go create mode 100644 cmd/runecode-tui/route_approvals_resolve_helpers.go create mode 100644 cmd/runecode-tui/route_dashboard_watch_helpers.go create mode 100644 cmd/runecode-tui/route_status_render.go create mode 100644 cmd/runecode-tui/shared_state_card.go diff --git a/cmd/runecode-tui/command_surface.go b/cmd/runecode-tui/command_surface.go index 0de72663..7171aba9 100644 --- a/cmd/runecode-tui/command_surface.go +++ b/cmd/runecode-tui/command_surface.go @@ -90,7 +90,7 @@ func (m shellModel) appendActionCenterPaletteEntries(add func(string, string, st } for family, items := range actionModel.familyBuckets() { 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 +100,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/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 475e63bf..2176253e 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -216,7 +216,16 @@ 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", @@ -229,30 +238,35 @@ func assertTUIBrokerBackedRoutes(t *testing.T) { chat := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, recording) assertRouteOutputContainsAll(t, chat, routeChat, - "Sessions: 1 active=session-tui", - "Composer: idle", + "Active session", + "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", ) assertRouteInspectorContainsAll(t, runs, routeRuns, - "Authoritative broker state (control-plane truth):", - "Coordination summary:", + "Workflow identity (authoritative):", + "Evidence links:", ) approvals := newApprovalsRouteModel(routeDefinition{ID: routeApprovals, Label: "Approvals"}, recording) assertRouteOutputContainsAll(t, approvals, routeApprovals, - "Approval safety strip", + "Approval posture", ) assertRouteInspectorContainsAll(t, approvals, routeApprovals, "Approval trigger code:", - "Canonical bound identity:", + "Exact gated object/action:", ) artifacts := newArtifactsRouteModel(routeDefinition{ID: routeArtifacts, Label: "Artifacts"}, recording) @@ -266,12 +280,8 @@ func assertTUIBrokerBackedRoutes(t *testing.T) { "Runtime/audit readiness strip", "Broker ready=true local_only=true", "Protocol posture:", - "Project substrate posture:", + "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) { @@ -335,12 +345,12 @@ 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 posture", "Timeline paging: page=1 entries=1 has_next=no", "Verification posture:"} { 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):") { + if !strings.Contains(view, "Verification findings") { t.Fatalf("audit view missing verification findings section: %s", view) } updated, cmd = updated.Update(teaKey("enter")) diff --git a/cmd/runecode-tui/route_action_center.go b/cmd/runecode-tui/route_action_center.go index eafe13a5..e23cd711 100644 --- a/cmd/runecode-tui/route_action_center.go +++ b/cmd/runecode-tui/route_action_center.go @@ -12,7 +12,9 @@ import ( type actionCenterLoadedMsg struct { approvals []brokerapi.ApprovalSummary runs []brokerapi.RunSummary + project brokerapi.ProjectSubstratePostureGetResponse audit brokerapi.AuditVerificationGetResponse + auditErr string err error seq uint64 } @@ -27,13 +29,15 @@ 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 + RequiredAction string + TargetLabel string + EvidenceCue string + Target paletteTarget } type actionCenterRouteModel struct { @@ -44,7 +48,9 @@ type actionCenterRouteModel struct { statusText string approvals []brokerapi.ApprovalSummary runs []brokerapi.RunSummary + project brokerapi.ProjectSubstratePostureGetResponse audit *brokerapi.AuditVerificationGetResponse + auditErr string watch dashboardLiveActivity watchHealth shellSyncHealth inspectorOn bool @@ -80,25 +86,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 +103,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,24 +119,69 @@ 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() + summary := buildActionCenterSummary(families) return compactLines( sectionTitle("Action Center")+" "+focusBadge(focus), + renderStateCardSpec(stateCardSpec{ + State: summary.State, + Title: summary.Title, + Message: summary.Message, + Reason: summary.Reason, + NextAction: summary.NextAction, + RouteCue: "Approvals, Runs, Audit, Status", + }), 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]))), + "Action Center is the operator home for broker-known follow-up. Question/answer queues remain reserved for future canonical broker models and are intentionally not implemented locally.", + renderActionCenterDirectory("Approvals queue", families[actionCenterFamilyApprovals], m.selectedIndex(actionCenterFamilyApprovals, len(families[actionCenterFamilyApprovals])), width), + renderActionCenterDirectory("Operational attention", families[actionCenterFamilyOps], m.selectedIndex(actionCenterFamilyOps, len(families[actionCenterFamilyOps])), width), + renderActionCenterDirectory("Blocked-work impact", families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(families[actionCenterFamilyBlocked])), width), muted("If every bucket is empty, the control plane is currently waiting on new canonical work or operator intervention."), keyHint("Route keys: [/] change family, j/k move, enter drill-down, i toggle inspector, r reload"), ) @@ -226,10 +265,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_helpers.go b/cmd/runecode-tui/route_action_center_helpers.go index 1c13f355..b174d14d 100644 --- a/cmd/runecode-tui/route_action_center_helpers.go +++ b/cmd/runecode-tui/route_action_center_helpers.go @@ -9,10 +9,18 @@ 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 { buckets := map[actionCenterFamily][]actionCenterItem{ actionCenterFamilyApprovals: buildApprovalActionItems(m.approvals), - actionCenterFamilyOps: buildOperationalAttentionItems(m.audit, m.watch, m.watchHealth, m.runs), + 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 { @@ -35,19 +43,34 @@ 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 status=%s", valueOrNA(ap.Status)), fmt.Sprintf("trigger=%s", valueOrNA(ap.ApprovalTriggerCode))} + if staleCue != "fresh" { + reasons = append(reasons, staleCue) + } + if expiryCue != "no_expiry" { + reasons = append(reasons, expiryCue) + } + impact := fmt.Sprintf("Workflow work stays gated for run %s stage %s action %s.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) + 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, + RequiredAction: "Open Approvals, review the exact gated action and evidence, then decide.", + 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: "canonical approval queue is currently empty", Impact: "No workflow work is waiting on approval decisions from the current broker surfaces.", RequiredAction: "No approval follow-up is needed right now.", TargetLabel: "Approvals", EvidenceCue: "source=approval_list empty"}} } return out } @@ -97,14 +120,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.", RequiredAction: "No operational remediation is needed right now.", TargetLabel: "Action Center", EvidenceCue: "source=operational_attention empty"}} } return items } @@ -118,13 +142,15 @@ 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 state=%s error=%s", health.State, defaultPlaceholder(health.ErrorText, "n/a")), + Impact: "Live operator follow-up coverage is degraded until shell watch sync recovers.", + 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 +165,34 @@ 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("errors=%d last_status=%s last_subject=%s", family.errorCount, valueOrNA(family.lastStatus), valueOrNA(family.lastSubject)), + Impact: "Operator follow-up cues from this watch family may be incomplete or stale.", + 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 load failed: %s", auditErr), + Impact: "Evidence posture is shown with degraded fallback until broker verification becomes available again.", + 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,13 +205,15 @@ 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=%s anchoring=%s degraded=%t", valueOrNA(s.IntegrityStatus), valueOrNA(s.AnchoringStatus), s.CurrentlyDegraded), + Impact: fmt.Sprintf("Evidence confidence is reduced; hard_failures=%d degraded_reasons=%d.", len(s.HardFailures), len(s.DegradedReasons)), + 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}, }} } @@ -181,18 +224,43 @@ func operationalRunItems(runs []brokerapi.RunSummary) []actionCenterItem { continue } 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: "high", + Reason: fmt.Sprintf("runtime_degraded=%t audit_degraded=%t", run.RuntimePostureDegraded, run.AuditCurrentlyDegraded), + Impact: fmt.Sprintf("Execution confidence is reduced for backend=%s isolation=%s.", valueOrNA(run.BackendKind), valueOrNA(run.IsolationAssuranceLevel)), + 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.", + 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{} @@ -216,47 +284,29 @@ func buildBlockedImpactItems(runs []brokerapi.RunSummary, approvals []brokerapi. if blockedCount == 0 { blockedCount = byRun[run.RunID] } + requiredAction := "Open Runs to inspect the blocked reason and linked approvals, then continue from the target route." + stateCard := routeLoadStateBlocked + if run.PendingApprovalCount > 0 { + requiredAction = "Open Approvals or Runs, review the exact gate, then decide so workflow progress can resume." + } 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}, + Title: fmt.Sprintf("run %s blocked impact", valueOrNA(run.RunID)), + State: stateCard, + Urgency: urgency, + Reason: fmt.Sprintf("lifecycle=%s reason=%s", valueOrNA(run.LifecycleState), valueOrNA(run.BlockingReasonCode)), + Impact: fmt.Sprintf("Workflow progress is waiting; pending_approvals=%d linked_queue_items=%d.", run.PendingApprovalCount, blockedCount), + 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}, }) } 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.", 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() for _, family := range []actionCenterFamily{actionCenterFamilyApprovals, actionCenterFamilyOps, actionCenterFamilyBlocked} { 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..5bdd7e89 --- /dev/null +++ b/cmd/runecode-tui/route_action_center_render.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "strings" +) + +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 { + parts := []string{ + fmt.Sprintf("%s %s urgency=%s", tableHeader(strings.ToUpper(string(item.State))), item.Title, valueOrNA(item.Urgency)), + " reason: " + valueOrNA(item.Reason), + " impact: " + valueOrNA(item.Impact), + " action: " + valueOrNA(item.RequiredAction), + " target: " + valueOrNA(item.TargetLabel), + " evidence: " + valueOrNA(item.EvidenceCue), + } + if width <= 0 { + return strings.Join(parts, "\n") + } + wrapped := make([]string, 0, len(parts)) + for _, part := range parts { + wrapped = append(wrapped, wrapDashboardLine(part, width)) + } + return strings.Join(wrapped, "\n") +} + +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(" state=%s", valueOrNA(string(item.State))), + fmt.Sprintf(" urgency=%s", valueOrNA(item.Urgency)), + fmt.Sprintf(" reason=%s", valueOrNA(item.Reason)), + fmt.Sprintf(" impact=%s", valueOrNA(item.Impact)), + fmt.Sprintf(" required_action=%s", valueOrNA(item.RequiredAction)), + fmt.Sprintf(" target=%s", valueOrNA(item.TargetLabel)), + fmt.Sprintf(" evidence=%s", valueOrNA(item.EvidenceCue)), + fmt.Sprintf(" drill_down_target=%s/%s", valueOrNA(item.Target.Kind), valueOrNA(string(item.Target.RouteID))), + ) +} + +func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem) actionCenterSummary { + approvals := countActiveActionCenterItems(families[actionCenterFamilyApprovals]) + ops := countActiveActionCenterItems(families[actionCenterFamilyOps]) + blocked := countActiveActionCenterItems(families[actionCenterFamilyBlocked]) + if blocked > 0 { + return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked follow-up", Message: "Action Center has broker-known blockers that are holding workflow progress.", Reason: fmt.Sprintf("blocked_work_impact=%d operational_attention=%d approvals=%d", blocked, ops, approvals), NextAction: "Start with blocked-work impact items, then review linked approvals or setup guidance."} + } + if ops > 0 { + return actionCenterSummary{State: routeLoadStateDegraded, Title: "Needs operational attention", Message: "Action Center has degraded runtime, evidence, setup, or sync follow-up to review.", Reason: fmt.Sprintf("operational_attention=%d approvals=%d", ops, approvals), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} + } + if approvals > 0 { + return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Approval follow-up waiting", Message: "Action Center has broker-known approval decisions waiting on the operator.", Reason: fmt.Sprintf("approvals=%d", approvals), NextAction: "Open the approvals queue and review the exact gated action before deciding."} + } + return actionCenterSummary{State: routeLoadStateReady, Title: "No immediate follow-up", Message: "No broker-known attention items are currently waiting in Action Center.", Reason: "Approvals, blocked-work impact, and operational attention queues are clear on the current broker surfaces.", NextAction: "Return to Dashboard or Chat and continue work until new follow-up appears."} +} + +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..9b9452e4 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -29,23 +29,26 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) view := updated.View(140, 40, focusContent) mustContainAll(t, view, "Action Center", + "Blocked follow-up", "Queue families:", "approvals", "operational_attention", "blocked_work_impact", - "Approvals queue (canonical)", + "Approvals queue", "Operational attention", "Blocked-work impact", - "Question/answer queues are reserved for future canonical broker models", + "Action Center is the operator home for broker-known follow-up.", ) surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, "family=", + "state=", "urgency=", - "expiry=", - "stale_or_superseded=", + "reason=", "impact=", + "required_action=", + "evidence=", ) } @@ -96,11 +99,13 @@ func TestBuildApprovalActionItemsIncludesExpiryAndSupersededCues(t *testing.T) { 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", + "action:", + "evidence:", ) } @@ -111,7 +116,7 @@ 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", @@ -119,6 +124,7 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin "audit verification posture", "anchoring=degraded", "run run-1 operational posture", + "action:", ) } 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..368320a1 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,8 +153,10 @@ func (m approvalsRouteModel) View(width, height int, focus focusArea) string { } body := []string{ sectionTitle("Approvals") + " " + focusBadge(focus), + renderApprovalOverviewCard(m.active), renderApprovalSafetyStrip(m.active), renderApprovalFlowPath(m.active), + renderApprovalReviewPlan(m.active), renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), renderDirectory("Approval queue", renderApprovalDirectoryItems(m.items), m.selected), } @@ -164,7 +166,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 +189,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 +254,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 +279,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 +294,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..48151578 --- /dev/null +++ b/cmd/runecode-tui/route_approvals_followup_helpers.go @@ -0,0 +1,125 @@ +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 { + if approvalResolveSupported(summary, detail) { + return "available from this route after reviewing the evidence" + } + switch strings.TrimSpace(summary.BoundScope.ActionKind) { + case "promotion": + return "unavailable here because promotion approvals must be completed in the promotion flow to preserve exact binding" + 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 { + 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) + } + if digest := strings.TrimSpace(detail.BoundIdentity.DiffDigest); digest != "" { + return "diff artifact " + shortIdentity(digest) + } + if digest := strings.TrimSpace(detail.BoundIdentity.ArtifactSetDigest); digest != "" { + return "artifact set " + shortIdentity(digest) + } + if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { + return "run evidence for " + run + } + return "the linked broker evidence" +} + +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 "follow the linked workflow-specific route because resolve is unavailable here" +} + +func approvalFollowUpRoute(summary brokerapi.ApprovalSummary) string { + if strings.TrimSpace(summary.BoundScope.RunID) != "" { + return "Artifacts → Audit" + } + return "Audit" +} + +func approvalDisplayLabel(summary brokerapi.ApprovalSummary) string { + action := strings.TrimSpace(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 { + trigger := strings.TrimSpace(summary.ApprovalTriggerCode) + if trigger == "" { + return "review pending" + } + return "reason=" + approvalUIValue(trigger) +} + +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..f0b08014 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,7 @@ 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)) + out = append(out, fmt.Sprintf("%s %s %s %s", valueOrNA(item.ApprovalID), approvalDisplayLabel(item), approvalPrimaryStateBadge(item), approvalQueueReason(item))) } return out } @@ -203,9 +53,9 @@ 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)), + Summary: fmt.Sprintf("approval=%s state=%s reason=%s", summary.ApprovalID, approvalDisplayState(summary, detail), approvalPrimaryReason(summary, detail)), + Identity: fmt.Sprintf("approval=%s run=%s action=%s", summary.ApprovalID, valueOrNA(boundScope.RunID), valueOrNA(boundScope.ActionKind)), + Status: fmt.Sprintf("workflow_state=%s resolve=%s", workflowApprovalState(summary, detail), approvalResolveStatus(summary, detail)), Badges: []string{stateBadgeWithLabel("status", summary.Status), appTheme.InspectorHint.Render("policy/trigger/system cues are distinct")}, References: []inspectorReference{ {Label: "run", Items: mapReferenceIDs([]string{boundScope.RunID}, func(id string) paletteActionMsg { @@ -256,29 +106,37 @@ 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))), ) } 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("Exact gated object/action: %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("Resolve availability: %s", approvalUIValue(approvalResolveSummary(summary, detail))), + fmt.Sprintf("Resolve status: %s", approvalUIValue(approvalResolveStatus(summary, detail))), + fmt.Sprintf("Workflow posture: %s", approvalUIValue(workflowApprovalState(summary, detail))), + fmt.Sprintf("Approval type: %s (binding_kind=%s) %s", approvalUIValue(bindingLabel), approvalUIValue(detail.BindingKind), infoBadge("type cue")), + fmt.Sprintf("Lifecycle state: %s (%s) %s", approvalUIValue(lifecycleState), approvalUIValue(lifecycleFlags), postureBadge(lifecycleState)), + fmt.Sprintf("Lifecycle reason code: %s", approvalUIValue(detail.LifecycleDetail.LifecycleReasonCode)), + fmt.Sprintf("Policy reason code: %s %s", approvalUIValue(detail.PolicyReasonCode), warnBadge("policy cue")), + fmt.Sprintf("Approval trigger code: %s %s", approvalUIValue(summary.ApprovalTriggerCode), infoBadge("trigger cue")), + fmt.Sprintf("Distinct blocking semantics: trigger=%s cue=%s", approvalUIValue(summary.ApprovalTriggerCode), renderBlockingStateCue(true, summary.ApprovalTriggerCode)), + "Execution/system errors stay separate from approval policy and lifecycle cues. "+dangerBadge("system cue"), + fmt.Sprintf("Blocked work scope: kind=%s action=%s run=%s stage=%s step=%s role=%s", approvalUIValue(detail.BlockedWorkScope.ScopeKind), approvalUIValue(detail.BlockedWorkScope.ActionKind), approvalUIValue(detail.BlockedWorkScope.RunID), approvalUIValue(detail.BlockedWorkScope.StageID), approvalUIValue(detail.BlockedWorkScope.StepID), approvalUIValue(detail.BlockedWorkScope.RoleInstanceID)), + fmt.Sprintf("Canonical bound identity: request=%s decision=%s manifest=%s policy_decision=%s", approvalUIValue(identity.ApprovalRequestDigest), approvalUIValue(identity.ApprovalDecisionDigest), approvalUIValue(identity.ManifestHash), approvalUIValue(identity.PolicyDecisionHash)), + fmt.Sprintf("Exact bound scope: workspace=%s run=%s stage=%s step=%s role=%s action=%s", approvalUIValue(boundScope.WorkspaceID), approvalUIValue(boundScope.RunID), approvalUIValue(boundScope.StageID), approvalUIValue(boundScope.StepID), approvalUIValue(boundScope.RoleInstanceID), approvalUIValue(boundScope.ActionKind)), ) } @@ -296,20 +154,24 @@ 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: "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 +223,157 @@ 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_reason_code=%s %s | approval_trigger_code=%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: approvalPrimaryReason(summary, detail), + NextAction: fmt.Sprintf("Review %s first, then %s.", approvalReviewFirst(summary, detail), approvalNextAction(summary, detail)), + ShortcutCue: "enter / a", + RouteCue: approvalFollowUpRoute(summary), + }) +} + +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)), + ) +} + +func approvalPrimaryStateBadge(summary brokerapi.ApprovalSummary) string { + return workflowApprovalBadge(summary, brokerapi.ApprovalDetail{}) +} + +func approvalSupportBadge(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { + 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 "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 := strings.ToLower(strings.TrimSpace(detail.LifecycleDetail.LifecycleState)) + if lifecycle == "expired" || status == "expired" { + return "expired" + } + if lifecycle == "approved" || lifecycle == "consumed" || status == "approved" || status == "consumed" || status == "resolved" { + return "resolved" + } + if lifecycle == "pending" || status == "pending" { + return "approval required" + } + if strings.TrimSpace(summary.ApprovalID) == "" { + return "pending" + } + return valueOrNA(status) +} + +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" + default: + policy := strings.TrimSpace(detail.PolicyReasonCode) + trigger := strings.TrimSpace(summary.ApprovalTriggerCode) + if policy != "" { + return fmt.Sprintf("policy requires operator review before %s can continue (%s)", approvalDisplayLabel(summary), policy) + } + 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 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 "action=" + action + } + return strings.Join(parts, " • ") + " • action=" + action } 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..6a6b3844 --- /dev/null +++ b/cmd/runecode-tui/route_approvals_resolve_helpers.go @@ -0,0 +1,164 @@ +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 { + 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") + } + 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) == "" { + 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 +} diff --git a/cmd/runecode-tui/route_approvals_validation_test.go b/cmd/runecode-tui/route_approvals_validation_test.go index df442d65..a7311dad 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,51 @@ 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 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) + } +} diff --git a/cmd/runecode-tui/route_artifacts.go b/cmd/runecode-tui/route_artifacts.go index adcc8ae4..340566b6 100644 --- a/cmd/runecode-tui/route_artifacts.go +++ b/cmd/runecode-tui/route_artifacts.go @@ -155,14 +155,16 @@ 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]), + renderArtifactOverviewCard(m.active, artifactClassFilters[m.classIndex]), + renderArtifactEvidenceTrail(m.active), + fmt.Sprintf("Filter: evidence class=%q", artifactClassFilters[m.classIndex]), renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), 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")) + body = append(body, keyHint("Keys: j/k move, enter detail, [/] filter, m detail view, v mode, r reload")) return compactLines(body...) } @@ -187,7 +189,7 @@ 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}}, @@ -314,9 +316,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..0f979520 100644 --- a/cmd/runecode-tui/route_artifacts_detail.go +++ b/cmd/runecode-tui/route_artifacts_detail.go @@ -25,7 +25,7 @@ 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, fmt.Sprintf("%s %s class=%s bytes=%d %s", artifactDisplayLabel(item), shortIdentity(item.Reference.Digest), item.Reference.DataClass, item.Reference.SizeBytes, artifactRunLabel(item.RunID))) } return out } @@ -44,7 +44,9 @@ 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("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring actions", valueOrNA(a.RunID), artifactDisplayLabel(a)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), @@ -53,9 +55,9 @@ 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), + Summary: fmt.Sprintf("artifact=%s class=%s bytes=%d", artifactDisplayLabel(a), a.Reference.DataClass, a.Reference.SizeBytes), + Identity: fmt.Sprintf("artifact=%s digest=%s", artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)), + Status: fmt.Sprintf("evidence_trail=run:%s -> audit -> verification posture", valueOrNA(a.RunID)), Badges: []string{stateBadgeWithLabel("class", fmt.Sprintf("%v", a.Reference.DataClass)), appTheme.InspectorHint.Render("typed metadata first")}, 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}} @@ -212,7 +214,9 @@ 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("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring actions", valueOrNA(a.RunID), artifactDisplayLabel(a)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), @@ -222,3 +226,43 @@ 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 an artifact to inspect the evidence trail from workflow result to audit verification." + if strings.TrimSpace(classFilter) != "" { + message = fmt.Sprintf("Select an artifact in class %q to inspect the evidence trail from workflow result to audit verification.", classFilter) + } + return renderStateCardSpec(stateCardSpec{State: routeLoadStateWaiting, Title: "Evidence trail", Message: message, Reason: "No artifact detail is loaded yet.", NextAction: "Choose an artifact, then continue to Audit for verification posture and anchoring.", ShortcutCue: "enter", RouteCue: "Artifacts → Audit"}) + } + a := head.Artifact + return renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Evidence trail", Message: fmt.Sprintf("%s is ready for inspection.", artifactDisplayLabel(a)), Reason: fmt.Sprintf("This artifact belongs to run %s and carries broker metadata plus copyable raw digests.", valueOrNA(a.RunID)), NextAction: "Inspect content here, then open Audit to review verification posture, export, or anchoring actions.", ShortcutCue: "enter / m", RouteCue: "Audit"}) +} + +func renderArtifactEvidenceTrail(head *brokerapi.LocalArtifactHeadResponse) string { + if head == nil { + return "Evidence path: workflow result -> artifact -> audit record -> verification posture -> export/offline verification or anchoring where available" + } + a := head.Artifact + return fmt.Sprintf("Evidence path: run %s -> artifact %s -> Audit -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)) +} + +func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { + class := strings.TrimSpace(fmt.Sprintf("%v", 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 artifactRunLabel(runID string) string { + runID = strings.TrimSpace(runID) + if runID == "" { + return "" + } + return "run=" + runID +} diff --git a/cmd/runecode-tui/route_audit.go b/cmd/runecode-tui/route_audit.go index c8fb6bf9..1cb9a1d4 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,9 +210,11 @@ func (m auditRouteModel) View(width, height int, focus focusArea) string { } body := []string{ sectionTitle("Audit") + " " + focusBadge(focus), + renderAuditOverviewCard(m.verify, m.active), renderAuditSafetyAlertStrip(m.verify), renderAuditFinalizeSummary(m.finalize), renderAuditAnchorActionSummary(m), + renderAuditEvidenceTrail(m.verify, m.active), renderAuditPageSummary(m.cursor, m.nextCursor, len(m.prevCursors), len(m.timeline)), renderAuditSummary(m.verify), renderAuditFindings(m.verify, m.presentation), @@ -223,7 +225,7 @@ func (m auditRouteModel) View(width, height int, focus focusArea) string { 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 +250,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 +309,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..16dd2069 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -9,11 +9,11 @@ import ( func renderAuditSummary(verify *brokerapi.AuditVerificationGetResponse) string { if verify == nil { - return "Verification posture unavailable" + return "Verification posture: 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("Verification posture: integrity=%s %s anchoring=%s (%s) storage=%s lifecycle=%s %s findings=%d", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.StoragePostureStatus, s.SegmentLifecycleStatus, boolBadge("degraded", s.CurrentlyDegraded), s.FindingCount) } func renderAuditFinalizeSummary(finalize *brokerapi.AuditFinalizeVerifyResponse) string { @@ -32,7 +32,7 @@ func renderAuditFinalizeSummary(finalize *brokerapi.AuditFinalizeVerifyResponse) } line := fmt.Sprintf("%s status=%s segment=%s report=%s", tableHeader("Finalize/verify"), status, valueOrNA(strings.TrimSpace(finalize.SegmentID)), report) if status == "ok" { - return line + return line + " next=review Audit findings or anchor/export the latest verified segment" } reason := strings.TrimSpace(finalize.FailureCode) if reason == "" { @@ -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; 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 := "Verification findings (operator summary):" for i, finding := range verify.Report.Findings { if presentation == presentationRendered && i >= 4 { line += fmt.Sprintf("\n ... (%d more)", len(verify.Report.Findings)-i) @@ -105,7 +105,7 @@ func renderAuditTimeline(timeline []brokerapi.AuditTimelineViewEntry, selected i 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 += selectedLine(i == selected, fmt.Sprintf(" %s %s event=%s posture=%s reasons=%s summary=%s", marker, shortIdentity(digest), entry.EventType, posture, valueOrNA(reasonCodes), entry.Summary)) + "\n" } return line } @@ -113,8 +113,7 @@ 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, fmt.Sprintf("%s event=%s %s", auditRecordDisplayLabel(entry), valueOrNA(entry.EventType), auditTimelinePostureLabel(entry))) } return items } @@ -140,8 +139,8 @@ 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)), + Summary: fmt.Sprintf("record=%s event=%s linked_refs=%d", shortIdentity(identity), valueOrNA(r.EventType), len(r.LinkedReferences)), + Identity: fmt.Sprintf("record_family=%s event_type=%s digest=%s", valueOrNA(r.RecordFamily), valueOrNA(r.EventType), shortIdentity(identity)), Status: fmt.Sprintf("verification=%s reasons=%d", valueOrNA(status), reasons), Badges: []string{stateBadgeWithLabel("posture", status), appTheme.InspectorHint.Render("typed record details")}, References: []inspectorReference{{Label: "records", Items: referenceItems}, {Label: "reason codes", Items: reasonItems}}, @@ -170,6 +169,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 { @@ -184,6 +184,7 @@ func auditInspectorContent(record brokerapi.AuditRecordDetail, status string, re fmt.Sprintf("Occurred at: %s", record.OccurredAt), fmt.Sprintf("Verification posture: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), fmt.Sprintf("Linked references: %d", len(record.LinkedReferences)), + "Evidence trail: follow linked runs/artifacts/approvals, then use Audit verification and anchor/export actions for proof handling.", ) } @@ -291,10 +292,10 @@ 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 posture") + " " + dangerBadge("AUDIT_POSTURE_UNAVAILABLE") + " audit verification unavailable" } s := verify.Summary - parts := []string{tableHeader("Audit safety strip")} + parts := []string{tableHeader("Audit posture")} if strings.EqualFold(strings.TrimSpace(s.AnchoringStatus), "degraded") || s.CurrentlyDegraded { parts = append(parts, auditDegradedBadge("UNANCHORED_OR_DEGRADED_AUDIT")) } @@ -306,3 +307,49 @@ func renderAuditSafetyAlertStrip(verify *brokerapi.AuditVerificationGetResponse) } 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 verification posture for %s.", recordLabel), Reason: renderAuditSummary(verify), NextAction: "Review findings, then use finalize/verify, export copy, or anchoring 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 path: %s -> verification posture unavailable until broker verification data loads", recordLabel) + } + return fmt.Sprintf("Evidence path: %s -> verification posture -> export/offline verification -> anchoring where available", recordLabel) +} + +func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { + digest, _ := entry.RecordDigest.Identity() + if strings.TrimSpace(entry.Summary) != "" { + return strings.TrimSpace(entry.Summary) + } + return shortIdentity(digest) +} + +func auditTimelinePostureLabel(entry brokerapi.AuditTimelineViewEntry) string { + if entry.VerificationPosture == nil { + return "posture=n/a" + } + return "posture=" + valueOrNA(strings.TrimSpace(entry.VerificationPosture.Status)) +} diff --git a/cmd/runecode-tui/route_audit_status_test.go b/cmd/runecode-tui/route_audit_status_test.go index 53674b3a..11cf8153 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -21,13 +21,15 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Audit safety strip", + "Verification trail", + "Audit posture", "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):", + "Evidence path:", + "Verification findings (operator summary):", "code=anchor_receipt_missing severity=warning dimension=anchoring", "degraded_reason_codes=", "posture=degraded reasons=anchor_receipt_missing", @@ -60,6 +62,9 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { if !strings.Contains(inspector, "Verification posture: degraded (unanchored/degraded)") { t.Fatalf("expected record inspector posture rendering, got %q", inspector) } + if !strings.Contains(inspector, "Evidence trail: follow linked runs/artifacts/approvals") { + t.Fatalf("expected evidence trail guidance in inspector, got %q", inspector) + } } func TestRenderAuditSafetyAlertStripUsesVerifierAnchoringStatusOnly(t *testing.T) { @@ -163,6 +168,7 @@ func TestAuditRouteAnchorActionDispatchesToBrokerAndRendersSuccess(t *testing.T) "Anchor action: ok", "receipt=sha256:", "export_copy=off", + "next=use receipt for export/offline verification when available", ) updated = mustRunAuditRouteKey(t, updated, 'f', "expected finalize+verify command") view = updated.View(120, 40, focusContent) @@ -170,6 +176,7 @@ func TestAuditRouteAnchorActionDispatchesToBrokerAndRendersSuccess(t *testing.T) "Finalize/verify", "status=ok", "segment=segment-000001", + "next=review Audit findings or anchor/export the latest verified segment", ) } diff --git a/cmd/runecode-tui/route_chat.go b/cmd/runecode-tui/route_chat.go index afb23fd7..b384bf1b 100644 --- a/cmd/runecode-tui/route_chat.go +++ b/cmd/runecode-tui/route_chat.go @@ -12,6 +12,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 +22,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 +39,7 @@ type chatExecutionWatchLoadedMsg struct { triggerID string turnExecution *brokerapi.SessionTurnExecution detail *brokerapi.SessionDetail + runDetail *brokerapi.RunDetail sessions []brokerapi.SessionSummary posture *brokerapi.ProjectSubstratePostureGetResponse continueWatch bool @@ -69,6 +73,8 @@ type chatRouteModel struct { watching bool watchSession string watchTrigger string + posture *brokerapi.ProjectSubstratePostureGetResponse + runDetail *brokerapi.RunDetail detailDoc longFormDocumentState } @@ -111,29 +117,33 @@ 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 + activeLine := activeSessionSummaryLine(m.active) + if activeLine == "" { + activeLine = "No active session selected." + } + composerSummary := "Composer is ready for a follow-up prompt." + if !m.composeOn { + composerSummary = "Composer is closed. Press c when you want to continue the conversation." } 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)), + renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Active session", Message: activeLine, Reason: fmt.Sprintf("Canonical session directory: %d session(s) available.", len(m.sessions)), NextAction: "Review the session below, or move through the directory to switch context.", ShortcutCue: "j/k move • enter review", RouteCue: "Chat"}), + renderStateCardSpec(chatExecutionStateCard(m.active, m.posture, m.runDetail)), + renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Composer", Message: fmt.Sprintf("Composer is %s.", composerState(m.composeOn)), Reason: composerSummary, NextAction: "Write a prompt here when you want the broker to start the next workflow turn.", ShortcutCue: "c compose • alt+enter send", RouteCue: "Chat"}), renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), - renderStateCard(routeLoadStateReady, "Active session", activeSessionSummaryLine(m.active)), + 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 +151,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 +178,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..05ef4477 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers.go +++ b/cmd/runecode-tui/route_chat_execution_helpers.go @@ -1,11 +1,342 @@ package main import ( + "fmt" "strings" "github.com/runecode-ai/runecode/internal/brokerapi" ) +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: chatExecutionEvidenceSummary(detail, nil), + 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)) + 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 + } +} + +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." + } +} + +func chatExecutionReason(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, posture *brokerapi.ProjectSubstratePostureGetResponse, run *brokerapi.RunDetail) string { + parts := []string{} + 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 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 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 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{ + fmt.Sprintf("linked runs %d", runs), + fmt.Sprintf("approvals %d", approvals), + fmt.Sprintf("artifacts %d", maxInt(artifacts, runArtifactCount(run))), + fmt.Sprintf("audit %d", audit), + } + return "Evidence: " + strings.Join(parts, " • ") +} + +func chatExecutionStageSummary(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, run *brokerapi.RunDetail) string { + stages := make([]string, 0, 6) + if run != nil && runPlanAuthorityAvailable(run) { + stages = append(stages, "plan compiled") + } + if chatRunIDFromExecution(exec) != "" { + stages = append(stages, "run linked") + } + if runRunnerActive(run) { + stages = append(stages, "runner active") + } + if runCheckpointCode(run) != "" { + stages = append(stages, "checkpoint received") + } + if chatExecutionApprovalRequired(exec, run) { + stages = append(stages, "approval required") + } + if len(exec.LinkedArtifactDigests) > 0 || len(detail.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 { + action := strings.TrimSpace(blockedExecutionAction(exec)) + if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") || chatExecutionApprovalRequired(exec, run) { + 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) != "" || len(exec.LinkedApprovalIDs) > 0 { + 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 strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" || len(exec.LinkedApprovalIDs) > 0 { + return true + } + return run != nil && len(run.PendingApprovalIDs) > 0 +} + +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 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" { @@ -16,27 +347,20 @@ func chatExecutionTerminal(exec brokerapi.SessionTurnExecution) bool { } func chatExecutionStatusAndAction(exec brokerapi.SessionTurnExecution) (string, string) { - state := strings.TrimSpace(exec.ExecutionState) - if state == "" { - state = "unknown" - } - status := "Execution progress: " + state - if wait := strings.TrimSpace(exec.WaitState); wait != "" { - status += " (" + wait + ")" - } + status := chatExecutionHeadline(exec, nil) action := blockedExecutionAction(exec) - if strings.EqualFold(strings.TrimSpace(exec.ExecutionState), "waiting") { + 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 = "Execution is blocked by project posture; diagnostics/remediation attach remains inspect-only." + action = "Open Status and complete the recommended project setup remediation before retrying." } return status, strings.TrimSpace(action) } func blockedExecutionAction(exec brokerapi.SessionTurnExecution) string { if blocked := strings.TrimSpace(exec.BlockedReasonCode); blocked != "" { - return "Blocked reason: " + blocked + return "Review the blocking reason in Runs or the inspector: " + humanizeExecutionToken(blocked) + "." } return "" } @@ -44,13 +368,13 @@ func blockedExecutionAction(exec brokerapi.SessionTurnExecution) string { 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." + return "Provide the missing operator input when you are ready to continue." case "approval": - return "Follow-up: open Approvals and resolve the pending formal approval." + return "Open Approvals and review the exact gated action before deciding." case "external_dependency": - return "Follow-up: wait for external dependency readiness, then continue." + return "Wait for the dependency to become ready, then check Runs for the resumed workflow." case "project_blocked": - return "Follow-up: project substrate posture blocks execution; apply remediation posture before continue." + return "Use Status to fix project setup before retrying this workflow." default: return blockedExecutionAction(exec) } diff --git a/cmd/runecode-tui/route_chat_execution_helpers_test.go b/cmd/runecode-tui/route_chat_execution_helpers_test.go index 79295c0b..2c20e24c 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,43 @@ 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: plan compiled") { + t.Fatalf("expected stage summary in reason, got %q", card.Reason) + } + if !strings.Contains(card.Reason, "Evidence: linked runs 1") { + 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) + } +} diff --git a/cmd/runecode-tui/route_chat_inspector.go b/cmd/runecode-tui/route_chat_inspector.go index c550f01e..7a8a6610 100644 --- a/cmd/runecode-tui/route_chat_inspector.go +++ b/cmd/runecode-tui/route_chat_inspector.go @@ -24,7 +24,7 @@ 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() @@ -51,8 +51,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 +84,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_rendering.go b/cmd/runecode-tui/route_chat_rendering.go index abf70229..75175ba8 100644 --- a/cmd/runecode-tui/route_chat_rendering.go +++ b/cmd/runecode-tui/route_chat_rendering.go @@ -30,36 +30,28 @@ 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, fmt.Sprintf("%s %s %s turns=%d runs=%d artifacts=%d approvals=%d", s.Identity.SessionID, stateBadgeWithLabel("status", s.Status), stateBadgeWithLabel("work", sessionHighLevelCue(s)), s.TurnCount, s.LinkedRunCount, s.LinkedArtifactCount, s.LinkedApprovalCount)) } return items } 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("Session %s in workspace %s is %s.", s.Identity.SessionID, valueOrNA(s.Identity.WorkspaceID), sessionHighLevelCue(s))} + if exec != nil { + statusParts = append(statusParts, fmt.Sprintf("Latest 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 renderTranscriptTurns(turns []brokerapi.SessionTranscriptTurn) string { diff --git a/cmd/runecode-tui/route_chat_state.go b/cmd/runecode-tui/route_chat_state.go index 9531eff4..edf609c4 100644 --- a/cmd/runecode-tui/route_chat_state.go +++ b/cmd/runecode-tui/route_chat_state.go @@ -1,6 +1,7 @@ package main import ( + "context" "strings" "time" @@ -79,6 +80,8 @@ 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.watchSession = "" m.watchTrigger = "" @@ -128,6 +131,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 @@ -197,6 +202,8 @@ func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMs } m.errText = "" m.activeID = strings.TrimSpace(msg.sessionID) + m.runDetail = msg.runDetail + m.posture = msg.posture if msg.detail != nil { m.active = msg.detail } @@ -311,11 +318,15 @@ func (m chatRouteModel) sendCmd(sessionID, content string) tea.Cmd { if err != nil { return chatMessageSentMsg{err: err} } + runDetail, err := m.loadChatRunDetail(ctx, getResp.Session) + 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} + return chatMessageSentMsg{sessions: listResp.Sessions, detail: &getResp.Session, ack: &sendResp, turnExecution: turnExecution, posture: &posture, runDetail: runDetail} } } @@ -345,6 +356,10 @@ func (m chatRouteModel) watchLoadCmd(sessionID, triggerID string, seq uint64) te if err != nil { return chatExecutionWatchLoadedMsg{seq: seq, err: err} } + runDetail, err := m.loadChatRunDetail(ctx, getResp.Session) + 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} @@ -361,6 +376,7 @@ func (m chatRouteModel) watchLoadCmd(sessionID, triggerID string, seq uint64) te triggerID: triggerID, turnExecution: turnExecution, detail: &getResp.Session, + runDetail: runDetail, sessions: listResp.Sessions, posture: &posture, continueWatch: continueWatch, @@ -368,6 +384,25 @@ func (m chatRouteModel) watchLoadCmd(sessionID, triggerID string, seq uint64) te } } +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 { for i := range events { if events[i].TurnExecution == nil { diff --git a/cmd/runecode-tui/route_chat_test.go b/cmd/runecode-tui/route_chat_test.go index f6383cc5..6b5adff8 100644 --- a/cmd/runecode-tui/route_chat_test.go +++ b/cmd/runecode-tui/route_chat_test.go @@ -119,7 +119,7 @@ func TestChatRouteRendersOrderedTranscriptAndLinkedReferences(t *testing.T) { mustContainAll(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:", ) @@ -176,8 +176,13 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { } 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) + mustContainAll(t, view, + "Workflow is waiting for approval.", + "Session directory", + "Composer is idle.", + ) + if strings.Contains(view, "SessionExecutionTrigger") { + t.Fatalf("expected product language instead of protocol phrasing in primary view, got %q", view) } } @@ -280,13 +285,13 @@ func TestChatRouteComposeUsesTurnExecutionWatchStateOverTriggerAck(t *testing.T) 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, "Workflow cannot continue yet.") { + t.Fatalf("expected blocked workflow headline from turn execution watch 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) + if !strings.Contains(view, "Transcript remains the durable conversation record") { + t.Fatalf("expected transcript/evidence 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..b61706f1 100644 --- a/cmd/runecode-tui/route_contract_usage_test.go +++ b/cmd/runecode-tui/route_contract_usage_test.go @@ -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..250ca8ca 100644 --- a/cmd/runecode-tui/route_dashboard.go +++ b/cmd/runecode-tui/route_dashboard.go @@ -110,10 +110,26 @@ 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 { @@ -121,22 +137,41 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { } primaryRun := primaryDashboardRun(m.data.runs) innerWidth := dashboardContentWidth(width) + executive := buildDashboardExecutiveSummary(m.data) sections := []string{ - compactLines(sectionTitle("Dashboard")+" "+focusBadge(focus)+" "+navStateBadge(focusLabel == "active"), tableHeader("Now")+" "+renderDashboardNowBar(primaryRun, len(m.data.approvals), focusLabel == "active", innerWidth)), + compactLines( + sectionTitle("Dashboard")+" "+focusBadge(focus)+" "+navStateBadge(focusLabel == "active"), + renderStateCardSpec(stateCardSpec{ + State: executive.State, + Title: executive.Title, + Message: executive.Message, + Reason: executive.Reason, + NextAction: executive.NextAction, + RouteCue: "Action Center", + }), + tableHeader("Now")+" "+renderDashboardNowBar(primaryRun, len(m.data.approvals), focusLabel == "active", innerWidth), + ), + compactLines( + tableHeader("Executive overview"), + wrapDashboardLine(renderDashboardWorkflowPosture(m.data), innerWidth), + wrapDashboardLine(renderDashboardHighValueCounts(m.data, innerWidth), innerWidth), + wrapDashboardLine(renderDashboardNextActions(m.data), innerWidth), + wrapDashboardLine(renderDashboardActionCenterCue(m.data), 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(tableHeader("Actions")+" "+keyHint("r reload")+" "+muted("tab moves focus • : opens command surface"), wrapDashboardLine("Primary follow-up lives in Action Center; use Runs, Approvals, Audit, and Status for detail.", innerWidth), keyHint("Route keys: r reload")), } return joinDashboardSections(sections...) } func (m dashboardRouteModel) controlPlaneSection(width int) string { parts := []string{ - tableHeader("Control Plane"), + tableHeader("Supporting detail"), 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), + wrapPartsByWidth([]string{tableHeader("Evidence 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)) @@ -144,8 +179,7 @@ func (m dashboardRouteModel) controlPlaneSection(width int) string { 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), + wrapDashboardLine(fmt.Sprintf("Overview data source: runs=%d approvals=%d version=%s (%s)", len(m.data.runs), len(m.data.approvals), m.data.version.ProductVersion, m.data.version.BuildRevision), width), ) return compactLines(parts...) } diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index f10db081..93b1d298 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -8,6 +8,14 @@ import ( "github.com/runecode-ai/runecode/internal/trustpolicy" ) +type dashboardExecutiveSummary struct { + State routeLoadState + Title string + Message string + Reason string + NextAction string +} + func degradedDashboardAuditFallback() brokerapi.AuditVerificationGetResponse { return brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{ CryptographicallyValid: false, @@ -48,107 +56,230 @@ func renderDashboardNowBar(run brokerapi.RunSummary, approvalCount int, focusAct 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) dashboardExecutiveSummary { + pendingApprovals := pendingApprovalCount(data.runs, data.approvals) + blockedRuns := dashboardBlockedRunCount(data.runs) + degradedSignals := dashboardDegradedSignalCount(data) + activeRuns := dashboardActiveRunCount(data.runs) + setupBlocked := dashboardProjectSubstrateBlocked(data.project) + setupNeedsAttention := dashboardProjectSubstrateNeedsAttention(data.project) + noWork := len(data.runs) == 0 && len(data.approvals) == 0 + + switch { + case noWork: + return dashboardNoWorkSummary() + case setupBlocked || blockedRuns > 0: + return dashboardBlockedSummary(data, setupBlocked, blockedRuns) + case degradedSignals > 0: + return dashboardDegradedSummary(data) + case pendingApprovals > 0 || setupNeedsAttention: + return dashboardAttentionSummary(data, pendingApprovals, setupNeedsAttention) + case activeRuns > 0: + return dashboardActiveWorkSummary(activeRuns) + 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, setupBlocked bool, blockedRuns int) dashboardExecutiveSummary { + reasons := []string{} + if 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 blockedRuns > 0 { + reasons = append(reasons, fmt.Sprintf("%d run(s) report blocked or waiting workflow posture", blockedRuns)) } - return strings.Join(lines, "\n") + return dashboardExecutiveSummary{State: routeLoadStateBlocked, Title: "Blocked", 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) dashboardExecutiveSummary { + reasons := []string{} + if !data.readiness.Ready { + reasons = append(reasons, "broker readiness is not fully settled") } - return s + if dashboardAuditDegraded(data.audit) { + reasons = append(reasons, "audit or runtime evidence needs review") + } + if count := dashboardRunRuntimeDegradedCount(data.runs); count > 0 { + reasons = append(reasons, fmt.Sprintf("%d run(s) report degraded runtime posture", count)) + } + return dashboardExecutiveSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Evidence or runtime posture needs review.", Reason: strings.Join(reasons, "; "), 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 +func dashboardAttentionSummary(data dashboardData, pendingApprovals int, setupNeedsAttention bool) dashboardExecutiveSummary { + reasons := []string{} + if pendingApprovals > 0 { + reasons = append(reasons, fmt.Sprintf("%d approval decision(s) are waiting", pendingApprovals)) + } + if setupNeedsAttention { + reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) + } + return dashboardExecutiveSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", Message: "Operator follow-up is waiting.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center for the exact follow-up."} +} + +func dashboardActiveWorkSummary(activeRuns int) dashboardExecutiveSummary { + return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%d active run(s) are visible on the current broker surfaces.", activeRuns), NextAction: "Use Action Center or open Runs for detail."} +} + +func renderDashboardWorkflowPosture(data dashboardData) string { + pendingApprovals := pendingApprovalCount(data.runs, data.approvals) + blockedRuns := dashboardBlockedRunCount(data.runs) + activeRuns := dashboardActiveRunCount(data.runs) + setupBlocked := dashboardProjectSubstrateBlocked(data.project) + setupNeedsAttention := dashboardProjectSubstrateNeedsAttention(data.project) + auditDegraded := dashboardAuditDegraded(data.audit) + + parts := []string{} + if setupBlocked { + parts = append(parts, "normal product work is blocked until project setup is remediated") + } else if blockedRuns > 0 { + parts = append(parts, fmt.Sprintf("%d workflow(s) are blocked or waiting on follow-up", blockedRuns)) + } else if activeRuns > 0 { + parts = append(parts, fmt.Sprintf("%d workflow(s) are active", activeRuns)) + } else { + parts = append(parts, "no active workflow work is currently running") + } + if pendingApprovals > 0 { + parts = append(parts, fmt.Sprintf("%d approval decision(s) are waiting", pendingApprovals)) + } + if setupNeedsAttention && !setupBlocked { + parts = append(parts, "project setup guidance is available") + } + if auditDegraded { + parts = append(parts, "evidence posture needs review") + } + return "Workflow posture: " + strings.Join(parts, "; ") + "." +} + +func renderDashboardHighValueCounts(data dashboardData, width int) string { + parts := []string{ + fmt.Sprintf("active work=%d", dashboardActiveRunCount(data.runs)), + fmt.Sprintf("approval follow-up=%d", pendingApprovalCount(data.runs, data.approvals)), + fmt.Sprintf("blocked or waiting=%d", dashboardBlockedRunCount(data.runs)), + fmt.Sprintf("degraded cues=%d", dashboardDegradedSignalCount(data)), + } + return "High-value counts: " + wrapPartsByWidth(parts, " | ", width) +} + +func renderDashboardNextActions(data dashboardData) string { + primaryRun := primaryDashboardRun(data.runs) + pendingApprovals := pendingApprovalCount(data.runs, data.approvals) + if dashboardProjectSubstrateBlocked(data.project) { + return "Next action: Open Action Center, follow the setup blocker, then use Status for broker-owned remediation steps." + } + if pendingApprovals > 0 { + return "Next action: Open Action Center and review the pending approval before workflow progress resumes." + } + if dashboardDegradedSignalCount(data) > 0 { + return "Next action: Open Action Center for degraded evidence or runtime follow-up, then inspect Audit or Runs." + } + if strings.TrimSpace(primaryRun.RunID) != "" { + return fmt.Sprintf("Next action: Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(primaryRun.RunID)) + } + return "Next action: Start a workflow from Chat, then return here for the executive overview." +} + +func renderDashboardActionCenterCue(data dashboardData) string { + return fmt.Sprintf( + "Action Center is the operator home for approvals, blocked work, degraded posture, setup follow-up, and waiting queues. Current follow-up: approvals=%d blocked_or_waiting=%d degraded=%d.", + pendingApprovalCount(data.runs, data.approvals), + dashboardBlockedRunCount(data.runs), + dashboardDegradedSignalCount(data), + ) +} + +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++ } - if event.Error != nil { - s.errorCount++ - s.lastStatus = "error" + } + 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++ } - switch event.EventType { - case "session_watch_snapshot": - s.snapshotCount++ - case "session_watch_upsert": - s.upsertCount++ + } + return total +} + +func dashboardRunRuntimeDegradedCount(runs []brokerapi.RunSummary) int { + total := 0 + for _, run := range runs { + if run.RuntimePostureDegraded || run.AuditCurrentlyDegraded { + total++ } } - return s + return total } -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 dashboardDegradedSignalCount(data dashboardData) int { + total := dashboardRunRuntimeDegradedCount(data.runs) + if dashboardAuditDegraded(data.audit) { + total++ + } + if !data.readiness.Ready { + 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 + } + if !posture.PostureSummary.NormalOperationAllowed { + return true + } + return false +} + +func dashboardProjectSubstrateNeedsAttention(posture brokerapi.ProjectSubstratePostureGetResponse) bool { + if strings.TrimSpace(posture.PostureSummary.SchemaID) == "" { + return false + } + if dashboardProjectSubstrateBlocked(posture) { + return true + } + if 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=%s", sanitizeUIText(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 { @@ -170,18 +301,18 @@ func pendingApprovalCount(runs []brokerapi.RunSummary, approvals []brokerapi.App func renderRunHighlights(runs []brokerapi.RunSummary) string { if len(runs) == 0 { - return " No runs returned" + return "Current work: no recent runs returned by the broker." } 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 fmt.Sprintf("Current work: latest run %s is %s on %s runtime with %d pending approval(s).", sanitizeUIText(first.RunID), valueOrNA(sanitizeUIText(first.LifecycleState)), valueOrNA(sanitizeUIText(first.BackendKind)), first.PendingApprovalCount) } func renderApprovalHighlights(approvals []brokerapi.ApprovalSummary) string { if len(approvals) == 0 { - return " No pending approvals" + return "Approval follow-up: no pending approvals returned by the broker." } 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))) + return fmt.Sprintf("Approval follow-up: %s is %s for %s on run %s.", sanitizeUIText(first.ApprovalID), valueOrNA(sanitizeUIText(first.Status)), valueOrNA(sanitizeUIText(first.ApprovalTriggerCode)), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) } func primaryDashboardRun(runs []brokerapi.RunSummary) brokerapi.RunSummary { @@ -215,10 +346,10 @@ func renderDashboardSafetyAlerts(data dashboardData) string { func renderDashboardProjectSubstrateLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { summary := posture.PostureSummary if strings.TrimSpace(summary.SchemaID) == "" { - return "Project substrate: unavailable" + return "Project setup: unavailable" } return fmt.Sprintf( - "Project substrate: validation=%s compatibility=%s normal_operation_allowed=%t", + "Project setup: validation=%s compatibility=%s normal_operation_allowed=%t", sanitizeUIText(summary.ValidationState), sanitizeUIText(summary.CompatibilityPosture), summary.NormalOperationAllowed, @@ -228,20 +359,20 @@ func renderDashboardProjectSubstrateLine(posture brokerapi.ProjectSubstratePostu func renderDashboardProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { parts := []string{} if strings.TrimSpace(posture.BlockedExplanation) != "" { - parts = append(parts, "Project substrate block: "+sanitizeUIText(posture.BlockedExplanation)) + parts = append(parts, "Project setup block: "+sanitizeUIText(posture.BlockedExplanation)) } if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Project substrate remediation:") + parts = append(parts, "Project setup guidance:") parts = append(parts, joinCSVWithWrapHint(posture.RemediationGuidance)) } if strings.TrimSpace(posture.InitPreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate init=%s", posture.InitPreview.Status)) + parts = append(parts, fmt.Sprintf("Project setup init preview=%s", posture.InitPreview.Status)) } if strings.TrimSpace(posture.UpgradePreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project substrate upgrade=%s", posture.UpgradePreview.Status)) + parts = append(parts, fmt.Sprintf("Project setup upgrade preview=%s", posture.UpgradePreview.Status)) } if len(parts) == 0 { - return "Project substrate guidance: none" + return "Project setup guidance: none" } return strings.Join(parts, " | ") } diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index 24dd3b44..0d826e5c 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -28,8 +28,14 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, + "Degraded", + "Executive overview", "Now", "CONTENT_READY", + "Workflow posture:", + "High-value counts:", + "Next action:", + "Action Center is the operator home", "Safety Summary", "Safety strip", "backend_kind=workspace", @@ -38,10 +44,10 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { "approval_profile=n/a", "Safety alerts:", "ALERT_AUDIT_UNANCHORED", - "Control Plane", - "Project substrate:", + "Supporting detail", + "Project setup:", "compatibility=supported_with_upgrade_available", - "Project substrate remediation:", + "Project setup guidance:", "Live Activity", "Live activity (typed watch families; logs are supplemental inspection only):", "totals events=2 snapshot=1 upsert=0 terminal=1 errors=0", @@ -82,14 +88,15 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { mustContainAll(t, view, "Dashboard", + "Degraded", "Now", - "Safety posture", - "Project substrate:", + "Evidence posture", + "Project setup:", "FAILED", "degraded=true", "AUDIT_VERIFICATION_UNAVAILABLE", "showing degraded fallback posture (gateway_failure)", - "Control Plane", + "Supporting detail", "Live Activity", "Live activity (typed watch families; logs are supplemental inspection only):", ) @@ -103,7 +110,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{"degraded=1.\n\nSafety Summary", "ALERT_AUDIT_UNANCHORED audit posture unanchored/degraded\n\nSupporting detail", "Overview data source: runs=1 approvals=1 version=0.1.0 (abc123)\n\nLive Activity"} { if !strings.Contains(view, want) { t.Fatalf("expected preserved blank section gap %q in view, got %q", want, view) } @@ -118,7 +125,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, "Evidence posture\n\nOverview data source") { t.Fatalf("did not expect extra blank line when audit fallback notice absent, got %q", view) } } @@ -161,8 +168,9 @@ func TestDashboardViewNarrowWidthKeepsBoundedLinesAndSectionSpacing(t *testing.T } mustContainAll(t, view, "Dashboard", + "Executive overview", "Safety Summary", - "Control Plane", + "Supporting detail", "Live Activity", ) } 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_runs.go b/cmd/runecode-tui/route_runs.go index 46a54a69..2f9a0eea 100644 --- a/cmd/runecode-tui/route_runs.go +++ b/cmd/runecode-tui/route_runs.go @@ -139,13 +139,14 @@ 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), + renderStateCardSpec(runStateCard(m.active)), renderRunSafetyStrip(m.activeSummary(), width-4), renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), renderDirectory("Run directory", renderRunDirectoryItems(m.runs), m.selected), @@ -153,7 +154,8 @@ func (m runsRouteModel) View(width, height int, focus focusArea) string { 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 broker-owned outcome, coordination, and evidence trail together for the selected workflow.")) + body = append(body, keyHint("Keys: j/k move • enter review • i inspector • v mode • r reload")) return compactLines(body...) } @@ -176,7 +178,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}}, @@ -299,7 +301,49 @@ func renderRunList(runs []brokerapi.RunSummary, selected int) string { 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)) + items = append(items, strings.TrimSpace(strings.Join([]string{ + run.RunID, + stateBadgeWithLabel("state", run.LifecycleState), + fmt.Sprintf("workflow=%s", valueOrNA(run.WorkflowKind)), + fmt.Sprintf("backend=%s", valueOrNA(run.BackendKind)), + fmt.Sprintf("approvals=%d", run.PendingApprovalCount), + }, " "))) } return items } + +func runStateCard(detail *brokerapi.RunDetail) stateCardSpec { + if detail == nil { + return stateCardSpec{ + State: routeLoadStateEmpty, + Title: "Selected run", + Message: "Pick a run to review workflow outcome and evidence.", + Reason: "The directory stays broker-owned; no local progress is synthesized here.", + NextAction: "Select a run from the directory.", + ShortcutCue: "j/k move • enter review", + RouteCue: "Runs", + } + } + 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: "Selected run", + Message: fmt.Sprintf("Run %s is %s.", detail.Summary.RunID, valueOrNA(detail.Summary.LifecycleState)), + Reason: fmt.Sprintf("Workflow %s • %s • %s", runWorkflowOperation(detail.Summary, detail), runPlanAuthoritySummary(detail.Summary, detail), runEvidenceSummary(detail)), + NextAction: fmt.Sprintf("Use the inspector for detail, then jump to session=%s / approvals=%d / artifacts=%d / audit=%d as needed.", valueOrNA(authoritativeString(detail.AuthoritativeState, "session_id")), len(detail.PendingApprovalIDs), runArtifactCount(detail), runAuditReferenceCount(detail)), + ShortcutCue: "enter review • i inspector", + RouteCue: "Chat / Approvals / Artifacts / Audit", + } +} diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index e942a350..b8b3ae5f 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -15,34 +15,30 @@ 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 0 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 actions: run id | workspace id | raw block", + "Workflow operation: change_draft", + "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", + "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_", + "Workflow identity (authoritative): workflow_kind=change_draft workflow_definition_hash=sha256:", "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", ) - 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 0 pending approval(s).") { t.Fatalf("expected run detail only in inspector region, got %q", view) } } @@ -55,29 +51,32 @@ 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", + "Summary: approval=ap-1 state=approval required reason=policy requires operator review before promotion for run-1 can continue (requires_human_review)", + "Identity: approval=ap-1 run=run-1 action=promotion", "Local actions: resolve:typed | jump:runs | jump:audit | copy:approval_id", "Copy actions: approval id | bound run id | raw block", + "Approval state: approval required", + "Why this approval exists: policy requires operator review before promotion for run-1 can", + "Exact gated object/action: run run-1 • stage stage-1 • action=promotion", + "Review first: run evidence for run-1", + "If approved next: Promotion continues (effect=unblock_next_stage)", + "Resolve availability: unavailable here because promotion approvals must be completed in the prom", + "Resolve status: unsupported", "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", ) - 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") { + if strings.Contains(view, "Summary: approval=ap-1 state=approval required") { t.Fatalf("expected approval detail only in inspector region, got %q", view) } } @@ -94,16 +93,18 @@ 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", + "Summary: artifact=diffs for run-1 class=diffs bytes=128", + "Identity: artifact=diffs for run-1 digest=sha256:bbbbbbbbbbbb", "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt", "Copy actions: artifact digest | provenance receipt | artifact preview", + "Evidence label: diffs for run-1", + "Evidence trail: run run-1 -> artifact diffs for run-1 -> Audit for verification posture", "Typed 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") { + if strings.Contains(view, "Summary: artifact=diffs for run-1") { t.Fatalf("expected artifact detail only in inspector region, got %q", view) } @@ -131,11 +132,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") { + if !strings.Contains(view, "Evidence path: promotion for run-1 -> artifacts (run evidence for run-1) -> audit (run run-1)") { t.Fatalf("expected typed flow-path summary 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, "verification posture (Audit) -> anchor/export actions where available") { + t.Fatalf("expected evidence trail copy in flow path, got %q", view) } updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) @@ -149,7 +150,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,7 +187,7 @@ 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) } } @@ -207,6 +208,11 @@ func TestRunsReloadKeepsSelectedDetailAligned(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Selected run", + "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) } @@ -257,7 +263,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") { 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}) @@ -333,10 +339,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") { 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) } } diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index 22202def..fb91532c 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "sort" "strings" "github.com/runecode-ai/runecode/internal/brokerapi" @@ -9,7 +10,7 @@ import ( 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() @@ -24,14 +25,12 @@ func renderRunInspector(detail *brokerapi.RunDetail, presentation contentPresent 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), LocalActions: runInspectorLocalActions(), CopyActions: runRouteCopyActions(detail), ModeTabs: []string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, @@ -42,6 +41,7 @@ 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}}}, @@ -49,16 +49,41 @@ func runInspectorLocalActions() []routeActionItem { } } +func runInspectorReferences(detail *brokerapi.RunDetail) []inspectorReference { + if detail == nil { + return nil + } + refs := []inspectorReference{} + if sessionID := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")); 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(detail.PendingApprovalIDs, func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "approval", RouteID: routeApprovals, ApprovalID: id}} + })}, + inspectorReference{Label: "artifacts", Items: mapReferenceIDs(runArtifactReferenceLabels(detail), func(id string) paletteActionMsg { + return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeArtifacts}} + })}, + inspectorReference{Label: "audit", Items: mapReferenceIDs(runAuditReferenceLabels(detail), 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) + 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 } @@ -86,19 +111,26 @@ func waitingRoleCount(roles []brokerapi.RunRoleSummary) int { func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDetail, waitingStages int, waitingRoles int, 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( + runOutcomeHeadline(summary, detail), + fmt.Sprintf("Workflow operation: %s", runWorkflowOperation(summary, detail)), + 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("Navigation cues: session=%s approvals=%d artifacts=%d audit=%d", valueOrNA(authoritativeString(detail.AuthoritativeState, "session_id")), len(detail.PendingApprovalIDs), runArtifactCount(detail), runAuditReferenceCount(detail)), 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), @@ -119,6 +151,119 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta ) } +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), summary.PendingApprovalCount) +} + +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("workflow=%s blocked=%t reason=%s", 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 runWorkflowOperation(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { + if workflow := strings.TrimSpace(summary.WorkflowKind); workflow != "" { + return workflow + } + return valueOrNA(authoritativeString(detail.AuthoritativeState, "workflow_kind")) +} + +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 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) +} + +func runEvidenceSummary(detail *brokerapi.RunDetail) string { + 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{} + if len(detail.ActiveManifestHashes) > 0 { + labels = append(labels, fmt.Sprintf("manifests (%d)", len(detail.ActiveManifestHashes))) + } + if len(detail.LatestPolicyDecisionRefs) > 0 { + labels = append(labels, fmt.Sprintf("policy refs (%d)", len(detail.LatestPolicyDecisionRefs))) + } + 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) diff --git a/cmd/runecode-tui/route_status.go b/cmd/runecode-tui/route_status.go index 9f3cb695..58cad133 100644 --- a/cmd/runecode-tui/route_status.go +++ b/cmd/runecode-tui/route_status.go @@ -28,7 +28,7 @@ type projectSubstrateActionResultMsg struct { err error } -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 = "" @@ -112,7 +112,7 @@ func (m statusRouteModel) handleStatusLoaded(msg statusLoadedMsg) (routeModel, t m.data = msg m.errText = "" 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,12 +127,12 @@ 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) } @@ -160,18 +160,55 @@ 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...") + 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) @@ -184,10 +221,11 @@ func (m statusRouteModel) View(width, height int, focus focusArea) string { 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), + renderLifecycleOperationCard(m.data.lifecycle), renderLifecycleStatusLine(m.data.lifecycle), renderLifecycleBlockedReasonLine(m.data.lifecycle), renderLifecycleDegradedReasonLine(m.data.lifecycle), - renderLifecycleAttachGuidance(m.data.lifecycle), + renderProjectSubstrateStatusCard(m.data.project), renderProjectSubstrateStatusLine(m.data.project), renderProjectSubstrateGuidance(m.data.project), renderBackendPostureLine(m.data.posture), @@ -196,57 +234,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 +252,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..7fd6541d 100644 --- a/cmd/runecode-tui/route_status_project_substrate.go +++ b/cmd/runecode-tui/route_status_project_substrate.go @@ -25,15 +25,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 } @@ -47,7 +47,7 @@ func (m statusRouteModel) projectSubstrateAdoptCmd() tea.Cmd { if err != nil { return projectSubstrateActionResultMsg{err: err} } - status := fmt.Sprintf("Project substrate adopt status=%s", valueOrNA(resp.Adoption.Status)) + status := fmt.Sprintf("Project setup adoption: status=%s mutation=none", valueOrNA(resp.Adoption.Status)) if len(resp.Adoption.ReasonCodes) > 0 { status += " reasons=" + joinCSV(resp.Adoption.ReasonCodes) } @@ -63,10 +63,13 @@ func (m statusRouteModel) projectSubstrateInitPreviewCmd() tea.Cmd { if err != nil { return projectSubstrateActionResultMsg{err: err} } - status := fmt.Sprintf("Project substrate init preview status=%s token=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewToken)) + status := fmt.Sprintf("Project setup init preview: status=%s mutation=preview_only handle=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewToken)) if len(resp.Preview.ReasonCodes) > 0 { status += " reasons=" + joinCSV(resp.Preview.ReasonCodes) } + if strings.TrimSpace(resp.Preview.PreviewToken) == "" { + status += " next=reload_or_retry_preview" + } return projectSubstrateActionResultMsg{status: status} } } @@ -78,16 +81,17 @@ 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")} } applyResp, err := m.client.ProjectSubstrateInitApply(ctx, token) if err != nil { return projectSubstrateActionResultMsg{err: err} } - status := fmt.Sprintf("Project substrate init apply status=%s preview_status=%s token=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(token)) + status := fmt.Sprintf("Project setup init apply: status=%s preview_status=%s mutation=applied handle=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(token)) if len(applyResp.ApplyResult.ReasonCodes) > 0 { status += " reasons=" + joinCSV(applyResp.ApplyResult.ReasonCodes) } + status += " next=reload_validation_status" return projectSubstrateActionResultMsg{status: status} } } @@ -100,10 +104,13 @@ func (m statusRouteModel) projectSubstrateUpgradePreviewCmd() tea.Cmd { if err != nil { return projectSubstrateActionResultMsg{err: err} } - status := fmt.Sprintf("Project substrate upgrade preview status=%s digest=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewDigest)) + status := fmt.Sprintf("Project setup upgrade preview: status=%s mutation=preview_only digest=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewDigest)) if len(resp.Preview.ReasonCodes) > 0 { status += " reasons=" + joinCSV(resp.Preview.ReasonCodes) } + if strings.TrimSpace(resp.Preview.PreviewDigest) == "" { + status += " next=reload_or_retry_preview" + } return projectSubstrateActionResultMsg{status: status} } } @@ -115,16 +122,17 @@ 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")} } applyResp, err := m.client.ProjectSubstrateUpgradeApply(ctx, digest) if err != nil { return projectSubstrateActionResultMsg{err: err} } - status := fmt.Sprintf("Project substrate upgrade apply status=%s preview_status=%s digest=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(digest)) + status := fmt.Sprintf("Project setup upgrade apply: status=%s preview_status=%s mutation=applied digest=%s", valueOrNA(applyResp.ApplyResult.Status), valueOrNA(preview.Status), projectSubstrateHandleDisplay(digest)) if len(applyResp.ApplyResult.ReasonCodes) > 0 { status += " reasons=" + joinCSV(applyResp.ApplyResult.ReasonCodes) } + status += " next=reload_validation_status" return projectSubstrateActionResultMsg{status: status} } } diff --git a/cmd/runecode-tui/route_status_render.go b/cmd/runecode-tui/route_status_render.go new file mode 100644 index 00000000..e76be08b --- /dev/null +++ b/cmd/runecode-tui/route_status_render.go @@ -0,0 +1,219 @@ +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 := fmt.Sprintf("Broker lifecycle posture=%s attach_mode=%s", valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode)) + 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 details: unavailable" + } + return fmt.Sprintf("Managed-operation details: instance=%s generation=%s posture=%s attach_mode=%s attachable=%t normal_operation_allowed=%t", valueOrNA(posture.ProductInstanceID), valueOrNA(posture.LifecycleGeneration), valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode), posture.Attachable, posture.NormalOperationAllowed) +} + +func renderLifecycleBlockedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return "Managed-operation blocking reasons: unavailable" + } + if len(posture.BlockedReasonCodes) == 0 { + return "Managed-operation blocking reasons: none" + } + return "Managed-operation blocking reasons: " + joinCSV(posture.BlockedReasonCodes) +} + +func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { + if strings.TrimSpace(posture.SchemaID) == "" { + return "Managed-operation degraded reasons: unavailable" + } + if len(posture.DegradedReasonCodes) == 0 { + return "Managed-operation degraded reasons: none" + } + return "Managed-operation degraded reasons: " + joinCSV(posture.DegradedReasonCodes) +} + +func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) string { + parts := []string{} + if len(posture.BlockedReasonCodes) > 0 { + parts = append(parts, "blocked="+joinCSV(posture.BlockedReasonCodes)) + } + if len(posture.DegradedReasonCodes) > 0 { + parts = append(parts, "degraded="+joinCSV(posture.DegradedReasonCodes)) + } + if len(parts) == 0 { + return fmt.Sprintf("Broker lifecycle posture=%s attach_mode=%s", valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode)) + } + return strings.Join(parts, " • ") +} + +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 setup details: unavailable" + } + return fmt.Sprintf("Project setup details: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed) +} + +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 := fmt.Sprintf("validation=%s compatibility=%s", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture)) + 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("Guided setup/remediation flow")} + parts = append(parts, fmt.Sprintf("Inspect current posture: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed)) + parts = append(parts, fmt.Sprintf("Compatible adoption (a): no mutation; broker only records compatible existing substrate status=%s", projectSubstrateStepStatus(posture.Adoption.Status))) + parts = append(parts, fmt.Sprintf("Init preview/apply (i/I): preview status=%s mutation=%s handle=%s", projectSubstrateStepStatus(posture.InitPreview.Status), projectSubstrateMutationLabel(posture.InitPreview.Status, posture.InitPreview.PreviewToken), projectSubstrateHandleDisplay(posture.InitPreview.PreviewToken))) + parts = append(parts, fmt.Sprintf("Upgrade preview/apply (u/U): preview status=%s mutation=%s digest=%s", projectSubstrateStepStatus(posture.UpgradePreview.Status), projectSubstrateMutationLabel(posture.UpgradePreview.Status, posture.UpgradePreview.PreviewDigest), projectSubstrateHandleDisplay(posture.UpgradePreview.PreviewDigest))) + 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: "+joinCSV(posture.RemediationGuidance)) + } + if !summary.NormalOperationAllowed { + parts = append(parts, "Next action: use adopt if the repository is already compatible; otherwise run preview before any apply, then reload to validate resulting posture.") + } else if strings.Contains(strings.ToLower(strings.TrimSpace(summary.CompatibilityPosture)), "upgrade") { + parts = append(parts, "Next action: managed work is allowed, but review the broker-owned upgrade preview and revalidate after any apply.") + } else { + parts = append(parts, "Next action: no setup remediation is required right now; reload after any external change if you want fresh broker validation.") + } + 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="+joinCSV(summary.BlockedReasonCodes)) + } + if len(summary.ReasonCodes) > 0 { + parts = append(parts, "reasons="+joinCSV(summary.ReasonCodes)) + } + if explanation := strings.TrimSpace(posture.BlockedExplanation); explanation != "" { + parts = append(parts, sanitizeUIText(explanation)) + } + if len(parts) == 0 { + return fmt.Sprintf("validation=%s compatibility=%s", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture)) + } + return strings.Join(parts, " • ") +} + +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..8635eeaf 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:", + "Managed operation", + "Attach and normal managed operation are available.", + "Managed-operation details:", "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:", + "Managed-operation blocking reasons: none", + "Managed-operation degraded reasons: none", + "Project setup", + "Project setup is usable, but a broker-owned upgrade is available.", + "Project setup details:", "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", + "Guided setup/remediation flow", + "Compatible adoption (a): no mutation", + "Init preview/apply (i/I):", + "Upgrade preview/apply (u/U):", + "Keys: r reload", ) } @@ -59,11 +60,11 @@ type statusRouteActionCase struct { 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: status=", expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, + {key: 'i', expectedStatus: "Project setup init preview: status=", expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, + {key: 'I', expectedStatus: "Project setup init apply: status=", expectedRPCCall: []string{"ProjectSubstrateInitApply"}}, + {key: 'u', expectedStatus: "Project setup upgrade preview: status=", expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, + {key: 'U', expectedStatus: "Project setup upgrade apply: status=", expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}}, } } @@ -164,13 +165,14 @@ func TestStatusRouteRendersDiagnosticsOnlyAttachGuidanceWhenNormalOperationBlock updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) mustContainAll(t, view, + "Managed operation", + "You can attach for diagnostics and remediation only; managed work stays blocked.", "attach_mode=diagnostics_only", - "lifecycle_posture=blocked", + "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 blocking reasons: project_substrate_unsupported_too_new", + "Managed-operation degraded reasons: project_substrate_upgrade_available", ) } @@ -224,11 +226,13 @@ func TestStatusRouteRendersBlockedProjectSubstrateGuidance(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Project substrate posture:", - "state=missing", + "Project setup", + "Project setup needs attention before managed work can continue.", + "Project setup details:", + "validation=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", + "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..731c9ee6 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, 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/shared_components.go b/cmd/runecode-tui/shared_components.go index 6c64b955..187a96fb 100644 --- a/cmd/runecode-tui/shared_components.go +++ b/cmd/runecode-tui/shared_components.go @@ -385,33 +385,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..8eb65b5a 100644 --- a/cmd/runecode-tui/shared_components_test.go +++ b/cmd/runecode-tui/shared_components_test.go @@ -216,3 +216,35 @@ 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.", + ) +} diff --git a/cmd/runecode-tui/shared_state_card.go b/cmd/runecode-tui/shared_state_card.go new file mode 100644 index 00000000..eab8f61d --- /dev/null +++ b/cmd/runecode-tui/shared_state_card.go @@ -0,0 +1,83 @@ +package main + +import "strings" + +type stateCardSpec struct { + State routeLoadState + Title string + Message string + Reason string + NextAction string + ShortcutCue string + RouteCue 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{ + tableHeader(strings.ToUpper(label)) + " " + strings.TrimSpace(title), + appTheme.SurfaceCard.Padding(0, 1).Render(compactLines("Message: "+message, "Reason: "+reason)), + } + if nextAction != "" { + lines = append(lines, muted("Next: "+nextAction)) + } + if cues != "" { + lines = append(lines, muted(cues)) + } + return compactLines(lines...) +} + +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/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_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index b3c729b0..d3226524 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -18,17 +18,19 @@ func (m shellModel) renderBottomStrip(surface routeSurface) string { if bottom == "" { bottom = muted("No route composer or status actions for this screen.") } - selectionHint := "Selection mode off; mouse capture remains enabled." - if m.selectionMode { - selectionHint = "Selection mode on; drag-to-select is enabled until you exit it." + discovery := m.renderQuitDiscoverabilityHint() + if routeCue := strings.TrimSpace(m.renderRouteActionHints(surface)); routeCue != "" { + if discovery != "" { + discovery += " | " + } + discovery += routeCue } - selectionHint = m.renderQuitDiscoverabilityHint() + " | " + selectionHint + diagnostic := m.renderBottomDiagnosticLine() return compactLines( tableHeader("Bottom strip"), bottom, - m.renderRouteActionHints(surface), - m.renderRouteCopyActions(), - selectionHint, + discovery, + muted(diagnostic), ) } @@ -47,15 +49,15 @@ func (m shellModel) renderQuitDiscoverabilityHint() string { 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))) + parts = append(parts, fmt.Sprintf("%d linked refs", len(surface.Actions.ReferenceActions))) } if len(surface.Actions.LocalActions) > 0 { - parts = append(parts, fmt.Sprintf("Local actions executable=%d", len(surface.Actions.LocalActions))) + parts = append(parts, fmt.Sprintf("%d local actions", len(surface.Actions.LocalActions))) } if len(parts) == 0 { - return muted("Actionable refs/actions: none for the current view") + return "" } - return "Actionable refs/actions: " + strings.Join(parts, " | ") + return "Next actions: " + strings.Join(parts, " | ") } func (m shellModel) renderStatusSurface(surface routeSurface) string { @@ -63,31 +65,19 @@ func (m shellModel) renderStatusSurface(surface routeSurface) string { 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()) + return "Status: " + status } -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).") +func (m shellModel) renderBottomDiagnosticLine() string { + parts := []string{} + if m.selectionMode { + parts = append(parts, "Selection mode on") } - 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) + if actions := len(m.activeShellSurface().Actions.CopyActions); actions > 0 { + parts = append(parts, fmt.Sprintf("copy actions %d via action entry", actions)) + } + if len(parts) == 0 { + parts = append(parts, "Use Status, inspectors, or command discovery for detailed diagnostics") } - return "Copy actions (use action entry): " + strings.Join(items, " | ") + return strings.Join(parts, " • ") } diff --git a/cmd/runecode-tui/shell_layout.go b/cmd/runecode-tui/shell_layout.go index 8093be4e..20e7b6ec 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 = 4 shellStatusHeight = 1 - shellFooterHeight = 3 + shellFooterHeight = 2 ) func shellChromeReservedHeight() int { diff --git a/cmd/runecode-tui/shell_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index a9cce8ef..ea44a40b 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -390,7 +390,7 @@ 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):") { + if !strings.Contains(bottom, "copy actions 3 via action entry") { t.Fatalf("expected updated copy hint wording, got %q", bottom) } if strings.Contains(bottom, "Y cycles/copies") { diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index fdfd6880..404a946a 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -150,7 +150,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 } diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 0c9d7c07..b5f5503a 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -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", "Product truth:", "Main pane", "Sidebar", "Bottom strip", "Status:"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in view, got %q", want, v) } } + for _, retired := range []string{"Path:", "History:", "clipboard="} { + 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) @@ -559,7 +564,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) } } @@ -613,8 +618,8 @@ 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) + if !strings.Contains(v, "Use Status, inspectors, or command discovery for detailed diagnostics") { + t.Fatalf("expected calm diagnostic hint in bottom strip, got %q", v) } } @@ -855,7 +860,7 @@ func TestShellWatchManagerUpdatesRoutesAndSyncHealth(t *testing.T) { } view := shell.View() mustContainAll(t, view, - "Sync health:", + "Product truth:", "sync=healthy", "last_event=run_watch_terminal subject=run-1 status=completed", "event=session_watch_terminal subject=session-1 status=completed", diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index f2a53f45..7c9664b0 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -42,10 +42,6 @@ func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, la 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)) @@ -60,9 +56,7 @@ 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(constrainShellBlock(muted("Truth via typed broker state; details in Status, inspectors, and command discovery."), viewportWidth, 1)) b.WriteString("\n") } @@ -200,27 +194,25 @@ 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 TUI α shell"), neutralBadge("THEME "+string(m.themePreset))) + routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("RuneCode Workbench"), 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), + fmt.Sprintf("Route %s", sanitizeUIText(m.routeLabel(m.currentRouteID()))), + fmt.Sprintf("Focus %s", strings.ToUpper(m.focus.Label())), + activity, } - if routeInspectorAvailable(surface) { - workbenchSummary = append(workbenchSummary, fmt.Sprintf("inspector=%t", layout.InspectorVisible)) + if activeWork := strings.TrimSpace(m.renderActiveWorkSummary()); activeWork != "" { + workbenchSummary = append(workbenchSummary, activeWork) } if strings.TrimSpace(m.activeSessionID) != "" { - workbenchSummary = append(workbenchSummary, fmt.Sprintf("session=%s", sanitizeUIText(m.activeSessionID))) + workbenchSummary = append(workbenchSummary, fmt.Sprintf("Session %s", sanitizeUIText(m.activeSessionID))) + } + if diag := strings.TrimSpace(m.renderChromeDiagnosticHint(surface, layout)); diag != "" { + workbenchSummary = append(workbenchSummary, muted(diag)) } 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, " • ")+renderRunningSuffix(m.renderRunningIndicator())), ) } @@ -233,10 +225,11 @@ func renderRunningSuffix(indicator string) string { } func (m shellModel) renderSyncHealth() string { - text := "Sync health: " + renderShellSyncState(m.watch.projection.Health.State) + text := "Product truth: " + 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))) } + text += " • " + muted(localBrokerBoundaryPosture()) if strings.TrimSpace(m.watch.projection.Health.ErrorText) != "" { text += " " + muted("("+sanitizeUIText(m.watch.projection.Health.ErrorText)+")") } @@ -285,6 +278,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" From 1b8d34cd518c30926bdde37784a31b22a235b5a9 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Fri, 8 May 2026 21:02:27 -0500 Subject: [PATCH 03/39] CLoses gaps found between implwmwntation and change docs Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/command_surface.go | 2 +- cmd/runecode-tui/help.go | 17 +- cmd/runecode-tui/help_test.go | 11 + .../local_rpc_integration_linux_test.go | 6 +- cmd/runecode-tui/route_action_center.go | 43 ++-- .../route_action_center_blocked_helpers.go | 65 ++++++ .../route_action_center_helpers.go | 92 ++++---- .../route_action_center_render.go | 26 ++- cmd/runecode-tui/route_action_center_test.go | 38 +++- .../route_approvals_followup_helpers.go | 26 ++- cmd/runecode-tui/route_approvals_helpers.go | 142 ++++-------- .../route_approvals_resolve_helpers.go | 9 +- .../route_approvals_state_helpers.go | 176 +++++++++++++++ .../route_approvals_validation_test.go | 46 ++++ cmd/runecode-tui/route_artifacts_detail.go | 48 ++-- cmd/runecode-tui/route_audit_render.go | 24 +- cmd/runecode-tui/route_audit_status_test.go | 4 +- cmd/runecode-tui/route_chat.go | 59 ++--- .../route_chat_execution_helpers.go | 201 +++++------------ .../route_chat_execution_helpers_test.go | 39 +++- .../route_chat_execution_state_helpers.go | 169 ++++++++++++++ cmd/runecode-tui/route_chat_inspector.go | 14 +- cmd/runecode-tui/route_chat_loading.go | 182 +++++++++++++++ cmd/runecode-tui/route_chat_rendering.go | 13 +- cmd/runecode-tui/route_chat_state.go | 171 ++------------- cmd/runecode-tui/route_chat_test.go | 207 ++++++++++++------ cmd/runecode-tui/route_contract_usage_test.go | 2 +- cmd/runecode-tui/route_dashboard.go | 27 ++- cmd/runecode-tui/route_dashboard_helpers.go | 159 +++++++------- cmd/runecode-tui/route_dashboard_test.go | 31 ++- cmd/runecode-tui/route_runs.go | 2 + .../route_runs_approvals_artifacts_test.go | 105 +++++++-- cmd/runecode-tui/route_runs_detail.go | 106 +++++++-- cmd/runecode-tui/route_status.go | 80 +++++-- .../route_status_project_substrate.go | 201 ++++++++++++++--- cmd/runecode-tui/route_status_render.go | 17 +- cmd/runecode-tui/route_status_test.go | 149 +++++++++++-- cmd/runecode-tui/route_tests_helpers_test.go | 126 ++++------- ...te_tests_project_substrate_helpers_test.go | 43 ++++ .../route_tests_provider_helpers_test.go | 43 ++++ .../shell_action_metadata_test.go | 19 ++ cmd/runecode-tui/shell_chrome_test.go | 35 +++ cmd/runecode-tui/shell_footer_view.go | 50 +++-- cmd/runecode-tui/shell_modes_quit_test.go | 17 +- cmd/runecode-tui/shell_render_helpers.go | 17 +- cmd/runecode-tui/shell_test.go | 26 +-- cmd/runecode-tui/shell_view.go | 40 ++-- runner/package-lock.json | 6 +- 48 files changed, 2167 insertions(+), 964 deletions(-) create mode 100644 cmd/runecode-tui/route_action_center_blocked_helpers.go create mode 100644 cmd/runecode-tui/route_approvals_state_helpers.go create mode 100644 cmd/runecode-tui/route_chat_execution_state_helpers.go create mode 100644 cmd/runecode-tui/route_chat_loading.go create mode 100644 cmd/runecode-tui/route_tests_project_substrate_helpers_test.go create mode 100644 cmd/runecode-tui/route_tests_provider_helpers_test.go create mode 100644 cmd/runecode-tui/shell_action_metadata_test.go create mode 100644 cmd/runecode-tui/shell_chrome_test.go diff --git a/cmd/runecode-tui/command_surface.go b/cmd/runecode-tui/command_surface.go index 7171aba9..831526f5 100644 --- a/cmd/runecode-tui/command_surface.go +++ b/cmd/runecode-tui/command_surface.go @@ -88,7 +88,7 @@ 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.Reason) == "" { continue diff --git a/cmd/runecode-tui/help.go b/cmd/runecode-tui/help.go index 3aa8e71e..4b6ff8b7 100644 --- a/cmd/runecode-tui/help.go +++ b/cmd/runecode-tui/help.go @@ -21,8 +21,23 @@ func renderHelp(keys shellKeyMap, paletteOpen bool, actions shellActionGraph) st h := help.New() h.ShowAll = false view := "Help: " + h.View(shellHelpKeys(bubbleBindings)) - if entries := actions.helpEntries(6); len(entries) > 0 { + if entries := compactActionHelpEntries(actions.helpEntries(4)); len(entries) > 0 { view += " | Actions: " + strings.Join(entries, " · ") } return view } + +func compactActionHelpEntries(entries []string) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if before, _, ok := strings.Cut(entry, " — "); ok { + entry = strings.TrimSpace(before) + } + out = append(out, entry) + } + return out +} diff --git a/cmd/runecode-tui/help_test.go b/cmd/runecode-tui/help_test.go index a0fa92d1..0cd91a4d 100644 --- a/cmd/runecode-tui/help_test.go +++ b/cmd/runecode-tui/help_test.go @@ -32,3 +32,14 @@ func TestShellViewLeaderOverlayKeepsBaseFooterHelp(t *testing.T) { 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) + if !strings.Contains(help, "Help:") || !strings.Contains(help, "Actions:") { + t.Fatalf("expected compact footer help with actions discovery, got %q", help) + } + if strings.Contains(help, "copy current object identity") { + t.Fatalf("expected footer actions summary not to expand into long-form descriptions, 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 2176253e..9a9bc15c 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -232,14 +232,14 @@ func assertDashboardAndChatBackedRoutes(t *testing.T, recording localBrokerClien "Safety strip", "backend_kind=unknown", "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", + "Live activity detail is available here when you need typed broker watch confirmation.", "feed: waiting for shell watch manager", ) chat := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, recording) assertRouteOutputContainsAll(t, chat, routeChat, "Active session", - "Session session-tui in workspace workspace-local is active.", + "Canonical session session-tui in workspace workspace-local is active.", "Composer is idle.", ) assertRouteInspectorContainsAll(t, chat, routeChat, @@ -265,7 +265,7 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { "Approval posture", ) assertRouteInspectorContainsAll(t, approvals, routeApprovals, - "Approval trigger code:", + "Why this approval exists:", "Exact gated object/action:", ) diff --git a/cmd/runecode-tui/route_action_center.go b/cmd/runecode-tui/route_action_center.go index e23cd711..a9c94148 100644 --- a/cmd/runecode-tui/route_action_center.go +++ b/cmd/runecode-tui/route_action_center.go @@ -34,15 +34,22 @@ type actionCenterItem struct { 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 @@ -63,6 +70,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{ @@ -164,24 +172,23 @@ func (m actionCenterRouteModel) View(width, height int, focus focusArea) string RouteCue: "Status or Dashboard", }) } - families := m.familyBuckets() - summary := buildActionCenterSummary(families) + vm := m.snapshot() return compactLines( sectionTitle("Action Center")+" "+focusBadge(focus), renderStateCardSpec(stateCardSpec{ - State: summary.State, - Title: summary.Title, - Message: summary.Message, - Reason: summary.Reason, - NextAction: summary.NextAction, + 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", }), - 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("Queue families: %s=%d %s=%d %s=%d", infoBadge("approvals"), len(vm.Families[actionCenterFamilyApprovals]), warnBadge("operational_attention"), len(vm.Families[actionCenterFamilyOps]), dangerBadge("blocked_work_impact"), len(vm.Families[actionCenterFamilyBlocked])), fmt.Sprintf("Active triage family: %s", stateBadgeWithLabel("family", string(m.family))), "Action Center is the operator home for broker-known follow-up. Question/answer queues remain reserved for future canonical broker models and are intentionally not implemented locally.", - renderActionCenterDirectory("Approvals queue", families[actionCenterFamilyApprovals], m.selectedIndex(actionCenterFamilyApprovals, len(families[actionCenterFamilyApprovals])), width), - renderActionCenterDirectory("Operational attention", families[actionCenterFamilyOps], m.selectedIndex(actionCenterFamilyOps, len(families[actionCenterFamilyOps])), width), - renderActionCenterDirectory("Blocked-work impact", families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(families[actionCenterFamilyBlocked])), width), + renderActionCenterDirectory("Approvals queue", 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 impact", vm.Families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(vm.Families[actionCenterFamilyBlocked])), width), muted("If every bucket is empty, the control plane is currently waiting on new canonical work or operator intervention."), keyHint("Route keys: [/] change family, j/k move, enter drill-down, i toggle inspector, r reload"), ) @@ -194,8 +201,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 { @@ -213,6 +220,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": 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..5cf96901 --- /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; pending_approvals=%d linked_queue_items=%d.", 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=%s", valueOrNA(run.LifecycleState))} + if strings.TrimSpace(run.BlockingReasonCode) != "" { + reasonParts = append(reasonParts, fmt.Sprintf("reason=%s", valueOrNA(run.BlockingReasonCode))) + } + if blockedCount > 0 { + reasonParts = append(reasonParts, fmt.Sprintf("approval_queue_items=%d", 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 b174d14d..b4e902a0 100644 --- a/cmd/runecode-tui/route_action_center_helpers.go +++ b/cmd/runecode-tui/route_action_center_helpers.go @@ -18,8 +18,12 @@ type actionCenterSummary struct { } 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), + 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), } @@ -32,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) @@ -54,7 +58,7 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary) []actionCenterI if expiryCue != "no_expiry" { reasons = append(reasons, expiryCue) } - impact := fmt.Sprintf("Workflow work stays gated for run %s stage %s action %s.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) + impact := fmt.Sprintf("Workflow progress stays gated for run %s stage %s action %s until this decision is resolved.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) 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{ @@ -63,14 +67,15 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary) []actionCenterI Urgency: urgency, Reason: strings.Join(reasons, "; "), Impact: impact, - RequiredAction: "Open Approvals, review the exact gated action and evidence, then decide.", + 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", State: routeLoadStateEmpty, Urgency: "low", Reason: "canonical approval queue is currently empty", Impact: "No workflow work is waiting on approval decisions from the current broker surfaces.", RequiredAction: "No approval follow-up is needed right now.", TargetLabel: "Approvals", EvidenceCue: "source=approval_list empty"}} + return []actionCenterItem{{Title: "no pending approvals", State: routeLoadStateEmpty, Urgency: "low", Reason: "canonical 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 } @@ -128,7 +133,7 @@ func buildOperationalAttentionItems(audit *brokerapi.AuditVerificationGetRespons items = append(items, operationalRunItems(runs)...) items = append(items, operationalProjectSetupItems(project)...) if len(items) == 0 { - 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.", RequiredAction: "No operational remediation is needed right now.", TargetLabel: "Action Center", EvidenceCue: "source=operational_attention empty"}} + 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 } @@ -147,6 +152,7 @@ func operationalSyncHealthItem(health shellSyncHealth) []actionCenterItem { Urgency: urgency, Reason: fmt.Sprintf("watch sync state=%s error=%s", health.State, defaultPlaceholder(health.ErrorText, "n/a")), 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), @@ -170,6 +176,7 @@ func operationalWatchFamilyItems(watch dashboardLiveActivity) []actionCenterItem Urgency: urgency, Reason: fmt.Sprintf("errors=%d last_status=%s last_subject=%s", family.errorCount, valueOrNA(family.lastStatus), valueOrNA(family.lastSubject)), 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)), @@ -187,6 +194,7 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Urgency: "high", Reason: fmt.Sprintf("audit verification load failed: %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", @@ -210,6 +218,7 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Urgency: urgency, Reason: fmt.Sprintf("integrity=%s anchoring=%s degraded=%t", valueOrNA(s.IntegrityStatus), valueOrNA(s.AnchoringStatus), s.CurrentlyDegraded), Impact: fmt.Sprintf("Evidence confidence is reduced; hard_failures=%d degraded_reasons=%d.", 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), @@ -220,15 +229,30 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE 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=%s", valueOrNA(run.LifecycleState))} + if run.RuntimePostureDegraded { + reasonParts = append(reasonParts, "runtime_degraded=true") + } + if run.AuditCurrentlyDegraded { + reasonParts = append(reasonParts, "audit_degraded=true") + } items = append(items, actionCenterItem{ Title: fmt.Sprintf("run %s operational posture", valueOrNA(run.RunID)), State: routeLoadStateDegraded, - Urgency: "high", - Reason: fmt.Sprintf("runtime_degraded=%t audit_degraded=%t", run.RuntimePostureDegraded, run.AuditCurrentlyDegraded), - Impact: fmt.Sprintf("Execution confidence is reduced for backend=%s isolation=%s.", valueOrNA(run.BackendKind), valueOrNA(run.IsolationAssuranceLevel)), + Urgency: urgency, + Reason: strings.Join(reasonParts, " "), + Impact: fmt.Sprintf("Execution confidence is reduced for run %s on backend=%s isolation=%s.", valueOrNA(run.RunID), valueOrNA(run.BackendKind), valueOrNA(run.IsolationAssuranceLevel)), + 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)), @@ -254,6 +278,7 @@ func operationalProjectSetupItems(project brokerapi.ProjectSubstratePostureGetRe 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)), @@ -263,52 +288,21 @@ func operationalProjectSetupItems(project brokerapi.ProjectSubstratePostureGetRe 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] - } - requiredAction := "Open Runs to inspect the blocked reason and linked approvals, then continue from the target route." - stateCard := routeLoadStateBlocked - if run.PendingApprovalCount > 0 { - requiredAction = "Open Approvals or Runs, review the exact gate, then decide so workflow progress can resume." - } - items = append(items, actionCenterItem{ - Title: fmt.Sprintf("run %s blocked impact", valueOrNA(run.RunID)), - State: stateCard, - Urgency: urgency, - Reason: fmt.Sprintf("lifecycle=%s reason=%s", valueOrNA(run.LifecycleState), valueOrNA(run.BlockingReasonCode)), - Impact: fmt.Sprintf("Workflow progress is waiting; pending_approvals=%d linked_queue_items=%d.", run.PendingApprovalCount, blockedCount), - 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}, - }) + items = append(items, blockedImpactItem(run, linkedApprovalCount(run, byRun))) } if len(items) == 0 { - 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.", RequiredAction: "No blocked-work follow-up is needed right now.", TargetLabel: "Runs", EvidenceCue: "source=run_list no_blocked_items"}} + 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 (m *actionCenterRouteModel) normalizeSelection() { - buckets := m.familyBuckets() + buckets := m.snapshot().Families for _, family := range []actionCenterFamily{actionCenterFamilyApprovals, actionCenterFamilyOps, actionCenterFamilyBlocked} { max := len(buckets[family]) if max <= 0 { @@ -325,7 +319,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 @@ -342,7 +336,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 index 5bdd7e89..b82b60e1 100644 --- a/cmd/runecode-tui/route_action_center_render.go +++ b/cmd/runecode-tui/route_action_center_render.go @@ -33,7 +33,7 @@ func renderActionCenterItem(item actionCenterItem, width int) string { fmt.Sprintf("%s %s urgency=%s", tableHeader(strings.ToUpper(string(item.State))), item.Title, valueOrNA(item.Urgency)), " reason: " + valueOrNA(item.Reason), " impact: " + valueOrNA(item.Impact), - " action: " + valueOrNA(item.RequiredAction), + " owner/action: " + valueOrNA(strings.TrimSpace(strings.Join([]string{item.Owner, item.RequiredAction}, " • "))), " target: " + valueOrNA(item.TargetLabel), " evidence: " + valueOrNA(item.EvidenceCue), } @@ -60,6 +60,7 @@ func renderActionCenterInspector(family actionCenterFamily, items []actionCenter fmt.Sprintf(" urgency=%s", valueOrNA(item.Urgency)), fmt.Sprintf(" reason=%s", valueOrNA(item.Reason)), fmt.Sprintf(" impact=%s", valueOrNA(item.Impact)), + fmt.Sprintf(" owner=%s", valueOrNA(item.Owner)), fmt.Sprintf(" required_action=%s", valueOrNA(item.RequiredAction)), fmt.Sprintf(" target=%s", valueOrNA(item.TargetLabel)), fmt.Sprintf(" evidence=%s", valueOrNA(item.EvidenceCue)), @@ -70,19 +71,30 @@ func renderActionCenterInspector(family actionCenterFamily, items []actionCenter func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem) actionCenterSummary { approvals := countActiveActionCenterItems(families[actionCenterFamilyApprovals]) ops := countActiveActionCenterItems(families[actionCenterFamilyOps]) - blocked := countActiveActionCenterItems(families[actionCenterFamilyBlocked]) + blocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateBlocked) + approvalBlocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateApprovalRequired) if blocked > 0 { - return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked follow-up", Message: "Action Center has broker-known blockers that are holding workflow progress.", Reason: fmt.Sprintf("blocked_work_impact=%d operational_attention=%d approvals=%d", blocked, ops, approvals), NextAction: "Start with blocked-work impact items, then review linked approvals or setup guidance."} + return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress is blocked and needs operator follow-up.", Reason: fmt.Sprintf("blocked_work_impact=%d approval_gated_work=%d operational_attention=%d approvals=%d", blocked, approvalBlocked, ops, approvals), NextAction: "Start with blocked-work impact items, then review linked approvals or setup guidance."} } - if ops > 0 { - return actionCenterSummary{State: routeLoadStateDegraded, Title: "Needs operational attention", Message: "Action Center has degraded runtime, evidence, setup, or sync follow-up to review.", Reason: fmt.Sprintf("operational_attention=%d approvals=%d", ops, approvals), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} + if approvals > 0 || approvalBlocked > 0 { + return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", Message: "Approval decisions are waiting on the operator.", Reason: fmt.Sprintf("approvals=%d approval_gated_work=%d operational_attention=%d", approvals, approvalBlocked, ops), NextAction: "Open the approvals queue and review the exact gated action before deciding."} } - if approvals > 0 { - return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Approval follow-up waiting", Message: "Action Center has broker-known approval decisions waiting on the operator.", Reason: fmt.Sprintf("approvals=%d", approvals), NextAction: "Open the approvals queue and review the exact gated action before deciding."} + if ops > 0 { + return actionCenterSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Runtime, evidence, setup, or sync posture needs review.", Reason: fmt.Sprintf("operational_attention=%d", ops), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} } return actionCenterSummary{State: routeLoadStateReady, Title: "No immediate follow-up", Message: "No broker-known attention items are currently waiting in Action Center.", Reason: "Approvals, blocked-work impact, and operational attention queues are clear on the current broker surfaces.", NextAction: "Return to Dashboard or Chat and continue work until new follow-up appears."} } +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 { diff --git a/cmd/runecode-tui/route_action_center_test.go b/cmd/runecode-tui/route_action_center_test.go index 9b9452e4..e8ab6989 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -29,7 +29,7 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) view := updated.View(140, 40, focusContent) mustContainAll(t, view, "Action Center", - "Blocked follow-up", + "Blocked", "Queue families:", "approvals", "operational_attention", @@ -38,6 +38,9 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) "Operational attention", "Blocked-work impact", "Action Center is the operator home for broker-known follow-up.", + "owner/action:", + "target:", + "evidence:", ) surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body @@ -47,6 +50,7 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) "urgency=", "reason=", "impact=", + "owner=", "required_action=", "evidence=", ) @@ -60,6 +64,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}) @@ -94,7 +99,7 @@ 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, @@ -104,7 +109,8 @@ func TestBuildApprovalActionItemsIncludesExpiryAndSupersededCues(t *testing.T) { "expiring_soon", "approval ap-super", "superseded", - "action:", + "owner/action:", + "owner: operator decision", "evidence:", ) } @@ -124,7 +130,31 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin "audit verification posture", "anchoring=degraded", "run run-1 operational posture", - "action:", + "owner/action:", + ) +} + +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, + "reason:", + "impact:", + "owner/action:", + "target:", + "evidence:", ) } diff --git a/cmd/runecode-tui/route_approvals_followup_helpers.go b/cmd/runecode-tui/route_approvals_followup_helpers.go index 48151578..cc25a9ab 100644 --- a/cmd/runecode-tui/route_approvals_followup_helpers.go +++ b/cmd/runecode-tui/route_approvals_followup_helpers.go @@ -23,6 +23,13 @@ func approvalEffectSummary(detail brokerapi.ApprovalDetail) string { } 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" } @@ -40,6 +47,19 @@ func approvalResolveSupported(summary brokerapi.ApprovalSummary, detail brokerap } 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" } @@ -48,13 +68,13 @@ func approvalResolveStatus(summary brokerapi.ApprovalSummary, detail brokerapi.A func approvalReviewFirst(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { if digest := strings.TrimSpace(detail.BoundIdentity.SummaryPreviewDigest); digest != "" { - return "summary preview artifact " + shortIdentity(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) + 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) + 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 diff --git a/cmd/runecode-tui/route_approvals_helpers.go b/cmd/runecode-tui/route_approvals_helpers.go index f0b08014..ab567e59 100644 --- a/cmd/runecode-tui/route_approvals_helpers.go +++ b/cmd/runecode-tui/route_approvals_helpers.go @@ -26,7 +26,7 @@ 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 %s %s", valueOrNA(item.ApprovalID), approvalDisplayLabel(item), approvalPrimaryStateBadge(item), approvalQueueReason(item))) + out = append(out, fmt.Sprintf("%s %s %s %s gate=%s", valueOrNA(item.ApprovalID), approvalDisplayLabel(item), approvalPrimaryStateBadge(item), approvalQueueReason(item), approvalBoundScopeCue(item))) } return out } @@ -61,9 +61,7 @@ func renderApprovalInspector(resp *brokerapi.ApprovalGetResponse, presentation c {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), @@ -77,6 +75,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"}, } @@ -93,6 +92,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 } @@ -120,12 +147,15 @@ func approvalInspectorContent(summary brokerapi.ApprovalSummary, detail brokerap } return compactLines( fmt.Sprintf("Approval state: %s %s", approvalUIValue(approvalDisplayState(summary, detail)), approvalPrimaryStateBadge(summary)), + fmt.Sprintf("State taxonomy: approval-required=%s pending=%s resolved=%s expired=%s unsupported=%s", boolWord(approvalDisplayState(summary, detail) == "approval required"), boolWord(approvalLifecycleState(detail) == "pending"), boolWord(workflowApprovalState(summary, detail) == "resolved"), boolWord(workflowApprovalState(summary, detail) == "expired"), boolWord(approvalResolveStatus(summary, detail) == "unsupported")), fmt.Sprintf("Why this approval exists: %s", approvalUIValue(approvalPrimaryReason(summary, detail))), fmt.Sprintf("Exact gated object/action: %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("After approval route: %s", approvalUIValue(approvalFollowUpRoute(summary))), fmt.Sprintf("Resolve availability: %s", approvalUIValue(approvalResolveSummary(summary, detail))), fmt.Sprintf("Resolve status: %s", approvalUIValue(approvalResolveStatus(summary, detail))), + fmt.Sprintf("Resolve unavailable because: %s", approvalUIValue(approvalResolveBlockedReason(summary, detail))), fmt.Sprintf("Workflow posture: %s", approvalUIValue(workflowApprovalState(summary, detail))), fmt.Sprintf("Approval type: %s (binding_kind=%s) %s", approvalUIValue(bindingLabel), approvalUIValue(detail.BindingKind), infoBadge("type cue")), fmt.Sprintf("Lifecycle state: %s (%s) %s", approvalUIValue(lifecycleState), approvalUIValue(lifecycleFlags), postureBadge(lifecycleState)), @@ -164,6 +194,8 @@ func approvalRouteCopyActions(resp *brokerapi.ApprovalGetResponse) []routeCopyAc return compactCopyActions([]routeCopyAction{ {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)}, }) } @@ -274,106 +306,6 @@ func renderApprovalReviewPlan(resp *brokerapi.ApprovalGetResponse) string { 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)), ) } - -func approvalPrimaryStateBadge(summary brokerapi.ApprovalSummary) string { - return workflowApprovalBadge(summary, brokerapi.ApprovalDetail{}) -} - -func approvalSupportBadge(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { - 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 "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 := strings.ToLower(strings.TrimSpace(detail.LifecycleDetail.LifecycleState)) - if lifecycle == "expired" || status == "expired" { - return "expired" - } - if lifecycle == "approved" || lifecycle == "consumed" || status == "approved" || status == "consumed" || status == "resolved" { - return "resolved" - } - if lifecycle == "pending" || status == "pending" { - return "approval required" - } - if strings.TrimSpace(summary.ApprovalID) == "" { - return "pending" - } - return valueOrNA(status) -} - -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" - default: - policy := strings.TrimSpace(detail.PolicyReasonCode) - trigger := strings.TrimSpace(summary.ApprovalTriggerCode) - if policy != "" { - return fmt.Sprintf("policy requires operator review before %s can continue (%s)", approvalDisplayLabel(summary), policy) - } - 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 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 "action=" + action - } - return strings.Join(parts, " • ") + " • action=" + action -} diff --git a/cmd/runecode-tui/route_approvals_resolve_helpers.go b/cmd/runecode-tui/route_approvals_resolve_helpers.go index 6a6b3844..dffead0d 100644 --- a/cmd/runecode-tui/route_approvals_resolve_helpers.go +++ b/cmd/runecode-tui/route_approvals_resolve_helpers.go @@ -55,6 +55,9 @@ func approvalResolveUnavailableReason(err error) string { } 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) @@ -100,7 +103,7 @@ func approvalDecisionMatchesRequest(envelope trustpolicy.SignedObjectEnvelope, r return fmt.Errorf("invalid approval_request_hash: %w", err) } if identity != requestID { - return fmt.Errorf("decision payload does not match approval request") + return fmt.Errorf("decision payload does not match approval request: decision=%s request=%s", shortIdentity(identity), shortIdentity(requestID)) } return nil } @@ -137,10 +140,10 @@ func approvalResolveRequestFromDetail(resp brokerapi.ApprovalGetResponse) (broke summary := resp.Approval boundScope := summary.BoundScope if strings.TrimSpace(boundScope.SchemaID) == "" { - boundScope.SchemaID = "runecode.protocol.v0.ApprovalBoundScope" + return brokerapi.ApprovalResolveRequest{}, fmt.Errorf("approval resolve requires broker-provided bound scope schema id") } if strings.TrimSpace(boundScope.SchemaVersion) == "" { - boundScope.SchemaVersion = "0.1.0" + return brokerapi.ApprovalResolveRequest{}, fmt.Errorf("approval resolve requires broker-provided bound scope schema version") } resolveReq := brokerapi.ApprovalResolveRequest{ SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", 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..486651de --- /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 (%s)", approvalDisplayLabel(summary), policy) + } + 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, "action="+action) + } + if len(parts) == 0 { + return "broker_scope=n/a" + } + 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 "action=" + action + } + return strings.Join(parts, " • ") + " • action=" + action +} diff --git a/cmd/runecode-tui/route_approvals_validation_test.go b/cmd/runecode-tui/route_approvals_validation_test.go index a7311dad..88e6c689 100644 --- a/cmd/runecode-tui/route_approvals_validation_test.go +++ b/cmd/runecode-tui/route_approvals_validation_test.go @@ -26,6 +26,27 @@ func TestValidateApprovalResolveInputRejectsUnsupportedActionKind(t *testing.T) } } +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 { @@ -69,3 +90,28 @@ func TestApprovalDecisionMatchesRequestRejectsInvalidHash(t *testing.T) { 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_detail.go b/cmd/runecode-tui/route_artifacts_detail.go index 0f979520..4469995d 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" @@ -46,7 +49,8 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art 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("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring actions", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Evidence trail: workflow result/run %s -> artifact %s -> audit records -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), @@ -57,10 +61,12 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art Title: "Artifact inspector", Summary: fmt.Sprintf("artifact=%s class=%s bytes=%d", artifactDisplayLabel(a), a.Reference.DataClass, a.Reference.SizeBytes), Identity: fmt.Sprintf("artifact=%s digest=%s", artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)), - Status: fmt.Sprintf("evidence_trail=run:%s -> audit -> verification posture", valueOrNA(a.RunID)), + Status: fmt.Sprintf("evidence_trail=run:%s -> artifact:%s -> audit -> verification posture", valueOrNA(a.RunID), shortIdentity(a.Reference.Digest)), Badges: []string{stateBadgeWithLabel("class", fmt.Sprintf("%v", a.Reference.DataClass)), appTheme.InspectorHint.Render("typed metadata first")}, 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), @@ -76,6 +82,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"}, } } @@ -86,9 +93,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 } @@ -99,12 +113,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}, @@ -187,12 +196,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 { @@ -216,7 +237,8 @@ func (m *artifactsRouteModel) syncDetailDocument() { content := compactLines( fmt.Sprintf("Evidence label: %s", artifactDisplayLabel(a)), fmt.Sprintf("Data class: %s", a.Reference.DataClass), - fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring actions", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Evidence trail: workflow result/run %s -> artifact %s -> audit records -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), fmt.Sprintf("Provenance receipt: %s", a.Reference.ProvenanceReceiptHash), @@ -244,7 +266,7 @@ func renderArtifactEvidenceTrail(head *brokerapi.LocalArtifactHeadResponse) stri return "Evidence path: workflow result -> artifact -> audit record -> verification posture -> export/offline verification or anchoring where available" } a := head.Artifact - return fmt.Sprintf("Evidence path: run %s -> artifact %s -> Audit -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)) + return fmt.Sprintf("Evidence path: workflow result/run %s -> artifact %s (%s) -> Audit -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)) } func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index 16dd2069..ec6c890a 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -34,9 +34,9 @@ func renderAuditFinalizeSummary(finalize *brokerapi.AuditFinalizeVerifyResponse) if status == "ok" { return line + " next=review Audit 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 @@ -113,7 +113,7 @@ func renderAuditTimeline(timeline []brokerapi.AuditTimelineViewEntry, selected i func renderAuditDirectoryItems(timeline []brokerapi.AuditTimelineViewEntry) []string { items := make([]string, 0, len(timeline)) for _, entry := range timeline { - items = append(items, fmt.Sprintf("%s event=%s %s", auditRecordDisplayLabel(entry), valueOrNA(entry.EventType), auditTimelinePostureLabel(entry))) + items = append(items, fmt.Sprintf("%s digest=%s event=%s %s refs=%d", auditRecordDisplayLabel(entry), auditRecordDigestLabel(entry), valueOrNA(entry.EventType), auditTimelinePostureLabel(entry), len(entry.LinkedReferences))) } return items } @@ -182,9 +182,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("Primary digest display: %s (copy raw digest below)", auditRecordDigestDetail(record)), fmt.Sprintf("Verification posture: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), fmt.Sprintf("Linked references: %d", len(record.LinkedReferences)), - "Evidence trail: follow linked runs/artifacts/approvals, then use Audit verification and anchor/export actions for proof handling.", + "Evidence trail: workflow result -> artifacts -> audit records -> verification posture -> export/offline verification -> anchoring where available.", + "Trust posture: broker-linked references stay authoritative; copied digests and any raw content are supplemental proof material.", ) } @@ -334,9 +336,9 @@ func renderAuditEvidenceTrail(verify *brokerapi.AuditVerificationGetResponse, re recordLabel = "record " + shortIdentity(identity) } if verify == nil { - return fmt.Sprintf("Evidence path: %s -> verification posture unavailable until broker verification data loads", recordLabel) + return fmt.Sprintf("Evidence path: workflow result -> artifacts -> audit records (%s) -> verification posture unavailable until broker verification data loads", recordLabel) } - return fmt.Sprintf("Evidence path: %s -> verification posture -> export/offline verification -> anchoring where available", recordLabel) + return fmt.Sprintf("Evidence path: workflow result -> artifacts -> audit records (%s) -> verification posture -> export/offline verification -> anchoring where available", recordLabel) } func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { @@ -347,6 +349,16 @@ func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { 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 "posture=n/a" diff --git a/cmd/runecode-tui/route_audit_status_test.go b/cmd/runecode-tui/route_audit_status_test.go index 11cf8153..55051af3 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -56,13 +56,13 @@ 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)") { t.Fatalf("expected record inspector posture rendering, got %q", inspector) } - if !strings.Contains(inspector, "Evidence trail: follow linked runs/artifacts/approvals") { + if !strings.Contains(inspector, "Evidence trail: workflow result -> artifacts -> audit records -> verification posture -> export/") { t.Fatalf("expected evidence trail guidance in inspector, got %q", inspector) } } diff --git a/cmd/runecode-tui/route_chat.go b/cmd/runecode-tui/route_chat.go index b384bf1b..e4af718b 100644 --- a/cmd/runecode-tui/route_chat.go +++ b/cmd/runecode-tui/route_chat.go @@ -51,31 +51,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 - posture *brokerapi.ProjectSubstratePostureGetResponse - runDetail *brokerapi.RunDetail - 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 { @@ -129,15 +132,19 @@ func (m chatRouteModel) View(width, height int, focus focusArea) string { if activeLine == "" { activeLine = "No active session selected." } + composerMessage := fmt.Sprintf("Composer is %s.", composerState(m.composeOn)) composerSummary := "Composer is ready for a follow-up prompt." if !m.composeOn { composerSummary = "Composer is closed. Press c when you want to continue the conversation." + } else { + composerMessage = composeDraftStatusLine(m.composer.Value()) + composerSummary = "Draft stays local until you send it. Use alt+enter when this follow-up is ready for the broker." } body := []string{ sectionTitle("Chat") + " " + focusBadge(focus), renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Active session", Message: activeLine, Reason: fmt.Sprintf("Canonical session directory: %d session(s) available.", len(m.sessions)), NextAction: "Review the session below, or move through the directory to switch context.", ShortcutCue: "j/k move • enter review", RouteCue: "Chat"}), renderStateCardSpec(chatExecutionStateCard(m.active, m.posture, m.runDetail)), - renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Composer", Message: fmt.Sprintf("Composer is %s.", composerState(m.composeOn)), Reason: composerSummary, NextAction: "Write a prompt here when you want the broker to start the next workflow turn.", ShortcutCue: "c compose • alt+enter send", RouteCue: "Chat"}), + renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Composer", Message: composerMessage, Reason: composerSummary, NextAction: "Write a prompt here when you want the broker to start the next workflow turn.", ShortcutCue: "c compose • alt+enter send", RouteCue: "Chat"}), renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), renderDirectory("Session directory", renderSessionDirectoryItems(m.sessions), m.selected), renderComposer(m.composeOn, m.draft, m.composer.View()), diff --git a/cmd/runecode-tui/route_chat_execution_helpers.go b/cmd/runecode-tui/route_chat_execution_helpers.go index 05ef4477..b8fcf386 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers.go +++ b/cmd/runecode-tui/route_chat_execution_helpers.go @@ -35,7 +35,7 @@ func chatExecutionStateCard(detail *brokerapi.SessionDetail, posture *brokerapi. State: routeLoadStateEmpty, Title: "Workflow execution", Message: "No workflow execution is attached to this session yet.", - Reason: chatExecutionEvidenceSummary(detail, nil), + 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", @@ -103,6 +103,9 @@ func chatExecutionHeadline(exec brokerapi.SessionTurnExecution, run *brokerapi.R 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) + } if reason := chatExecutionWaitReason(exec, posture, run); reason != "" { parts = append(parts, reason) } @@ -115,6 +118,35 @@ func chatExecutionReason(exec brokerapi.SessionTurnExecution, detail *brokerapi. 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 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 strings.Join(parts, " / ") +} + 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." @@ -158,22 +190,22 @@ func chatExecutionEvidenceSummary(detail *brokerapi.SessionDetail, run *brokerap runs = 1 } parts := []string{ - fmt.Sprintf("linked runs %d", runs), - fmt.Sprintf("approvals %d", approvals), - fmt.Sprintf("artifacts %d", maxInt(artifacts, runArtifactCount(run))), - fmt.Sprintf("audit %d", audit), + 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 chatExecutionStageSummary(exec brokerapi.SessionTurnExecution, detail *brokerapi.SessionDetail, run *brokerapi.RunDetail) string { - stages := make([]string, 0, 6) + stages := make([]string, 0, 8) + if executionWaiting(exec, run) { + stages = append(stages, "waiting") + } if run != nil && runPlanAuthorityAvailable(run) { stages = append(stages, "plan compiled") } - if chatRunIDFromExecution(exec) != "" { - stages = append(stages, "run linked") - } if runRunnerActive(run) { stages = append(stages, "runner active") } @@ -183,7 +215,7 @@ func chatExecutionStageSummary(exec brokerapi.SessionTurnExecution, detail *brok if chatExecutionApprovalRequired(exec, run) { stages = append(stages, "approval required") } - if len(exec.LinkedArtifactDigests) > 0 || len(detail.LinkedArtifactDigests) > 0 || runArtifactCount(run) > 0 { + 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") { @@ -217,8 +249,14 @@ func uniqueStageLabels(labels []string) []string { } 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") || chatExecutionApprovalRequired(exec, run) { + 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 { @@ -234,7 +272,7 @@ func chatExecutionNextAction(exec brokerapi.SessionTurnExecution, posture *broke } func chatExecutionShortcutCue(exec brokerapi.SessionTurnExecution) string { - if strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" || len(exec.LinkedApprovalIDs) > 0 { + 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") { @@ -258,144 +296,11 @@ func chatExecutionRouteCue(exec brokerapi.SessionTurnExecution, detail *brokerap } func chatExecutionApprovalRequired(exec brokerapi.SessionTurnExecution, run *brokerapi.RunDetail) bool { - if strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" || len(exec.LinkedApprovalIDs) > 0 { - return true - } - return run != nil && len(run.PendingApprovalIDs) > 0 -} - -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 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 { + if chatExecutionTerminal(exec) { 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" { + if strings.EqualFold(strings.TrimSpace(exec.WaitKind), "approval") || strings.TrimSpace(exec.PendingApprovalID) != "" { 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 = "Open Status and complete the recommended project setup remediation before retrying." - } - 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 "Use Status to fix project setup before retrying this workflow." - 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 - } - 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 2c20e24c..03965589 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers_test.go +++ b/cmd/runecode-tui/route_chat_execution_helpers_test.go @@ -89,13 +89,48 @@ func TestChatExecutionStateCardReflectsApprovalEvidenceAndStages(t *testing.T) { if card.State != routeLoadStateApprovalRequired { t.Fatalf("card state = %q, want %q", card.State, routeLoadStateApprovalRequired) } - if !strings.Contains(card.Reason, "Broker-known stages: plan compiled") { + 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, "Evidence: linked runs 1") { + 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) + } +} 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..7c226214 --- /dev/null +++ b/cmd/runecode-tui/route_chat_execution_state_helpers.go @@ -0,0 +1,169 @@ +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, " | ") +} diff --git a/cmd/runecode-tui/route_chat_inspector.go b/cmd/runecode-tui/route_chat_inspector.go index 7a8a6610..08b65dc3 100644 --- a/cmd/runecode-tui/route_chat_inspector.go +++ b/cmd/runecode-tui/route_chat_inspector.go @@ -31,19 +31,25 @@ func renderSessionInspector(detail *brokerapi.SessionDetail, presentation conten 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{ 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 75175ba8..39a1e110 100644 --- a/cmd/runecode-tui/route_chat_rendering.go +++ b/cmd/runecode-tui/route_chat_rendering.go @@ -41,9 +41,9 @@ func activeSessionSummaryLine(detail *brokerapi.SessionDetail) string { } s := detail.Summary exec := chatVisibleExecution(detail) - statusParts := []string{fmt.Sprintf("Session %s in workspace %s is %s.", s.Identity.SessionID, valueOrNA(s.Identity.WorkspaceID), sessionHighLevelCue(s))} + 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("Latest workflow state: %s", humanizeExecutionToken(exec.ExecutionState))) + 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))) } @@ -54,6 +54,15 @@ func activeSessionSummaryLine(detail *brokerapi.SessionDetail) string { return strings.Join(statusParts, " • ") } +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 { 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 edf609c4..b7d995f2 100644 --- a/cmd/runecode-tui/route_chat_state.go +++ b/cmd/runecode-tui/route_chat_state.go @@ -1,7 +1,6 @@ package main import ( - "context" "strings" "time" @@ -83,6 +82,7 @@ func (m chatRouteModel) applyLoaded(typed chatLoadedMsg) (routeModel, tea.Cmd) { m.runDetail = typed.runDetail m.posture = typed.posture m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" m.syncDetailDocument() @@ -117,6 +117,7 @@ func (m *chatRouteModel) resetSendSuccessState() { m.composer.Blur() m.composeOn = false m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" } @@ -168,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") @@ -184,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) { @@ -204,6 +208,7 @@ func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMs m.activeID = strings.TrimSpace(msg.sessionID) m.runDetail = msg.runDetail m.posture = msg.posture + m.watchPollCount++ if msg.detail != nil { m.active = msg.detail } @@ -222,6 +227,7 @@ func (m chatRouteModel) applyExecutionWatchLoaded(msg chatExecutionWatchLoadedMs } if !msg.continueWatch { m.watching = false + m.watchPollCount = 0 m.watchSession = "" m.watchTrigger = "" m.syncDetailDocument() @@ -237,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++ @@ -245,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 { @@ -262,157 +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} - } - runDetail, err := m.loadChatRunDetail(ctx, getResp.Session) - 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, runDetail: 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) 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} - } - runDetail, err := m.loadChatRunDetail(ctx, getResp.Session) - 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, - runDetail: runDetail, - sessions: listResp.Sessions, - posture: &posture, - continueWatch: continueWatch, - } - } -} - -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 { - 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 6b5adff8..3e5d5945 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,7 +125,7 @@ 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:session-run | jump:runs | jump:approvals | jump:artifacts | jump:audit | copy:session_id", @@ -147,20 +156,30 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { } updated, _ = updated.Update(cmd()) + assertChatSendAndWatchRequests(t, spy) + + view := updated.View(120, 40, focusContent) + chatMustContainAll(t, view, + "Canonical session session-1", + "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", + "Session directory", + "Composer is idle.", + ) + if strings.Contains(view, "SessionExecutionTrigger") { + t.Fatalf("expected product language instead of protocol phrasing in primary view, got %q", view) + } +} + +func assertChatSendAndWatchRequests(t *testing.T, spy *chatBrokerClientSpy) { + t.Helper() if spy.sentReq == nil { t.Fatal("expected SessionExecutionTrigger request to be captured") } - if spy.sentReq.SessionID != "session-1" { - t.Fatalf("expected session-1 send target, got %q", spy.sentReq.SessionID) - } - if spy.sentReq.TriggerSource != "interactive_user" { - t.Fatalf("expected interactive_user trigger source, got %q", spy.sentReq.TriggerSource) - } - if spy.sentReq.RequestedOperation != "start" { - t.Fatalf("expected requested operation start, got %q", spy.sentReq.RequestedOperation) - } - 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,21 +187,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) - mustContainAll(t, view, - "Workflow is waiting for approval.", - "Session directory", - "Composer is idle.", + 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", ) - if strings.Contains(view, "SessionExecutionTrigger") { - t.Fatalf("expected product language instead of protocol phrasing in primary view, got %q", view) +} + +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") } } @@ -245,53 +358,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, "Workflow cannot continue yet.") { - t.Fatalf("expected blocked workflow headline from turn execution watch 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, "Transcript remains the durable conversation record") { - t.Fatalf("expected transcript/evidence 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 b61706f1..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", diff --git a/cmd/runecode-tui/route_dashboard.go b/cmd/runecode-tui/route_dashboard.go index 250ca8ca..b5791477 100644 --- a/cmd/runecode-tui/route_dashboard.go +++ b/cmd/runecode-tui/route_dashboard.go @@ -135,32 +135,31 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { if focus == focusContent { focusLabel = "active" } - primaryRun := primaryDashboardRun(m.data.runs) innerWidth := dashboardContentWidth(width) - executive := buildDashboardExecutiveSummary(m.data) + snapshot := buildDashboardSnapshot(m.data) sections := []string{ compactLines( sectionTitle("Dashboard")+" "+focusBadge(focus)+" "+navStateBadge(focusLabel == "active"), renderStateCardSpec(stateCardSpec{ - State: executive.State, - Title: executive.Title, - Message: executive.Message, - Reason: executive.Reason, - NextAction: executive.NextAction, + State: snapshot.Executive.State, + Title: snapshot.Executive.Title, + Message: snapshot.Executive.Message, + Reason: snapshot.Executive.Reason, + NextAction: snapshot.Executive.NextAction, RouteCue: "Action Center", }), - tableHeader("Now")+" "+renderDashboardNowBar(primaryRun, len(m.data.approvals), focusLabel == "active", innerWidth), + tableHeader("Now")+" "+renderDashboardNowBar(snapshot.PrimaryRun, snapshot.PendingApprovals, focusLabel == "active", innerWidth), ), compactLines( tableHeader("Executive overview"), - wrapDashboardLine(renderDashboardWorkflowPosture(m.data), innerWidth), - wrapDashboardLine(renderDashboardHighValueCounts(m.data, innerWidth), innerWidth), - wrapDashboardLine(renderDashboardNextActions(m.data), innerWidth), - wrapDashboardLine(renderDashboardActionCenterCue(m.data), innerWidth), + wrapDashboardLine(renderDashboardWorkflowPosture(snapshot), innerWidth), + wrapDashboardLine(renderDashboardHighValueCounts(snapshot, innerWidth), innerWidth), + wrapDashboardLine(renderDashboardNextActions(m.data, snapshot), innerWidth), + wrapDashboardLine(renderDashboardActionCenterCue(snapshot), innerWidth), ), - compactLines(tableHeader("Safety Summary"), renderRunSafetyStrip(primaryDashboardRun(m.data.runs), innerWidth), wrapDashboardLine(renderDashboardSafetyAlerts(m.data), innerWidth)), + compactLines(tableHeader("Safety Summary"), renderRunSafetyStrip(snapshot.PrimaryRun, 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("Live Activity"), wrapDashboardLine("Live activity detail is available here when you need typed broker watch confirmation.", innerWidth), wrapDashboardLine(muted("Typed watch family status and event detail are secondary to the executive overview."), 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"), wrapDashboardLine("Primary follow-up lives in Action Center; use Runs, Approvals, Audit, and Status for detail.", innerWidth), keyHint("Route keys: r reload")), } diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index 93b1d298..407caf7e 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -16,6 +16,43 @@ type dashboardExecutiveSummary struct { 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, @@ -56,26 +93,18 @@ func renderDashboardNowBar(run brokerapi.RunSummary, approvalCount int, focusAct return wrapPartsByWidth(parts, " ", width) } -func buildDashboardExecutiveSummary(data dashboardData) dashboardExecutiveSummary { - pendingApprovals := pendingApprovalCount(data.runs, data.approvals) - blockedRuns := dashboardBlockedRunCount(data.runs) - degradedSignals := dashboardDegradedSignalCount(data) - activeRuns := dashboardActiveRunCount(data.runs) - setupBlocked := dashboardProjectSubstrateBlocked(data.project) - setupNeedsAttention := dashboardProjectSubstrateNeedsAttention(data.project) - noWork := len(data.runs) == 0 && len(data.approvals) == 0 - +func buildDashboardExecutiveSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { switch { - case noWork: + 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() - case setupBlocked || blockedRuns > 0: - return dashboardBlockedSummary(data, setupBlocked, blockedRuns) - case degradedSignals > 0: - return dashboardDegradedSummary(data) - case pendingApprovals > 0 || setupNeedsAttention: - return dashboardAttentionSummary(data, pendingApprovals, setupNeedsAttention) - case activeRuns > 0: - return dashboardActiveWorkSummary(activeRuns) default: return defaultDashboardExecutiveSummary() } @@ -89,37 +118,37 @@ 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, setupBlocked bool, blockedRuns int) dashboardExecutiveSummary { +func dashboardBlockedSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { reasons := []string{} - if setupBlocked { + if snapshot.SetupBlocked { reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) } - if blockedRuns > 0 { - reasons = append(reasons, fmt.Sprintf("%d run(s) report blocked or waiting workflow posture", blockedRuns)) + if snapshot.BlockedRuns > 0 { + reasons = append(reasons, fmt.Sprintf("%d run(s) report blocked or waiting workflow posture", snapshot.BlockedRuns)) } return dashboardExecutiveSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress is blocked.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center and clear the blocker."} } -func dashboardDegradedSummary(data dashboardData) dashboardExecutiveSummary { +func dashboardDegradedSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { reasons := []string{} if !data.readiness.Ready { reasons = append(reasons, "broker readiness is not fully settled") } - if dashboardAuditDegraded(data.audit) { + if snapshot.AuditDegraded { reasons = append(reasons, "audit or runtime evidence needs review") } - if count := dashboardRunRuntimeDegradedCount(data.runs); count > 0 { - reasons = append(reasons, fmt.Sprintf("%d run(s) report degraded runtime posture", count)) + if snapshot.RunRuntimeDegraded > 0 { + reasons = append(reasons, fmt.Sprintf("%d run(s) report degraded runtime posture", snapshot.RunRuntimeDegraded)) } return dashboardExecutiveSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Evidence or runtime posture needs review.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center, then confirm Audit or Runs."} } -func dashboardAttentionSummary(data dashboardData, pendingApprovals int, setupNeedsAttention bool) dashboardExecutiveSummary { +func dashboardAttentionSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { reasons := []string{} - if pendingApprovals > 0 { - reasons = append(reasons, fmt.Sprintf("%d approval decision(s) are waiting", pendingApprovals)) + if snapshot.PendingApprovals > 0 { + reasons = append(reasons, fmt.Sprintf("%d approval decision(s) are waiting", snapshot.PendingApprovals)) } - if setupNeedsAttention { + if snapshot.SetupNeedsAttention { reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) } return dashboardExecutiveSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", Message: "Operator follow-up is waiting.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center for the exact follow-up."} @@ -129,70 +158,61 @@ func dashboardActiveWorkSummary(activeRuns int) dashboardExecutiveSummary { return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%d active run(s) are visible on the current broker surfaces.", activeRuns), NextAction: "Use Action Center or open Runs for detail."} } -func renderDashboardWorkflowPosture(data dashboardData) string { - pendingApprovals := pendingApprovalCount(data.runs, data.approvals) - blockedRuns := dashboardBlockedRunCount(data.runs) - activeRuns := dashboardActiveRunCount(data.runs) - setupBlocked := dashboardProjectSubstrateBlocked(data.project) - setupNeedsAttention := dashboardProjectSubstrateNeedsAttention(data.project) - auditDegraded := dashboardAuditDegraded(data.audit) - +func renderDashboardWorkflowPosture(snapshot dashboardSnapshot) string { parts := []string{} - if setupBlocked { + if snapshot.SetupBlocked { parts = append(parts, "normal product work is blocked until project setup is remediated") - } else if blockedRuns > 0 { - parts = append(parts, fmt.Sprintf("%d workflow(s) are blocked or waiting on follow-up", blockedRuns)) - } else if activeRuns > 0 { - parts = append(parts, fmt.Sprintf("%d workflow(s) are active", activeRuns)) + } else if snapshot.BlockedRuns > 0 { + parts = append(parts, fmt.Sprintf("%d workflow(s) are blocked or waiting on follow-up", snapshot.BlockedRuns)) + } else if snapshot.ActiveRuns > 0 { + parts = append(parts, fmt.Sprintf("%d workflow(s) are active", snapshot.ActiveRuns)) } else { parts = append(parts, "no active workflow work is currently running") } - if pendingApprovals > 0 { - parts = append(parts, fmt.Sprintf("%d approval decision(s) are waiting", pendingApprovals)) + if snapshot.PendingApprovals > 0 { + parts = append(parts, fmt.Sprintf("%d approval decision(s) are waiting", snapshot.PendingApprovals)) } - if setupNeedsAttention && !setupBlocked { + if snapshot.SetupNeedsAttention && !snapshot.SetupBlocked { parts = append(parts, "project setup guidance is available") } - if auditDegraded { + if snapshot.AuditDegraded { parts = append(parts, "evidence posture needs review") } return "Workflow posture: " + strings.Join(parts, "; ") + "." } -func renderDashboardHighValueCounts(data dashboardData, width int) string { +func renderDashboardHighValueCounts(snapshot dashboardSnapshot, width int) string { parts := []string{ - fmt.Sprintf("active work=%d", dashboardActiveRunCount(data.runs)), - fmt.Sprintf("approval follow-up=%d", pendingApprovalCount(data.runs, data.approvals)), - fmt.Sprintf("blocked or waiting=%d", dashboardBlockedRunCount(data.runs)), - fmt.Sprintf("degraded cues=%d", dashboardDegradedSignalCount(data)), + 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 "High-value counts: " + wrapPartsByWidth(parts, " | ", width) } -func renderDashboardNextActions(data dashboardData) string { - primaryRun := primaryDashboardRun(data.runs) - pendingApprovals := pendingApprovalCount(data.runs, data.approvals) - if dashboardProjectSubstrateBlocked(data.project) { +func renderDashboardNextActions(data dashboardData, snapshot dashboardSnapshot) string { + if snapshot.SetupBlocked { return "Next action: Open Action Center, follow the setup blocker, then use Status for broker-owned remediation steps." } - if pendingApprovals > 0 { + if snapshot.PendingApprovals > 0 { return "Next action: Open Action Center and review the pending approval before workflow progress resumes." } - if dashboardDegradedSignalCount(data) > 0 { + if snapshot.DegradedSignals > 0 { return "Next action: Open Action Center for degraded evidence or runtime follow-up, then inspect Audit or Runs." } - if strings.TrimSpace(primaryRun.RunID) != "" { - return fmt.Sprintf("Next action: Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(primaryRun.RunID)) + if strings.TrimSpace(snapshot.PrimaryRun.RunID) != "" { + return fmt.Sprintf("Next action: Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(snapshot.PrimaryRun.RunID)) } return "Next action: Start a workflow from Chat, then return here for the executive overview." } -func renderDashboardActionCenterCue(data dashboardData) string { +func renderDashboardActionCenterCue(snapshot dashboardSnapshot) string { return fmt.Sprintf( "Action Center is the operator home for approvals, blocked work, degraded posture, setup follow-up, and waiting queues. Current follow-up: approvals=%d blocked_or_waiting=%d degraded=%d.", - pendingApprovalCount(data.runs, data.approvals), - dashboardBlockedRunCount(data.runs), - dashboardDegradedSignalCount(data), + snapshot.PendingApprovals, + snapshot.BlockedRuns, + snapshot.DegradedSignals, ) } @@ -228,17 +248,6 @@ func dashboardRunRuntimeDegradedCount(runs []brokerapi.RunSummary) int { return total } -func dashboardDegradedSignalCount(data dashboardData) int { - total := dashboardRunRuntimeDegradedCount(data.runs) - if dashboardAuditDegraded(data.audit) { - total++ - } - if !data.readiness.Ready { - 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") diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index 0d826e5c..ba9e9481 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -49,7 +49,8 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { "compatibility=supported_with_upgrade_available", "Project setup guidance:", "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", + "Live activity detail is available here when you need typed broker watch confirmation.", + "Typed watch family status and event detail are secondary to the executive overview.", "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", @@ -98,10 +99,36 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { "showing degraded fallback posture (gateway_failure)", "Supporting detail", "Live Activity", - "Live activity (typed watch families; logs are supplemental inspection only):", + "Live activity detail is available here when you need typed broker watch confirmation.", ) } +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.") + overviewIndex := strings.Index(view, "Executive overview") + detailIndex := strings.Index(view, "Supporting detail") + if cardIndex < 0 || overviewIndex < 0 || detailIndex < 0 { + t.Fatalf("expected executive hierarchy sections in view, got %q", view) + } + if !(cardIndex < overviewIndex && overviewIndex < detailIndex) { + t.Fatalf("expected state card before overview before supporting detail, got %q", view) + } + if strings.Contains(view, "Protocol bundle") || strings.Contains(view, "watch_family") { + t.Fatalf("expected no debug-heavy primary wording, got %q", view) + } + if !strings.Contains(view, "Action Center is the operator home") { + t.Fatalf("expected dashboard to point operators to Action Center, got %q", view) + } +} + func TestDashboardViewPreservesSectionGaps(t *testing.T) { model := newDashboardRouteModel(routeDefinition{ID: routeDashboard, Label: "Dashboard"}, &fakeBrokerClient{}) updated, cmd := model.Update(routeActivatedMsg{RouteID: routeDashboard}) diff --git a/cmd/runecode-tui/route_runs.go b/cmd/runecode-tui/route_runs.go index 2f9a0eea..7fe4b975 100644 --- a/cmd/runecode-tui/route_runs.go +++ b/cmd/runecode-tui/route_runs.go @@ -305,8 +305,10 @@ func renderRunDirectoryItems(runs []brokerapi.RunSummary) []string { run.RunID, stateBadgeWithLabel("state", run.LifecycleState), fmt.Sprintf("workflow=%s", valueOrNA(run.WorkflowKind)), + fmt.Sprintf("operation=%s", valueOrNA(run.CurrentStageID)), fmt.Sprintf("backend=%s", valueOrNA(run.BackendKind)), fmt.Sprintf("approvals=%d", run.PendingApprovalCount), + fmt.Sprintf("audit=%s/%s", valueOrNA(run.AuditIntegrityStatus), valueOrNA(run.AuditAnchoringStatus)), }, " "))) } 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 b8b3ae5f..9449d21a 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) { @@ -19,26 +21,25 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: Run run-1 is active with 0 pending approval(s).", + "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 actions: run id | workspace id | raw block", - "Workflow operation: change_draft", + "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", + "Copy cues: use Copy actions for raw run/session ids plus broker-linked approval ids", "backend_kind=workspace", "Workflow identity (authoritative): workflow_kind=change_draft workflow_definition_hash=sha256:", "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", ) - if strings.Contains(view, "Summary: Run run-1 is active with 0 pending approval(s).") { + 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) } } @@ -57,21 +58,21 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { mustContainAll(t, inspector, "Summary: approval=ap-1 state=approval required reason=policy requires operator review before promotion for run-1 can continue (requires_human_review)", "Identity: approval=ap-1 run=run-1 action=promotion", - "Local actions: resolve:typed | jump:runs | jump:audit | copy:approval_id", - "Copy actions: approval id | bound run id | raw block", + "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", + "State taxonomy: approval-required=yes pending=yes resolved=no expired=no unsupported=yes", "Why this approval exists: policy requires operator review before promotion for run-1 can", "Exact gated object/action: run run-1 • stage stage-1 • action=promotion", "Review first: run evidence for run-1", "If approved next: Promotion continues (effect=unblock_next_stage)", + "After approval route: Artifacts → Audit", "Resolve availability: unavailable here because promotion approvals must be completed in the prom", "Resolve status: unsupported", + "Resolve unavailable because: promotion approvals must stay in the promotion flow", "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", ) if !strings.Contains(view, "Approval review") { t.Fatalf("expected approval overview card in main view, got %q", view) @@ -95,10 +96,12 @@ func TestArtifactsRouteUsesTypedReadAndInspectableModes(t *testing.T) { mustContainAll(t, inspector, "Summary: artifact=diffs for run-1 class=diffs bytes=128", "Identity: artifact=diffs for run-1 digest=sha256:bbbbbbbbbbbb", - "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt", + "Status: evidence_trail=run:run-1 -> artifact:sha256:bbbbbbbbbbbb -> audit -> verification posture", + "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt | copy:artifact_preview", "Copy actions: artifact digest | provenance receipt | artifact preview", "Evidence label: diffs for run-1", - "Evidence trail: run run-1 -> artifact diffs for run-1 -> Audit for verification posture", + "Evidence trail: workflow result/run run-1 -> artifact diffs for run-1 -> audit records -> verifi", + "Primary digest display: sha256:bbbbbbbbbbbb (copy raw digest below)", "Typed detail mode:", "Inspectable content is supplemental evidence, not authoritative run/approval truth.", "diff preview (secrets redacted):", @@ -192,6 +195,72 @@ func TestApprovalsRouteResolvesBackendPostureViaTypedApprovalResolve(t *testing. } } +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/binding posture (authoritative): provisioning posture=attested", + "PROVISIONING_OK", + "Attestation posture (authoritative): attestation posture=valid", + "Runtime attestation truthfulness (authoritative): post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence", + "Verifier class (authoritative): verifier class=trusted_domain_local", + ) +} + +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, + "Policy reason code: requires_human_review", + "Approval trigger code: policy_gate", + "Distinct blocking semantics: trigger=policy_gate", + ) +} + +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 records -> verification posture", + "Trust posture: broker-linked references stay authoritative", + ) + view := updated.View(120, 40, focusContent) + if !strings.Contains(view, "digest=sha256:aaaaaaaaaaaa") { + t.Fatalf("expected bounded digest in timeline directory, got %q", 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}) @@ -242,7 +311,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, "Exact gated object/action: run run-2 • stage stage-2 • action=stage_summary_sign_off") { t.Fatalf("expected ap-2 detail to remain active after reload, got %q", surface.Regions.Inspector.Body) } } diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index fb91532c..216e910c 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -8,6 +8,13 @@ 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 to review workflow status, outcome, and linked evidence." @@ -20,7 +27,8 @@ 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) @@ -30,7 +38,7 @@ func renderRunInspector(detail *brokerapi.RunDetail, presentation contentPresent 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), + References: runInspectorReferences(detail, links), LocalActions: runInspectorLocalActions(), CopyActions: runRouteCopyActions(detail), ModeTabs: []string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, @@ -46,25 +54,26 @@ func runInspectorLocalActions() []routeActionItem { {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) []inspectorReference { +func runInspectorReferences(detail *brokerapi.RunDetail, links runEvidenceLinks) []inspectorReference { if detail == nil { return nil } refs := []inspectorReference{} - if sessionID := strings.TrimSpace(authoritativeString(detail.AuthoritativeState, "session_id")); sessionID != "" { + 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(detail.PendingApprovalIDs, func(id string) paletteActionMsg { + 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(runArtifactReferenceLabels(detail), func(id string) paletteActionMsg { + 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(runAuditReferenceLabels(detail), func(id string) paletteActionMsg { + inspectorReference{Label: "audit", Items: mapReferenceIDs(links.AuditLabels, func(id string) paletteActionMsg { return paletteActionMsg{Verb: verbJump, Target: paletteTarget{Kind: "route", RouteID: routeAudit}} })}, ) @@ -75,7 +84,7 @@ func runInspectorReferenceActions(detail *brokerapi.RunDetail) []routeActionItem if detail == nil { return nil } - refs := runInspectorReferences(detail) + refs := runInspectorReferences(detail, buildRunEvidenceLinks(detail)) out := make([]routeActionItem, 0, 12) for _, ref := range refs { for _, item := range ref.Items { @@ -108,7 +117,19 @@ 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 workflow=%s status=%s", summary.RunID, valueOrNA(summary.WorkflowKind), valueOrNA(summary.LifecycleState)), @@ -130,7 +151,11 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta 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("Navigation cues: session=%s approvals=%d artifacts=%d audit=%d", valueOrNA(authoritativeString(detail.AuthoritativeState, "session_id")), len(detail.PendingApprovalIDs), runArtifactCount(detail), runAuditReferenceCount(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)), + "Copy cues: use Copy actions for raw run/session ids plus broker-linked approval ids and audit hashes; artifacts stay route-linked until the broker exposes exact object ids.", 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), @@ -152,7 +177,7 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta } 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), summary.PendingApprovalCount) + return fmt.Sprintf("Run %s is %s with %d pending approval(s).", summary.RunID, valueOrNA(summary.LifecycleState), runPendingApprovalCount(summary, detail)) } func runInspectorIdentity(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { @@ -160,7 +185,7 @@ func runInspectorIdentity(summary brokerapi.RunSummary, detail *brokerapi.RunDet } func runInspectorStatus(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { - return fmt.Sprintf("workflow=%s blocked=%t reason=%s", runWorkflowOperation(summary, detail), detail.Coordination.Blocked, valueOrNA(runBlockingReasonCode(summary, detail))) + 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 { @@ -168,10 +193,31 @@ func runOutcomeHeadline(summary brokerapi.RunSummary, detail *brokerapi.RunDetai } func runWorkflowOperation(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { - if workflow := strings.TrimSpace(summary.WorkflowKind); workflow != "" { + 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 workflow != "" { return workflow } - return valueOrNA(authoritativeString(detail.AuthoritativeState, "workflow_kind")) + if operation != "" { + return "stage=" + operation + } + return "n/a" +} + +func runPendingApprovalCount(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) int { + if detail != nil && len(detail.PendingApprovalIDs) > 0 { + return len(detail.PendingApprovalIDs) + } + return summary.PendingApprovalCount } func runPlanAuthoritySummary(summary brokerapi.RunSummary, detail *brokerapi.RunDetail) string { @@ -199,6 +245,13 @@ func runReportingPostureSummary(detail *brokerapi.RunDetail) string { 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 @@ -248,11 +301,19 @@ func runAuditReferenceLabels(detail *brokerapi.RunDetail) []string { return nil } labels := []string{} - if len(detail.ActiveManifestHashes) > 0 { - labels = append(labels, fmt.Sprintf("manifests (%d)", len(detail.ActiveManifestHashes))) + for _, digest := range detail.ActiveManifestHashes { + digest = strings.TrimSpace(digest) + if digest == "" { + continue + } + labels = append(labels, "manifest "+shortIdentity(digest)) } - if len(detail.LatestPolicyDecisionRefs) > 0 { - labels = append(labels, fmt.Sprintf("policy refs (%d)", len(detail.LatestPolicyDecisionRefs))) + for _, digest := range detail.LatestPolicyDecisionRefs { + digest = strings.TrimSpace(digest) + if digest == "" { + continue + } + 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)) @@ -318,15 +379,22 @@ func runRouteCopyActions(detail *brokerapi.RunDetail) []routeCopyAction { 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}, }) } @@ -350,7 +418,7 @@ func (m *runsRouteModel) syncDetailDocument() { presentation := normalizePresentationMode(m.presentation) waitingStages := pendingApprovalStageCount(m.active.StageSummaries) waitingRoles := waitingRoleCount(m.active.RoleSummaries) - content := runInspectorContent(summary, m.active, waitingStages, waitingRoles, presentation) + 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_status.go b/cmd/runecode-tui/route_status.go index 58cad133..b23ada4e 100644 --- a/cmd/runecode-tui/route_status.go +++ b/cmd/runecode-tui/route_status.go @@ -24,8 +24,13 @@ 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 = "Keys: r reload • c backend • a adopt • i/I init • u/U upgrade" @@ -33,16 +38,18 @@ const statusRouteKeyHintText = "Keys: r reload • c backend • a adopt • i/I 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,13 +113,25 @@ 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 = "" + 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 = "Status refreshed. Review managed-operation and project setup guidance below." } @@ -139,13 +160,36 @@ func (m statusRouteModel) handlePostureChanged(msg postureChangedMsg) (routeMode 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) } @@ -168,6 +212,15 @@ func (m statusRouteModel) View(width, height int, focus focusArea) string { func (m statusRouteModel) renderTransientStatusCard() string { if m.loading { + 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", @@ -226,6 +279,7 @@ func (m statusRouteModel) renderReadyStatusView(focus focusArea) string { renderLifecycleBlockedReasonLine(m.data.lifecycle), renderLifecycleDegradedReasonLine(m.data.lifecycle), renderProjectSubstrateStatusCard(m.data.project), + renderProjectSubstrateActionCard(m.projectSubstrateActionCard), renderProjectSubstrateStatusLine(m.data.project), renderProjectSubstrateGuidance(m.data.project), renderBackendPostureLine(m.data.posture), diff --git a/cmd/runecode-tui/route_status_project_substrate.go b/cmd/runecode-tui/route_status_project_substrate.go index 7fd6541d..f6bf0cc7 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 = "" @@ -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 setup adoption: status=%s mutation=none", 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,16 +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 setup init preview: status=%s mutation=preview_only handle=%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), } - if strings.TrimSpace(resp.Preview.PreviewToken) == "" { - status += " next=reload_or_retry_preview" - } - return projectSubstrateActionResultMsg{status: status} } } @@ -81,18 +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 setup init apply unavailable: preview handle missing; reload or run init 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 setup init apply: status=%s preview_status=%s mutation=applied handle=%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, } - status += " next=reload_validation_status" - return projectSubstrateActionResultMsg{status: status} } } @@ -102,16 +118,17 @@ func (m statusRouteModel) projectSubstrateUpgradePreviewCmd() tea.Cmd { defer cancel() resp, err := m.client.ProjectSubstrateUpgradePreview(ctx) if err != nil { - return projectSubstrateActionResultMsg{err: err} - } - status := fmt.Sprintf("Project setup upgrade preview: status=%s mutation=preview_only digest=%s", valueOrNA(resp.Preview.Status), projectSubstrateHandleDisplay(resp.Preview.PreviewDigest)) - if len(resp.Preview.ReasonCodes) > 0 { - status += " reasons=" + joinCSV(resp.Preview.ReasonCodes) + 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."), + } } - if strings.TrimSpace(resp.Preview.PreviewDigest) == "" { - status += " next=reload_or_retry_preview" + 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} } } @@ -122,18 +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 setup upgrade apply unavailable: preview digest missing; reload or run upgrade 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 setup upgrade apply: status=%s preview_status=%s mutation=applied 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, } - status += " next=reload_validation_status" - return projectSubstrateActionResultMsg{status: status} } } @@ -143,3 +168,109 @@ func projectSubstrateHandleDisplay(value string) string { } return projectSubstrateHandleAcquiredText } + +func projectSubstrateAdoptionCard(adoption projectsubstrate.AdoptionResult, posture brokerapi.ProjectSubstratePostureGetResponse) *stateCardSpec { + return &stateCardSpec{ + State: routeLoadStateReady, + Title: "Compatible adoption", + Message: fmt.Sprintf("Broker recognition status=%s. Adoption is read-only and does not mutate the repository. This is read-only recognition only.", valueOrNA(adoption.Status)), + 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 status=%s. Mutation=%s. %s=%s.", status, mutation, identityLabel, handle) + if handle == "n/a" { + message = fmt.Sprintf("Preview status=%s. Mutation=%s. No %s is currently available.", status, 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: fmt.Sprintf("Apply status=%s. Mutation occurred through the broker-owned init flow. Handle=%s.", valueOrNA(result.Status), projectSubstrateHandleDisplay(preview.PreviewToken)), Reason: fmt.Sprintf("Preview status=%s • %s", valueOrNA(preview.Status), 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: fmt.Sprintf("Apply status=%s. Mutation occurred through the broker-owned upgrade flow. Digest=%s.", valueOrNA(result.Status), projectSubstrateHandleDisplay(preview.PreviewDigest)), Reason: fmt.Sprintf("Preview status=%s • %s", valueOrNA(preview.Status), 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 mutation; preview is unavailable until the broker publishes a handle" + } + if strings.EqualFold(strings.TrimSpace(status), "ready_for_apply") { + return "no mutation yet; apply is available if you explicitly choose it" + } + return "no mutation yet; preview is advisory until you explicitly apply" +} + +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 + " • follow_up=" + joinCSV(followUp) +} + +func projectSubstrateActionReason(reasons []string, posture brokerapi.ProjectSubstratePostureGetResponse) string { + parts := []string{} + if len(reasons) > 0 { + parts = append(parts, "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" +} diff --git a/cmd/runecode-tui/route_status_render.go b/cmd/runecode-tui/route_status_render.go index e76be08b..c81280eb 100644 --- a/cmd/runecode-tui/route_status_render.go +++ b/cmd/runecode-tui/route_status_render.go @@ -133,6 +133,13 @@ func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureG return fmt.Sprintf("Project setup details: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed) } +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) == "" { @@ -160,17 +167,23 @@ func renderProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGet summary := posture.PostureSummary parts := []string{tableHeader("Guided setup/remediation flow")} parts = append(parts, fmt.Sprintf("Inspect current posture: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed)) - parts = append(parts, fmt.Sprintf("Compatible adoption (a): no mutation; broker only records compatible existing substrate status=%s", projectSubstrateStepStatus(posture.Adoption.Status))) + parts = append(parts, fmt.Sprintf("Compatible adoption (a): status=%s mutation=none; read-only recognition of compatible existing substrate", projectSubstrateStepStatus(posture.Adoption.Status))) parts = append(parts, fmt.Sprintf("Init preview/apply (i/I): preview status=%s mutation=%s handle=%s", projectSubstrateStepStatus(posture.InitPreview.Status), projectSubstrateMutationLabel(posture.InitPreview.Status, posture.InitPreview.PreviewToken), projectSubstrateHandleDisplay(posture.InitPreview.PreviewToken))) parts = append(parts, fmt.Sprintf("Upgrade preview/apply (u/U): preview status=%s mutation=%s digest=%s", projectSubstrateStepStatus(posture.UpgradePreview.Status), projectSubstrateMutationLabel(posture.UpgradePreview.Status, posture.UpgradePreview.PreviewDigest), projectSubstrateHandleDisplay(posture.UpgradePreview.PreviewDigest))) if strings.TrimSpace(posture.BlockedExplanation) != "" { parts = append(parts, "What blocks normal work: "+sanitizeUIText(posture.BlockedExplanation)) } + if strings.TrimSpace(posture.InitPreview.Status) == "" { + parts = append(parts, "If init preview/apply is unavailable: reload current posture, then run init preview before any init apply.") + } + if strings.TrimSpace(posture.UpgradePreview.Status) == "" { + parts = append(parts, "If upgrade preview/apply is unavailable: reload current posture, then run upgrade preview before any upgrade apply.") + } if len(posture.RemediationGuidance) > 0 { parts = append(parts, "Broker guidance: "+joinCSV(posture.RemediationGuidance)) } if !summary.NormalOperationAllowed { - parts = append(parts, "Next action: use adopt if the repository is already compatible; otherwise run preview before any apply, then reload to validate resulting posture.") + parts = append(parts, "Next action: use adopt only for read-only compatible recognition; otherwise run preview before any apply, then reload to validate resulting posture.") } else if strings.Contains(strings.ToLower(strings.TrimSpace(summary.CompatibilityPosture)), "upgrade") { parts = append(parts, "Next action: managed work is allowed, but review the broker-owned upgrade preview and revalidate after any apply.") } else { diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index 8635eeaf..5114abfd 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -28,12 +28,12 @@ func TestStatusRouteRendersProjectSubstratePostureAndGuidance(t *testing.T) { "Managed-operation degraded reasons: none", "Project setup", "Project setup is usable, but a broker-owned upgrade is available.", + "Compatible adoption (a): status=unavailable mutation=none; read-only recognition of compatible existing substrate", + "Init preview/apply (i/I): preview status=ready_for_apply mutation=apply will mutate the repository through the broker-owned flow handle=", + "Upgrade preview/apply (u/U): preview status=ready_for_apply mutation=apply will mutate the repository through the broker-owned flow digest=", "Project setup details:", "compatibility=supported_with_upgrade_available", "Guided setup/remediation flow", - "Compatible adoption (a): no mutation", - "Init preview/apply (i/I):", - "Upgrade preview/apply (u/U):", "Keys: r reload", ) } @@ -53,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 setup adoption: status=", expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, - {key: 'i', expectedStatus: "Project setup init preview: status=", expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, - {key: 'I', expectedStatus: "Project setup init apply: status=", expectedRPCCall: []string{"ProjectSubstrateInitApply"}}, - {key: 'u', expectedStatus: "Project setup upgrade preview: status=", expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, - {key: 'U', expectedStatus: "Project setup upgrade apply: status=", expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}}, + {key: 'a', expectedStatus: "Project setup adoption refreshed: status=compatible_existing", expectedSnippets: []string{"Compatible adoption", "Adoption is read-only and does not mutate the repository.", "read-only recognition only"}, expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, + {key: 'i', expectedStatus: "Project setup init preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Init preview", "Mutation=no mutation yet; apply is available if you explicitly choose it.", "Handle=."}, expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, + {key: 'I', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Init apply", "Mutation occurred through the broker-owned init flow. Handle=.", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateInitApply"}, expectReload: true}, + {key: 'u', expectedStatus: "Project setup upgrade preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Upgrade preview", "Mutation=no mutation yet; apply is available if you explicitly choose it.", "Digest=."}, expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, + {key: 'U', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Upgrade apply", "Mutation occurred through the broker-owned upgrade flow. Digest=.", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}, expectReload: true}, } } @@ -76,25 +78,137 @@ 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 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)) { @@ -126,10 +240,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) diff --git a/cmd/runecode-tui/route_tests_helpers_test.go b/cmd/runecode-tui/route_tests_helpers_test.go index 731c9ee6..b6e0d1c8 100644 --- a/cmd/runecode-tui/route_tests_helpers_test.go +++ b/cmd/runecode-tui/route_tests_helpers_test.go @@ -266,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" @@ -383,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 == "" { @@ -636,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) { @@ -869,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/shell_action_metadata_test.go b/cmd/runecode-tui/shell_action_metadata_test.go new file mode 100644 index 00000000..36ef1429 --- /dev/null +++ b/cmd/runecode-tui/shell_action_metadata_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "strings" + "testing" +) + +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") || strings.Contains(help, "custom help text") { + t.Fatalf("expected help to include action-metadata help text, got %q", help) + } +} diff --git a/cmd/runecode-tui/shell_chrome_test.go b/cmd/runecode-tui/shell_chrome_test.go new file mode 100644 index 00000000..ab347ad4 --- /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 calm diagnostic hint in bottom strip, 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", "Focus SIDEBAR", "Action Center", "Approvals", "Commands ctrl+p/:", "Quick action: Quit RuneCode"} { + 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_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index d3226524..fce980d2 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -16,21 +16,15 @@ func (m shellModel) renderBottomStrip(surface routeSurface) string { bottom = strings.TrimSpace(surface.Regions.Bottom.Body) } if bottom == "" { - bottom = muted("No route composer or status actions for this screen.") + bottom = muted("No route composer active.") } - discovery := m.renderQuitDiscoverabilityHint() - if routeCue := strings.TrimSpace(m.renderRouteActionHints(surface)); routeCue != "" { - if discovery != "" { - discovery += " | " - } - discovery += routeCue - } - diagnostic := m.renderBottomDiagnosticLine() + discovery := m.renderBottomActionLine(surface) + diagnostic := m.renderBottomDiagnosticLine(surface) return compactLines( - tableHeader("Bottom strip"), + tableHeader("Workbench actions"), bottom, discovery, - muted(diagnostic), + diagnostic, ) } @@ -46,18 +40,35 @@ func (m shellModel) renderQuitDiscoverabilityHint() string { return "Quick action: " + label + " (:quit)" } +func (m shellModel) renderBottomActionLine(surface routeSurface) string { + parts := make([]string, 0, 3) + if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { + parts = append(parts, quit) + } + if actions := strings.TrimSpace(m.renderRouteActionHints(surface)); actions != "" { + parts = append(parts, actions) + } + if len(parts) == 0 { + return muted("Use command discovery for more actions.") + } + return strings.Join(parts, " • ") +} + func (m shellModel) renderRouteActionHints(surface routeSurface) string { parts := []string{} + if len(surface.Actions.LocalActions) > 0 { + parts = append(parts, fmt.Sprintf("%d route actions", len(surface.Actions.LocalActions))) + } if len(surface.Actions.ReferenceActions) > 0 { parts = append(parts, fmt.Sprintf("%d linked refs", len(surface.Actions.ReferenceActions))) } - if len(surface.Actions.LocalActions) > 0 { - parts = append(parts, fmt.Sprintf("%d local actions", len(surface.Actions.LocalActions))) + if len(surface.Actions.CopyActions) > 0 { + parts = append(parts, fmt.Sprintf("%d copy actions", len(surface.Actions.CopyActions))) } if len(parts) == 0 { return "" } - return "Next actions: " + strings.Join(parts, " | ") + return "Route actions: " + strings.Join(parts, " · ") } func (m shellModel) renderStatusSurface(surface routeSurface) string { @@ -68,16 +79,19 @@ func (m shellModel) renderStatusSurface(surface routeSurface) string { return "Status: " + status } -func (m shellModel) renderBottomDiagnosticLine() string { +func (m shellModel) renderBottomDiagnosticLine(surface routeSurface) string { parts := []string{} if m.selectionMode { parts = append(parts, "Selection mode on") } - if actions := len(m.activeShellSurface().Actions.CopyActions); actions > 0 { - parts = append(parts, fmt.Sprintf("copy actions %d via action entry", actions)) + if actions := len(surface.Actions.CopyActions); actions > 0 { + parts = append(parts, fmt.Sprintf("Copy via action entry (%d)", actions)) + } + if m.commandMode.Active() { + parts = append(parts, "Command mode") } if len(parts) == 0 { - parts = append(parts, "Use Status, inspectors, or command discovery for detailed diagnostics") + parts = append(parts, muted("Details stay in Status, inspectors, and command discovery")) } return strings.Join(parts, " • ") } diff --git a/cmd/runecode-tui/shell_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index ea44a40b..7a7a30b0 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -354,6 +354,9 @@ func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarHiddenNarrow(t *test if !strings.Contains(bottom, "Quick action: Quit RuneCode") || !strings.Contains(bottom, "(:quit)") { t.Fatalf("expected bottom strip to expose beginner quit affordance when sidebar hidden, got %q", bottom) } + if !strings.Contains(bottom, "Workbench actions") { + t.Fatalf("expected calm workbench actions header, got %q", bottom) + } } func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarToggledOff(t *testing.T) { @@ -390,7 +393,7 @@ 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 3 via action entry") { + if !strings.Contains(bottom, "Copy via action entry (3)") { t.Fatalf("expected updated copy hint wording, got %q", bottom) } if strings.Contains(bottom, "Y cycles/copies") { @@ -398,6 +401,18 @@ func TestShellBottomStripCopyHintUsesActionEntryWording(t *testing.T) { } } +func TestShellBottomStripSummarizesRouteActionsCalmly(t *testing.T) { + m := newShellModel() + m.width = 150 + bottom := m.renderBottomStrip(m.activeShellSurface()) + if !strings.Contains(bottom, "Route actions:") { + t.Fatalf("expected calm route action summary, 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_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 404a946a..c85d7e28 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -67,6 +67,7 @@ func (m shellModel) renderSessionQuickSwitcher() string { 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 } + preview := truncateText(sanitizeUIText(s.LastActivityPreview), 50) line := fmt.Sprintf(" %s %s | ws=%s | activity=%s/%s | cue=%s | preview=%q | incomplete=%t | runs=%d approvals=%d", marker, sessionLabel, @@ -74,7 +75,7 @@ func (m shellModel) renderSessionQuickSwitcher() string { defaultPlaceholder(s.LastActivityAt, "n/a"), defaultPlaceholder(s.LastActivityKind, "n/a"), sessionHighLevelCue(s), - truncateText(s.LastActivityPreview, 50), + preview, s.HasIncompleteTurn, s.LinkedRunCount, s.LinkedApprovalCount, @@ -222,3 +223,17 @@ func (m shellModel) renderRunningIndicator() string { } 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 { + parts = append(parts, "Commands ctrl+p/:") + } + return strings.Join(parts, " · ") +} diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index b5f5503a..74a3a1c1 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -349,12 +349,12 @@ func TestShellViewRendersShellSurfaces(t *testing.T) { m := newShellModel() m.width = 150 v := m.View() - for _, want := range []string{"RuneCode Workbench", "Product truth:", "Main pane", "Sidebar", "Bottom strip", "Status:"} { + for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "Workbench actions", "Status:"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in view, got %q", want, v) } } - for _, retired := range []string{"Path:", "History:", "clipboard="} { + for _, retired := range []string{"Path:", "History:", "clipboard=", localBrokerBoundaryPosture(), "inspector on", "copy actions 3 via action entry"} { if strings.Contains(v, retired) { t.Fatalf("did not expect retired dense chrome text %q in view, got %q", retired, v) } @@ -614,15 +614,6 @@ func TestShellOverlayDoesNotBlockWatchUpdates(t *testing.T) { } } -func TestShellBottomStripSelectionHintUsesCtrlT(t *testing.T) { - m := newShellModel() - m.width = 150 - v := m.View() - if !strings.Contains(v, "Use Status, inspectors, or command discovery for detailed diagnostics") { - t.Fatalf("expected calm diagnostic hint in bottom strip, got %q", v) - } -} - func TestShellScrollDispatchTargetsRouteViewportState(t *testing.T) { m := newShellModel() m.width = 150 @@ -723,19 +714,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{} diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 7c9664b0..8587d830 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -27,8 +27,8 @@ 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) + m.writeShellFrame(&b, surface, layout, viewportWidth) + m.writeShellFooter(&b, viewportWidth) frame := padShellBlock(strings.TrimRight(b.String(), "\n"), viewportWidth, m.availableShellHeight()) if strings.TrimSpace(overlayBody) == "" { return lipgloss.JoinVertical(lipgloss.Left, frame) @@ -36,8 +36,7 @@ func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayou return lipgloss.JoinVertical(lipgloss.Left, frame, overlayBody) } -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)) @@ -52,11 +51,10 @@ func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, la b.WriteString("\n") } -func (m shellModel) writeShellFooter(b *strings.Builder) { - viewportWidth, _ := normalizedShellViewport(m.width, m.height) +func (m shellModel) writeShellFooter(b *strings.Builder, viewportWidth int) { b.WriteString(constrainShellBlock(renderHelp(m.keys, m.palette.IsOpen() || m.sessions.IsOpen(), m.actions), viewportWidth, 1)) b.WriteString("\n") - b.WriteString(constrainShellBlock(muted("Truth via typed broker state; details in Status, inspectors, and command discovery."), viewportWidth, 1)) + b.WriteString(constrainShellBlock(muted("Broker-owned truth stays in Status, inspectors, and command discovery."), viewportWidth, 1)) b.WriteString("\n") } @@ -142,7 +140,8 @@ func (m shellModel) narrowInspectorOverlayBody(surface routeSurface, layout shel } func (m shellModel) activeOverlayHeight(viewportHeight int) int { - _, height := m.overlayBodyWithHeight(m.activeShellSurfaceWithoutOverlayHeight(), m.planShellLayout(m.activeShellSurfaceWithoutOverlayHeight()), viewportHeight) + surface := m.activeShellSurfaceWithoutOverlayHeight() + _, height := m.overlayBodyWithHeight(surface, m.planShellLayout(surface), viewportHeight) return height } @@ -195,20 +194,19 @@ func constrainShellBlock(block string, width int, height int) string { func (m shellModel) renderTopStatus(surface routeSurface, layout shellLayoutPlan) string { activity := renderShellActivityState(m.watch.projection.Activity.State) - routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("RuneCode Workbench"), neutralBadge("THEME "+string(m.themePreset))) - workbenchSummary := []string{ - fmt.Sprintf("Route %s", sanitizeUIText(m.routeLabel(m.currentRouteID()))), - fmt.Sprintf("Focus %s", strings.ToUpper(m.focus.Label())), - activity, - } + routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("RuneCode Workbench"), neutralBadge("ROUTE "+strings.ToUpper(sanitizeUIText(m.routeLabel(m.currentRouteID()))))) + workbenchSummary := []string{fmt.Sprintf("Focus %s", strings.ToUpper(m.focus.Label())), activity} if activeWork := strings.TrimSpace(m.renderActiveWorkSummary()); activeWork != "" { workbenchSummary = append(workbenchSummary, activeWork) } if strings.TrimSpace(m.activeSessionID) != "" { workbenchSummary = append(workbenchSummary, fmt.Sprintf("Session %s", sanitizeUIText(m.activeSessionID))) } - if diag := strings.TrimSpace(m.renderChromeDiagnosticHint(surface, layout)); diag != "" { - workbenchSummary = append(workbenchSummary, muted(diag)) + if m.selectionMode { + workbenchSummary = append(workbenchSummary, "Selection mode on") + } + if routeInspectorAvailable(surface) && layout.InspectorVisible { + workbenchSummary = append(workbenchSummary, "Inspector ready") } return compactLines( appTheme.SurfaceChrome.Padding(0, 1).Render(routeSummary), @@ -226,12 +224,12 @@ func renderRunningSuffix(indicator string) string { func (m shellModel) renderSyncHealth() string { text := "Product truth: " + 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))) - } - text += " • " + muted(localBrokerBoundaryPosture()) + text += " • broker-owned via local broker IPC" if strings.TrimSpace(m.watch.projection.Health.ErrorText) != "" { - text += " " + muted("("+sanitizeUIText(m.watch.projection.Health.ErrorText)+")") + text += " • " + muted("check Status: "+sanitizeUIText(m.watch.projection.Health.ErrorText)) + } + if actions := strings.TrimSpace(m.renderPrimaryWorkbenchActions()); actions != "" { + text += " • " + actions } return text } 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": [ { From f7620d5ab5ad579e109c026f819f6d26b194204b Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Fri, 8 May 2026 23:26:01 -0500 Subject: [PATCH 04/39] Several more adjustments Signed-off-by: Zebulon Rouse-Frantzich --- README.md | 11 +++++ .../local_rpc_integration_linux_test.go | 6 +-- cmd/runecode-tui/route_dashboard_helpers.go | 24 +++++----- cmd/runecode-tui/route_dashboard_test.go | 26 +++++----- .../route_runs_approvals_artifacts_test.go | 4 +- cmd/runecode-tui/safety_ux.go | 38 ++++++++------- cmd/runecode-tui/session_workspace.go | 25 +++++----- cmd/runecode-tui/session_workspace_test.go | 12 ++--- cmd/runecode-tui/shell_activity_summary.go | 47 +++++++++++++++++++ cmd/runecode-tui/shell_chrome_test.go | 2 +- cmd/runecode-tui/shell_footer_view.go | 17 +++---- cmd/runecode-tui/shell_render_helpers.go | 16 ++----- cmd/runecode-tui/shell_test.go | 4 +- cmd/runecode-tui/shell_view.go | 12 ++--- cmd/runecode-tui/shell_watch_projection.go | 20 ++++---- cmd/runecode-tui/theme.go | 7 ++- justfile | 22 +++++++++ 17 files changed, 183 insertions(+), 110 deletions(-) create mode 100644 cmd/runecode-tui/shell_activity_summary.go diff --git a/README.md b/README.md index 7928c894..e9a444fe 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,17 @@ 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 +``` + +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. + `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/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 9a9bc15c..7d4ee3c9 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -229,8 +229,8 @@ func assertDashboardAndChatBackedRoutes(t *testing.T, recording localBrokerClien dashboard := newDashboardRouteModel(routeDefinition{ID: routeDashboard, Label: "Dashboard"}, recording) assertRouteOutputContainsAll(t, dashboard, routeDashboard, "Now", - "Safety strip", - "backend_kind=unknown", + "Runtime and evidence", + "Backend: unknown", "Live Activity", "Live activity detail is available here when you need typed broker watch confirmation.", "feed: waiting for shell watch manager", @@ -253,7 +253,7 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { t.Helper() runs := newRunsRouteModel(routeDefinition{ID: routeRuns, Label: "Runs"}, recording) assertRouteOutputContainsAll(t, runs, routeRuns, - "backend_kind=unknown", + "Backend: unknown", ) assertRouteInspectorContainsAll(t, runs, routeRuns, "Workflow identity (authoritative):", diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index 407caf7e..a68f59f1 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -70,25 +70,25 @@ 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) } @@ -124,7 +124,7 @@ func dashboardBlockedSummary(data dashboardData, snapshot dashboardSnapshot) das reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) } if snapshot.BlockedRuns > 0 { - reasons = append(reasons, fmt.Sprintf("%d run(s) report blocked or waiting workflow posture", snapshot.BlockedRuns)) + reasons = append(reasons, fmt.Sprintf("%d workflow(s) are blocked or waiting", snapshot.BlockedRuns)) } return dashboardExecutiveSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress is blocked.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center and clear the blocker."} } @@ -155,7 +155,7 @@ func dashboardAttentionSummary(data dashboardData, snapshot dashboardSnapshot) d } func dashboardActiveWorkSummary(activeRuns int) dashboardExecutiveSummary { - return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%d active run(s) are visible on the current broker surfaces.", activeRuns), NextAction: "Use Action Center or open Runs for detail."} + return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%d active workflow(s) are visible.", activeRuns), NextAction: "Use Action Center or open Runs for detail."} } func renderDashboardWorkflowPosture(snapshot dashboardSnapshot) string { @@ -188,7 +188,7 @@ func renderDashboardHighValueCounts(snapshot dashboardSnapshot, width int) strin fmt.Sprintf("blocked or waiting=%d", snapshot.BlockedRuns), fmt.Sprintf("degraded cues=%d", snapshot.DegradedSignals), } - return "High-value counts: " + wrapPartsByWidth(parts, " | ", width) + return "At a glance: " + wrapPartsByWidth(parts, " | ", width) } func renderDashboardNextActions(data dashboardData, snapshot dashboardSnapshot) string { @@ -209,7 +209,7 @@ func renderDashboardNextActions(data dashboardData, snapshot dashboardSnapshot) func renderDashboardActionCenterCue(snapshot dashboardSnapshot) string { return fmt.Sprintf( - "Action Center is the operator home for approvals, blocked work, degraded posture, setup follow-up, and waiting queues. Current follow-up: approvals=%d blocked_or_waiting=%d degraded=%d.", + "Action Center has the exact follow-up list: approvals=%d, blocked or waiting=%d, degraded cues=%d.", snapshot.PendingApprovals, snapshot.BlockedRuns, snapshot.DegradedSignals, diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index ba9e9481..a51bc300 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -31,17 +31,17 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { "Degraded", "Executive overview", "Now", - "CONTENT_READY", + "Overview focused", "Workflow posture:", - "High-value counts:", + "At a glance:", "Next action:", - "Action Center is the operator home", + "Action Center has the exact follow-up list", "Safety Summary", - "Safety strip", - "backend_kind=workspace", - "runtime isolation=sandboxed", - "audit posture=ok/degraded (unanchored/degraded)", - "approval_profile=n/a", + "Runtime and evidence", + "Backend: workspace", + "Isolation: sandboxed", + "Audit evidence: ok/degraded (unanchored or degraded)", + "Approval profile: n/a", "Safety alerts:", "ALERT_AUDIT_UNANCHORED", "Supporting detail", @@ -95,8 +95,8 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { "Project setup:", "FAILED", "degraded=true", - "AUDIT_VERIFICATION_UNAVAILABLE", - "showing degraded fallback posture (gateway_failure)", + "Evidence verification unavailable", + "Reason: gateway_failure", "Supporting detail", "Live Activity", "Live activity detail is available here when you need typed broker watch confirmation.", @@ -124,7 +124,7 @@ func TestDashboardExecutiveHierarchyAndCalmPrimaryWording(t *testing.T) { if strings.Contains(view, "Protocol bundle") || strings.Contains(view, "watch_family") { t.Fatalf("expected no debug-heavy primary wording, got %q", view) } - if !strings.Contains(view, "Action Center is the operator home") { + if !strings.Contains(view, "Action Center has the exact follow-up list") { t.Fatalf("expected dashboard to point operators to Action Center, got %q", view) } } @@ -137,7 +137,7 @@ func TestDashboardViewPreservesSectionGaps(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, want := range []string{"degraded=1.\n\nSafety Summary", "ALERT_AUDIT_UNANCHORED audit posture unanchored/degraded\n\nSupporting detail", "Overview data source: runs=1 approvals=1 version=0.1.0 (abc123)\n\nLive Activity"} { + for _, want := range []string{"degraded cues=1.\n\nSafety Summary", "ALERT_AUDIT_UNANCHORED audit posture unanchored/degraded\n\nSupporting detail", "Overview data source: runs=1 approvals=1 version=0.1.0 (abc123)\n\nLive Activity"} { if !strings.Contains(view, want) { t.Fatalf("expected preserved blank section gap %q in view, got %q", want, view) } @@ -170,7 +170,7 @@ 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") { + if !strings.Contains(view, "Runtime posture nominal") { t.Fatalf("expected wrapped safety strip content retained, got %q", view) } if !strings.Contains(view, "AUDIT_UNANCHORED_OR_DEGRADED") { diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index 9449d21a..f03682fc 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -37,7 +37,7 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { "Copy cues: use Copy actions for raw run/session ids plus broker-linked approval ids", "backend_kind=workspace", "Workflow identity (authoritative): workflow_kind=change_draft workflow_definition_hash=sha256:", - "Runtime isolation assurance (authoritative): runtime isolation=sandboxed", + "Runtime isolation assurance (authoritative): Isolation: sandboxed", ) 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) @@ -199,7 +199,7 @@ 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/binding posture (authoritative): provisioning posture=attested", + "Provisioning/binding posture (authoritative): Provisioning: attested", "PROVISIONING_OK", "Attestation posture (authoritative): attestation posture=valid", "Runtime attestation truthfulness (authoritative): post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence", diff --git a/cmd/runecode-tui/safety_ux.go b/cmd/runecode-tui/safety_ux.go index c6063f60..4fa19d4f 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)...) @@ -147,17 +151,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 +173,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")} } } @@ -213,12 +217,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 +232,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..ae9607b9 100644 --- a/cmd/runecode-tui/session_workspace.go +++ b/cmd/runecode-tui/session_workspace.go @@ -180,18 +180,21 @@ 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, + parts := []string{ + fmt.Sprintf("%s%s", sid, markerText), + fmt.Sprintf("workspace %s", valueOrNA(summary.Identity.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 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..1ada7ee3 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) 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 index ab347ad4..f7ed035b 100644 --- a/cmd/runecode-tui/shell_chrome_test.go +++ b/cmd/runecode-tui/shell_chrome_test.go @@ -22,7 +22,7 @@ func TestShellChromeShowsCalmWorkbenchSummary(t *testing.T) { m.watch.projection.Activity = shellActivitySemantics{State: shellActivityStateRunning, Active: shellActivityFocus{Kind: "session", ID: "session-1"}} v := m.View() - for _, want := range []string{"ROUTE", "Focus SIDEBAR", "Action Center", "Approvals", "Commands ctrl+p/:", "Quick action: Quit RuneCode"} { + for _, want := range []string{"ROUTE", "Sidebar focus", "Working on session session-1", "Action Center", "Approvals", "Commands ctrl+p/:", "Quick action: Quit RuneCode"} { if !strings.Contains(v, want) { t.Fatalf("expected calm chrome cue %q in view, got %q", want, v) } diff --git a/cmd/runecode-tui/shell_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index fce980d2..2559be98 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -15,17 +15,14 @@ func (m shellModel) renderBottomStrip(surface routeSurface) string { if bottom == "" { bottom = strings.TrimSpace(surface.Regions.Bottom.Body) } - if bottom == "" { - bottom = muted("No route composer active.") - } discovery := m.renderBottomActionLine(surface) diagnostic := m.renderBottomDiagnosticLine(surface) - return compactLines( - tableHeader("Workbench actions"), - bottom, - discovery, - diagnostic, - ) + lines := []string{tableHeader("Workbench actions")} + if bottom != "" { + lines = append(lines, bottom) + } + lines = append(lines, discovery, diagnostic) + return compactLines(lines...) } func (m shellModel) renderQuitDiscoverabilityHint() string { @@ -74,7 +71,7 @@ func (m shellModel) renderRouteActionHints(surface routeSurface) string { 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())) + status = fmt.Sprintf("%s ready", m.routeLabel(m.currentRouteID())) } return "Status: " + status } diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index c85d7e28..94cb1ee8 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -200,16 +200,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 { @@ -217,9 +211,9 @@ 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) } diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 74a3a1c1..c843577d 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -839,7 +839,7 @@ func TestShellWatchManagerUpdatesRoutesAndSyncHealth(t *testing.T) { view := shell.View() mustContainAll(t, view, "Product truth:", - "sync=healthy", + "Sync healthy", "last_event=run_watch_terminal subject=run-1 status=completed", "event=session_watch_terminal subject=session-1 status=completed", ) @@ -856,7 +856,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_view.go b/cmd/runecode-tui/shell_view.go index 8587d830..f870de42 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -193,13 +193,11 @@ func constrainShellBlock(block string, width int, height int) string { } func (m shellModel) renderTopStatus(surface routeSurface, layout shellLayoutPlan) string { - activity := renderShellActivityState(m.watch.projection.Activity.State) routeSummary := fmt.Sprintf("%s %s", appTheme.AppTitle.Render("RuneCode Workbench"), neutralBadge("ROUTE "+strings.ToUpper(sanitizeUIText(m.routeLabel(m.currentRouteID()))))) - workbenchSummary := []string{fmt.Sprintf("Focus %s", strings.ToUpper(m.focus.Label())), activity} - if activeWork := strings.TrimSpace(m.renderActiveWorkSummary()); activeWork != "" { - workbenchSummary = append(workbenchSummary, activeWork) - } - if strings.TrimSpace(m.activeSessionID) != "" { + 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))) } if m.selectionMode { @@ -210,7 +208,7 @@ func (m shellModel) renderTopStatus(surface routeSurface, layout shellLayoutPlan } return compactLines( appTheme.SurfaceChrome.Padding(0, 1).Render(routeSummary), - appTheme.SurfaceChrome.Padding(0, 1).Render(strings.Join(workbenchSummary, " • ")+renderRunningSuffix(m.renderRunningIndicator())), + appTheme.SurfaceChrome.Padding(0, 1).Render(strings.Join(workbenchSummary, " • ")), ) } 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/theme.go b/cmd/runecode-tui/theme.go index 0344a0a5..7f3589c6 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" @@ -269,14 +268,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..88a893a2 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,7 @@ 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" default: @just --list @@ -69,3 +72,22 @@ 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 From 8bf75052eae908aef77501429c5f9da573a943d0 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Fri, 8 May 2026 23:53:50 -0500 Subject: [PATCH 05/39] More enhancements to the TUI polish change docs Signed-off-by: Zebulon Rouse-Frantzich --- .../design.md | 108 ++++++++++++++++++ .../proposal.md | 4 +- .../status.yaml | 2 +- .../tasks.md | 40 +++++-- .../verification.md | 28 ++++- 5 files changed, 166 insertions(+), 16 deletions(-) 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 c94b0d56..6b3ebcf5 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 @@ -178,6 +178,114 @@ The first once-over identified these concrete polish directions: - 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. + +The current implementation partially satisfies the CHG-060 intent: + +- `Dashboard` has an executive-summary shape, high-value counts, next-action language, and Action Center cues, but it still exposes readiness fields, evidence posture internals, version/build data, and watch-family detail in the primary pane. +- `Action Center` exists and has approval, operational-attention, and blocked-work buckets with reason, impact, owner/action, target, and evidence cues, but it is not the first/default operator home and still uses internal family names such as `operational_attention`, `blocked_work_impact`, and watch-family language in primary content. +- `Chat` has the strongest route-level alignment: active session, composer state, broker-owned execution state, linked evidence counts, and honest broker-known stages such as waiting, plan compiled, runner active, checkpoint received, approval required, artifact ready, failed, and completed. +- `Runs` keeps outcomes, coordination, plan authority, runner/reporting posture, approvals, artifacts, and audit evidence together, but rendered inspector content still includes debug-heavy details such as `backend_kind`, workflow hashes, authoritative/advisory key counts, coordination locks/conflicts, approval profile, and raw posture taxonomy. +- `Approvals` distinguishes approval-required, pending, resolved, expired, unsupported, lifecycle, policy, trigger, and bound-scope cues, but the rendered view still surfaces contract vocabulary and policy codes too early for a non-debug operator. +- `Artifacts` and `Audit` provide useful evidence continuity and copyable raw identifiers, but digest labels, event names, verification codes, and anchoring internals still dominate too much of the primary visual hierarchy. +- `Status` and project-substrate remediation are closest to the target: inspect/current posture, adoption, init preview/apply, upgrade preview/apply, apply-unavailable states, post-apply validation, and broker-owned mutation language are present and mostly understandable. +- `Model Providers`, `Git Setup`, and `Git Remote` are the least polished product surfaces. They still read like broker API/state dumps because primary panes expose provider family, endpoint, profile IDs, auth modes, bootstrap modes, policy booleans, prepared mutation IDs, typed request hashes, action hashes, approval hashes, credential leases, lifecycle reason codes, and derived git metadata without enough product hierarchy. +- The shell chrome is improved from a raw debug frame, but it still reserves a lot of vertical space for top status, sync strip, bottom strip, status row, and footer. The resulting workbench can feel dense even before route content renders. +- The command, session, leader, inspector, and quit overlays are functional, but they are appended as additional blocks rather than visually layered modal surfaces. They do not yet match the clean centered command-palette pattern suggested by the reference images: dimmed background, centered elevated panel, compact search field, grouped actions, strong selected row, and low-noise shortcut hints. +- The implementation uses Lip Gloss styles and tokens, but the styling is mostly utility-level: bordered panes, badges, muted text, and surface colors. It does not yet use a strong visual system with accent rails, differentiated message blocks, sparse typography, focused cards, subdued secondary text, and consistent color semantics across all routes. + +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 +- 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, 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. + +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, and debug details. Runs, Approvals, Artifacts, Audit, 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 for Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status/project substrate, Model Providers, Git Setup, Git Remote, command palette, session switcher, leader help, narrow layout, and degraded/disconnected states. Review those frames against CHG-060 before marking the TUI acceptance criterion complete. + 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: 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..dc1106b6 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,7 @@ 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 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 still needs a calmer shell, professional modal overlays, stricter rendered/structured/raw separation, route-by-route product-language cleanup, display-width-safe styling, and captured visual walkthrough evidence before it can be called polished. - 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 +57,7 @@ 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, product language in primary panes, debug detail moved to structured/raw modes, clear state cards, command discovery that feels intentional, responsive narrow/wide layouts, and honest broker-owned progress without synthetic optimism. - 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 +77,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 46888f1b..5109dc9a 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,21 +41,37 @@ ## Phase 5: TUI And Product Polish During Dogfooding -- [ ] 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, and setup/remediation routes, and 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. -- [ ] Reframe the global shell chrome into a calm product workbench rather than a constant debug/status dump: keep route, focus, product posture, sync truth, and active work visible, but reduce repeated long trust-boundary text, raw layout/focus diagnostics, history noise, clipboard noise, and exhaustive shortcuts in the primary frame; preserve detailed truth through command discovery, inspectors, status/detail surfaces, or copy actions instead of deleting it. -- [ ] 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 the reason, impact, owner/action required, target route/object, and evidence link source without inferring state outside broker-owned surfaces. -- [ ] 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, protocol bundle details, watch-family terminology, and low-level audit facts into secondary/detail views. -- [ ] Create a unified state-card UX pattern for loading, empty, ready, waiting, blocked, degraded, failed, resumed, completed, and approval-required states: each card should include a short state label, a plain-English reason, the authoritative source or object identity when useful, the next recommended action, and the shortcut/route to take that action; the pattern must avoid fake progress and must distinguish broker-known state from unknown or unavailable state. +- [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; 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, and footer 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. +- [ ] 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. +- [ ] 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. +- [ ] 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, API details, and debug posture taxonomy; Runs, Approvals, Artifacts, Audit, 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 run directory and inspector clearly show status, workflow operation, plan authority, runner/reporting posture, failure or blocked reason, linked approvals, artifacts, and audit records; provide obvious navigation/copy cues from a selected run to its session, artifacts, approvals, and audit evidence. -- [ ] Improve Approvals route confidence and safety: distinguish pending, resolved, expired, unsupported, 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. -- [ ] Improve Artifacts, Audit, and verification discoverability as one evidence trail: make it obvious how to move from a 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 the primary visual content when a shorter stable identity or label is available. +- [ ] 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`, 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 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`, and `setup required` in primary panes; reserve protocol names, schema names, raw contract method names, and implementation terms for detail modes where they help debugging or verification. -- [ ] 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, 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. -- [ ] 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, 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: visual refinements, optional theme changes, secondary keyboard customization, advanced filtering, additional inspector modes, or future collaboration/publishing affordances should be tracked as follow-up unless they are required for a new Linux user to coherently test the supported local workflow path. +- [ ] 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. +- [ ] 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, narrow layout, disconnected/degraded watch state, blocked project setup, approval required, failed run, and empty/no-work states. +- [ ] 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 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..0cbf910a 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,15 @@ - 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. A focused TUI once-over found partial CHG-060 adherence, but the remaining presentation, route hierarchy, overlay, and Bubble Tea/Lip Gloss best-practice gaps 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. +- The TUI partially satisfies CHG-060: Chat, Status/project substrate, evidence links, Action Center queues, state-card foundations, inspectors, copy actions, and plan-authoritative workflow surfaces are present. +- The TUI does not fully satisfy CHG-060 yet: Dashboard, Action Center, Runs, Approvals, Artifacts, Audit, Model Providers, Git Setup, Git Remote, shell chrome, footers, overlays, and rendered/structured/raw separation still expose too much control-plane/debug detail in primary surfaces. +- 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 +35,21 @@ - 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, narrow layout, medium layout, and wide layout. +- 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 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, 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. +- 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. + ## 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 +62,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. From 8db70a37368d841133f4ff9df52b5016d7231e52 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 00:37:02 -0500 Subject: [PATCH 06/39] Some polish on the dashboard and hrader and footer sections. Also some polish on the command palette Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/help.go | 3 + .../local_rpc_integration_linux_test.go | 13 +- cmd/runecode-tui/palette.go | 30 +++ cmd/runecode-tui/route_action_center.go | 6 +- .../route_action_center_blocked_helpers.go | 10 +- .../route_action_center_helpers.go | 32 +-- .../route_action_center_render.go | 42 +++- cmd/runecode-tui/route_action_center_test.go | 23 +-- cmd/runecode-tui/route_approvals_helpers.go | 17 +- .../route_chat_execution_state_helpers.go | 12 ++ cmd/runecode-tui/route_dashboard.go | 53 ++--- cmd/runecode-tui/route_dashboard_helpers.go | 187 +++++++++--------- cmd/runecode-tui/route_dashboard_test.go | 101 ++++------ cmd/runecode-tui/route_git_remote_mutation.go | 77 ++++++-- .../route_git_remote_mutation_test.go | 8 +- cmd/runecode-tui/route_git_setup.go | 56 +++++- cmd/runecode-tui/route_provider_setup.go | 42 +++- cmd/runecode-tui/route_provider_setup_test.go | 4 +- .../route_runs_approvals_artifacts_test.go | 26 ++- cmd/runecode-tui/route_runs_detail.go | 25 +-- cmd/runecode-tui/session_workspace.go | 40 ++++ cmd/runecode-tui/shared_components.go | 12 +- cmd/runecode-tui/shared_state_card.go | 26 ++- cmd/runecode-tui/shared_visual.go | 141 +++++++++++++ cmd/runecode-tui/shell_footer_view.go | 33 ++-- cmd/runecode-tui/shell_modes_quit_test.go | 7 +- cmd/runecode-tui/shell_overlay_render.go | 9 +- cmd/runecode-tui/shell_render_helpers.go | 44 +++-- cmd/runecode-tui/shell_services_test.go | 4 +- cmd/runecode-tui/shell_sidebar.go | 2 +- cmd/runecode-tui/shell_test.go | 12 +- cmd/runecode-tui/shell_view.go | 36 +++- 32 files changed, 748 insertions(+), 385 deletions(-) create mode 100644 cmd/runecode-tui/shared_visual.go diff --git a/cmd/runecode-tui/help.go b/cmd/runecode-tui/help.go index 4b6ff8b7..7647e134 100644 --- a/cmd/runecode-tui/help.go +++ b/cmd/runecode-tui/help.go @@ -21,6 +21,9 @@ func renderHelp(keys shellKeyMap, paletteOpen bool, actions shellActionGraph) st h := help.New() h.ShowAll = false view := "Help: " + h.View(shellHelpKeys(bubbleBindings)) + if !paletteOpen { + view = "ctrl+p commands • " + view + } if entries := compactActionHelpEntries(actions.helpEntries(4)); len(entries) > 0 { view += " | Actions: " + strings.Join(entries, " · ") } diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 7d4ee3c9..c54e53b9 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -228,12 +228,11 @@ func assertDashboardAndChatBackedRoutes(t *testing.T, recording localBrokerClien t.Helper() dashboard := newDashboardRouteModel(routeDefinition{ID: routeDashboard, Label: "Dashboard"}, recording) assertRouteOutputContainsAll(t, dashboard, routeDashboard, - "Now", - "Runtime and evidence", - "Backend: unknown", - "Live Activity", - "Live activity detail is available here when you need typed broker watch confirmation.", - "feed: waiting for shell watch manager", + "Current work", + "At a glance", + "Approvals waiting:", + "Next action", + "Action Center explains blockers and degraded cues", ) chat := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, recording) @@ -256,7 +255,7 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { "Backend: unknown", ) assertRouteInspectorContainsAll(t, runs, routeRuns, - "Workflow identity (authoritative):", + "Workflow operation:", "Evidence links:", ) diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index 8cd311aa..ee53545f 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -196,6 +196,36 @@ func paletteMatchLine(entry paletteEntry, selected bool) string { return line } +func paletteMatchLineBounded(entry paletteEntry, width int) string { + left := fmt.Sprintf("%s", entry.Label) + if strings.TrimSpace(entry.Description) != "" { + left += " " + muted(entry.Description) + } + right := paletteEntryShortcut(entry) + return rightAlignMetadata(left, right, width) +} + +func paletteEntryShortcut(entry paletteEntry) string { + if commandID := strings.TrimSpace(entry.Action.Target.CommandID); commandID != "" { + return commandID + } + if route := strings.TrimSpace(string(entry.Action.Target.RouteID)); route != "" { + return route + } + 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/route_action_center.go b/cmd/runecode-tui/route_action_center.go index a9c94148..b08a48aa 100644 --- a/cmd/runecode-tui/route_action_center.go +++ b/cmd/runecode-tui/route_action_center.go @@ -183,9 +183,9 @@ func (m actionCenterRouteModel) View(width, height int, focus focusArea) string NextAction: vm.Summary.NextAction, RouteCue: "Approvals, Runs, Audit, Status", }), - fmt.Sprintf("Queue families: %s=%d %s=%d %s=%d", infoBadge("approvals"), len(vm.Families[actionCenterFamilyApprovals]), warnBadge("operational_attention"), len(vm.Families[actionCenterFamilyOps]), dangerBadge("blocked_work_impact"), len(vm.Families[actionCenterFamilyBlocked])), - fmt.Sprintf("Active triage family: %s", stateBadgeWithLabel("family", string(m.family))), - "Action Center is the operator home for broker-known follow-up. Question/answer queues remain reserved for future canonical broker models and are intentionally not implemented locally.", + fmt.Sprintf("Queues: Approvals %d • Operational attention %d • Blocked work %d", len(vm.Families[actionCenterFamilyApprovals]), len(vm.Families[actionCenterFamilyOps]), len(vm.Families[actionCenterFamilyBlocked])), + fmt.Sprintf("Active queue: %s", actionCenterFamilyLabel(m.family)), + "Action Center is the operator home for blocked work, approval gates, degraded sync, setup, runtime, and evidence follow-up.", renderActionCenterDirectory("Approvals queue", 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 impact", vm.Families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(vm.Families[actionCenterFamilyBlocked])), width), diff --git a/cmd/runecode-tui/route_action_center_blocked_helpers.go b/cmd/runecode-tui/route_action_center_blocked_helpers.go index 5cf96901..f0554395 100644 --- a/cmd/runecode-tui/route_action_center_blocked_helpers.go +++ b/cmd/runecode-tui/route_action_center_blocked_helpers.go @@ -37,7 +37,7 @@ func blockedImpactItem(run brokerapi.RunSummary, blockedCount int) actionCenterI State: stateCard, Urgency: urgency, Reason: blockedImpactReason(run, blockedCount), - Impact: fmt.Sprintf("Workflow progress is waiting for run %s; pending_approvals=%d linked_queue_items=%d.", valueOrNA(run.RunID), run.PendingApprovalCount, 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)), @@ -54,12 +54,12 @@ func blockedImpactDisposition(run brokerapi.RunSummary) (string, routeLoadState, } func blockedImpactReason(run brokerapi.RunSummary, blockedCount int) string { - reasonParts := []string{fmt.Sprintf("lifecycle=%s", valueOrNA(run.LifecycleState))} + reasonParts := []string{fmt.Sprintf("Lifecycle is %s", humanizeExecutionToken(run.LifecycleState))} if strings.TrimSpace(run.BlockingReasonCode) != "" { - reasonParts = append(reasonParts, fmt.Sprintf("reason=%s", valueOrNA(run.BlockingReasonCode))) + reasonParts = append(reasonParts, fmt.Sprintf("reason is %s", humanizeExecutionToken(run.BlockingReasonCode))) } if blockedCount > 0 { - reasonParts = append(reasonParts, fmt.Sprintf("approval_queue_items=%d", blockedCount)) + reasonParts = append(reasonParts, fmt.Sprintf("%d approval queue item(s) are linked", blockedCount)) } - return strings.Join(reasonParts, "; ") + 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 b4e902a0..f9032e03 100644 --- a/cmd/runecode-tui/route_action_center_helpers.go +++ b/cmd/runecode-tui/route_action_center_helpers.go @@ -51,12 +51,12 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary, now time.Time) if urgency == "critical" { state = routeLoadStateBlocked } - reasons := []string{fmt.Sprintf("approval status=%s", valueOrNA(ap.Status)), fmt.Sprintf("trigger=%s", valueOrNA(ap.ApprovalTriggerCode))} + reasons := []string{fmt.Sprintf("Approval is %s", humanizeExecutionToken(ap.Status)), fmt.Sprintf("triggered by %s", humanizeExecutionToken(ap.ApprovalTriggerCode))} if staleCue != "fresh" { - reasons = append(reasons, staleCue) + reasons = append(reasons, humanizeExecutionToken(staleCue)) } if expiryCue != "no_expiry" { - reasons = append(reasons, expiryCue) + reasons = append(reasons, humanizeExecutionToken(expiryCue)) } impact := fmt.Sprintf("Workflow progress stays gated for run %s stage %s action %s until this decision is resolved.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) targetLabel := fmt.Sprintf("Approvals › %s", approvalID) @@ -75,7 +75,7 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary, now time.Time) }) } if len(out) == 0 { - return []actionCenterItem{{Title: "no pending approvals", State: routeLoadStateEmpty, Urgency: "low", Reason: "canonical 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 []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 } @@ -133,7 +133,7 @@ func buildOperationalAttentionItems(audit *brokerapi.AuditVerificationGetRespons items = append(items, operationalRunItems(runs)...) items = append(items, operationalProjectSetupItems(project)...) if len(items) == 0 { - 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 []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 } @@ -150,7 +150,7 @@ func operationalSyncHealthItem(health shellSyncHealth) []actionCenterItem { Title: "shell watch sync health", State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("watch sync state=%s error=%s", health.State, defaultPlaceholder(health.ErrorText, "n/a")), + Reason: fmt.Sprintf("Watch sync is %s; last error: %s.", humanizeExecutionToken(string(health.State)), defaultPlaceholder(health.ErrorText, "n/a")), 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.", @@ -174,7 +174,7 @@ func operationalWatchFamilyItems(watch dashboardLiveActivity) []actionCenterItem Title: fmt.Sprintf("watch family %s", valueOrNA(family.family)), State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("errors=%d last_status=%s last_subject=%s", family.errorCount, valueOrNA(family.lastStatus), valueOrNA(family.lastSubject)), + Reason: fmt.Sprintf("%s reported %d error(s); last status %s; subject %s.", humanizeExecutionToken(family.family), family.errorCount, humanizeExecutionToken(family.lastStatus), valueOrNA(family.lastSubject)), 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.", @@ -192,7 +192,7 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Title: "audit verification unavailable", State: routeLoadStateDegraded, Urgency: "high", - Reason: fmt.Sprintf("audit verification load failed: %s", auditErr), + Reason: fmt.Sprintf("Audit verification load failed: %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.", @@ -216,8 +216,8 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Title: "audit verification posture", State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("integrity=%s anchoring=%s degraded=%t", valueOrNA(s.IntegrityStatus), valueOrNA(s.AnchoringStatus), s.CurrentlyDegraded), - Impact: fmt.Sprintf("Evidence confidence is reduced; hard_failures=%d degraded_reasons=%d.", len(s.HardFailures), len(s.DegradedReasons)), + Reason: fmt.Sprintf("Integrity is %s, anchoring is %s, and evidence currently needs review: %t.", humanizeExecutionToken(s.IntegrityStatus), humanizeExecutionToken(s.AnchoringStatus), s.CurrentlyDegraded), + 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", @@ -239,19 +239,19 @@ func operationalRunItems(runs []brokerapi.RunSummary) []actionCenterItem { if failedLifecycle { urgency = "critical" } - reasonParts := []string{fmt.Sprintf("lifecycle=%s", valueOrNA(run.LifecycleState))} + reasonParts := []string{fmt.Sprintf("Lifecycle is %s", humanizeExecutionToken(run.LifecycleState))} if run.RuntimePostureDegraded { - reasonParts = append(reasonParts, "runtime_degraded=true") + reasonParts = append(reasonParts, "runtime posture needs review") } if run.AuditCurrentlyDegraded { - reasonParts = append(reasonParts, "audit_degraded=true") + reasonParts = append(reasonParts, "audit posture needs review") } items = append(items, actionCenterItem{ 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 on backend=%s isolation=%s.", valueOrNA(run.RunID), valueOrNA(run.BackendKind), valueOrNA(run.IsolationAssuranceLevel)), + 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)), @@ -296,7 +296,7 @@ func buildBlockedImpactItems(runs []brokerapi.RunSummary, approvals []brokerapi. items = append(items, blockedImpactItem(run, linkedApprovalCount(run, byRun))) } if len(items) == 0 { - 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 []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 } diff --git a/cmd/runecode-tui/route_action_center_render.go b/cmd/runecode-tui/route_action_center_render.go index b82b60e1..2b1baed1 100644 --- a/cmd/runecode-tui/route_action_center_render.go +++ b/cmd/runecode-tui/route_action_center_render.go @@ -30,12 +30,12 @@ func renderActionCenterItems(items []actionCenterItem) []string { func renderActionCenterItem(item actionCenterItem, width int) string { parts := []string{ - fmt.Sprintf("%s %s urgency=%s", tableHeader(strings.ToUpper(string(item.State))), item.Title, valueOrNA(item.Urgency)), + fmt.Sprintf("%s %s", tableHeader(actionCenterStateLabel(item.State)), item.Title), + " urgency: " + humanizeExecutionToken(item.Urgency), " reason: " + valueOrNA(item.Reason), " impact: " + valueOrNA(item.Impact), " owner/action: " + valueOrNA(strings.TrimSpace(strings.Join([]string{item.Owner, item.RequiredAction}, " • "))), " target: " + valueOrNA(item.TargetLabel), - " evidence: " + valueOrNA(item.EvidenceCue), } if width <= 0 { return strings.Join(parts, "\n") @@ -47,6 +47,36 @@ func renderActionCenterItem(item actionCenterItem, width int) string { return strings.Join(wrapped, "\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 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." @@ -54,7 +84,7 @@ func renderActionCenterInspector(family actionCenterFamily, items []actionCenter item := items[selected] return compactLines( renderInspectorHeader("Action Center inspector", appTheme.InspectorHint.Render("triage detail")), - fmt.Sprintf(" family=%s", family), + fmt.Sprintf(" queue=%s", actionCenterFamilyLabel(family)), fmt.Sprintf(" title=%s", item.Title), fmt.Sprintf(" state=%s", valueOrNA(string(item.State))), fmt.Sprintf(" urgency=%s", valueOrNA(item.Urgency)), @@ -74,13 +104,13 @@ func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem blocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateBlocked) approvalBlocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateApprovalRequired) if blocked > 0 { - return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress is blocked and needs operator follow-up.", Reason: fmt.Sprintf("blocked_work_impact=%d approval_gated_work=%d operational_attention=%d approvals=%d", blocked, approvalBlocked, ops, approvals), NextAction: "Start with blocked-work impact items, then review linked approvals or setup guidance."} + return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress 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 impact items, then review linked approvals or setup guidance."} } if approvals > 0 || approvalBlocked > 0 { - return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", Message: "Approval decisions are waiting on the operator.", Reason: fmt.Sprintf("approvals=%d approval_gated_work=%d operational_attention=%d", approvals, approvalBlocked, ops), NextAction: "Open the approvals queue and review the exact gated action before deciding."} + return actionCenterSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", 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 the approvals queue and review the exact gated action before deciding."} } if ops > 0 { - return actionCenterSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Runtime, evidence, setup, or sync posture needs review.", Reason: fmt.Sprintf("operational_attention=%d", ops), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} + return actionCenterSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Runtime, evidence, setup, or sync posture needs review.", Reason: fmt.Sprintf("%d operational attention item(s) are visible.", ops), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} } return actionCenterSummary{State: routeLoadStateReady, Title: "No immediate follow-up", Message: "No broker-known attention items are currently waiting in Action Center.", Reason: "Approvals, blocked-work impact, and operational attention queues are clear on the current broker surfaces.", NextAction: "Return to Dashboard or Chat and continue work until new follow-up appears."} } diff --git a/cmd/runecode-tui/route_action_center_test.go b/cmd/runecode-tui/route_action_center_test.go index e8ab6989..47532972 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -30,22 +30,21 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) mustContainAll(t, view, "Action Center", "Blocked", - "Queue families:", - "approvals", - "operational_attention", - "blocked_work_impact", + "Queues:", + "Approvals", + "Operational attention", + "Blocked work", "Approvals queue", "Operational attention", "Blocked-work impact", - "Action Center is the operator home for broker-known follow-up.", + "Action Center is the operator home for blocked work", "owner/action:", "target:", - "evidence:", ) surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "family=", + "queue=", "state=", "urgency=", "reason=", @@ -84,7 +83,7 @@ func TestActionCenterKeyboardTriageAndDrillDown(t *testing.T) { } view := updated.View(140, 40, focusContent) - if !strings.Contains(view, "Active triage family") { + if !strings.Contains(view, "Active queue") { t.Fatalf("expected family indicator in view, got %q", view) } surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) @@ -106,12 +105,11 @@ func TestBuildApprovalActionItemsIncludesExpiryAndSupersededCues(t *testing.T) { "approval ap-expired", "expired", "approval ap-soon", - "expiring_soon", + "expiring soon", "approval ap-super", "superseded", "owner/action:", "owner: operator decision", - "evidence:", ) } @@ -126,9 +124,9 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin text := strings.Join(renderActionCenterItems(items), "\n") mustContainAll(t, text, "shell watch sync health", - "state=disconnected", + "Watch sync is disconnected", "audit verification posture", - "anchoring=degraded", + "anchoring is degraded", "run run-1 operational posture", "owner/action:", ) @@ -154,7 +152,6 @@ func TestActionCenterItemsStateReasonImpactOwnerTargetEvidence(t *testing.T) { "impact:", "owner/action:", "target:", - "evidence:", ) } diff --git a/cmd/runecode-tui/route_approvals_helpers.go b/cmd/runecode-tui/route_approvals_helpers.go index ab567e59..5cba2714 100644 --- a/cmd/runecode-tui/route_approvals_helpers.go +++ b/cmd/runecode-tui/route_approvals_helpers.go @@ -136,6 +136,7 @@ func approvalInspectorContent(summary brokerapi.ApprovalSummary, detail brokerap 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 { @@ -147,26 +148,16 @@ func approvalInspectorContent(summary brokerapi.ApprovalSummary, detail brokerap } return compactLines( fmt.Sprintf("Approval state: %s %s", approvalUIValue(approvalDisplayState(summary, detail)), approvalPrimaryStateBadge(summary)), - fmt.Sprintf("State taxonomy: approval-required=%s pending=%s resolved=%s expired=%s unsupported=%s", boolWord(approvalDisplayState(summary, detail) == "approval required"), boolWord(approvalLifecycleState(detail) == "pending"), boolWord(workflowApprovalState(summary, detail) == "resolved"), boolWord(workflowApprovalState(summary, detail) == "expired"), boolWord(approvalResolveStatus(summary, detail) == "unsupported")), fmt.Sprintf("Why this approval exists: %s", approvalUIValue(approvalPrimaryReason(summary, detail))), fmt.Sprintf("Exact gated object/action: %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("After approval route: %s", approvalUIValue(approvalFollowUpRoute(summary))), fmt.Sprintf("Resolve availability: %s", approvalUIValue(approvalResolveSummary(summary, detail))), - fmt.Sprintf("Resolve status: %s", approvalUIValue(approvalResolveStatus(summary, detail))), fmt.Sprintf("Resolve unavailable because: %s", approvalUIValue(approvalResolveBlockedReason(summary, detail))), - fmt.Sprintf("Workflow posture: %s", approvalUIValue(workflowApprovalState(summary, detail))), - fmt.Sprintf("Approval type: %s (binding_kind=%s) %s", approvalUIValue(bindingLabel), approvalUIValue(detail.BindingKind), infoBadge("type cue")), - fmt.Sprintf("Lifecycle state: %s (%s) %s", approvalUIValue(lifecycleState), approvalUIValue(lifecycleFlags), postureBadge(lifecycleState)), - fmt.Sprintf("Lifecycle reason code: %s", approvalUIValue(detail.LifecycleDetail.LifecycleReasonCode)), - fmt.Sprintf("Policy reason code: %s %s", approvalUIValue(detail.PolicyReasonCode), warnBadge("policy cue")), - fmt.Sprintf("Approval trigger code: %s %s", approvalUIValue(summary.ApprovalTriggerCode), infoBadge("trigger cue")), - fmt.Sprintf("Distinct blocking semantics: trigger=%s cue=%s", approvalUIValue(summary.ApprovalTriggerCode), renderBlockingStateCue(true, summary.ApprovalTriggerCode)), - "Execution/system errors stay separate from approval policy and lifecycle cues. "+dangerBadge("system cue"), - fmt.Sprintf("Blocked work scope: kind=%s action=%s run=%s stage=%s step=%s role=%s", approvalUIValue(detail.BlockedWorkScope.ScopeKind), approvalUIValue(detail.BlockedWorkScope.ActionKind), approvalUIValue(detail.BlockedWorkScope.RunID), approvalUIValue(detail.BlockedWorkScope.StageID), approvalUIValue(detail.BlockedWorkScope.StepID), approvalUIValue(detail.BlockedWorkScope.RoleInstanceID)), - fmt.Sprintf("Canonical bound identity: request=%s decision=%s manifest=%s policy_decision=%s", approvalUIValue(identity.ApprovalRequestDigest), approvalUIValue(identity.ApprovalDecisionDigest), approvalUIValue(identity.ManifestHash), approvalUIValue(identity.PolicyDecisionHash)), - fmt.Sprintf("Exact bound scope: workspace=%s run=%s stage=%s step=%s role=%s action=%s", approvalUIValue(boundScope.WorkspaceID), approvalUIValue(boundScope.RunID), approvalUIValue(boundScope.StageID), approvalUIValue(boundScope.StepID), approvalUIValue(boundScope.RoleInstanceID), approvalUIValue(boundScope.ActionKind)), + fmt.Sprintf("Workflow posture: %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))), ) } diff --git a/cmd/runecode-tui/route_chat_execution_state_helpers.go b/cmd/runecode-tui/route_chat_execution_state_helpers.go index 7c226214..c8297021 100644 --- a/cmd/runecode-tui/route_chat_execution_state_helpers.go +++ b/cmd/runecode-tui/route_chat_execution_state_helpers.go @@ -167,3 +167,15 @@ func chooseActionTextByPosture(existing string, posture brokerapi.ProjectSubstra } 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_dashboard.go b/cmd/runecode-tui/route_dashboard.go index b5791477..dfbcada4 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" @@ -131,15 +130,15 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { RouteCue: "Status or Action Center", }) } - focusLabel := "inactive" - if focus == focusContent { - focusLabel = "active" - } 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"), + sectionTitle("Dashboard")+" "+focusBadge(focus), renderStateCardSpec(stateCardSpec{ State: snapshot.Executive.State, Title: snapshot.Executive.Title, @@ -148,41 +147,20 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { NextAction: snapshot.Executive.NextAction, RouteCue: "Action Center", }), - tableHeader("Now")+" "+renderDashboardNowBar(snapshot.PrimaryRun, snapshot.PendingApprovals, focusLabel == "active", innerWidth), - ), - compactLines( - tableHeader("Executive overview"), - wrapDashboardLine(renderDashboardWorkflowPosture(snapshot), innerWidth), - wrapDashboardLine(renderDashboardHighValueCounts(snapshot, innerWidth), innerWidth), - wrapDashboardLine(renderDashboardNextActions(m.data, snapshot), innerWidth), - wrapDashboardLine(renderDashboardActionCenterCue(snapshot), innerWidth), ), - compactLines(tableHeader("Safety Summary"), renderRunSafetyStrip(snapshot.PrimaryRun, innerWidth), wrapDashboardLine(renderDashboardSafetyAlerts(m.data), innerWidth)), - m.controlPlaneSection(innerWidth), - compactLines(tableHeader("Live Activity"), wrapDashboardLine("Live activity detail is available here when you need typed broker watch confirmation.", innerWidth), wrapDashboardLine(muted("Typed watch family status and event detail are secondary to the executive overview."), 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"), wrapDashboardLine("Primary follow-up lives in Action Center; use Runs, Approvals, Audit, and Status for detail.", innerWidth), keyHint("Route keys: r reload")), + 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: dashboardMetricLines(snapshot)}), + 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("Supporting detail"), - 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("Evidence 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("Overview data source: runs=%d approvals=%d version=%s (%s)", len(m.data.runs), len(m.data.approvals), m.data.version.ProductVersion, m.data.version.BuildRevision), width), - ) - return compactLines(parts...) -} - func joinDashboardSections(sections ...string) string { nonEmpty := make([]string, 0, len(sections)) for _, section := range sections { @@ -248,7 +226,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 a68f59f1..0ffb623d 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -191,20 +191,96 @@ func renderDashboardHighValueCounts(snapshot dashboardSnapshot, width int) strin return "At a glance: " + wrapPartsByWidth(parts, " | ", width) } +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 + } +} + +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: broker returned follow-up items, but no primary run is selected." + } + 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 active") + } + if snapshot.DegradedSignals > 0 { + parts = append(parts, "evidence or runtime posture needs review") + } + if data.readiness.RecoveryComplete { + parts = append(parts, "recovery is complete") + } + return strings.Join(parts, "; ") + "." +} + +func renderDashboardApprovalCue(data dashboardData, snapshot dashboardSnapshot) string { + if snapshot.PendingApprovals == 0 { + return "Approvals: none waiting." + } + first := firstPendingApproval(data.approvals) + if strings.TrimSpace(first.ApprovalID) == "" { + return fmt.Sprintf("Approvals: %d decision(s) are waiting.", snapshot.PendingApprovals) + } + return fmt.Sprintf("Approvals: %d decision(s) waiting; next is %s for run %s.", snapshot.PendingApprovals, sanitizeUIText(first.ApprovalID), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) +} + +func dashboardMetricLines(snapshot dashboardSnapshot) []string { + return []string{ + fmt.Sprintf("Active work: %d", snapshot.ActiveRuns), + fmt.Sprintf("Approvals waiting: %d", snapshot.PendingApprovals), + fmt.Sprintf("Blocked or waiting: %d", snapshot.BlockedRuns), + fmt.Sprintf("Needs review: %d", snapshot.DegradedSignals), + } +} + +func renderDashboardDetailCue(snapshot dashboardSnapshot) string { + if snapshot.BlockedRuns > 0 || snapshot.PendingApprovals > 0 || snapshot.DegradedSignals > 0 || snapshot.SetupNeedsAttention { + return "Detail: Action Center explains blockers and degraded cues; Audit, Runs, and Status keep the raw proof." + } + return "Detail: use Runs, Audit, or Status when you need broker proof instead of the overview." +} + +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 "Next action: Open Action Center, follow the setup blocker, then use Status for broker-owned remediation steps." + return "Open Action Center and clear the setup blocker, then use Status for broker-owned remediation steps." } if snapshot.PendingApprovals > 0 { - return "Next action: Open Action Center and review the pending approval before workflow progress resumes." + return "Open Action Center and review the pending approval before workflow progress resumes." } if snapshot.DegradedSignals > 0 { - return "Next action: Open Action Center for degraded evidence or runtime follow-up, then inspect Audit or Runs." + return "Open Action Center for degraded evidence or runtime follow-up, then inspect Audit or Runs." } if strings.TrimSpace(snapshot.PrimaryRun.RunID) != "" { - return fmt.Sprintf("Next action: Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(snapshot.PrimaryRun.RunID)) + return fmt.Sprintf("Open Runs for %s or Action Center if new follow-up appears.", sanitizeUIText(snapshot.PrimaryRun.RunID)) } - return "Next action: Start a workflow from Chat, then return here for the executive overview." + return "Start a workflow from Chat, then return here for the executive overview." } func renderDashboardActionCenterCue(snapshot dashboardSnapshot) string { @@ -216,6 +292,22 @@ func renderDashboardActionCenterCue(snapshot dashboardSnapshot) string { ) } +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++ + } + } + if degraded > 0 { + return fmt.Sprintf("Live updates need attention in %d stream(s); Action Center and Status have the details.", degraded) + } + if len(live.feed) > 0 { + return fmt.Sprintf("Live updates are healthy; %d recent broker event(s) are visible below.", len(live.feed)) + } + return "Live updates are quiet; RuneCode will show broker-owned activity here when work changes." +} + func dashboardActiveRunCount(runs []brokerapi.RunSummary) int { total := 0 for _, run := range runs { @@ -283,7 +375,7 @@ func dashboardProjectSubstrateReason(posture brokerapi.ProjectSubstratePostureGe } compatibility := strings.TrimSpace(posture.PostureSummary.CompatibilityPosture) if compatibility != "" { - return fmt.Sprintf("project setup posture=%s", sanitizeUIText(compatibility)) + return fmt.Sprintf("project setup posture is %s", humanizeExecutionToken(compatibility)) } if len(posture.RemediationGuidance) > 0 { return "project setup guidance is available" @@ -308,92 +400,9 @@ func pendingApprovalCount(runs []brokerapi.RunSummary, approvals []brokerapi.App return pending } -func renderRunHighlights(runs []brokerapi.RunSummary) string { - if len(runs) == 0 { - return "Current work: no recent runs returned by the broker." - } - first := runs[0] - return fmt.Sprintf("Current work: latest run %s is %s on %s runtime with %d pending approval(s).", sanitizeUIText(first.RunID), valueOrNA(sanitizeUIText(first.LifecycleState)), valueOrNA(sanitizeUIText(first.BackendKind)), first.PendingApprovalCount) -} - -func renderApprovalHighlights(approvals []brokerapi.ApprovalSummary) string { - if len(approvals) == 0 { - return "Approval follow-up: no pending approvals returned by the broker." - } - first := approvals[0] - return fmt.Sprintf("Approval follow-up: %s is %s for %s on run %s.", sanitizeUIText(first.ApprovalID), valueOrNA(sanitizeUIText(first.Status)), valueOrNA(sanitizeUIText(first.ApprovalTriggerCode)), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) -} - func primaryDashboardRun(runs []brokerapi.RunSummary) brokerapi.RunSummary { if len(runs) == 0 { return brokerapi.RunSummary{} } return runs[0] } - -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") - } - 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") - } - if run.RuntimePostureDegraded { - alerts = append(alerts, reducedAssuranceBadge("ALERT_REDUCED_ASSURANCE_RUNTIME")+" reduced-assurance runtime posture is active") - } - 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 len(alerts) == 0 { - return "Safety alerts: " + successBadge("NO_ACTIVE_DEGRADATION") - } - return "Safety alerts: " + strings.Join(alerts, " ") -} - -func renderDashboardProjectSubstrateLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { - summary := posture.PostureSummary - if strings.TrimSpace(summary.SchemaID) == "" { - return "Project setup: unavailable" - } - return fmt.Sprintf( - "Project setup: validation=%s compatibility=%s normal_operation_allowed=%t", - sanitizeUIText(summary.ValidationState), - sanitizeUIText(summary.CompatibilityPosture), - summary.NormalOperationAllowed, - ) -} - -func renderDashboardProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { - parts := []string{} - if strings.TrimSpace(posture.BlockedExplanation) != "" { - parts = append(parts, "Project setup block: "+sanitizeUIText(posture.BlockedExplanation)) - } - if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Project setup guidance:") - parts = append(parts, joinCSVWithWrapHint(posture.RemediationGuidance)) - } - if strings.TrimSpace(posture.InitPreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project setup init preview=%s", posture.InitPreview.Status)) - } - if strings.TrimSpace(posture.UpgradePreview.Status) != "" { - parts = append(parts, fmt.Sprintf("Project setup upgrade preview=%s", posture.UpgradePreview.Status)) - } - if len(parts) == 0 { - return "Project setup guidance: none" - } - 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_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index a51bc300..14ac5352 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -29,37 +29,22 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { mustContainAll(t, view, "Degraded", - "Executive overview", - "Now", - "Overview focused", - "Workflow posture:", - "At a glance:", - "Next action:", - "Action Center has the exact follow-up list", - "Safety Summary", - "Runtime and evidence", - "Backend: workspace", - "Isolation: sandboxed", - "Audit evidence: ok/degraded (unanchored or degraded)", - "Approval profile: n/a", - "Safety alerts:", - "ALERT_AUDIT_UNANCHORED", - "Supporting detail", - "Project setup:", - "compatibility=supported_with_upgrade_available", - "Project setup guidance:", - "Live Activity", - "Live activity detail is available here when you need typed broker watch confirmation.", - "Typed watch family status and event detail are secondary to the executive overview.", - "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", + "Current work", + "run-1 is active", + "Approvals: 1 decision(s) waiting", + "At a glance", + "Active work: 1", + "Approvals waiting: 1", + "Needs review: 1", + "Next action", + "Open Action Center", + "Audit, Runs, and Status keep the raw proof", ) + 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 } @@ -90,17 +75,15 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { mustContainAll(t, view, "Dashboard", "Degraded", - "Now", - "Evidence posture", - "Project setup:", - "FAILED", - "degraded=true", - "Evidence verification unavailable", - "Reason: gateway_failure", - "Supporting detail", - "Live Activity", - "Live activity detail is available here when you need typed broker watch confirmation.", + "Current work", + "At a glance", + "Needs 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) { @@ -113,18 +96,19 @@ func TestDashboardExecutiveHierarchyAndCalmPrimaryWording(t *testing.T) { view := updated.View(120, 40, focusContent) cardIndex := strings.Index(view, "Evidence or runtime posture needs review.") - overviewIndex := strings.Index(view, "Executive overview") - detailIndex := strings.Index(view, "Supporting detail") - if cardIndex < 0 || overviewIndex < 0 || detailIndex < 0 { + 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 < overviewIndex && overviewIndex < detailIndex) { - t.Fatalf("expected state card before overview before supporting detail, 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") { + 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, "Action Center has the exact follow-up list") { + if !strings.Contains(view, "Action Center explains blockers and degraded cues") { t.Fatalf("expected dashboard to point operators to Action Center, got %q", view) } } @@ -137,7 +121,7 @@ func TestDashboardViewPreservesSectionGaps(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, want := range []string{"degraded cues=1.\n\nSafety Summary", "ALERT_AUDIT_UNANCHORED audit posture unanchored/degraded\n\nSupporting detail", "Overview data source: runs=1 approvals=1 version=0.1.0 (abc123)\n\nLive Activity"} { + for _, want := range []string{"Current work", "\n\n| At a glance", "Needs 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) } @@ -152,7 +136,7 @@ func TestDashboardAuditFallbackWithoutErrorDoesNotAddExtraBlankLine(t *testing.T } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if strings.Contains(view, "Evidence posture\n\nOverview data source") { + if strings.Contains(view, "\n\n\n") { t.Fatalf("did not expect extra blank line when audit fallback notice absent, got %q", view) } } @@ -170,11 +154,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 nominal") { - t.Fatalf("expected wrapped safety strip content retained, got %q", view) + if !strings.Contains(view, "Audit, Runs, 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) } } @@ -190,14 +174,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", - "Executive overview", - "Safety Summary", - "Supporting detail", - "Live Activity", + "Current work", + "At a glance", + "Next action", ) } diff --git a/cmd/runecode-tui/route_git_remote_mutation.go b/cmd/runecode-tui/route_git_remote_mutation.go index 15d001db..8214ad6d 100644 --- a/cmd/runecode-tui/route_git_remote_mutation.go +++ b/cmd/runecode-tui/route_git_remote_mutation.go @@ -183,23 +183,76 @@ func (m gitRemoteMutationRouteModel) View(width, height int, focus focusArea) st } 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("Prepared change: %s", gitRemotePreparedSummary(summary)), + fmt.Sprintf("Target: repository=%s refs=%s", valueOrNA(summary.RepositoryIdentity), joinCSV(summary.TargetRefs)), + fmt.Sprintf("Approval: %s", gitRemoteApprovalSummary(m.prepared)), + fmt.Sprintf("Credential lease: %s", gitRemoteLeaseSummary(m.providerAuthLeaseID)), + fmt.Sprintf("Execution: %s", gitRemoteExecutionSummary(m.prepared)), + "Safety: execute remains fail-closed until approval bindings and a broker-issued provider credential lease match this prepared mutation.", + muted(fmt.Sprintf("Structured/raw detail can show prepared=%s typed_request=%s action=%s policy=%s approval_request=%s approval_decision=%s tree=%s patches=%s.", valueOrNA(m.prepared.PreparedMutationID), requestHash, actionHash, decisionHash, approvalReqHash, approvalDecisionHash, digestIdentityOrNA(summary.ExpectedResultTreeHash), joinCSV(patches))), m.status, keyHint("Route keys: r reload prepared state, e execute prepared mutation"), ) } +func gitRemoteStateCard(prepared brokerapi.GitRemoteMutationPreparedState, leaseID string) stateCardSpec { + state := routeLoadStateWaiting + message := "Prepared remote mutation is ready for review." + next := "Review the target, approval, and lease state 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 prepared mutation." + } + if strings.TrimSpace(leaseID) != "" { + state = routeLoadStateReady + message = "Credential lease is present for execution." + next = "Press e only after confirming the prepared change and approval state." + } + if strings.Contains(strings.ToLower(prepared.ExecutionState), "fail") { + state = routeLoadStateDegraded + message = "Remote mutation execution failed." + next = "Review the broker execution reason before retrying." + } + return stateCardSpec{State: state, Title: "Guarded remote review", Message: message, Reason: "Git remote mutation stays behind broker prepare/get/execute contracts and fail-closed approval/lease checks.", NextAction: next, ShortcutCue: "r reload • e execute", EvidenceCue: "prepared mutation, approval binding, provider lease"} +} + +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 mutation" +} + +func gitRemoteApprovalSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { + if strings.TrimSpace(prepared.RequiredApprovalID) == "" { + return "no approval binding reported" + } + if prepared.RequiredApprovalDecisionHash != nil { + return fmt.Sprintf("approval %s has decision evidence", prepared.RequiredApprovalID) + } + return fmt.Sprintf("approval %s required before execution", prepared.RequiredApprovalID) +} + +func gitRemoteLeaseSummary(leaseID string) string { + if strings.TrimSpace(leaseID) == "" { + return "not issued yet" + } + return "issued for this prepared mutation" +} + +func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { + state := strings.TrimSpace(prepared.ExecutionState) + if state == "" { + state = strings.TrimSpace(prepared.LifecycleState) + } + return valueOrNA(state) +} + func (m gitRemoteMutationRouteModel) 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_remote_mutation_test.go b/cmd/runecode-tui/route_git_remote_mutation_test.go index 1875cc73..831ef7c2 100644 --- a/cmd/runecode-tui/route_git_remote_mutation_test.go +++ b/cmd/runecode-tui/route_git_remote_mutation_test.go @@ -18,10 +18,10 @@ 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", + "Prepared change:", + "Approval:", + "Safety: execute remains fail-closed", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q in %q", want, view) diff --git a/cmd/runecode-tui/route_git_setup.go b/cmd/runecode-tui/route_git_setup.go index b203efd2..5e2d98e5 100644 --- a/cmd/runecode-tui/route_git_setup.go +++ b/cmd/runecode-tui/route_git_setup.go @@ -196,18 +196,60 @@ func (m gitSetupRouteModel) View(width, height int, focus focusArea) string { } 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 account: %s", gitProviderAccountSummary(account)), + fmt.Sprintf("Authentication: %s", gitAuthSummary(auth)), + fmt.Sprintf("Commit identity: %s", gitIdentitySummary(profiles, control.DefaultIdentityProfileID)), + fmt.Sprintf("Policy safety: %s", gitPolicySafetySummary(m.data.PolicySurface)), + muted(fmt.Sprintf("Structured/raw detail can show bootstrap_mode=%s headless=%t token_fallback=%t profiles=%s last_view=%s recent_repositories=%d.", valueOrNA(auth.BootstrapMode), auth.HeadlessBootstrapSupported, auth.InteractiveTokenFallbackSupport, profileSummary, valueOrNA(control.LastSetupView), len(control.RecentRepositories))), 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 := "Git account setup is not linked yet." + next := "Press a for browser auth or d for device-code auth." + 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 refresh if provider state changed." + } else if account.Linked { + message = "Git account is linked; commit identity needs setup." + next = "Press i to upsert the default commit identity profile." + } + return stateCardSpec{State: state, Title: "Git setup", Message: message, Reason: "Remote mutation stays broker-gated and artifact-managed; this route only prepares account and identity state.", NextAction: next, ShortcutCue: "a browser • d device • i identity", EvidenceCue: "broker git setup profile"} +} + +func gitProviderAccountSummary(account brokerapi.GitProviderAccountState) string { + if account.Linked { + return fmt.Sprintf("%s linked as %s", valueOrNA(account.Provider), valueOrNA(account.AccountUsername)) + } + return fmt.Sprintf("%s not linked", valueOrNA(account.Provider)) +} + +func gitAuthSummary(auth brokerapi.GitAuthPostureState) string { + if strings.TrimSpace(auth.AuthStatus) == "" { + return "not started" + } + return fmt.Sprintf("%s via %s", valueOrNA(auth.AuthStatus), valueOrNA(auth.BootstrapMode)) +} + +func gitIdentitySummary(profiles []brokerapi.GitCommitIdentityProfile, defaultID string) string { + if len(profiles) == 0 { + return "no identity profile yet" + } + return fmt.Sprintf("%d profile(s), default %s", len(profiles), valueOrNA(defaultID)) +} + +func gitPolicySafetySummary(policy brokerapi.GitPolicySurfaceState) string { + if policy.ArtifactManagedOnly && !policy.DirectMutationSupport { + return "artifact-managed review only; direct mutation disabled" + } + return fmt.Sprintf("artifact_managed_only=%t direct_mutation=%t", policy.ArtifactManagedOnly, policy.DirectMutationSupport) +} + 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_provider_setup.go b/cmd/runecode-tui/route_provider_setup.go index 057d7adc..e37c61b9 100644 --- a/cmd/runecode-tui/route_provider_setup.go +++ b/cmd/runecode-tui/route_provider_setup.go @@ -218,20 +218,46 @@ func (m providerSetupRouteModel) View(width, height int, focus focusArea) string } 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)), + renderStateCardSpec(providerSetupStateCard(m, current)), + "Credential safety: raw secrets are masked locally, submitted through trusted secret ingress, and never displayed in ordinary route output.", + fmt.Sprintf("Provider: %s", valueOrNA(m.selected.DisplayLabel)), + fmt.Sprintf("Readiness: %s", valueOrNA(current.ReadinessPosture.EffectiveReadiness)), + fmt.Sprintf("Credential setup: %s", providerCredentialSetupState(current, m.entryActive)), fmt.Sprintf("Masked credential input: %s", masked), - fmt.Sprintf("Broker-projected profiles discovered: %d", len(m.profiles)), + fmt.Sprintf("Profiles discovered: %d", len(m.profiles)), + muted(fmt.Sprintf("Structured/raw detail can show provider_family=%s endpoint=%s%s profile=%s auth_modes=[%s].", valueOrNA(m.selected.ProviderFamily), valueOrNA(m.selected.CanonicalHost), valueOrNA(m.selected.CanonicalPathPrefix), valueOrNA(current.ProviderProfileID), supportedAuthModes)), 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 { + state := routeLoadStateWaiting + message := "Model provider setup is ready for a credential." + next := "Press s to start trusted secret ingress." + if strings.TrimSpace(current.ProviderProfileID) != "" && strings.EqualFold(strings.TrimSpace(current.ReadinessPosture.CredentialState), "present") { + state = routeLoadStateReady + message = "A broker-managed model provider credential is present." + next = "Refresh readiness or continue with Chat when workflow execution needs a model." + } + if m.entryActive { + state = routeLoadStateWaiting + message = "Secret entry is active and masked." + 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 uses broker-owned direct-credential setup and trusted secret ingress.", NextAction: next, ShortcutCue: "s setup • f provider • r refresh", EvidenceCue: "broker provider profiles and secret-ingress session"} +} + +func providerCredentialSetupState(profile brokerapi.ProviderProfile, entryActive bool) string { + if entryActive { + return "masked entry active" + } + if strings.EqualFold(strings.TrimSpace(profile.ReadinessPosture.CredentialState), "present") { + return "credential stored" + } + return "credential needed" +} + 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..44bd0e5e 100644 --- a/cmd/runecode-tui/route_provider_setup_test.go +++ b/cmd/runecode-tui/route_provider_setup_test.go @@ -17,10 +17,10 @@ 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") { + if !strings.Contains(view, "Credential setup: credential stored") { t.Fatalf("expected auth-mode posture in provider view, got %q", view) } - if !strings.Contains(view, "Compatibility posture: unverified") { + if !strings.Contains(view, "Structured/raw detail can show provider_family=openai_compatible") { t.Fatalf("expected compatibility posture in provider view, got %q", view) } assertStringSliceEqual(t, recording.Calls(), []string{"ProviderProfileList"}) diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index f03682fc..9dcc4e5f 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -34,10 +34,9 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { "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", - "Copy cues: use Copy actions for raw run/session ids plus broker-linked approval ids", "backend_kind=workspace", - "Workflow identity (authoritative): workflow_kind=change_draft workflow_definition_hash=sha256:", - "Runtime isolation assurance (authoritative): Isolation: sandboxed", + "Structured/raw modes expose workflow hashes", + "Runtime assurance: Isolation: sandboxed", ) 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) @@ -61,18 +60,15 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { "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", - "State taxonomy: approval-required=yes pending=yes resolved=no expired=no unsupported=yes", "Why this approval exists: policy requires operator review before promotion for run-1 can", "Exact gated object/action: run run-1 • stage stage-1 • action=promotion", "Review first: run evidence for run-1", "If approved next: Promotion continues (effect=unblock_next_stage)", "After approval route: Artifacts → Audit", "Resolve availability: unavailable here because promotion approvals must be completed in the prom", - "Resolve status: unsupported", "Resolve unavailable because: promotion approvals must stay in the promotion flow", - "Approval type: exact-action approval (binding_kind=exact_action)", - "Lifecycle state: pending (stale)", - "Lifecycle reason code: awaiting_decision", + "Workflow posture: approval required; lifecycle=pending (stale)", + "Structured/raw modes expose exact-action approval", ) if !strings.Contains(view, "Approval review") { t.Fatalf("expected approval overview card in main view, got %q", view) @@ -199,11 +195,11 @@ 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/binding posture (authoritative): Provisioning: attested", + "Provisioning posture: Provisioning: attested", "PROVISIONING_OK", - "Attestation posture (authoritative): attestation posture=valid", - "Runtime attestation truthfulness (authoritative): post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence", - "Verifier class (authoritative): verifier class=trusted_domain_local", + "Attestation truth: attestation posture=valid", + "post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence", + "Structured/raw modes expose workflow hashes", ) } @@ -211,9 +207,9 @@ 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, - "Policy reason code: requires_human_review", - "Approval trigger code: policy_gate", - "Distinct blocking semantics: trigger=policy_gate", + "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", ) } diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index 216e910c..7f23275d 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -147,6 +147,8 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta return compactLines( 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)), @@ -155,24 +157,11 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta 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)), - "Copy cues: use Copy actions for raw run/session ids plus broker-linked approval ids and audit hashes; artifacts stay route-linked until the broker exposes exact object ids.", - 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)), + "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)), ) } diff --git a/cmd/runecode-tui/session_workspace.go b/cmd/runecode-tui/session_workspace.go index ae9607b9..d1e65821 100644 --- a/cmd/runecode-tui/session_workspace.go +++ b/cmd/runecode-tui/session_workspace.go @@ -197,6 +197,46 @@ func sessionDirectoryLine(summary brokerapi.SessionSummary, activeSessionID stri 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 { + sid := strings.TrimSpace(summary.Identity.SessionID) + if sid == "" { + sid = "session" + } + markers := conciseSessionMarkers(summary, activeSessionID, pinned, recentOrder, viewed[sid], 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 { recentOrder := map[string]int{} for i, sid := range recents { diff --git a/cmd/runecode-tui/shared_components.go b/cmd/runecode-tui/shared_components.go index 187a96fb..4ee2eaaa 100644 --- a/cmd/runecode-tui/shared_components.go +++ b/cmd/runecode-tui/shared_components.go @@ -221,17 +221,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 { diff --git a/cmd/runecode-tui/shared_state_card.go b/cmd/runecode-tui/shared_state_card.go index eab8f61d..51594021 100644 --- a/cmd/runecode-tui/shared_state_card.go +++ b/cmd/runecode-tui/shared_state_card.go @@ -10,6 +10,7 @@ type stateCardSpec struct { NextAction string ShortcutCue string RouteCue string + EvidenceCue string } func renderStateCard(state routeLoadState, title, message string) string { @@ -35,17 +36,30 @@ func renderStateCardSpec(spec stateCardSpec) string { nextAction = stateCardNextStep(spec.State) } cues := stateCardCueLine(spec.ShortcutCue, spec.RouteCue) - lines := []string{ - tableHeader(strings.ToUpper(label)) + " " + strings.TrimSpace(title), - appTheme.SurfaceCard.Padding(0, 1).Render(compactLines("Message: "+message, "Reason: "+reason)), - } + lines := []string{appTheme.SurfaceCard.Padding(0, 1).Render(compactLines("Message: "+message, "Reason: "+reason))} if nextAction != "" { - lines = append(lines, muted("Next: "+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 compactLines(lines...) + 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 { diff --git a/cmd/runecode-tui/shared_visual.go b/cmd/runecode-tui/shared_visual.go new file mode 100644 index 00000000..a81ff4e0 --- /dev/null +++ b/cmd/runecode-tui/shared_visual.go @@ -0,0 +1,141 @@ +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 appTheme.StateSuccess.Bold(true) + case visualToneAttention: + return appTheme.StateWarn.Bold(true) + case visualToneDanger: + return appTheme.StateDanger.Bold(true) + case visualToneCommand: + return appTheme.Selected.Bold(true) + case visualToneInfo: + return appTheme.StateInfo.Bold(true) + default: + return appTheme.BorderStrong.Bold(true) + } +} + +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 + } + leftWidth := lipgloss.Width(left) + rightWidth := lipgloss.Width(right) + 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) + muted(right) +} diff --git a/cmd/runecode-tui/shell_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index 2559be98..6930de60 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -6,23 +6,26 @@ import ( ) func (m shellModel) renderBottomStrip(surface routeSurface) string { - bottom := "" 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 } - discovery := m.renderBottomActionLine(surface) - diagnostic := m.renderBottomDiagnosticLine(surface) - lines := []string{tableHeader("Workbench actions")} - if bottom != "" { - lines = append(lines, bottom) + parts := []string{"ctrl+p commands", ": command", "tab focus"} + if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { + parts = append(parts, quit) + } + if actions := strings.TrimSpace(m.renderRouteActionHints(surface)); actions != "" { + parts = append(parts, actions) + } + if diagnostic := strings.TrimSpace(m.renderBottomDiagnosticLine(surface)); diagnostic != "" && !strings.Contains(diagnostic, "Broker-owned truth stays") { + parts = append(parts, diagnostic) } - lines = append(lines, discovery, diagnostic) - return compactLines(lines...) + if bottom := strings.TrimSpace(surface.Regions.Bottom.Body); bottom != "" { + parts = append(parts, bottom) + } + return strings.Join(parts, " • ") } func (m shellModel) renderQuitDiscoverabilityHint() string { @@ -46,7 +49,7 @@ func (m shellModel) renderBottomActionLine(surface routeSurface) string { parts = append(parts, actions) } if len(parts) == 0 { - return muted("Use command discovery for more actions.") + return muted("ctrl+p opens command discovery") } return strings.Join(parts, " • ") } @@ -88,7 +91,7 @@ func (m shellModel) renderBottomDiagnosticLine(surface routeSurface) string { parts = append(parts, "Command mode") } if len(parts) == 0 { - parts = append(parts, muted("Details stay in Status, inspectors, and command discovery")) + parts = append(parts, muted("Broker-owned truth stays in Status, inspectors, and command discovery")) } return strings.Join(parts, " • ") } diff --git a/cmd/runecode-tui/shell_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index 7a7a30b0..230a9abe 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -354,8 +354,8 @@ func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarHiddenNarrow(t *test if !strings.Contains(bottom, "Quick action: Quit RuneCode") || !strings.Contains(bottom, "(:quit)") { t.Fatalf("expected bottom strip to expose beginner quit affordance when sidebar hidden, got %q", bottom) } - if !strings.Contains(bottom, "Workbench actions") { - t.Fatalf("expected calm workbench actions header, got %q", bottom) + if strings.Contains(bottom, "\n") { + t.Fatalf("expected one-line bottom strip, got %q", bottom) } } @@ -408,6 +408,9 @@ func TestShellBottomStripSummarizesRouteActionsCalmly(t *testing.T) { if !strings.Contains(bottom, "Route actions:") { t.Fatalf("expected calm route action summary, got %q", bottom) } + if strings.Contains(bottom, "\n") { + t.Fatalf("expected one-line route action summary, got %q", bottom) + } if strings.Contains(bottom, "Next actions:") { t.Fatalf("expected retired next-actions wording removed, got %q", bottom) } diff --git a/cmd/runecode-tui/shell_overlay_render.go b/cmd/runecode-tui/shell_overlay_render.go index 205366d3..c7434e20 100644 --- a/cmd/runecode-tui/shell_overlay_render.go +++ b/cmd/runecode-tui/shell_overlay_render.go @@ -51,8 +51,8 @@ func centeredOverlayBlockBounded(title shellOverlayID, body string, viewportWidt Render(content) rendered := compactLines( - tableHeader("Overlay")+" "+neutralBadge(strings.ToUpper(string(title))), - lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Left).Render(frame), + lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(tableHeader("Overlay")+" "+neutralBadge(strings.ToUpper(string(title)))+" "+muted("esc close")), + lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(frame), ) if maxHeight <= 0 { return rendered @@ -79,7 +79,10 @@ 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 if contentEndX < contentStartX { diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 94cb1ee8..75fb2994 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -18,25 +18,25 @@ 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)) + width := boundedOverlayListWidth(m.width) + b.WriteString(rightAlignMetadata(tableHeader("Commands")+" "+muted("Workbench Command Surface"), "esc", width) + "\n") + b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(m.palette.query)) + "\n") if len(m.palette.matches) == 0 { - b.WriteString(muted("No matches. Press esc to close.")) + 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") + " " + muted("Matches")) 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}) + rows = append(rows, boundedListRow{Text: paletteMatchLineBounded(entry, width), Selectable: true}) } b.WriteString(renderBoundedList(boundedListSpec{ Rows: rows, Selected: m.palette.selectedIndex, - Width: boundedOverlayListWidth(m.width), - Height: 8, + Width: width, + Height: 10, GapMarker: "...", PreserveGaps: true, ApplySelected: true, @@ -48,14 +48,15 @@ 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(rightAlignMetadata(tableHeader("Switch Session"), "esc", width) + "\n") + b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(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 { @@ -68,24 +69,25 @@ func (m shellModel) renderSessionQuickSwitcher() string { sessionLabel = "● " + sessionLabel } preview := truncateText(sanitizeUIText(s.LastActivityPreview), 50) - 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, + left := fmt.Sprintf("%s %s %s", marker, sessionLabel, sessionHighLevelCue(s)) + right := fmt.Sprintf("runs=%d approvals=%d", s.LinkedRunCount, s.LinkedApprovalCount) + line := rightAlignMetadata(left, right, width) + detail := fmt.Sprintf(" activity=%s/%s ws=%s preview=%q", defaultPlaceholder(s.LastActivityAt, "n/a"), defaultPlaceholder(s.LastActivityKind, "n/a"), - sessionHighLevelCue(s), + s.Identity.WorkspaceID, preview, - s.HasIncompleteTurn, - s.LinkedRunCount, - s.LinkedApprovalCount, ) + if s.HasIncompleteTurn { + detail += " waiting" + } + line = compactLines(line, muted(clipDisplayText(detail, width))) rows = append(rows, boundedListRow{Text: line, Selectable: true}) } b.WriteString(renderBoundedList(boundedListSpec{ Rows: rows, Selected: m.sessions.selectedIndex, - Width: boundedOverlayListWidth(m.width), + Width: width, Height: 8, GapMarker: "...", PreserveGaps: true, @@ -137,7 +139,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 { diff --git a/cmd/runecode-tui/shell_services_test.go b/cmd/runecode-tui/shell_services_test.go index 43b61f18..cf02302a 100644 --- a/cmd/runecode-tui/shell_services_test.go +++ b/cmd/runecode-tui/shell_services_test.go @@ -179,7 +179,7 @@ func TestCenteredOverlayBlockBoundedClipsToHeight(t *testing.T) { 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 != 73 { + t.Fatalf("expected centered content bounds [6,73], got [%d,%d]", start, end) } } 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 c843577d..3bac3228 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -349,12 +349,12 @@ func TestShellViewRendersShellSurfaces(t *testing.T) { m := newShellModel() m.width = 150 v := m.View() - for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "Workbench actions", "Status:"} { + for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "ctrl+p commands", "Status:"} { 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"} { + 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) } @@ -394,7 +394,7 @@ func TestShellOverlayRemainsVisibleWithinViewport(t *testing.T) { if !strings.Contains(v, "Workbench Command Surface") { t.Fatalf("expected palette overlay content in viewport, got %q", v) } - for _, want := range []string{"Overlay", "Matches"} { + for _, want := range []string{"Overlay", "Suggested"} { if !strings.Contains(v, want) { t.Fatalf("expected styled overlay affordance %q in viewport, got %q", want, v) } @@ -421,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, "Commands") { t.Fatalf("expected palette overlay content in narrow viewport, got %q", v) } } @@ -840,8 +840,8 @@ func TestShellWatchManagerUpdatesRoutesAndSyncHealth(t *testing.T) { mustContainAll(t, view, "Product truth:", "Sync healthy", - "last_event=run_watch_terminal subject=run-1 status=completed", - "event=session_watch_terminal subject=session-1 status=completed", + "Open Action Center for degraded evidence or runtime follow-up", + "Action Center explains blockers and degraded cues", ) } diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index f870de42..8a810792 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -29,11 +29,11 @@ func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayou b := strings.Builder{} m.writeShellFrame(&b, surface, layout, viewportWidth) m.writeShellFooter(&b, viewportWidth) - frame := padShellBlock(strings.TrimRight(b.String(), "\n"), viewportWidth, m.availableShellHeight()) + frame := padShellBlock(strings.TrimRight(b.String(), "\n"), viewportWidth, viewportHeight) if strings.TrimSpace(overlayBody) == "" { return lipgloss.JoinVertical(lipgloss.Left, frame) } - return lipgloss.JoinVertical(lipgloss.Left, frame, overlayBody) + return applyModalOverlay(frame, overlayBody, viewportWidth, viewportHeight) } func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, layout shellLayoutPlan, viewportWidth int) { @@ -222,9 +222,8 @@ func renderRunningSuffix(indicator string) string { func (m shellModel) renderSyncHealth() string { text := "Product truth: " + renderShellSyncState(m.watch.projection.Health.State) - text += " • broker-owned via local broker IPC" if strings.TrimSpace(m.watch.projection.Health.ErrorText) != "" { - text += " • " + muted("check Status: "+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 @@ -232,6 +231,35 @@ func (m shellModel) renderSyncHealth() string { return text } +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") +} + func (m shellModel) renderBreadcrumbs(surface routeSurface) string { breadcrumbs := surface.Chrome.Breadcrumbs if len(breadcrumbs) == 0 { From 3716f9065cc7f2ac29d812923da27155b20e318d Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 08:11:21 -0500 Subject: [PATCH 07/39] Adds more TUI polish and adds TUI snapshot tool. Signed-off-by: Zebulon Rouse-Frantzich --- README.md | 5 + cmd/runecode-tui/main.go | 36 +++- cmd/runecode-tui/main_test.go | 10 + cmd/runecode-tui/shell_model.go | 4 +- cmd/runecode-tui/snapshot_scenarios.go | 218 ++++++++++++++++++++++ cmd/runecode-tui/snapshot_svg.go | 243 +++++++++++++++++++++++++ justfile | 15 ++ 7 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 cmd/runecode-tui/snapshot_scenarios.go create mode 100644 cmd/runecode-tui/snapshot_svg.go diff --git a/README.md b/README.md index e9a444fe..2491eb6b 100644 --- a/README.md +++ b/README.md @@ -332,10 +332,15 @@ just tui-dev just tui-dev-restart just tui-dev-status just tui-dev-stop +just tui-snapshot-dashboard +just tui-snapshot-action-center +just tui-snapshot-all ``` 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 `/tmp/runecode-tui-snapshots/` using named scenarios. Each run writes a `manifest.json` plus per-scenario `.ansi`, `.txt`, and `.svg` files, and also derives `.png` files when ImageMagick `convert` is available locally. This is the intended fast UI/UX review loop for the repo-local TUI 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/main.go b/cmd/runecode-tui/main.go index f74e6e66..f9dba84e 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -32,6 +32,14 @@ func main() { return } + if cfg.snapshot.enabled { + if err := writeSnapshotArtifacts(cfg.snapshot); err != nil { + fmt.Fprintf(os.Stderr, "runecode-tui snapshot failed: %v\n", err) + os.Exit(1) + } + return + } + 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) @@ -63,6 +71,16 @@ type tuiCLIConfig struct { showHelp bool runtimeDir string socketName string + snapshot tuiSnapshotConfig +} + +type tuiSnapshotConfig struct { + enabled bool + scenario string + outputDir string + width int + height int + theme themePreset } func parseCLIConfig(args []string) (tuiCLIConfig, error) { @@ -73,13 +91,29 @@ func parseCLIConfig(args []string) (tuiCLIConfig, error) { fs.SetOutput(io.Discard) runtimeDir := fs.String("runtime-dir", "", "broker local IPC runtime directory override") socketName := fs.String("socket-name", "", "broker local IPC socket filename override") + snapshotScenario := fs.String("snapshot-scenario", "", "hidden dev option: deterministic TUI snapshot scenario") + snapshotOutputDir := fs.String("snapshot-output-dir", "/tmp/runecode-tui-snapshots", "hidden dev option: snapshot output directory") + snapshotWidth := fs.Int("snapshot-width", 160, "hidden dev option: snapshot terminal width") + snapshotHeight := fs.Int("snapshot-height", 48, "hidden dev option: snapshot terminal height") + snapshotTheme := fs.String("snapshot-theme", string(themePresetDark), "hidden dev option: snapshot theme preset") 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 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} + if *snapshotScenario != "" { + cfg.snapshot = tuiSnapshotConfig{ + enabled: true, + scenario: *snapshotScenario, + outputDir: *snapshotOutputDir, + width: *snapshotWidth, + height: *snapshotHeight, + theme: normalizeThemePreset(themePreset(*snapshotTheme)), + } + } + return cfg, nil } func setCLIIPCConfigOverrides(cfg tuiCLIConfig) { diff --git a/cmd/runecode-tui/main_test.go b/cmd/runecode-tui/main_test.go index 1e463974..950f2338 100644 --- a/cmd/runecode-tui/main_test.go +++ b/cmd/runecode-tui/main_test.go @@ -47,6 +47,16 @@ func TestParseCLIConfigParsesIPCOverrides(t *testing.T) { } } +func TestParseCLIConfigParsesSnapshotOptions(t *testing.T) { + cfg, err := parseCLIConfig([]string{"--snapshot-scenario", "dashboard", "--snapshot-output-dir", "/tmp/snaps", "--snapshot-width", "140", "--snapshot-height", "40", "--snapshot-theme", "dusk"}) + 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.width != 140 || cfg.snapshot.height != 40 || cfg.snapshot.theme != themePresetDusk { + t.Fatalf("parseCLIConfig snapshot cfg = %+v, want snapshot options populated", cfg.snapshot) + } +} + func TestWriteNonInteractiveMessageIncludesBrokerRemediation(t *testing.T) { var out bytes.Buffer if err := writeNonInteractiveMessage(&out); err != nil { diff --git a/cmd/runecode-tui/shell_model.go b/cmd/runecode-tui/shell_model.go index 7c933d5d..c41b6f9f 100644 --- a/cmd/runecode-tui/shell_model.go +++ b/cmd/runecode-tui/shell_model.go @@ -15,6 +15,8 @@ const ( emergencyQuitArmWindow = 1500 * time.Millisecond ) +var forceMemoryWorkbenchState bool + type shellOverlayID string const ( @@ -142,7 +144,7 @@ func newShellModel() shellModel { actions := newShellActionGraph(routes, commands) workbench := newDefaultWorkbenchStateStore() binaryPath := strings.ToLower(strings.TrimSpace(os.Args[0])) - if strings.HasSuffix(binaryPath, ".test") || strings.HasSuffix(binaryPath, ".test.exe") { + if forceMemoryWorkbenchState || strings.HasSuffix(binaryPath, ".test") || strings.HasSuffix(binaryPath, ".test.exe") { workbench = &memoryWorkbenchStateStore{} } scope := logicalBrokerTargetKey() diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go new file mode 100644 index 00000000..7e64ebc2 --- /dev/null +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "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 +} + +func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { + scenarios := []snapshotScenarioState{ + buildHealthyEmptySnapshot(), + buildApprovalWaitingSnapshot(), + buildBlockedSnapshot(), + buildDegradedSnapshot(), + buildActionCenterSnapshot(), + } + switch strings.TrimSpace(name) { + case "", "all": + return scenarios, nil + case "dashboard": + return scenarios[:4], nil + case "action-center": + return scenarios[4:], nil + default: + for _, scenario := range scenarios { + if scenario.Name == name { + return []snapshotScenarioState{scenario}, nil + } + } + return nil, fmt.Errorf("unknown snapshot scenario %q", name) + } +} + +func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) (string, routeID, error) { + return withSnapshotColorProfile(func() (string, routeID, error) { + forceMemoryWorkbenchState = true + defer func() { forceMemoryWorkbenchState = false }() + theme := cfg.theme + if theme == "" { + theme = state.Theme + } + appTheme = newTheme(theme) + m := newShellModel() + m.width = cfg.width + m.height = cfg.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() + m.setFocus(state.Focus) + m.syncSidebarCursorToLocation() + return m.View(), state.RouteID, nil + }) +} + +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 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 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/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go new file mode 100644 index 00000000..60bfae5c --- /dev/null +++ b/cmd/runecode-tui/snapshot_svg.go @@ -0,0 +1,243 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "image/color" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/muesli/termenv" +) + +const snapshotDefaultOutputDir = "/tmp/runecode-tui-snapshots" + +type snapshotManifest struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Theme string `json:"theme"` + Scenarios []snapshotManifestEntry `json:"scenarios"` +} + +type snapshotManifestEntry struct { + Name string `json:"name"` + Route string `json:"route"` + ANSI string `json:"ansi"` + Text string `json:"text"` + SVG string `json:"svg"` + PNG string `json:"png"` +} + +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 err := os.MkdirAll(cfg.outputDir, 0o755); err != nil { + return err + } + manifest := snapshotManifest{Version: 1, Width: cfg.width, Height: cfg.height, Theme: string(snapshotManifestTheme(cfg, scenarios))} + 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 cfg.width <= 0 || cfg.height <= 0 { + return tuiSnapshotConfig{}, fmt.Errorf("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) { + view, routeID, err := renderSnapshotScenario(scenario, cfg) + if err != nil { + return snapshotManifestEntry{}, err + } + paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name) + if err := os.WriteFile(paths.ANSI, []byte(view), 0o644); err != nil { + return snapshotManifestEntry{}, err + } + if err := os.WriteFile(paths.Text, []byte(ansi.Strip(view)), 0o644); err != nil { + return snapshotManifestEntry{}, err + } + if err := os.WriteFile(paths.SVG, []byte(renderSnapshotSVG(view, cfg.width, cfg.height)), 0o644); err != nil { + return snapshotManifestEntry{}, err + } + return snapshotManifestEntry{Name: scenario.Name, Route: string(routeID), ANSI: paths.ANSI, Text: paths.Text, SVG: paths.SVG, PNG: paths.PNG}, nil +} + +func snapshotPathsForScenario(outputDir string, scenario string) snapshotArtifactPaths { + base := filepath.Join(outputDir, scenario) + 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, `"`, """) + 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/justfile b/justfile index 88a893a2..cc5396d3 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,7 @@ 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_dir := "/tmp/runecode-tui-snapshots" default: @just --list @@ -91,3 +92,17 @@ tui-dev-status: tui-dev-build tui-dev-stop: tui-dev-build PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode stop + +_tui-snapshot scenario: tui-dev-build + mkdir -p {{tui_snapshot_dir}} + {{dev_bin_dir}}/runecode-tui --snapshot-scenario {{scenario}} --snapshot-output-dir {{tui_snapshot_dir}} + if command -v magick >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; magick "$svg" "${svg%.svg}.png"; done; elif command -v convert >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; convert "$svg" "${svg%.svg}.png"; done; fi + +tui-snapshot-dashboard: + just _tui-snapshot dashboard + +tui-snapshot-action-center: + just _tui-snapshot action-center + +tui-snapshot-all: + just _tui-snapshot all From ba97d09ff89f124936477533dfde6c41d238f71e Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 12:17:30 -0500 Subject: [PATCH 08/39] Enhances the dev-only TUI snapshot tool Signed-off-by: Zebulon Rouse-Frantzich --- .github/workflows/ci.yml | 2 +- .gitignore | 4 + README.md | 3 +- cmd/runecode-tui/main.go | 80 +++-- cmd/runecode-tui/main_test.go | 20 +- cmd/runecode-tui/snapshot_disabled.go | 27 ++ cmd/runecode-tui/snapshot_disabled_test.go | 52 +++ cmd/runecode-tui/snapshot_enabled.go | 81 +++++ cmd/runecode-tui/snapshot_enabled_test.go | 191 +++++++++++ cmd/runecode-tui/snapshot_paths.go | 125 +++++++ cmd/runecode-tui/snapshot_scenarios.go | 5 + cmd/runecode-tui/snapshot_svg.go | 134 +++++++- justfile | 41 ++- tools/test_tui_snapshot_review.py | 130 ++++++++ tools/tui_snapshot_review.py | 173 ++++++++++ tools/tuisnapshotci/main.go | 162 +++++++++ tools/tuisnapshotci/main_test.go | 363 +++++++++++++++++++++ tools/tuisnapshotci/path.go | 130 ++++++++ tools/tuisnapshotci/validate.go | 191 +++++++++++ 19 files changed, 1864 insertions(+), 50 deletions(-) create mode 100644 cmd/runecode-tui/snapshot_disabled.go create mode 100644 cmd/runecode-tui/snapshot_disabled_test.go create mode 100644 cmd/runecode-tui/snapshot_enabled.go create mode 100644 cmd/runecode-tui/snapshot_enabled_test.go create mode 100644 cmd/runecode-tui/snapshot_paths.go create mode 100644 tools/test_tui_snapshot_review.py create mode 100644 tools/tui_snapshot_review.py create mode 100644 tools/tuisnapshotci/main.go create mode 100644 tools/tuisnapshotci/main_test.go create mode 100644 tools/tuisnapshotci/path.go create mode 100644 tools/tuisnapshotci/validate.go 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..6eb38145 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ Thumbs.db *.swp *.swo +# Python cache +__pycache__/ +*.pyc + # Local secretsd runtime state cmd/runecode-broker/.runecode-secretsd/ audit-anchor-ed25519.private diff --git a/README.md b/README.md index 2491eb6b..9929d7c0 100644 --- a/README.md +++ b/README.md @@ -335,11 +335,12 @@ just tui-dev-stop just tui-snapshot-dashboard just tui-snapshot-action-center just tui-snapshot-all +just tui-snapshot-review ``` 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 `/tmp/runecode-tui-snapshots/` using named scenarios. Each run writes a `manifest.json` plus per-scenario `.ansi`, `.txt`, and `.svg` files, and also derives `.png` files when ImageMagick `convert` is available locally. This is the intended fast UI/UX review loop for the repo-local TUI and is structured so future visual-regression baselines can key off the stable scenario names in the manifest. +The `tui-snapshot-*` recipes generate deterministic TUI review artifacts under `/tmp/runecode-tui-snapshots/` using named scenarios. Each run writes a `manifest.json` plus per-scenario `.ansi`, `.txt`, and `.svg` files, and also derives `.png` files when ImageMagick `convert` is available locally. `just tui-snapshot-review` runs the snapshot generation flow first and then opens the generated PNGs when present, otherwise the SVGs; if no supported desktop opener is available it prints the manifest-selected artifact paths instead. 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. diff --git a/cmd/runecode-tui/main.go b/cmd/runecode-tui/main.go index f9dba84e..b6a3dbf2 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" @@ -74,46 +75,79 @@ type tuiCLIConfig struct { snapshot tuiSnapshotConfig } -type tuiSnapshotConfig struct { - enabled bool - scenario string - outputDir string - width int - height int - theme themePreset -} - func parseCLIConfig(args []string) (tuiCLIConfig, error) { if len(args) == 1 && isHelpArg(args[0]) { return tuiCLIConfig{showHelp: true}, nil } 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") - snapshotScenario := fs.String("snapshot-scenario", "", "hidden dev option: deterministic TUI snapshot scenario") - snapshotOutputDir := fs.String("snapshot-output-dir", "/tmp/runecode-tui-snapshots", "hidden dev option: snapshot output directory") - snapshotWidth := fs.Int("snapshot-width", 160, "hidden dev option: snapshot terminal width") - snapshotHeight := fs.Int("snapshot-height", 48, "hidden dev option: snapshot terminal height") - snapshotTheme := fs.String("snapshot-theme", string(themePresetDark), "hidden dev option: snapshot theme preset") + 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"} } cfg := tuiCLIConfig{runtimeDir: *runtimeDir, socketName: *socketName} - if *snapshotScenario != "" { - cfg.snapshot = tuiSnapshotConfig{ - enabled: true, - scenario: *snapshotScenario, - outputDir: *snapshotOutputDir, - width: *snapshotWidth, - height: *snapshotHeight, - theme: normalizeThemePreset(themePreset(*snapshotTheme)), + 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 cfg, nil + + 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 950f2338..a6feeddb 100644 --- a/cmd/runecode-tui/main_test.go +++ b/cmd/runecode-tui/main_test.go @@ -47,13 +47,19 @@ func TestParseCLIConfigParsesIPCOverrides(t *testing.T) { } } -func TestParseCLIConfigParsesSnapshotOptions(t *testing.T) { - cfg, err := parseCLIConfig([]string{"--snapshot-scenario", "dashboard", "--snapshot-output-dir", "/tmp/snaps", "--snapshot-width", "140", "--snapshot-height", "40", "--snapshot-theme", "dusk"}) - 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.width != 140 || cfg.snapshot.height != 40 || cfg.snapshot.theme != themePresetDusk { - t.Fatalf("parseCLIConfig snapshot cfg = %+v, want snapshot options populated", cfg.snapshot) +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) + } } } 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..fec0d70e --- /dev/null +++ b/cmd/runecode-tui/snapshot_enabled.go @@ -0,0 +1,81 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "flag" + "fmt" + "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 + outputDir string + viewport snapshotViewportPreset + width int + height int + theme themePreset +} + +type tuiSnapshotFlags struct { + scenario *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"), + 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 == "" { + return + } + cfg.snapshot = tuiSnapshotConfig{ + enabled: true, + scenario: *flags.scenario, + 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{}, fmt.Errorf("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..3920ba21 --- /dev/null +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -0,0 +1,191 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "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 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", "runecode-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 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 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) + } +} diff --git a/cmd/runecode-tui/snapshot_paths.go b/cmd/runecode-tui/snapshot_paths.go new file mode 100644 index 00000000..4f2d3c47 --- /dev/null +++ b/cmd/runecode-tui/snapshot_paths.go @@ -0,0 +1,125 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func snapshotDefaultOutputDir() string { + return filepath.Join(os.TempDir(), "runecode-tui-snapshots") +} + +func normalizeSnapshotOutputDir(outputDir string) (string, error) { + resolvedOutputDir, err := snapshotCanonicalPath(outputDir) + if err != nil { + return "", fmt.Errorf("resolve snapshot output directory: %w", err) + } + trustedTempRoots, err := snapshotTrustedTempRoots() + if err != nil { + return "", err + } + for _, trustedTempRoot := range trustedTempRoots { + if snapshotPathWithinBase(trustedTempRoot, resolvedOutputDir) { + return resolvedOutputDir, nil + } + } + return "", fmt.Errorf("snapshot output directory must stay under one of %s", strings.Join(trustedTempRoots, ", ")) +} + +func snapshotTrustedTempRoots() ([]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) + } + + return roots, nil +} + +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_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 7e64ebc2..9bf4fc7a 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -1,3 +1,5 @@ +//go:build runecode_tui_snapshot + package main import ( @@ -45,6 +47,9 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { } 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 }() diff --git a/cmd/runecode-tui/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go index 60bfae5c..031ca380 100644 --- a/cmd/runecode-tui/snapshot_svg.go +++ b/cmd/runecode-tui/snapshot_svg.go @@ -1,13 +1,19 @@ +//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" @@ -15,23 +21,36 @@ import ( "github.com/muesli/termenv" ) -const snapshotDefaultOutputDir = "/tmp/runecode-tui-snapshots" +var snapshotRenderMu sync.Mutex type snapshotManifest struct { Version int `json:"version"` Width int `json:"width"` Height int `json:"height"` + Viewport string `json:"viewport,omitempty"` Theme string `json:"theme"` 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"` - Route string `json:"route"` - ANSI string `json:"ansi"` - Text string `json:"text"` - SVG string `json:"svg"` - PNG string `json:"png"` + 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 { @@ -64,7 +83,7 @@ func writeSnapshotArtifacts(cfg tuiSnapshotConfig) error { if err := os.MkdirAll(cfg.outputDir, 0o755); err != nil { return err } - manifest := snapshotManifest{Version: 1, Width: cfg.width, Height: cfg.height, Theme: string(snapshotManifestTheme(cfg, scenarios))} + manifest := snapshotManifest{Version: 2, Width: cfg.width, Height: cfg.height, Viewport: string(cfg.viewport), Theme: string(snapshotManifestTheme(cfg, scenarios))} entries, err := writeSnapshotScenarioArtifacts(cfg, scenarios) if err != nil { return err @@ -75,7 +94,25 @@ func writeSnapshotArtifacts(cfg tuiSnapshotConfig) error { func normalizeSnapshotConfig(cfg tuiSnapshotConfig) (tuiSnapshotConfig, error) { if cfg.outputDir == "" { - cfg.outputDir = snapshotDefaultOutputDir + cfg.outputDir = snapshotDefaultOutputDir() + } + normalizedOutputDir, err := normalizeSnapshotOutputDir(cfg.outputDir) + if err != nil { + return tuiSnapshotConfig{}, 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 + } + if cfg.height == 0 { + cfg.height = viewport.Height } if cfg.width <= 0 || cfg.height <= 0 { return tuiSnapshotConfig{}, fmt.Errorf("snapshot dimensions must be positive") @@ -106,25 +143,89 @@ func writeSnapshotScenarioArtifacts(cfg tuiSnapshotConfig, scenarios []snapshotS } func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScenarioState) (snapshotManifestEntry, error) { + paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name, cfg.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 } - paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name) - if err := os.WriteFile(paths.ANSI, []byte(view), 0o644); err != nil { + ansiArtifact, err := writeSnapshotHashedArtifact(paths.ANSI, []byte(view)) + if err != nil { return snapshotManifestEntry{}, err } - if err := os.WriteFile(paths.Text, []byte(ansi.Strip(view)), 0o644); err != nil { + textArtifact, err := writeSnapshotHashedArtifact(paths.Text, []byte(ansi.Strip(view))) + if err != nil { return snapshotManifestEntry{}, err } - if err := os.WriteFile(paths.SVG, []byte(renderSnapshotSVG(view, cfg.width, cfg.height)), 0o644); err != nil { + svgArtifact, err := writeSnapshotHashedArtifact(paths.SVG, []byte(renderSnapshotSVG(view, cfg.width, cfg.height))) + if err != nil { return snapshotManifestEntry{}, err } - return snapshotManifestEntry{Name: scenario.Name, Route: string(routeID), ANSI: paths.ANSI, Text: paths.Text, SVG: paths.SVG, PNG: paths.PNG}, nil + ansiArtifact.Path = ansiManifestPath + textArtifact.Path = textManifestPath + svgArtifact.Path = svgManifestPath + return snapshotManifestEntry{ + Name: scenario.Name, + Viewport: string(cfg.viewport), + Width: cfg.width, + Height: cfg.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) snapshotArtifactPaths { - base := filepath.Join(outputDir, scenario) +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"} } @@ -227,6 +328,7 @@ 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 } diff --git a/justfile b/justfile index cc5396d3..f7a64147 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,7 @@ 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_dir := "/tmp/runecode-tui-snapshots" +tui_snapshot_tag := "runecode_tui_snapshot" default: @just --list @@ -48,14 +49,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 @@ -93,11 +101,24 @@ tui-dev-status: tui-dev-build tui-dev-stop: tui-dev-build PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode stop -_tui-snapshot scenario: tui-dev-build +_tui-snapshot scenario: tui-dev-snapshot-build mkdir -p {{tui_snapshot_dir}} {{dev_bin_dir}}/runecode-tui --snapshot-scenario {{scenario}} --snapshot-output-dir {{tui_snapshot_dir}} if command -v magick >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; magick "$svg" "${svg%.svg}.png"; done; elif command -v convert >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; convert "$svg" "${svg%.svg}.png"; done; fi +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 + mkdir -p {{tui_snapshot_dir}} + {{dev_bin_dir}}/runecode-tui --snapshot-scenario {{scenario}} --snapshot-output-dir {{tui_snapshot_dir}} --snapshot-viewport {{viewport}} + if command -v magick >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; magick "$svg" "${svg%.svg}.png"; done; elif command -v convert >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; convert "$svg" "${svg%.svg}.png"; done; fi + +tui-snapshot-dashboard-multi: + just _tui-snapshot-viewport dashboard desktop + just _tui-snapshot-viewport dashboard mobile + tui-snapshot-dashboard: just _tui-snapshot dashboard @@ -106,3 +127,19 @@ tui-snapshot-action-center: tui-snapshot-all: just _tui-snapshot all + +tui-snapshot-review: + just tui-snapshot-all + python3 ./tools/tui_snapshot_review.py "{{tui_snapshot_dir}}" + +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/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py new file mode 100644 index 00000000..41b10434 --- /dev/null +++ b/tools/test_tui_snapshot_review.py @@ -0,0 +1,130 @@ +import importlib.util +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 / "runecode-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_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())]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tui_snapshot_review.py b/tools/tui_snapshot_review.py new file mode 100644 index 00000000..505be60e --- /dev/null +++ b/tools/tui_snapshot_review.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +import json +import ntpath +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from typing import List, Optional +from urllib.parse import urlparse + + +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_temp_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 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 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 main() -> int: + requested_output_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join(tempfile.gettempdir(), "runecode-tui-snapshots") + output_dir = resolve_output_dir(requested_output_dir) + if output_dir is None: + print("Requested output directory must resolve inside the system temp directory", 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 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 + + paths = select_artifact_paths_from_manifest(output_dir, manifest) + if not paths: + print(f"No snapshot review artifacts found in {manifest_path}", file=sys.stderr) + return 1 + + opener = detect_opener() + if opener is None: + for path in paths: + print(path) + return 0 + + failed_paths: List[str] = [] + for path in paths: + try: + subprocess.run([opener, path], check=True) + except OSError: + failed_paths.append(path) + except subprocess.CalledProcessError: + failed_paths.append(path) + + for path in failed_paths: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go new file mode 100644 index 00000000..75fdb461 --- /dev/null +++ b/tools/tuisnapshotci/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + snapshotBuildTag = "runecode_tui_snapshot" + snapshotViewport = "desktop" + manifestVersion = 2 +) + +var expectedScenarioNames = []string{ + "dashboard-healthy-empty", + "dashboard-approval-waiting", + "dashboard-blocked", + "dashboard-degraded", + "action-center-triage", +} + +type snapshotManifest struct { + Version int `json:"version"` + Viewport string `json:"viewport,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Scenarios []snapshotManifestEntry `json:"scenarios"` +} + +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 { + outputDir, err := parseOutputDirArg(args, stderr) + if err != nil { + return err + } + + 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 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-scenario", "all", "--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..d35e07c4 --- /dev/null +++ b/tools/tuisnapshotci/main_test.go @@ -0,0 +1,363 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateSnapshotArtifactsAcceptsExpectedManifestWithoutPNGFiles(t *testing.T) { + outputDir := t.TempDir() + manifest := snapshotManifest{ + Version: manifestVersion, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + } + 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, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + } + 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, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + 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 := filepath.Join(realRoot, "nested", "artifacts") + 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 := filepath.Join("/tmp", "runecode-tui-snapshots") + if err := requirePathWithinTrustedRoot("artifact directory", candidate); err != nil { + t.Fatalf("requirePathWithinTrustedRoot returned error for /tmp path: %v", err) + } +} + +func testManifestForAllScenarios(t *testing.T, outputDir string) snapshotManifest { + t.Helper() + manifest := snapshotManifest{ + Version: manifestVersion, + Viewport: snapshotViewport, + Width: 160, + Height: 48, + } + 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: "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"}, + }, + } +} + +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..c82e2b44 --- /dev/null +++ b/tools/tuisnapshotci/path.go @@ -0,0 +1,130 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +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 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 := trustedTempRoots() + 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 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..3c995dbf --- /dev/null +++ b/tools/tuisnapshotci/validate.go @@ -0,0 +1,191 @@ +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) + } + 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.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) + } + entriesByName, err := manifestEntriesByName(manifest.Scenarios) + if err != nil { + return nil, err + } + if err := validateScenarioSet(entriesByName); err != nil { + return nil, err + } + return entriesByName, nil +} + +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 +} From 9d74dad6c9717e6cdc22b257d46173fba7e856b8 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 12:51:11 -0500 Subject: [PATCH 09/39] Updates TUI snapshot tool to not open the snapshots in GUI when reviewing them. Signed-off-by: Zebulon Rouse-Frantzich --- README.md | 5 +- justfile | 29 +++--- tools/test_tui_snapshot_review.py | 88 +++++++++++++++++ tools/tui_snapshot_local.sh | 113 +++++++++++++++++++++ tools/tui_snapshot_review.py | 158 ++++++++++++++++++++++++++---- tools/tuisnapshotci/main.go | 36 +++++++ tools/tuisnapshotci/main_test.go | 44 +++++++++ tools/tuisnapshotci/path.go | 40 ++++++++ tools/tuisnapshotci/validate.go | 3 + 9 files changed, 481 insertions(+), 35 deletions(-) create mode 100644 tools/tui_snapshot_local.sh diff --git a/README.md b/README.md index 9929d7c0..dfb97b9a 100644 --- a/README.md +++ b/README.md @@ -336,11 +336,14 @@ just tui-snapshot-dashboard just tui-snapshot-action-center just tui-snapshot-all just tui-snapshot-review +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 `/tmp/runecode-tui-snapshots/` using named scenarios. Each run writes a `manifest.json` plus per-scenario `.ansi`, `.txt`, and `.svg` files, and also derives `.png` files when ImageMagick `convert` is available locally. `just tui-snapshot-review` runs the snapshot generation flow first and then opens the generated PNGs when present, otherwise the SVGs; if no supported desktop opener is available it prints the manifest-selected artifact paths instead. 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. +The `tui-snapshot-*` recipes generate deterministic TUI review artifacts under `/tmp/runecode-tui-snapshots/` by default, or under `TUI_SNAPSHOT_DIR` when you want a different 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 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. diff --git a/justfile b/justfile index f7a64147..5ab3c1e1 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,6 @@ 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_dir := "/tmp/runecode-tui-snapshots" tui_snapshot_tag := "runecode_tui_snapshot" default: @@ -102,22 +101,18 @@ tui-dev-stop: tui-dev-build PATH={{dev_bin_dir}}:$PATH {{dev_bin_dir}}/runecode stop _tui-snapshot scenario: tui-dev-snapshot-build - mkdir -p {{tui_snapshot_dir}} - {{dev_bin_dir}}/runecode-tui --snapshot-scenario {{scenario}} --snapshot-output-dir {{tui_snapshot_dir}} - if command -v magick >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; magick "$svg" "${svg%.svg}.png"; done; elif command -v convert >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; convert "$svg" "${svg%.svg}.png"; done; fi + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario {{scenario}} 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 - mkdir -p {{tui_snapshot_dir}} - {{dev_bin_dir}}/runecode-tui --snapshot-scenario {{scenario}} --snapshot-output-dir {{tui_snapshot_dir}} --snapshot-viewport {{viewport}} - if command -v magick >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; magick "$svg" "${svg%.svg}.png"; done; elif command -v convert >/dev/null 2>&1; then for svg in {{tui_snapshot_dir}}/*.svg; do [ -e "$svg" ] || continue; convert "$svg" "${svg%.svg}.png"; done; fi + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario {{scenario}} --viewport {{viewport}} -tui-snapshot-dashboard-multi: - just _tui-snapshot-viewport dashboard desktop - just _tui-snapshot-viewport dashboard mobile +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 @@ -128,9 +123,17 @@ tui-snapshot-action-center: tui-snapshot-all: just _tui-snapshot all -tui-snapshot-review: - just tui-snapshot-all - python3 ./tools/tui_snapshot_review.py "{{tui_snapshot_dir}}" +tui-snapshot-review: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode summary + +tui-snapshot-review-summary: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode summary + +tui-snapshot-review-list: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode list + +tui-snapshot-review-open: tui-dev-snapshot-build + sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode open tui-snapshot-ci: go run ./tools/tuisnapshotci diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py index 41b10434..86daf9d1 100644 --- a/tools/test_tui_snapshot_review.py +++ b/tools/test_tui_snapshot_review.py @@ -1,4 +1,5 @@ import importlib.util +import io import json import tempfile import unittest @@ -126,5 +127,92 @@ def test_select_artifact_paths_skips_malformed_manifest_entries(self) -> None: 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, + "viewport": "desktop", + "theme": "dark", + "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("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( + run_mock.call_args_list, + [ + mock.call(["xdg-open", str(png_path.resolve())], check=True), + mock.call(["xdg-open", str(svg_path.resolve())], check=True), + ], + ) + + 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..3d72dc45 --- /dev/null +++ b/tools/tui_snapshot_local.sh @@ -0,0 +1,113 @@ +#!/bin/sh + +set -eu + +usage() { + printf '%s\n' 'usage: sh ./tools/tui_snapshot_local.sh --binary --scenario [--viewport ] [--review] [--review-mode ]' >&2 +} + +binary_path='' +scenario='' +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 + ;; + --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" ] || { 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" + +if [ -n "$viewport" ]; then + "$binary_path" --snapshot-scenario "$scenario" --snapshot-output-dir "$output_dir" --snapshot-viewport "$viewport" +else + "$binary_path" --snapshot-scenario "$scenario" --snapshot-output-dir "$output_dir" +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 index 505be60e..79f3446b 100644 --- a/tools/tui_snapshot_review.py +++ b/tools/tui_snapshot_review.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import argparse import json import ntpath import os @@ -8,10 +9,16 @@ import subprocess import sys import tempfile -from typing import List, Optional +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) + + def is_windows_absolute_path(path: str) -> bool: return ntpath.isabs(path) @@ -117,6 +124,53 @@ def select_artifact_paths(output_dir: str, manifest_path: str) -> List[str]: 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": @@ -126,8 +180,81 @@ def detect_opener() -> Optional[str]: 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("viewport"): + print(f"Viewport: {manifest['viewport']}") + if manifest.get("theme"): + print(f"Theme: {manifest['theme']}") + print(f"Scenarios: {scenario_count}") + print(f"Review artifacts: {len(entries)}") + for entry in entries: + 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)) + + +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] = [] + 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 + try: + subprocess.run([opener, artifact_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=os.path.join(tempfile.gettempdir(), "runecode-tui-snapshots"), + help="snapshot output directory under the trusted temp root", + ) + parser.add_argument( + "--mode", + choices=REVIEW_MODES, + default=REVIEW_MODE_SUMMARY, + help="review mode: summary (default), list, or open", + ) + return parser.parse_args(argv) + + def main() -> int: - requested_output_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join(tempfile.gettempdir(), "runecode-tui-snapshots") + 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 the system temp directory", file=sys.stderr) @@ -144,29 +271,18 @@ def main() -> int: print(f"Failed to parse snapshot manifest {manifest_path}: {err}", file=sys.stderr) return 1 - paths = select_artifact_paths_from_manifest(output_dir, manifest) - if not paths: + entries = select_review_entries_from_manifest(output_dir, manifest) + if not entries: print(f"No snapshot review artifacts found in {manifest_path}", file=sys.stderr) return 1 - opener = detect_opener() - if opener is None: - for path in paths: - print(path) + if args.mode == REVIEW_MODE_LIST: + print_review_list(entries) return 0 - - failed_paths: List[str] = [] - for path in paths: - try: - subprocess.run([opener, path], check=True) - except OSError: - failed_paths.append(path) - except subprocess.CalledProcessError: - failed_paths.append(path) - - for path in failed_paths: - print(path) - 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__": diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index 75fdb461..fc955e95 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -62,11 +62,23 @@ func main() { } 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) @@ -98,6 +110,30 @@ func run(args []string, stdout io.Writer, stderr io.Writer) error { 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) diff --git a/tools/tuisnapshotci/main_test.go b/tools/tuisnapshotci/main_test.go index d35e07c4..78d87989 100644 --- a/tools/tuisnapshotci/main_test.go +++ b/tools/tuisnapshotci/main_test.go @@ -10,6 +10,50 @@ import ( "testing" ) +func TestRunLocalOutputDirDefaultsToTempDir(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 := filepath.Join(os.TempDir(), localSnapshotDirName) + 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{ diff --git a/tools/tuisnapshotci/path.go b/tools/tuisnapshotci/path.go index c82e2b44..863b67f9 100644 --- a/tools/tuisnapshotci/path.go +++ b/tools/tuisnapshotci/path.go @@ -9,6 +9,8 @@ import ( "strings" ) +const localSnapshotDirName = "runecode-tui-snapshots" + func canonicalPath(path string) (string, error) { absPath, err := filepath.Abs(path) if err != nil { @@ -75,6 +77,44 @@ func prepareArtifactsDir(workDir string, outputDir string) (string, error) { return resolvedArtifactsDir, nil } +func resolveLocalSnapshotDir(outputDir string) (string, error) { + requestedDir := strings.TrimSpace(outputDir) + if requestedDir == "" { + requestedDir = filepath.Join(os.TempDir(), localSnapshotDirName) + } + 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 { diff --git a/tools/tuisnapshotci/validate.go b/tools/tuisnapshotci/validate.go index 3c995dbf..1b79a544 100644 --- a/tools/tuisnapshotci/validate.go +++ b/tools/tuisnapshotci/validate.go @@ -18,6 +18,9 @@ func validateSnapshotArtifacts(outputDir string) error { 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) From 4a4ec3fb1ae551ceff37d0ab4e54c6c4717ddce2 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 12:54:45 -0500 Subject: [PATCH 10/39] Adds info about the TUI snapshot tool to AGENTS.md Signed-off-by: Zebulon Rouse-Frantzich --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d81cfa58..5eae82a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,12 +29,27 @@ 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 snapshot review, when iterating on `cmd/runecode-tui` UI/polish: + - Generate isolated local snapshots: `just tui-snapshot-all` + - Preserve artifacts for inspection: `TUI_SNAPSHOT_KEEP=1 just tui-snapshot-all` + - Non-GUI review summary: `just tui-snapshot-review` + - Print selected artifact paths: `just tui-snapshot-review-list` + - Explicit GUI open for manual review: `just tui-snapshot-review-open` + - Override local snapshot temp dir: `TUI_SNAPSHOT_DIR=/tmp/my-snapshots ...` + - CI/dev-only validation: `just tui-snapshot-ci` + - Release safety check: `just tui-release-safety` - Protocol-focused checks: - `go test ./internal/protocolschema` - `cd runner && node --test scripts/protocol-fixtures.test.js` - `cd runner && npm run boundary-check` - Use `justfile` as the exact command source of truth. +### TUI Snapshot Usage +- Use the snapshot workflow when changing TUI layout, wording, spacing, color treatment, or other visual polish in `cmd/runecode-tui`. +- Prefer the default non-GUI review flow first so local runs stay uncluttered; only use `tui-snapshot-review-open` when a human explicitly wants desktop-opened images. +- Local snapshot runs clean before and after by default; use `TUI_SNAPSHOT_KEEP=1` only when artifacts need to persist for inspection. +- Snapshot tooling is dev/CI-only and must not be added to release artifacts or release-only verification paths. + ## If You Touch... ### `runner/` From 95afd180273b86230809b2e226cf0ceadaa4515f Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 13:05:27 -0500 Subject: [PATCH 11/39] Adds a skill describing how and when to use the TUI snapshot tool. Signed-off-by: Zebulon Rouse-Frantzich --- .claude/skills/tui-snapshot-review/SKILL.md | 184 ++++++++++++++++++++ AGENTS.md | 16 +- 2 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 .claude/skills/tui-snapshot-review/SKILL.md diff --git a/.claude/skills/tui-snapshot-review/SKILL.md b/.claude/skills/tui-snapshot-review/SKILL.md new file mode 100644 index 00000000..b30f2825 --- /dev/null +++ b/.claude/skills/tui-snapshot-review/SKILL.md @@ -0,0 +1,184 @@ +--- +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. + +## 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 must stay under trusted temp roots. Use `TUI_SNAPSHOT_DIR` only for a temp-backed local output directory. +- Snapshot tooling is dev/CI-only. Do not add snapshot-enabled binaries, flags, or helpers to release artifacts. +- Use `justfile` recipes as the command source of truth. + +## Available Commands + +- Full local snapshot set: `just tui-snapshot-all` +- 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` +- Non-GUI summary review: `just tui-snapshot-review` +- 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` + +## 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-all` + +## Snapshot Scenarios + +Use public recipes first. When a focused audit needs a specific scenario, build the snapshot binary and invoke the helper directly. + +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. Generate and preserve all snapshots for inspection: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-all +``` + +3. Produce a non-GUI summary: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review +``` + +4. Print artifact paths for direct file inspection: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list +``` + +5. Inspect the selected `.png` or `.svg` files directly with file-reading tools. Do not open GUI windows unless asked. +6. Review each scenario for route hierarchy, text density, sidebar behavior, footer/chrome weight, inspector layout, modal/overlay polish, color contrast, and raw debug-token leakage. +7. Compare desktop and mobile/compact behavior when the audit involves layout responsiveness: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-dashboard-multi +``` + +8. Record findings with scenario name, viewport, artifact path, and concrete issue. +9. If code changes are made, regenerate the relevant snapshots and compare the updated artifacts. +10. Run validation before handoff: + +```sh +just tui-snapshot-ci +``` + +11. 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. Prefer the nearest public recipe: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-dashboard +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-action-center +``` + +3. For a specific scenario or viewport, use the direct helper pattern from the Snapshot Scenarios section. +4. Use non-GUI review first: + +```sh +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list +``` + +5. Inspect only the relevant artifact paths. Ignore unrelated scenarios. +6. Analyze the focused area for the requested concern, such as wording, spacing, overflow, truncation, color, responsive layout, or debug-token exposure. +7. If a fix is implemented, regenerate only the focused route/scenario where possible. +8. 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 +- Scenario(s) and viewport(s) reviewed +- 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/AGENTS.md b/AGENTS.md index 5eae82a1..165d1951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,27 +29,13 @@ 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 snapshot review, when iterating on `cmd/runecode-tui` UI/polish: - - Generate isolated local snapshots: `just tui-snapshot-all` - - Preserve artifacts for inspection: `TUI_SNAPSHOT_KEEP=1 just tui-snapshot-all` - - Non-GUI review summary: `just tui-snapshot-review` - - Print selected artifact paths: `just tui-snapshot-review-list` - - Explicit GUI open for manual review: `just tui-snapshot-review-open` - - Override local snapshot temp dir: `TUI_SNAPSHOT_DIR=/tmp/my-snapshots ...` - - CI/dev-only validation: `just tui-snapshot-ci` - - Release safety check: `just tui-release-safety` +- 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` - `cd runner && npm run boundary-check` - Use `justfile` as the exact command source of truth. -### TUI Snapshot Usage -- Use the snapshot workflow when changing TUI layout, wording, spacing, color treatment, or other visual polish in `cmd/runecode-tui`. -- Prefer the default non-GUI review flow first so local runs stay uncluttered; only use `tui-snapshot-review-open` when a human explicitly wants desktop-opened images. -- Local snapshot runs clean before and after by default; use `TUI_SNAPSHOT_KEEP=1` only when artifacts need to persist for inspection. -- Snapshot tooling is dev/CI-only and must not be added to release artifacts or release-only verification paths. - ## If You Touch... ### `runner/` From 30cdd584b1ce4c2ad2564b13f0e85e3be77bec41 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 15:04:55 -0500 Subject: [PATCH 12/39] Improves the TUI snapshot skill and tool and expands the tracks it can walk Signed-off-by: Zebulon Rouse-Frantzich --- .claude/skills/tui-snapshot-review/SKILL.md | 79 ++++++++++++---- README.md | 5 +- cmd/runecode-tui/snapshot_bundles.go | 100 ++++++++++++++++++++ cmd/runecode-tui/snapshot_enabled.go | 6 +- cmd/runecode-tui/snapshot_enabled_test.go | 34 +++++++ cmd/runecode-tui/snapshot_scenarios.go | 95 ++++++++++++++++++- cmd/runecode-tui/snapshot_svg.go | 13 ++- justfile | 47 ++++++++- tools/test_tui_snapshot_review.py | 10 ++ tools/tui_snapshot_local.sh | 23 ++++- tools/tui_snapshot_review.py | 37 ++++++++ tools/tuisnapshotci/main.go | 18 ++++ tools/tuisnapshotci/main_test.go | 41 +++++++- tools/tuisnapshotci/validate.go | 34 +++++++ 14 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 cmd/runecode-tui/snapshot_bundles.go diff --git a/.claude/skills/tui-snapshot-review/SKILL.md b/.claude/skills/tui-snapshot-review/SKILL.md index b30f2825..a5c8856a 100644 --- a/.claude/skills/tui-snapshot-review/SKILL.md +++ b/.claude/skills/tui-snapshot-review/SKILL.md @@ -7,6 +7,8 @@ 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. @@ -18,9 +20,19 @@ Use this workflow when changing or reviewing `cmd/runecode-tui` UI/UX, layout, r ## 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` - Non-GUI summary review: `just tui-snapshot-review` - Non-GUI artifact path listing: `just tui-snapshot-review-list` - Explicit GUI review: `just tui-snapshot-review-open` @@ -33,9 +45,21 @@ Use this workflow when changing or reviewing `cmd/runecode-tui` UI/UX, layout, r - 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-all` -## Snapshot Scenarios +## 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: -Use public recipes first. When a focused audit needs a specific scenario, build the snapshot binary and invoke the helper directly. +- `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: @@ -72,41 +96,44 @@ TUI_SNAPSHOT_KEEP=1 sh ./tools/tui_snapshot_local.sh \ ## Full TUI Audit Workflow 1. Confirm the request is a full visual audit or broad polish review for `cmd/runecode-tui`. -2. Generate and preserve all snapshots for inspection: +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-all +TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-full ``` -3. Produce a non-GUI summary: +4. Produce a non-GUI summary: ```sh TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review ``` -4. Print artifact paths for direct file inspection: +5. Print artifact paths for direct file inspection: ```sh TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list ``` -5. Inspect the selected `.png` or `.svg` files directly with file-reading tools. Do not open GUI windows unless asked. -6. Review each scenario for route hierarchy, text density, sidebar behavior, footer/chrome weight, inspector layout, modal/overlay polish, color contrast, and raw debug-token leakage. -7. Compare desktop and mobile/compact behavior when the audit involves layout responsiveness: +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 ``` -8. Record findings with scenario name, viewport, artifact path, and concrete issue. -9. If code changes are made, regenerate the relevant snapshots and compare the updated artifacts. -10. Run validation before handoff: +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 ``` -11. If snapshot plumbing, build tags, release safety, or dev/CI boundaries changed, also run: +13. If snapshot plumbing, build tags, release safety, or dev/CI boundaries changed, also run: ```sh just tui-release-safety @@ -117,24 +144,32 @@ just tui-release-safety Use this workflow when the human asks to audit one route, state, viewport, or a small polish change. 1. Identify the target surface. -2. Prefer the nearest public recipe: +2. Map the request to the smallest matching audit bundle first: ```sh -TUI_SNAPSHOT_KEEP=1 just tui-snapshot-dashboard -TUI_SNAPSHOT_KEEP=1 just tui-snapshot-action-center +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. For a specific scenario or viewport, use the direct helper pattern from the Snapshot Scenarios section. +3. Only fall back to a raw scenario when the request is truly about one exact deterministic state. 4. Use non-GUI review first: ```sh TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list ``` -5. Inspect only the relevant artifact paths. Ignore unrelated scenarios. -6. Analyze the focused area for the requested concern, such as wording, spacing, overflow, truncation, color, responsive layout, or debug-token exposure. -7. If a fix is implemented, regenerate only the focused route/scenario where possible. -8. Run at least the relevant targeted verification: +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 @@ -168,7 +203,9 @@ If GUI opening fails, report the selected artifact paths from `just tui-snapshot 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 diff --git a/README.md b/README.md index dfb97b9a..f81b0ed1 100644 --- a/README.md +++ b/README.md @@ -335,13 +335,16 @@ just tui-dev-stop 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-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 `/tmp/runecode-tui-snapshots/` by default, or under `TUI_SNAPSHOT_DIR` when you want a different 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 `tui-snapshot-*` recipes generate deterministic TUI review artifacts under `/tmp/runecode-tui-snapshots/` by default, or under `TUI_SNAPSHOT_DIR` when you want a different 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`, and `action-center-audit` so review scope is captured in the manifest rather than inferred from filenames. 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. diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go new file mode 100644 index 00000000..2fd2cd24 --- /dev/null +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -0,0 +1,100 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "fmt" + "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" +) + +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) { + switch normalizeSnapshotBundle(name) { + case snapshotBundleFullAudit: + return snapshotBundleSpec{ + Name: snapshotBundleFullAudit, + Scenarios: []string{ + "dashboard-healthy-empty", + "dashboard-approval-waiting", + "dashboard-blocked", + "dashboard-degraded", + "chat-active-session", + "runs-active-detail", + "approvals-pending-detail", + "action-center-triage", + "audit-degraded-detail", + "status-ready-overview", + "model-providers-credential-needed", + "git-setup-identity-needed", + "git-remote-approval-ready", + }, + Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, + }, nil + case snapshotBundleDashboard: + return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded"}, Routes: []routeID{routeDashboard}}, nil + case snapshotBundleActionCenter: + return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil + case snapshotBundleRuns: + return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail"}, Routes: []routeID{routeRuns}}, nil + case snapshotBundleApprovals: + return snapshotBundleSpec{Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail"}, Routes: []routeID{routeApprovals}}, nil + case snapshotBundleAudit: + return snapshotBundleSpec{Name: snapshotBundleAudit, Scenarios: []string{"audit-degraded-detail"}, Routes: []routeID{routeAudit}}, nil + case snapshotBundleStatus: + return snapshotBundleSpec{Name: snapshotBundleStatus, Scenarios: []string{"status-ready-overview"}, Routes: []routeID{routeStatus}}, nil + case snapshotBundleSetup: + return snapshotBundleSpec{Name: snapshotBundleSetup, Scenarios: []string{"model-providers-credential-needed", "git-setup-identity-needed", "git-remote-approval-ready"}, Routes: []routeID{routeProviders, routeGitSetup, routeGitRemote}}, nil + case snapshotBundleChat: + return snapshotBundleSpec{Name: snapshotBundleChat, Scenarios: []string{"chat-active-session"}, Routes: []routeID{routeChat}}, nil + default: + return snapshotBundleSpec{}, fmt.Errorf("unknown snapshot bundle %q", name) + } +} + +func snapshotCoverageForBundle(bundle snapshotAuditBundle, scenarios []snapshotScenarioState, viewport snapshotViewportPreset) snapshotCoverage { + routeSet := make(map[string]struct{}, len(scenarios)) + scenarioNames := make([]string, 0, len(scenarios)) + for _, scenario := range scenarios { + routeSet[string(scenario.RouteID)] = struct{}{} + scenarioNames = append(scenarioNames, scenario.Name) + } + routes := make([]string, 0, len(routeSet)) + for route := range routeSet { + routes = append(routes, route) + } + slices.Sort(routes) + slices.Sort(scenarioNames) + return snapshotCoverage{Bundle: string(bundle), Routes: routes, Viewports: []string{string(viewport)}, Scenarios: scenarioNames} +} diff --git a/cmd/runecode-tui/snapshot_enabled.go b/cmd/runecode-tui/snapshot_enabled.go index fec0d70e..a7e3284a 100644 --- a/cmd/runecode-tui/snapshot_enabled.go +++ b/cmd/runecode-tui/snapshot_enabled.go @@ -25,6 +25,7 @@ type snapshotViewportSpec struct { type tuiSnapshotConfig struct { enabled bool scenario string + bundle string outputDir string viewport snapshotViewportPreset width int @@ -34,6 +35,7 @@ type tuiSnapshotConfig struct { type tuiSnapshotFlags struct { scenario *string + bundle *string outputDir *string viewport *string width *int @@ -44,6 +46,7 @@ type tuiSnapshotFlags struct { 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"), @@ -53,12 +56,13 @@ func registerSnapshotFlags(fs *flag.FlagSet) tuiSnapshotFlags { } func applySnapshotCLIConfig(cfg *tuiCLIConfig, flags tuiSnapshotFlags) { - if *flags.scenario == "" { + 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, diff --git a/cmd/runecode-tui/snapshot_enabled_test.go b/cmd/runecode-tui/snapshot_enabled_test.go index 3920ba21..149b466f 100644 --- a/cmd/runecode-tui/snapshot_enabled_test.go +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -23,6 +23,16 @@ func TestParseCLIConfigParsesSnapshotOptionsWithBuildTag(t *testing.T) { } } +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 { @@ -175,6 +185,30 @@ func TestWriteSnapshotArtifactsIncludesPerScenarioViewportAndDimensions(t *testi } } +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) != 4 { + t.Fatalf("manifest coverage scenarios = %+v, want 4 dashboard scenarios", manifest.Coverage) + } +} + func TestWriteSnapshotArtifactsRemovesStalePNG(t *testing.T) { outputDir := t.TempDir() stalePNG := filepath.Join(outputDir, "dashboard-healthy-empty.mobile.png") diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 9bf4fc7a..c6efc215 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -27,7 +27,15 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildApprovalWaitingSnapshot(), buildBlockedSnapshot(), buildDegradedSnapshot(), + buildChatSnapshot(), + buildRunsSnapshot(), + buildApprovalsSnapshot(), buildActionCenterSnapshot(), + buildAuditSnapshot(), + buildStatusSnapshot(), + buildProviderSetupSnapshot(), + buildGitSetupSnapshot(), + buildGitRemoteSnapshot(), } switch strings.TrimSpace(name) { case "", "all": @@ -35,7 +43,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { case "dashboard": return scenarios[:4], nil case "action-center": - return scenarios[4:], nil + return []snapshotScenarioState{buildActionCenterSnapshot()}, nil default: for _, scenario := range scenarios { if scenario.Name == name { @@ -46,6 +54,22 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { } } +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() @@ -180,6 +204,75 @@ func buildActionCenterSnapshot() snapshotScenarioState { return snapshotScenarioState{Name: "action-center-triage", RouteID: routeAction, Surface: action, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} } +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 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 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 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 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 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 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 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 watchProjection(msg shellWatchTransportLoadedMsg) shellWatchProjectionState { manager := newShellWatchManager() manager.applyTransport(msg) diff --git a/cmd/runecode-tui/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go index 031ca380..addacd87 100644 --- a/cmd/runecode-tui/snapshot_svg.go +++ b/cmd/runecode-tui/snapshot_svg.go @@ -25,10 +25,12 @@ 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"` } @@ -80,10 +82,16 @@ func writeSnapshotArtifacts(cfg tuiSnapshotConfig) error { 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, Width: cfg.width, Height: cfg.height, Viewport: string(cfg.viewport), Theme: string(snapshotManifestTheme(cfg, scenarios))} + 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.viewport)} entries, err := writeSnapshotScenarioArtifacts(cfg, scenarios) if err != nil { return err @@ -96,6 +104,9 @@ func normalizeSnapshotConfig(cfg tuiSnapshotConfig) (tuiSnapshotConfig, error) { if cfg.outputDir == "" { cfg.outputDir = snapshotDefaultOutputDir() } + if strings.TrimSpace(cfg.bundle) != "" && strings.TrimSpace(cfg.scenario) != "" { + return tuiSnapshotConfig{}, fmt.Errorf("snapshot bundle and snapshot scenario are mutually exclusive") + } normalizedOutputDir, err := normalizeSnapshotOutputDir(cfg.outputDir) if err != nil { return tuiSnapshotConfig{}, err diff --git a/justfile b/justfile index 5ab3c1e1..98948bf4 100644 --- a/justfile +++ b/justfile @@ -103,6 +103,9 @@ tui-dev-stop: tui-dev-build _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 @@ -123,17 +126,53 @@ 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-review: tui-dev-snapshot-build - sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode summary + 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-summary: tui-dev-snapshot-build - sh ./tools/tui_snapshot_local.sh --binary "{{dev_bin_dir}}/runecode-tui" --scenario all --review --review-mode summary + 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" --scenario all --review --review-mode list + 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" --scenario all --review --review-mode open + 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 diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py index 86daf9d1..0ae0ed8c 100644 --- a/tools/test_tui_snapshot_review.py +++ b/tools/test_tui_snapshot_review.py @@ -139,8 +139,15 @@ def create_snapshot_dir(self) -> tuple[Path, Path, Path]: 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", @@ -182,6 +189,9 @@ def test_default_mode_prints_summary_without_opening_gui(self) -> None: 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) diff --git a/tools/tui_snapshot_local.sh b/tools/tui_snapshot_local.sh index 3d72dc45..75d456cb 100644 --- a/tools/tui_snapshot_local.sh +++ b/tools/tui_snapshot_local.sh @@ -3,11 +3,12 @@ set -eu usage() { - printf '%s\n' 'usage: sh ./tools/tui_snapshot_local.sh --binary --scenario [--viewport ] [--review] [--review-mode ]' >&2 + 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' @@ -24,6 +25,11 @@ while [ "$#" -gt 0 ]; do scenario=$2 shift 2 ;; + --bundle) + [ "$#" -ge 2 ] || { usage; exit 2; } + bundle=$2 + shift 2 + ;; --viewport) [ "$#" -ge 2 ] || { usage; exit 2; } viewport=$2 @@ -46,7 +52,8 @@ while [ "$#" -gt 0 ]; do done [ -n "$binary_path" ] || { usage; exit 2; } -[ -n "$scenario" ] || { usage; exit 2; } +[ -n "$scenario" ] || [ -n "$bundle" ] || { usage; exit 2; } +[ -z "$scenario" ] || [ -z "$bundle" ] || { usage; exit 2; } case "$review_mode" in summary|list|open) ;; @@ -90,11 +97,17 @@ 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 - "$binary_path" --snapshot-scenario "$scenario" --snapshot-output-dir "$output_dir" --snapshot-viewport "$viewport" -else - "$binary_path" --snapshot-scenario "$scenario" --snapshot-output-dir "$output_dir" + set -- "$@" --snapshot-viewport "$viewport" fi +"$@" if command -v magick >/dev/null 2>&1; then for svg in "$output_dir"/*.svg; do diff --git a/tools/tui_snapshot_review.py b/tools/tui_snapshot_review.py index 79f3446b..988adb57 100644 --- a/tools/tui_snapshot_review.py +++ b/tools/tui_snapshot_review.py @@ -190,13 +190,33 @@ def print_review_summary(output_dir: str, manifest_path: str, manifest: object, 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']}") @@ -205,6 +225,23 @@ def print_review_summary(output_dir: str, manifest_path: str, manifest: object, 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 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: diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index fc955e95..a7060cc5 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -13,6 +13,7 @@ import ( const ( snapshotBuildTag = "runecode_tui_snapshot" + snapshotBundle = "full-audit" snapshotViewport = "desktop" manifestVersion = 2 ) @@ -22,17 +23,34 @@ var expectedScenarioNames = []string{ "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", + "chat-active-session", + "runs-active-detail", + "approvals-pending-detail", "action-center-triage", + "audit-degraded-detail", + "status-ready-overview", + "model-providers-credential-needed", + "git-setup-identity-needed", + "git-remote-approval-ready", } 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"` diff --git a/tools/tuisnapshotci/main_test.go b/tools/tuisnapshotci/main_test.go index 78d87989..f2638375 100644 --- a/tools/tuisnapshotci/main_test.go +++ b/tools/tuisnapshotci/main_test.go @@ -58,9 +58,11 @@ func TestValidateSnapshotArtifactsAcceptsExpectedManifestWithoutPNGFiles(t *test outputDir := t.TempDir() manifest := snapshotManifest{ Version: manifestVersion, + Bundle: snapshotBundle, Viewport: snapshotViewport, Width: 160, Height: 48, + Coverage: testCoverage(), } for _, scenario := range expectedScenarioNames { base := scenario + "." + snapshotViewport @@ -107,9 +109,11 @@ func TestValidateSnapshotArtifactsAcceptsSymlinkedOutputDir(t *testing.T) { manifest := snapshotManifest{ Version: manifestVersion, + Bundle: snapshotBundle, Viewport: snapshotViewport, Width: 160, Height: 48, + Coverage: testCoverage(), } for _, scenario := range expectedScenarioNames { base := scenario + "." + snapshotViewport @@ -139,9 +143,11 @@ func TestValidateSnapshotArtifactsRejectsMissingScenarioEntries(t *testing.T) { 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, @@ -339,9 +345,11 @@ func testManifestForAllScenarios(t *testing.T, outputDir string) snapshotManifes 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)) @@ -357,7 +365,7 @@ func scenarioEntryForDir(t *testing.T, outputDir string, scenario string) snapsh Viewport: snapshotViewport, Width: 160, Height: 48, - Route: "dashboard", + Route: routeForScenario(scenario), Artifacts: snapshotManifestArtifacts{ ANSI: writeTestArtifact(t, outputDir, base+".ansi", scenario+" ansi\n"), Text: writeTestArtifact(t, outputDir, base+".txt", scenario+" text\n"), @@ -367,6 +375,37 @@ func scenarioEntryForDir(t *testing.T, outputDir string, scenario string) snapsh } } +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 diff --git a/tools/tuisnapshotci/validate.go b/tools/tuisnapshotci/validate.go index 1b79a544..a055c363 100644 --- a/tools/tuisnapshotci/validate.go +++ b/tools/tuisnapshotci/validate.go @@ -51,12 +51,18 @@ func validateManifestStructure(manifest snapshotManifest) (map[string]snapshotMa 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 @@ -67,6 +73,34 @@ func validateManifestStructure(manifest snapshotManifest) (map[string]snapshotMa 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", "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 { From 391cb6ac37acdfc2f6290912fcf8af7cd2ee1cab Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 15:21:31 -0500 Subject: [PATCH 13/39] More TUI snapshot skill and tool improvements. Signed-off-by: Zebulon Rouse-Frantzich --- .claude/skills/tui-snapshot-review/SKILL.md | 79 +++++++++-- tools/test_tui_snapshot_review.py | 99 ++++++++++++++ tools/tui_snapshot_review.py | 137 +++++++++++++++++++- 3 files changed, 305 insertions(+), 10 deletions(-) diff --git a/.claude/skills/tui-snapshot-review/SKILL.md b/.claude/skills/tui-snapshot-review/SKILL.md index a5c8856a..e49b3d4d 100644 --- a/.claude/skills/tui-snapshot-review/SKILL.md +++ b/.claude/skills/tui-snapshot-review/SKILL.md @@ -15,6 +15,8 @@ The snapshot tool now supports agent-facing audit bundles. Use bundles for audit - Snapshot runs clean their output directory before and after by default. Use `TUI_SNAPSHOT_KEEP=1` when artifacts must persist for inspection. - Snapshot artifacts must stay under trusted temp roots. Use `TUI_SNAPSHOT_DIR` only for a temp-backed local 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 @@ -33,17 +35,75 @@ The snapshot tool now supports agent-facing audit bundles. Use bundles for audit - `just tui-snapshot-audit-status` - `just tui-snapshot-audit-setup` - `just tui-snapshot-audit-chat` -- Non-GUI summary review: `just tui-snapshot-review` -- Non-GUI artifact path listing: `just tui-snapshot-review-list` +- 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 /tmp/runecode-tui-snapshots` +- Read-only preserved-artifact path listing: `python3 ./tools/tui_snapshot_review.py --mode list /tmp/runecode-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-all` +- 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 \ + /tmp/runecode-tui-plan-review + +python3 ./tools/tui_snapshot_review.py \ + --mode list \ + --require-bundle full-audit \ + --require-viewport desktop \ + /tmp/runecode-tui-plan-review +``` + +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=/tmp/runecode-tui-plan-review 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 /tmp/runecode-tui-plan-review +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit --require-viewport desktop /tmp/runecode-tui-plan-review +``` + +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 /tmp/runecode-tui-snapshots +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit /tmp/runecode-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 a trusted temp dir, or + - write-producing snapshot commands are allowed again. ## Audit Bundles And Scenarios @@ -103,16 +163,16 @@ TUI_SNAPSHOT_KEEP=1 sh ./tools/tui_snapshot_local.sh \ TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-full ``` -4. Produce a non-GUI summary: +4. Produce a non-GUI summary from the preserved artifact directory: ```sh -TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle full-audit --require-viewport desktop /tmp/runecode-tui-snapshots ``` -5. Print artifact paths for direct file inspection: +5. Print artifact paths for direct file inspection from the same preserved directory: ```sh -TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit --require-viewport desktop /tmp/runecode-tui-snapshots ``` 6. Inspect the selected `.png` or `.svg` files directly with file-reading tools. Do not open GUI windows unless asked. @@ -158,10 +218,11 @@ 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: +4. Use non-GUI review first against the preserved output directory: ```sh -TUI_SNAPSHOT_KEEP=1 just tui-snapshot-review-list +python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle dashboard-audit /tmp/runecode-tui-snapshots +python3 ./tools/tui_snapshot_review.py --mode list --require-bundle dashboard-audit /tmp/runecode-tui-snapshots ``` 5. Confirm the summary/list output matches the requested focused bundle or focused scenario before analyzing it. diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py index 0ae0ed8c..3a0bfc05 100644 --- a/tools/test_tui_snapshot_review.py +++ b/tools/test_tui_snapshot_review.py @@ -223,6 +223,105 @@ def test_open_mode_uses_desktop_opener(self) -> None: ], ) + 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_review.py b/tools/tui_snapshot_review.py index 988adb57..8a6efc9c 100644 --- a/tools/tui_snapshot_review.py +++ b/tools/tui_snapshot_review.py @@ -19,6 +19,16 @@ REVIEW_MODES = (REVIEW_MODE_LIST, REVIEW_MODE_SUMMARY, REVIEW_MODE_OPEN) +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) @@ -230,6 +240,106 @@ def print_review_summary(output_dir: str, manifest_path: str, manifest: object, 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 "" @@ -286,6 +396,22 @@ def parse_args(argv: List[str]) -> argparse.Namespace: 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) @@ -301,6 +427,10 @@ def main() -> int: 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 @@ -308,9 +438,14 @@ def main() -> int: 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(f"No snapshot review artifacts found in {manifest_path}", file=sys.stderr) + print_missing_artifact_guidance(output_dir, manifest_path) return 1 if args.mode == REVIEW_MODE_LIST: From d7acd3fdaf882764b0f9b91fa64c9e8451483cfa Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 15:29:20 -0500 Subject: [PATCH 14/39] More tui polish change doc updates. Signed-off-by: Zebulon Rouse-Frantzich --- .../design.md | 38 ++++++++++--------- .../proposal.md | 4 +- .../tasks.md | 17 +++++---- .../verification.md | 16 +++++--- 4 files changed, 42 insertions(+), 33 deletions(-) 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 6b3ebcf5..998a793c 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 @@ -153,6 +153,8 @@ 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 @@ -181,19 +183,19 @@ The first once-over identified these concrete polish directions: ### 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. -The current implementation partially satisfies the CHG-060 intent: +The current implementation partially satisfies the CHG-060 intent, but the full snapshot audit clarifies the remaining gaps: -- `Dashboard` has an executive-summary shape, high-value counts, next-action language, and Action Center cues, but it still exposes readiness fields, evidence posture internals, version/build data, and watch-family detail in the primary pane. -- `Action Center` exists and has approval, operational-attention, and blocked-work buckets with reason, impact, owner/action, target, and evidence cues, but it is not the first/default operator home and still uses internal family names such as `operational_attention`, `blocked_work_impact`, and watch-family language in primary content. -- `Chat` has the strongest route-level alignment: active session, composer state, broker-owned execution state, linked evidence counts, and honest broker-known stages such as waiting, plan compiled, runner active, checkpoint received, approval required, artifact ready, failed, and completed. -- `Runs` keeps outcomes, coordination, plan authority, runner/reporting posture, approvals, artifacts, and audit evidence together, but rendered inspector content still includes debug-heavy details such as `backend_kind`, workflow hashes, authoritative/advisory key counts, coordination locks/conflicts, approval profile, and raw posture taxonomy. -- `Approvals` distinguishes approval-required, pending, resolved, expired, unsupported, lifecycle, policy, trigger, and bound-scope cues, but the rendered view still surfaces contract vocabulary and policy codes too early for a non-debug operator. -- `Artifacts` and `Audit` provide useful evidence continuity and copyable raw identifiers, but digest labels, event names, verification codes, and anchoring internals still dominate too much of the primary visual hierarchy. -- `Status` and project-substrate remediation are closest to the target: inspect/current posture, adoption, init preview/apply, upgrade preview/apply, apply-unavailable states, post-apply validation, and broker-owned mutation language are present and mostly understandable. -- `Model Providers`, `Git Setup`, and `Git Remote` are the least polished product surfaces. They still read like broker API/state dumps because primary panes expose provider family, endpoint, profile IDs, auth modes, bootstrap modes, policy booleans, prepared mutation IDs, typed request hashes, action hashes, approval hashes, credential leases, lifecycle reason codes, and derived git metadata without enough product hierarchy. -- The shell chrome is improved from a raw debug frame, but it still reserves a lot of vertical space for top status, sync strip, bottom strip, status row, and footer. The resulting workbench can feel dense even before route content renders. -- The command, session, leader, inspector, and quit overlays are functional, but they are appended as additional blocks rather than visually layered modal surfaces. They do not yet match the clean centered command-palette pattern suggested by the reference images: dimmed background, centered elevated panel, compact search field, grouped actions, strong selected row, and low-noise shortcut hints. -- The implementation uses Lip Gloss styles and tokens, but the styling is mostly utility-level: bordered panes, badges, muted text, and surface colors. It does not yet use a strong visual system with accent rails, differentiated message blocks, sparse typography, focused cards, subdued secondary text, and consistent color semantics across all routes. +- `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. @@ -207,7 +209,7 @@ The target visual language is: - 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 +- 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 @@ -260,13 +262,13 @@ To reach a polished and professional TUI experience, close the gaps in this orde 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, 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. + 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. + 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. @@ -275,7 +277,7 @@ To reach a polished and professional TUI experience, close the gaps in this orde 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, and debug details. Runs, Approvals, Artifacts, Audit, Model Providers, Git Setup, and Git Remote need this separation most. + 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. @@ -284,7 +286,7 @@ To reach a polished and professional TUI experience, close the gaps in this orde 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 for Dashboard, Action Center, Chat, Runs, Approvals, Artifacts, Audit, Status/project substrate, Model Providers, Git Setup, Git Remote, command palette, session switcher, leader help, narrow layout, and degraded/disconnected states. Review those frames against CHG-060 before marking the TUI acceptance criterion complete. + 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. 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. @@ -331,7 +333,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 dc1106b6..322431db 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,7 +33,7 @@ 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 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 still needs a calmer shell, professional modal overlays, stricter rendered/structured/raw separation, route-by-route product-language cleanup, display-width-safe styling, and captured visual walkthrough evidence before it can be called polished. +- 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. - 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. @@ -57,7 +57,7 @@ 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, product language in primary panes, debug detail moved to structured/raw modes, clear state cards, command discovery that feels intentional, responsive narrow/wide layouts, and honest broker-owned progress without synthetic optimism. +- 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. - 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. 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 5109dc9a..5c07c83a 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 @@ -42,24 +42,26 @@ ## Phase 5: TUI And Product Polish During Dogfooding - [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; do not rely on the broader workflow integration implementation as proof that TUI polish is complete. +- [ ] 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, and footer 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. +- [ ] 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. - [ ] 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, API details, and debug posture taxonomy; Runs, Approvals, Artifacts, Audit, Model Providers, Git Setup, and Git Remote are the priority routes for this split. +- [ ] 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`, 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 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 the primary visual content when a shorter stable identity or label is available. +- [ ] 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. @@ -69,7 +71,8 @@ - [ ] 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. - [ ] 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, narrow layout, disconnected/degraded watch state, blocked project setup, approval required, failed run, and empty/no-work states. +- [ ] 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. @@ -102,6 +105,6 @@ - [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. - [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 0cbf910a..3338ba32 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,14 +15,16 @@ - 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. A focused TUI once-over found partial CHG-060 adherence, but the remaining presentation, route hierarchy, overlay, and Bubble Tea/Lip Gloss best-practice gaps must be closed before the TUI acceptance criterion can pass. +- 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. -- The TUI partially satisfies CHG-060: Chat, Status/project substrate, evidence links, Action Center queues, state-card foundations, inspectors, copy actions, and plan-authoritative workflow surfaces are present. -- The TUI does not fully satisfy CHG-060 yet: Dashboard, Action Center, Runs, Approvals, Artifacts, Audit, Model Providers, Git Setup, Git Remote, shell chrome, footers, overlays, and rendered/structured/raw separation still expose too much control-plane/debug detail in primary surfaces. +- 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 @@ -37,15 +39,17 @@ ## 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, narrow layout, medium layout, and wide layout. +- 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. - 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, Model Providers, Git Setup, and Git Remote separate rendered/product language from structured and raw modes. +- 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. +- 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. From af95acb2522b88468f05a1cda001007fe0499d4f Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 16:03:04 -0500 Subject: [PATCH 15/39] Improves TUI snapshot tool and skill to use gitignored folder. Improves TUI Dashboard route. Signed-off-by: Zebulon Rouse-Frantzich --- .claude/skills/tui-snapshot-review/SKILL.md | 30 ++-- .gitignore | 3 + README.md | 2 +- .../local_rpc_integration_linux_test.go | 4 +- cmd/runecode-tui/route_dashboard.go | 13 +- cmd/runecode-tui/route_dashboard_helpers.go | 158 ++++++++++++++---- cmd/runecode-tui/route_dashboard_test.go | 23 ++- cmd/runecode-tui/shell_test.go | 4 +- cmd/runecode-tui/snapshot_enabled_test.go | 16 +- cmd/runecode-tui/snapshot_paths.go | 49 +++++- tools/test_tui_snapshot_review.py | 7 +- tools/tui_snapshot_review.py | 23 ++- tools/tuisnapshotci/main_test.go | 19 ++- tools/tuisnapshotci/path.go | 56 ++++++- 14 files changed, 320 insertions(+), 87 deletions(-) diff --git a/.claude/skills/tui-snapshot-review/SKILL.md b/.claude/skills/tui-snapshot-review/SKILL.md index e49b3d4d..073687b1 100644 --- a/.claude/skills/tui-snapshot-review/SKILL.md +++ b/.claude/skills/tui-snapshot-review/SKILL.md @@ -13,7 +13,7 @@ The snapshot tool now supports agent-facing audit bundles. Use bundles for audit - 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 must stay under trusted temp roots. Use `TUI_SNAPSHOT_DIR` only for a temp-backed local output directory. +- 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. @@ -40,8 +40,8 @@ The snapshot tool now supports agent-facing audit bundles. Use bundles for audit - 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 /tmp/runecode-tui-snapshots` -- Read-only preserved-artifact path listing: `python3 ./tools/tui_snapshot_review.py --mode list /tmp/runecode-tui-snapshots` +- 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 @@ -64,13 +64,13 @@ python3 ./tools/tui_snapshot_review.py \ --mode summary \ --require-bundle full-audit \ --require-viewport desktop \ - /tmp/runecode-tui-plan-review + ./.tui-snapshots python3 ./tools/tui_snapshot_review.py \ --mode list \ --require-bundle full-audit \ --require-viewport desktop \ - /tmp/runecode-tui-plan-review + ./.tui-snapshots ``` 5. If the preserved bundle is missing or incomplete, stop and tell the user exactly how to continue: @@ -80,12 +80,12 @@ I’m in OpenCode plan/read-only mode, so I can’t generate the TUI snapshot bu To continue, either switch me to build mode, or run this command yourself: -TUI_SNAPSHOT_KEEP=1 TUI_SNAPSHOT_DIR=/tmp/runecode-tui-plan-review just tui-snapshot-audit-full +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 /tmp/runecode-tui-plan-review -python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit --require-viewport desktop /tmp/runecode-tui-plan-review +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. @@ -96,13 +96,13 @@ python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit - 2. Only inspect an already-preserved temp directory: ```sh -python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle full-audit /tmp/runecode-tui-snapshots -python3 ./tools/tui_snapshot_review.py --mode list --require-bundle full-audit /tmp/runecode-tui-snapshots +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 a trusted temp dir, or + - a preserved bundle exists under an allowed snapshot dir, or - write-producing snapshot commands are allowed again. ## Audit Bundles And Scenarios @@ -166,13 +166,13 @@ 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 /tmp/runecode-tui-snapshots +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 /tmp/runecode-tui-snapshots +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. @@ -221,8 +221,8 @@ TUI_SNAPSHOT_KEEP=1 just tui-snapshot-audit-chat 4. Use non-GUI review first against the preserved output directory: ```sh -python3 ./tools/tui_snapshot_review.py --mode summary --require-bundle dashboard-audit /tmp/runecode-tui-snapshots -python3 ./tools/tui_snapshot_review.py --mode list --require-bundle dashboard-audit /tmp/runecode-tui-snapshots +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. diff --git a/.gitignore b/.gitignore index 6eb38145..04129dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ __pycache__/ cmd/runecode-broker/.runecode-secretsd/ audit-anchor-ed25519.private audit-anchor-presence-hmac.key + +# TUI snapshot tool +.tui-snapshots/ diff --git a/README.md b/README.md index f81b0ed1..6942bd4b 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ 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 `/tmp/runecode-tui-snapshots/` by default, or under `TUI_SNAPSHOT_DIR` when you want a different 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`, and `action-center-audit` so review scope is captured in the manifest rather than inferred from filenames. +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`, and `action-center-audit` so review scope is captured in the manifest rather than inferred from filenames. 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. diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index c54e53b9..486f5958 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -230,9 +230,9 @@ func assertDashboardAndChatBackedRoutes(t *testing.T, recording localBrokerClien assertRouteOutputContainsAll(t, dashboard, routeDashboard, "Current work", "At a glance", - "Approvals waiting:", + "Approvals:", "Next action", - "Action Center explains blockers and degraded cues", + "Evidence: Runs, Audit, and Status keep proof details.", ) chat := newChatRouteModel(routeDefinition{ID: routeChat, Label: "Chat"}, recording) diff --git a/cmd/runecode-tui/route_dashboard.go b/cmd/runecode-tui/route_dashboard.go index dfbcada4..39afc4df 100644 --- a/cmd/runecode-tui/route_dashboard.go +++ b/cmd/runecode-tui/route_dashboard.go @@ -139,20 +139,15 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { sections := []string{ compactLines( sectionTitle("Dashboard")+" "+focusBadge(focus), - renderStateCardSpec(stateCardSpec{ - State: snapshot.Executive.State, - Title: snapshot.Executive.Title, - Message: snapshot.Executive.Message, - Reason: snapshot.Executive.Reason, - NextAction: snapshot.Executive.NextAction, - RouteCue: "Action Center", - }), + 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: dashboardMetricLines(snapshot)}), + 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), diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index 0ffb623d..5983ac22 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -11,6 +11,7 @@ import ( type dashboardExecutiveSummary struct { State routeLoadState Title string + BadgeLabel string Message string Reason string NextAction string @@ -124,38 +125,84 @@ func dashboardBlockedSummary(data dashboardData, snapshot dashboardSnapshot) das reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) } if snapshot.BlockedRuns > 0 { - reasons = append(reasons, fmt.Sprintf("%d workflow(s) are blocked or waiting", snapshot.BlockedRuns)) + reasons = append(reasons, fmt.Sprintf("%s %s blocked or waiting", dashboardCountPhrase(snapshot.BlockedRuns, "workflow", "workflows"), dashboardIsAre(snapshot.BlockedRuns))) } - return dashboardExecutiveSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress is blocked.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center and clear the blocker."} + 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 dashboardDegradedSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { - reasons := []string{} + subjects := []string{} if !data.readiness.Ready { - reasons = append(reasons, "broker readiness is not fully settled") + subjects = append(subjects, "broker readiness") } if snapshot.AuditDegraded { - reasons = append(reasons, "audit or runtime evidence needs review") + subjects = append(subjects, "evidence posture") } if snapshot.RunRuntimeDegraded > 0 { - reasons = append(reasons, fmt.Sprintf("%d run(s) report degraded runtime posture", snapshot.RunRuntimeDegraded)) + 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 dashboardExecutiveSummary{State: routeLoadStateDegraded, Title: "Degraded", Message: "Evidence or runtime posture needs review.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center, then confirm Audit or Runs."} + 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 dashboardAttentionSummary(data dashboardData, snapshot dashboardSnapshot) dashboardExecutiveSummary { reasons := []string{} if snapshot.PendingApprovals > 0 { - reasons = append(reasons, fmt.Sprintf("%d approval decision(s) are waiting", snapshot.PendingApprovals)) + reasons = append(reasons, fmt.Sprintf("%s %s waiting", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals))) } if snapshot.SetupNeedsAttention { - reasons = append(reasons, dashboardProjectSubstrateReason(data.project)) + reasons = append(reasons, dashboardProjectSubstrateAttentionReason(data.project)) } - return dashboardExecutiveSummary{State: routeLoadStateApprovalRequired, Title: "Needs attention", Message: "Operator follow-up is waiting.", Reason: strings.Join(reasons, "; "), NextAction: "Open Action Center for the exact follow-up."} + 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 dashboardActiveWorkSummary(activeRuns int) dashboardExecutiveSummary { - return dashboardExecutiveSummary{State: routeLoadStateReady, Title: "Active work", Message: "Workflow work is active.", Reason: fmt.Sprintf("%d active workflow(s) are visible.", activeRuns), NextAction: "Use Action Center or open Runs for detail."} + 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 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 cue := dashboardHeroRouteCue(snapshot); cue != "" { + lines = append(lines, muted("Route: "+cue)) + } + 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 "" + } + if strings.EqualFold(label, strings.TrimSpace(exec.Title)) { + return "" + } + return toneBadge(stateCardTone(exec.State), strings.ToUpper(label)) +} + +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" + } + return "" } func renderDashboardWorkflowPosture(snapshot dashboardSnapshot) string { @@ -163,14 +210,14 @@ func renderDashboardWorkflowPosture(snapshot dashboardSnapshot) 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("%d workflow(s) are blocked or waiting on follow-up", snapshot.BlockedRuns)) + 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("%d workflow(s) are active", snapshot.ActiveRuns)) + 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("%d approval decision(s) are waiting", snapshot.PendingApprovals)) + 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") @@ -210,17 +257,17 @@ func renderDashboardCurrentWork(data dashboardData, snapshot dashboardSnapshot) if snapshot.NoWork { return "Current work: no active run is visible yet." } - return "Current work: broker returned follow-up items, but no primary run is selected." + return "Current work: follow-up is waiting, but no active run is highlighted yet." } 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 active") + parts = append(parts, "work is moving") } if snapshot.DegradedSignals > 0 { - parts = append(parts, "evidence or runtime posture needs review") + parts = append(parts, "review is still needed") } if data.readiness.RecoveryComplete { parts = append(parts, "recovery is complete") @@ -234,25 +281,26 @@ func renderDashboardApprovalCue(data dashboardData, snapshot dashboardSnapshot) } first := firstPendingApproval(data.approvals) if strings.TrimSpace(first.ApprovalID) == "" { - return fmt.Sprintf("Approvals: %d decision(s) are waiting.", snapshot.PendingApprovals) + return fmt.Sprintf("Approvals: %s %s waiting.", dashboardCountPhrase(snapshot.PendingApprovals, "approval", "approvals"), dashboardIsAre(snapshot.PendingApprovals)) } - return fmt.Sprintf("Approvals: %d decision(s) waiting; next is %s for run %s.", snapshot.PendingApprovals, sanitizeUIText(first.ApprovalID), valueOrNA(sanitizeUIText(first.BoundScope.RunID))) + 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 dashboardMetricLines(snapshot dashboardSnapshot) []string { - return []string{ - fmt.Sprintf("Active work: %d", snapshot.ActiveRuns), - fmt.Sprintf("Approvals waiting: %d", snapshot.PendingApprovals), - fmt.Sprintf("Blocked or waiting: %d", snapshot.BlockedRuns), - fmt.Sprintf("Needs review: %d", snapshot.DegradedSignals), +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), } + return strings.Join(parts, " • ") } func renderDashboardDetailCue(snapshot dashboardSnapshot) string { if snapshot.BlockedRuns > 0 || snapshot.PendingApprovals > 0 || snapshot.DegradedSignals > 0 || snapshot.SetupNeedsAttention { - return "Detail: Action Center explains blockers and degraded cues; Audit, Runs, and Status keep the raw proof." + return "Evidence: Runs, Audit, and Status keep proof details." } - return "Detail: use Runs, Audit, or Status when you need broker proof instead of the overview." + return "Evidence: open Runs, Audit, or Status for broker proof." } func firstPendingApproval(approvals []brokerapi.ApprovalSummary) brokerapi.ApprovalSummary { @@ -271,11 +319,14 @@ func renderDashboardNextActions(data dashboardData, snapshot dashboardSnapshot) 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 snapshot.PendingApprovals > 0 { - return "Open Action Center and review the pending approval before workflow progress resumes." + return "Open Action Center and review the pending approval." } if snapshot.DegradedSignals > 0 { - return "Open Action Center for degraded evidence or runtime follow-up, then inspect Audit or Runs." + return "Open Action Center for the follow-up, then inspect Audit or Runs." } 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)) @@ -300,14 +351,59 @@ func renderDashboardLiveActivitySummary(live dashboardLiveActivity) string { } } if degraded > 0 { - return fmt.Sprintf("Live updates need attention in %d stream(s); Action Center and Status have the details.", degraded) + 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; %d recent broker event(s) are visible below.", len(live.feed)) + 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." } +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 { diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index 14ac5352..1615b318 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -31,14 +31,14 @@ func TestDashboardRouteShowsTypedLiveWatchFamilies(t *testing.T) { "Degraded", "Current work", "run-1 is active", - "Approvals: 1 decision(s) waiting", + "Approvals: 1 approval is waiting", "At a glance", - "Active work: 1", - "Approvals waiting: 1", - "Needs review: 1", + "Work 1", + "Approvals 1", + "Review 1", "Next action", "Open Action Center", - "Audit, Runs, and Status keep the raw proof", + "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) { @@ -77,7 +77,7 @@ func TestDashboardRouteFallsBackWhenAuditVerificationUnavailable(t *testing.T) { "Degraded", "Current work", "At a glance", - "Needs review: 1", + "Review 1", "Next action", "Open Action Center", ) @@ -108,9 +108,14 @@ func TestDashboardExecutiveHierarchyAndCalmPrimaryWording(t *testing.T) { 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, "Action Center explains blockers and degraded cues") { + 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) { @@ -121,7 +126,7 @@ func TestDashboardViewPreservesSectionGaps(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, want := range []string{"Current work", "\n\n| At a glance", "Needs review: 1\n\n| Next action"} { + 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) } @@ -154,7 +159,7 @@ 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, "Audit, Runs, and Status keep") { + 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") { diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 3bac3228..a8826bf4 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -840,8 +840,8 @@ func TestShellWatchManagerUpdatesRoutesAndSyncHealth(t *testing.T) { mustContainAll(t, view, "Product truth:", "Sync healthy", - "Open Action Center for degraded evidence or runtime follow-up", - "Action Center explains blockers and degraded cues", + "Open Action Center for the follow-up, then inspect Audit or Runs", + "Evidence: Runs, Audit, and Status keep proof details.", ) } diff --git a/cmd/runecode-tui/snapshot_enabled_test.go b/cmd/runecode-tui/snapshot_enabled_test.go index 149b466f..efa1f80e 100644 --- a/cmd/runecode-tui/snapshot_enabled_test.go +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -92,7 +92,7 @@ func TestNormalizeSnapshotConfigAllowsCanonicalTmpWhenTempDirDiffers(t *testing. } t.Setenv("TMPDIR", customTempDir) - outputDir := filepath.Join("/tmp", "runecode-tui-snapshots", t.Name()) + 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) @@ -106,6 +106,20 @@ func TestNormalizeSnapshotConfigAllowsCanonicalTmpWhenTempDirDiffers(t *testing. } } +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") diff --git a/cmd/runecode-tui/snapshot_paths.go b/cmd/runecode-tui/snapshot_paths.go index 4f2d3c47..232b25b4 100644 --- a/cmd/runecode-tui/snapshot_paths.go +++ b/cmd/runecode-tui/snapshot_paths.go @@ -10,8 +10,18 @@ import ( "strings" ) +const snapshotDefaultDirName = ".tui-snapshots" + func snapshotDefaultOutputDir() string { - return filepath.Join(os.TempDir(), "runecode-tui-snapshots") + 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) { @@ -19,19 +29,19 @@ func normalizeSnapshotOutputDir(outputDir string) (string, error) { if err != nil { return "", fmt.Errorf("resolve snapshot output directory: %w", err) } - trustedTempRoots, err := snapshotTrustedTempRoots() + trustedRoots, err := snapshotTrustedOutputRoots() if err != nil { return "", err } - for _, trustedTempRoot := range trustedTempRoots { - if snapshotPathWithinBase(trustedTempRoot, resolvedOutputDir) { + 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(trustedTempRoots, ", ")) + return "", fmt.Errorf("snapshot output directory must stay under one of %s", strings.Join(trustedRoots, ", ")) } -func snapshotTrustedTempRoots() ([]string, error) { +func snapshotTrustedOutputRoots() ([]string, error) { roots := make([]string, 0, 2) seen := make(map[string]struct{}, 2) @@ -61,10 +71,37 @@ func snapshotTrustedTempRoots() ([]string, error) { } 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 { diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py index 3a0bfc05..847778f8 100644 --- a/tools/test_tui_snapshot_review.py +++ b/tools/test_tui_snapshot_review.py @@ -93,10 +93,15 @@ def test_resolve_output_dir_accepts_real_tmp_when_tempdir_differs(self) -> None: if not tmp_root.is_dir(): self.skipTest("/tmp unavailable on this platform") - requested = str(tmp_root / "runecode-tui-snapshots") + 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")) diff --git a/tools/tui_snapshot_review.py b/tools/tui_snapshot_review.py index 8a6efc9c..af8be915 100644 --- a/tools/tui_snapshot_review.py +++ b/tools/tui_snapshot_review.py @@ -17,6 +17,7 @@ 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" def normalize_string_list(value: object) -> List[str]: @@ -46,7 +47,7 @@ def resolve_output_dir(output_dir: object) -> Optional[str]: return None output_dir_real = os.path.realpath(output_dir) - for trusted_root in trusted_temp_roots(): + for trusted_root in trusted_output_roots(): try: if os.path.commonpath([trusted_root, output_dir_real]) == trusted_root: return output_dir_real @@ -68,6 +69,20 @@ def trusted_temp_roots() -> List[str]: 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 @@ -387,8 +402,8 @@ def parse_args(argv: List[str]) -> argparse.Namespace: parser.add_argument( "output_dir", nargs="?", - default=os.path.join(tempfile.gettempdir(), "runecode-tui-snapshots"), - help="snapshot output directory under the trusted temp root", + default=repo_snapshot_root(), + help="snapshot output directory under a trusted snapshot root", ) parser.add_argument( "--mode", @@ -420,7 +435,7 @@ def main() -> int: 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 the system temp directory", file=sys.stderr) + 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") diff --git a/tools/tuisnapshotci/main_test.go b/tools/tuisnapshotci/main_test.go index f2638375..9c3de583 100644 --- a/tools/tuisnapshotci/main_test.go +++ b/tools/tuisnapshotci/main_test.go @@ -10,13 +10,16 @@ import ( "testing" ) -func TestRunLocalOutputDirDefaultsToTempDir(t *testing.T) { +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 := filepath.Join(os.TempDir(), localSnapshotDirName) + 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) } @@ -335,12 +338,22 @@ func TestRequirePathWithinTrustedRootAcceptsCanonicalTmp(t *testing.T) { if _, err := os.Stat("/tmp"); err != nil { t.Skip("/tmp unavailable on this platform") } - candidate := filepath.Join("/tmp", "runecode-tui-snapshots") + candidate := filepath.Join("/tmp", ".tui-snapshots") 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{ diff --git a/tools/tuisnapshotci/path.go b/tools/tuisnapshotci/path.go index 863b67f9..e88e07e5 100644 --- a/tools/tuisnapshotci/path.go +++ b/tools/tuisnapshotci/path.go @@ -9,7 +9,7 @@ import ( "strings" ) -const localSnapshotDirName = "runecode-tui-snapshots" +const localSnapshotDirName = ".tui-snapshots" func canonicalPath(path string) (string, error) { absPath, err := filepath.Abs(path) @@ -80,7 +80,7 @@ func prepareArtifactsDir(workDir string, outputDir string) (string, error) { func resolveLocalSnapshotDir(outputDir string) (string, error) { requestedDir := strings.TrimSpace(outputDir) if requestedDir == "" { - requestedDir = filepath.Join(os.TempDir(), localSnapshotDirName) + requestedDir = defaultLocalSnapshotDir() } resolvedOutputDir, err := canonicalPath(requestedDir) if err != nil { @@ -127,7 +127,7 @@ func normalizeTrustedOutputDir(outputDir string) (string, error) { } func requirePathWithinTrustedRoot(label string, candidate string) error { - trustedRoots, err := trustedTempRoots() + trustedRoots, err := trustedSnapshotRoots() if err != nil { return err } @@ -139,6 +139,56 @@ func requirePathWithinTrustedRoot(label string, candidate string) error { 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()) From e3452cd78c1504a130b12f13bdd3fa310a0cdaae Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 16:25:26 -0500 Subject: [PATCH 16/39] Improves TUI Action Center polish and triage clarity. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_action_center.go | 30 +++--- .../route_action_center_render.go | 98 +++++++++++++------ cmd/runecode-tui/route_action_center_test.go | 55 +++++++---- 3 files changed, 118 insertions(+), 65 deletions(-) diff --git a/cmd/runecode-tui/route_action_center.go b/cmd/runecode-tui/route_action_center.go index b08a48aa..25dd790c 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" @@ -175,22 +174,19 @@ func (m actionCenterRouteModel) View(width, height int, focus focusArea) string vm := m.snapshot() return compactLines( sectionTitle("Action Center")+" "+focusBadge(focus), - 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", - }), - fmt.Sprintf("Queues: Approvals %d • Operational attention %d • Blocked work %d", len(vm.Families[actionCenterFamilyApprovals]), len(vm.Families[actionCenterFamilyOps]), len(vm.Families[actionCenterFamilyBlocked])), - fmt.Sprintf("Active queue: %s", actionCenterFamilyLabel(m.family)), - "Action Center is the operator home for blocked work, approval gates, degraded sync, setup, runtime, and evidence follow-up.", - renderActionCenterDirectory("Approvals queue", 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 impact", vm.Families[actionCenterFamilyBlocked], m.selectedIndex(actionCenterFamilyBlocked, len(vm.Families[actionCenterFamilyBlocked])), width), - muted("If every bucket is empty, the control plane is currently waiting on new canonical work or operator intervention."), - keyHint("Route keys: [/] change family, j/k move, enter drill-down, i toggle inspector, r reload"), + 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"), ) } diff --git a/cmd/runecode-tui/route_action_center_render.go b/cmd/runecode-tui/route_action_center_render.go index 2b1baed1..e9bd07c4 100644 --- a/cmd/runecode-tui/route_action_center_render.go +++ b/cmd/runecode-tui/route_action_center_render.go @@ -5,6 +5,16 @@ import ( "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."}) @@ -29,22 +39,12 @@ func renderActionCenterItems(items []actionCenterItem) []string { } func renderActionCenterItem(item actionCenterItem, width int) string { - parts := []string{ - fmt.Sprintf("%s %s", tableHeader(actionCenterStateLabel(item.State)), item.Title), - " urgency: " + humanizeExecutionToken(item.Urgency), - " reason: " + valueOrNA(item.Reason), - " impact: " + valueOrNA(item.Impact), - " owner/action: " + valueOrNA(strings.TrimSpace(strings.Join([]string{item.Owner, item.RequiredAction}, " • "))), - " target: " + valueOrNA(item.TargetLabel), - } + title := fmt.Sprintf("%s %s", tableHeader(actionCenterStateLabel(item.State)), item.Title) + summary := " " + actionCenterMainListSummary(item) if width <= 0 { - return strings.Join(parts, "\n") - } - wrapped := make([]string, 0, len(parts)) - for _, part := range parts { - wrapped = append(wrapped, wrapDashboardLine(part, width)) + return strings.Join([]string{title, summary}, "\n") } - return strings.Join(wrapped, "\n") + return strings.Join([]string{clipDisplayText(title, width), clipDisplayText(summary, width)}, "\n") } func actionCenterStateLabel(state routeLoadState) string { @@ -77,24 +77,64 @@ func actionCenterFamilyLabel(family actionCenterFamily) string { } } +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 "" + } + for _, sep := range []string{";", "."} { + if idx := strings.Index(reason, sep); idx > 0 { + reason = reason[:idx] + break + } + } + return strings.TrimSpace(reason) +} + +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(" queue=%s", actionCenterFamilyLabel(family)), - fmt.Sprintf(" title=%s", item.Title), - fmt.Sprintf(" state=%s", valueOrNA(string(item.State))), - fmt.Sprintf(" urgency=%s", valueOrNA(item.Urgency)), - fmt.Sprintf(" reason=%s", valueOrNA(item.Reason)), - fmt.Sprintf(" impact=%s", valueOrNA(item.Impact)), - fmt.Sprintf(" owner=%s", valueOrNA(item.Owner)), - fmt.Sprintf(" required_action=%s", valueOrNA(item.RequiredAction)), - fmt.Sprintf(" target=%s", valueOrNA(item.TargetLabel)), - fmt.Sprintf(" evidence=%s", valueOrNA(item.EvidenceCue)), - fmt.Sprintf(" drill_down_target=%s/%s", valueOrNA(item.Target.Kind), valueOrNA(string(item.Target.RouteID))), + 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", + " "+valueOrNA(strings.TrimSpace(strings.Join([]string{item.Owner, item.RequiredAction}, " • "))), + " Continue in", + " "+drillDown, + " Evidence", + " "+valueOrNA(item.EvidenceCue), ) } @@ -104,15 +144,15 @@ func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem blocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateBlocked) approvalBlocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateApprovalRequired) if blocked > 0 { - return actionCenterSummary{State: routeLoadStateBlocked, Title: "Blocked", Message: "Workflow progress 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 impact items, then review linked approvals or setup guidance."} + return actionCenterSummary{State: routeLoadStateBlocked, Title: "Action Center", Message: "Workflow progress 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: "Needs attention", 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 the approvals queue and review the exact gated action before deciding."} + 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: "Degraded", Message: "Runtime, evidence, setup, or sync posture needs review.", Reason: fmt.Sprintf("%d operational attention item(s) are visible.", ops), NextAction: "Review degraded items first, then confirm detail in Audit, Runs, or Status."} + 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: "No immediate follow-up", Message: "No broker-known attention items are currently waiting in Action Center.", Reason: "Approvals, blocked-work impact, and operational attention queues are clear on the current broker surfaces.", NextAction: "Return to Dashboard or Chat and continue work until new follow-up appears."} + 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 countActionCenterItemsByState(items []actionCenterItem, state routeLoadState) int { diff --git a/cmd/runecode-tui/route_action_center_test.go b/cmd/runecode-tui/route_action_center_test.go index 47532972..1cb0ea58 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -30,28 +30,30 @@ func TestActionCenterViewKeepsFamiliesDistinctAndReservedQANotice(t *testing.T) mustContainAll(t, view, "Action Center", "Blocked", - "Queues:", "Approvals", - "Operational attention", - "Blocked work", - "Approvals queue", - "Operational attention", + "Operational Attention", + "Blocked Work", + "Focus Approvals", + "Continue in", + ) + mustNotContainAny(t, view, "Blocked-work impact", - "Action Center is the operator home for blocked work", "owner/action:", - "target:", + "Focused queue:", ) surface := updated.ShellSurface(routeShellContext{Width: 140, Height: 40, Focus: focusContent, Breakpoint: shellBreakpointWide}) inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, + "Why this needs attention", + "What it affects", + "What to do", + "Continue in", + "Evidence", + ) + mustNotContainAny(t, inspector, "queue=", "state=", - "urgency=", - "reason=", - "impact=", - "owner=", "required_action=", - "evidence=", ) } @@ -83,11 +85,11 @@ func TestActionCenterKeyboardTriageAndDrillDown(t *testing.T) { } view := updated.View(140, 40, focusContent) - if !strings.Contains(view, "Active queue") { + 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) } } @@ -108,9 +110,9 @@ func TestBuildApprovalActionItemsIncludesExpiryAndSupersededCues(t *testing.T) { "expiring soon", "approval ap-super", "superseded", - "owner/action:", - "owner: operator decision", + "Continue in Approvals", ) + mustNotContainAny(t, text, "owner/action:", "Next:") } func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testing.T) { @@ -128,8 +130,9 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin "audit verification posture", "anchoring is degraded", "run run-1 operational posture", - "owner/action:", + "Continue in", ) + mustNotContainAny(t, text, "owner/action:", "Next:") } func TestActionCenterItemsStateReasonImpactOwnerTargetEvidence(t *testing.T) { @@ -148,13 +151,27 @@ func TestActionCenterItemsStateReasonImpactOwnerTargetEvidence(t *testing.T) { } text := renderActionCenterItem(item, 0) mustContainAll(t, text, - "reason:", - "impact:", + 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) + } + } +} From 1af81c334a6a00b6426c4d1aeedc19079a0734c2 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 16:31:45 -0500 Subject: [PATCH 17/39] Improves TUI Chat route hierarchy and session focus. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_chat.go | 17 +---- .../route_chat_execution_helpers_test.go | 25 +++++++ cmd/runecode-tui/route_chat_rendering.go | 70 ++++++++++++++++--- cmd/runecode-tui/route_chat_test.go | 10 ++- 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/cmd/runecode-tui/route_chat.go b/cmd/runecode-tui/route_chat.go index e4af718b..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" @@ -128,24 +127,10 @@ func (m chatRouteModel) View(width, height int, focus focusArea) string { if m.errText != "" { return renderStateCard(routeLoadStateError, "Chat", "Load failed: "+m.errText+" (press r to retry)") } - activeLine := activeSessionSummaryLine(m.active) - if activeLine == "" { - activeLine = "No active session selected." - } - composerMessage := fmt.Sprintf("Composer is %s.", composerState(m.composeOn)) - composerSummary := "Composer is ready for a follow-up prompt." - if !m.composeOn { - composerSummary = "Composer is closed. Press c when you want to continue the conversation." - } else { - composerMessage = composeDraftStatusLine(m.composer.Value()) - composerSummary = "Draft stays local until you send it. Use alt+enter when this follow-up is ready for the broker." - } body := []string{ sectionTitle("Chat") + " " + focusBadge(focus), - renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Active session", Message: activeLine, Reason: fmt.Sprintf("Canonical session directory: %d session(s) available.", len(m.sessions)), NextAction: "Review the session below, or move through the directory to switch context.", ShortcutCue: "j/k move • enter review", RouteCue: "Chat"}), renderStateCardSpec(chatExecutionStateCard(m.active, m.posture, m.runDetail)), - renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Composer", Message: composerMessage, Reason: composerSummary, NextAction: "Write a prompt here when you want the broker to start the next workflow turn.", ShortcutCue: "c compose • alt+enter send", RouteCue: "Chat"}), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderActiveSessionSummaryBlock(m.active, len(m.sessions)), renderDirectory("Session directory", renderSessionDirectoryItems(m.sessions), m.selected), renderComposer(m.composeOn, m.draft, m.composer.View()), } diff --git a/cmd/runecode-tui/route_chat_execution_helpers_test.go b/cmd/runecode-tui/route_chat_execution_helpers_test.go index 03965589..e0720069 100644 --- a/cmd/runecode-tui/route_chat_execution_helpers_test.go +++ b/cmd/runecode-tui/route_chat_execution_helpers_test.go @@ -134,3 +134,28 @@ func TestChatExecutionStatusAndActionForProjectBlockedUsesStatusRemediation(t *t 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_rendering.go b/cmd/runecode-tui/route_chat_rendering.go index 39a1e110..2eda33c5 100644 --- a/cmd/runecode-tui/route_chat_rendering.go +++ b/cmd/runecode-tui/route_chat_rendering.go @@ -30,11 +30,47 @@ 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 %s turns=%d runs=%d artifacts=%d approvals=%d", s.Identity.SessionID, stateBadgeWithLabel("status", s.Status), stateBadgeWithLabel("work", sessionHighLevelCue(s)), s.TurnCount, s.LinkedRunCount, s.LinkedArtifactCount, s.LinkedApprovalCount)) + 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 "No session selected." @@ -54,6 +90,29 @@ func activeSessionSummaryLine(detail *brokerapi.SessionDetail) string { 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)), + } + 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 == "" { @@ -101,7 +160,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.") } if strings.TrimSpace(textareaView) != "" { return compactLines("Compose draft:", textareaView) @@ -109,13 +168,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_test.go b/cmd/runecode-tui/route_chat_test.go index 3e5d5945..cce72bc8 100644 --- a/cmd/runecode-tui/route_chat_test.go +++ b/cmd/runecode-tui/route_chat_test.go @@ -160,17 +160,23 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { view := updated.View(120, 40, focusContent) chatMustContainAll(t, view, - "Canonical session session-1", "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 is idle.", + "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 strings.Contains(view, "Modes:") { + t.Fatalf("expected no duplicate mode tabs in main chat view, got %q", view) + } + if strings.Contains(view, "status=") || strings.Contains(view, "work=") { + t.Fatalf("expected calmer session directory rows without dump-style labels, got %q", view) + } } func assertChatSendAndWatchRequests(t *testing.T, spy *chatBrokerClientSpy) { From a4b51925cc38946f197f92f20fb7b5587b8e9d36 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 16:37:52 -0500 Subject: [PATCH 18/39] Improves TUI Runs route clarity and evidence cues. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_runs.go | 131 ++++++++++++++--- .../route_runs_approvals_artifacts_test.go | 14 +- cmd/runecode-tui/route_runs_detail.go | 132 ++++++++++++++++++ 3 files changed, 256 insertions(+), 21 deletions(-) diff --git a/cmd/runecode-tui/route_runs.go b/cmd/runecode-tui/route_runs.go index 7fe4b975..93915f17 100644 --- a/cmd/runecode-tui/route_runs.go +++ b/cmd/runecode-tui/route_runs.go @@ -136,7 +136,6 @@ 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 and linked workflow evidence...") @@ -147,14 +146,13 @@ func (m runsRouteModel) View(width, height int, focus focusArea) string { body := []string{ sectionTitle("Runs") + " " + focusBadge(focus), renderStateCardSpec(runStateCard(m.active)), - renderRunSafetyStrip(m.activeSummary(), width-4), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + 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, muted("Runs keeps the broker-owned outcome, coordination, and evidence trail together for the selected workflow.")) + 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...) } @@ -303,13 +301,11 @@ func renderRunDirectoryItems(runs []brokerapi.RunSummary) []string { for _, run := range runs { items = append(items, strings.TrimSpace(strings.Join([]string{ run.RunID, - stateBadgeWithLabel("state", run.LifecycleState), - fmt.Sprintf("workflow=%s", valueOrNA(run.WorkflowKind)), - fmt.Sprintf("operation=%s", valueOrNA(run.CurrentStageID)), - fmt.Sprintf("backend=%s", valueOrNA(run.BackendKind)), - fmt.Sprintf("approvals=%d", run.PendingApprovalCount), - fmt.Sprintf("audit=%s/%s", valueOrNA(run.AuditIntegrityStatus), valueOrNA(run.AuditAnchoringStatus)), - }, " "))) + postureBadge(run.LifecycleState), + runDirectoryWorkflowCue(run), + runDirectoryAttentionCue(run), + runDirectoryEvidenceCue(run), + }, " • "))) } return items } @@ -318,12 +314,12 @@ func runStateCard(detail *brokerapi.RunDetail) stateCardSpec { if detail == nil { return stateCardSpec{ State: routeLoadStateEmpty, - Title: "Selected run", - Message: "Pick a run to review workflow outcome and evidence.", - Reason: "The directory stays broker-owned; no local progress is synthesized here.", - NextAction: "Select a run from the directory.", + 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: "Runs", + RouteCue: "Approvals / Artifacts / Audit", } } state := routeLoadStateReady @@ -341,11 +337,106 @@ func runStateCard(detail *brokerapi.RunDetail) stateCardSpec { } return stateCardSpec{ State: state, - Title: "Selected run", - Message: fmt.Sprintf("Run %s is %s.", detail.Summary.RunID, valueOrNA(detail.Summary.LifecycleState)), - Reason: fmt.Sprintf("Workflow %s • %s • %s", runWorkflowOperation(detail.Summary, detail), runPlanAuthoritySummary(detail.Summary, detail), runEvidenceSummary(detail)), - NextAction: fmt.Sprintf("Use the inspector for detail, then jump to session=%s / approvals=%d / artifacts=%d / audit=%d as needed.", valueOrNA(authoritativeString(detail.AuthoritativeState, "session_id")), len(detail.PendingApprovalIDs), runArtifactCount(detail), runAuditReferenceCount(detail)), + 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 operating in an isolated environment" + 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_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index 9dcc4e5f..56f758f5 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -38,9 +38,21 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { "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 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) { @@ -274,7 +286,7 @@ func TestRunsReloadKeepsSelectedDetailAligned(t *testing.T) { view := updated.View(120, 40, focusContent) mustContainAll(t, view, - "Selected run", + "Run overview", "Run run-2 is blocked.", "Run directory", ) diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index 7f23275d..7846c1b8 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -181,6 +181,138 @@ func runOutcomeHeadline(summary brokerapi.RunSummary, detail *brokerapi.RunDetai 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) +} + +func runOverviewReason(detail *brokerapi.RunDetail) string { + if detail == nil { + return "n/a" + } + parts := []string{} + if workflow := strings.TrimSpace(runWorkflowOperatorCue(detail.Summary, detail)); workflow != "" { + parts = append(parts, workflow) + } + 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 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")) + } + 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." + } + 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 runOverviewEvidenceCue(detail *brokerapi.RunDetail) string { + if detail == nil { + return "" + } + 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 runOperatorProgressCue(detail *brokerapi.RunDetail) string { + if detail == nil { + return "" + } + 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 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 == "" { From 09aae218b0ee26a6539d8c1b7838404ed534d5b5 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 16:44:17 -0500 Subject: [PATCH 19/39] Improves TUI Approvals route decision workflow. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_approvals.go | 5 +- .../route_approvals_followup_helpers.go | 83 +++++++++++++++++-- cmd/runecode-tui/route_approvals_helpers.go | 28 ++++++- .../route_approvals_state_helpers.go | 10 +-- .../route_runs_approvals_artifacts_test.go | 17 +++- 5 files changed, 123 insertions(+), 20 deletions(-) diff --git a/cmd/runecode-tui/route_approvals.go b/cmd/runecode-tui/route_approvals.go index 368320a1..8ebbfe6c 100644 --- a/cmd/runecode-tui/route_approvals.go +++ b/cmd/runecode-tui/route_approvals.go @@ -154,10 +154,7 @@ func (m approvalsRouteModel) View(width, height int, focus focusArea) string { body := []string{ sectionTitle("Approvals") + " " + focusBadge(focus), renderApprovalOverviewCard(m.active), - renderApprovalSafetyStrip(m.active), - renderApprovalFlowPath(m.active), - renderApprovalReviewPlan(m.active), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + renderApprovalDecisionWorkbench(m.active), renderDirectory("Approval queue", renderApprovalDirectoryItems(m.items), m.selected), } if len(m.items) == 0 { diff --git a/cmd/runecode-tui/route_approvals_followup_helpers.go b/cmd/runecode-tui/route_approvals_followup_helpers.go index cc25a9ab..3374d7a8 100644 --- a/cmd/runecode-tui/route_approvals_followup_helpers.go +++ b/cmd/runecode-tui/route_approvals_followup_helpers.go @@ -82,6 +82,77 @@ func approvalReviewFirst(summary brokerapi.ApprovalSummary, detail brokerapi.App 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 @@ -107,7 +178,7 @@ func approvalFollowUpRoute(summary brokerapi.ApprovalSummary) string { } func approvalDisplayLabel(summary brokerapi.ApprovalSummary) string { - action := strings.TrimSpace(summary.BoundScope.ActionKind) + 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)) @@ -121,11 +192,13 @@ func approvalDisplayLabel(summary brokerapi.ApprovalSummary) string { } func approvalQueueReason(summary brokerapi.ApprovalSummary) string { - trigger := strings.TrimSpace(summary.ApprovalTriggerCode) - if trigger == "" { - return "review pending" + 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 "reason=" + approvalUIValue(trigger) + return "Review linked evidence" } func shortIdentity(value string) string { diff --git a/cmd/runecode-tui/route_approvals_helpers.go b/cmd/runecode-tui/route_approvals_helpers.go index 5cba2714..0efe2b21 100644 --- a/cmd/runecode-tui/route_approvals_helpers.go +++ b/cmd/runecode-tui/route_approvals_helpers.go @@ -26,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 %s %s gate=%s", valueOrNA(item.ApprovalID), approvalDisplayLabel(item), approvalPrimaryStateBadge(item), approvalQueueReason(item), approvalBoundScopeCue(item))) + 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 } @@ -279,13 +286,30 @@ func renderApprovalOverviewCard(resp *brokerapi.ApprovalGetResponse) string { State: state, Title: "Approval review", Message: fmt.Sprintf("%s needs attention for %s.", approvalDisplayLabel(summary), approvalDisplayState(summary, detail)), - Reason: approvalPrimaryReason(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." diff --git a/cmd/runecode-tui/route_approvals_state_helpers.go b/cmd/runecode-tui/route_approvals_state_helpers.go index 486651de..13f80406 100644 --- a/cmd/runecode-tui/route_approvals_state_helpers.go +++ b/cmd/runecode-tui/route_approvals_state_helpers.go @@ -131,18 +131,18 @@ func approvalResolveBlockedReason(summary brokerapi.ApprovalSummary, detail brok func approvalBoundScopeCue(summary brokerapi.ApprovalSummary) string { parts := []string{} if run := strings.TrimSpace(summary.BoundScope.RunID); run != "" { - parts = append(parts, "run="+run) + parts = append(parts, "run "+run) } if stage := strings.TrimSpace(summary.BoundScope.StageID); stage != "" { - parts = append(parts, "stage="+stage) + parts = append(parts, "stage "+stage) } if action := strings.TrimSpace(summary.BoundScope.ActionKind); action != "" { - parts = append(parts, "action="+action) + parts = append(parts, humanizeExecutionToken(action)) } if len(parts) == 0 { - return "broker_scope=n/a" + return "" } - return strings.Join(parts, ",") + return strings.Join(parts, " • ") } func approvalExactObjectAction(summary brokerapi.ApprovalSummary, detail brokerapi.ApprovalDetail) string { diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index 56f758f5..e232c599 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -85,9 +85,18 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { if !strings.Contains(view, "Approval review") { t.Fatalf("expected approval overview card in main view, got %q", view) } + mustContainAll(t, view, + "Decision workbench", + "Approval queue", + ) if strings.Contains(view, "Summary: approval=ap-1 state=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) { @@ -143,11 +152,11 @@ func TestApprovalsRouteSupportsTypedResolveFlowPath(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "Evidence path: promotion for run-1 -> artifacts (run evidence for run-1) -> audit (run run-1)") { - 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, "verification posture (Audit) -> anchor/export actions where available") { - t.Fatalf("expected evidence trail 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'}}) From fbe531f666bf68606f7f4107b14fe240f1cef45e Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:02:00 -0500 Subject: [PATCH 20/39] Improves TUI Audit route evidence workbench clarity. Signed-off-by: Zebulon Rouse-Frantzich --- .../local_rpc_integration_linux_test.go | 2 +- cmd/runecode-tui/route_audit.go | 12 +- cmd/runecode-tui/route_audit_render.go | 223 ++++++++++++++---- cmd/runecode-tui/route_audit_status_test.go | 29 ++- .../route_runs_approvals_artifacts_test.go | 9 +- 5 files changed, 207 insertions(+), 68 deletions(-) diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 486f5958..15ceaea2 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -344,7 +344,7 @@ func assertAuditRouteSupportsDrillDown(t *testing.T, model routeModel) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, needle := range []string{"Audit posture", "Timeline paging: page=1 entries=1 has_next=no", "Verification posture:"} { + for _, needle := range []string{"Audit posture", "Timeline: page 1 • 1 records • no more pages", "Verification posture:"} { if !strings.Contains(view, needle) { t.Fatalf("audit view missing %q: %s", needle, view) } diff --git a/cmd/runecode-tui/route_audit.go b/cmd/runecode-tui/route_audit.go index 1cb9a1d4..5e9a9ed4 100644 --- a/cmd/runecode-tui/route_audit.go +++ b/cmd/runecode-tui/route_audit.go @@ -211,16 +211,10 @@ func (m auditRouteModel) View(width, height int, focus focusArea) string { body := []string{ sectionTitle("Audit") + " " + focusBadge(focus), renderAuditOverviewCard(m.verify, m.active), - renderAuditSafetyAlertStrip(m.verify), - renderAuditFinalizeSummary(m.finalize), - renderAuditAnchorActionSummary(m), - renderAuditEvidenceTrail(m.verify, m.active), - 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))), + 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.")) diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index ec6c890a..0faf587f 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -13,12 +13,12 @@ func renderAuditSummary(verify *brokerapi.AuditVerificationGetResponse) string { } s := verify.Summary anchorLabel := renderAnchoringPostureLabel(s.AnchoringStatus) - return fmt.Sprintf("Verification posture: integrity=%s %s anchoring=%s (%s) storage=%s lifecycle=%s %s findings=%d", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.StoragePostureStatus, s.SegmentLifecycleStatus, boolBadge("degraded", s.CurrentlyDegraded), s.FindingCount) + return fmt.Sprintf("Verification posture: integrity=%s %s • anchoring=%s (%s) • storage=%s • lifecycle=%s • %s • findings=%d", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.StoragePostureStatus, s.SegmentLifecycleStatus, boolBadge("degraded", s.CurrentlyDegraded), s.FindingCount) } 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 + " next=review Audit findings or anchor/export the latest verified segment" + return line + " • next review findings or anchor/export the latest verified segment" } reason := sanitizeUIText(strings.TrimSpace(finalize.FailureCode)) if reason == "" { 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 { @@ -67,7 +67,7 @@ func renderAuditFindings(verify *brokerapi.AuditVerificationGetResponse, present 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 (operator summary):" + 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, shortIdentity(digest), entry.EventType, posture, valueOrNA(reasonCodes), entry.Summary)) + "\n" + line += fmt.Sprintf("\n hard failures: %s", joinCSV(verify.Report.HardFailures)) } return line } @@ -113,11 +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 { - items = append(items, fmt.Sprintf("%s digest=%s event=%s %s refs=%d", auditRecordDisplayLabel(entry), auditRecordDigestLabel(entry), valueOrNA(entry.EventType), auditTimelinePostureLabel(entry), len(entry.LinkedReferences))) + 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("Verification: %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." @@ -336,9 +356,9 @@ func renderAuditEvidenceTrail(verify *brokerapi.AuditVerificationGetResponse, re recordLabel = "record " + shortIdentity(identity) } if verify == nil { - return fmt.Sprintf("Evidence path: workflow result -> artifacts -> audit records (%s) -> verification posture unavailable until broker verification data loads", recordLabel) + return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> verification posture unavailable until broker verification data loads", recordLabel) } - return fmt.Sprintf("Evidence path: workflow result -> artifacts -> audit records (%s) -> verification posture -> export/offline verification -> anchoring where available", recordLabel) + return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> verification posture -> export/offline verification -> anchoring", recordLabel) } func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { @@ -346,6 +366,9 @@ func auditRecordDisplayLabel(entry brokerapi.AuditTimelineViewEntry) string { if strings.TrimSpace(entry.Summary) != "" { return strings.TrimSpace(entry.Summary) } + if label := auditEventLabel(entry.EventType); label != "Audit record" { + return label + } return shortIdentity(digest) } @@ -361,7 +384,125 @@ func auditRecordDigestDetail(record brokerapi.AuditRecordDetail) string { func auditTimelinePostureLabel(entry brokerapi.AuditTimelineViewEntry) string { if entry.VerificationPosture == nil { - return "posture=n/a" + 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, ", ")) +} + +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": + if len(reasonCodes) > 0 { + return "verification 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 "posture=" + valueOrNA(strings.TrimSpace(entry.VerificationPosture.Status)) + return fmt.Sprintf("Anchoring: %s • %s", status, copyState) } diff --git a/cmd/runecode-tui/route_audit_status_test.go b/cmd/runecode-tui/route_audit_status_test.go index 55051af3..083223f0 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -23,16 +23,16 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { mustContainAll(t, view, "Verification trail", "Audit posture", - "Finalize/verify", - "status=unavailable", + "Evidence workbench", + "Finalize/verify: unavailable", "UNANCHORED_OR_DEGRADED_AUDIT", - "Timeline paging: page=1 entries=1 has_next=yes", + "Timeline: page 1 • 1 records • next page available • back 0", "anchoring=degraded (unanchored/degraded)", - "Evidence path:", - "Verification findings (operator summary):", - "code=anchor_receipt_missing severity=warning dimension=anchoring", - "degraded_reason_codes=", - "posture=degraded reasons=anchor_receipt_missing", + "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'}}) @@ -41,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) } @@ -168,15 +168,14 @@ func TestAuditRouteAnchorActionDispatchesToBrokerAndRendersSuccess(t *testing.T) "Anchor action: ok", "receipt=sha256:", "export_copy=off", - "next=use receipt for export/offline verification when available", + "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", - "next=review Audit findings or anchor/export the latest verified segment", + "Finalize/verify: ok", + "segment segment-000001", + "next review findings or anchor/export the latest verified segment", ) } diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index e232c599..a8fc722c 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -255,8 +255,13 @@ func TestAuditRouteInspectorShowsBoundedTrailAndCopyableDigest(t *testing.T) { "Trust posture: broker-linked references stay authoritative", ) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "digest=sha256:aaaaaaaaaaaa") { - t.Fatalf("expected bounded digest in timeline directory, got %q", view) + 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) + } } } From 8cc1cfac02753b1c4df764c28b32d0b08e93c51a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:09:04 -0500 Subject: [PATCH 21/39] Improves TUI Status route lifecycle and setup guidance. Signed-off-by: Zebulon Rouse-Frantzich --- .../local_rpc_integration_linux_test.go | 4 +- cmd/runecode-tui/route_audit_status_test.go | 9 +- cmd/runecode-tui/route_status.go | 11 +- .../route_status_project_substrate.go | 39 ++- cmd/runecode-tui/route_status_render.go | 245 +++++++++++++++--- cmd/runecode-tui/route_status_test.go | 54 ++-- 6 files changed, 270 insertions(+), 92 deletions(-) diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 15ceaea2..3238c9df 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -277,8 +277,8 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { status := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, recording) assertRouteOutputContainsAll(t, status, routeStatus, "Runtime/audit readiness strip", - "Broker ready=true local_only=true", - "Protocol posture:", + "Overview:", + "Version posture:", "Project setup", ) } diff --git a/cmd/runecode-tui/route_audit_status_test.go b/cmd/runecode-tui/route_audit_status_test.go index 083223f0..917a1532 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -100,11 +100,10 @@ func TestStatusRouteExplainsDegradedSubsystemPosture(t *testing.T) { "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", + "Overview: • broker reachable • normal work available • local broker mode", + "Broker health: attention needed for runtime verification material is unavailable.", + "Version posture: RuneCode 0.1.0", + "protocol bundle 0.9.0", ) } diff --git a/cmd/runecode-tui/route_status.go b/cmd/runecode-tui/route_status.go index b23ada4e..2527b3e6 100644 --- a/cmd/runecode-tui/route_status.go +++ b/cmd/runecode-tui/route_status.go @@ -264,16 +264,13 @@ func (m statusRouteModel) renderTransientStatusCard() string { 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), diff --git a/cmd/runecode-tui/route_status_project_substrate.go b/cmd/runecode-tui/route_status_project_substrate.go index f6bf0cc7..53fe7c1e 100644 --- a/cmd/runecode-tui/route_status_project_substrate.go +++ b/cmd/runecode-tui/route_status_project_substrate.go @@ -173,7 +173,7 @@ func projectSubstrateAdoptionCard(adoption projectsubstrate.AdoptionResult, post return &stateCardSpec{ State: routeLoadStateReady, Title: "Compatible adoption", - Message: fmt.Sprintf("Broker recognition status=%s. Adoption is read-only and does not mutate the repository. This is read-only recognition only.", valueOrNA(adoption.Status)), + 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", @@ -189,19 +189,19 @@ func projectSubstrateUpgradePreviewCard(preview projectsubstrate.UpgradePreview, } func previewCard(title, identityLabel, status, handle, mutation, reason string, followUp []string, next string) *stateCardSpec { - message := fmt.Sprintf("Preview status=%s. Mutation=%s. %s=%s.", status, mutation, identityLabel, handle) + 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 status=%s. Mutation=%s. No %s is currently available.", status, mutation, strings.ToLower(identityLabel)) + 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: fmt.Sprintf("Apply status=%s. Mutation occurred through the broker-owned init flow. Handle=%s.", valueOrNA(result.Status), projectSubstrateHandleDisplay(preview.PreviewToken)), Reason: fmt.Sprintf("Preview status=%s • %s", valueOrNA(preview.Status), projectSubstrateActionReason(result.ReasonCodes, posture)), NextAction: "RuneCode is reloading broker posture now to validate the resulting project setup before normal work resumes."} + 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: fmt.Sprintf("Apply status=%s. Mutation occurred through the broker-owned upgrade flow. Digest=%s.", valueOrNA(result.Status), projectSubstrateHandleDisplay(preview.PreviewDigest)), Reason: fmt.Sprintf("Preview status=%s • %s", valueOrNA(preview.Status), projectSubstrateActionReason(result.ReasonCodes, posture)), NextAction: "RuneCode is reloading broker posture now to validate the resulting project setup before normal work resumes."} + 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 { @@ -226,12 +226,12 @@ func projectSubstrateValidationFailureCard(posture brokerapi.ProjectSubstratePos func projectSubstratePreviewMutationLine(handle, status string) string { if strings.TrimSpace(handle) == "" { - return "no mutation; preview is unavailable until the broker publishes a 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 mutation yet; apply is available if you explicitly choose it" + return "No changes have been made yet; apply is available if you explicitly choose it" } - return "no mutation yet; preview is advisory until you explicitly apply" + return "No changes have been made yet; this preview stays advisory until you explicitly apply it" } func previewCardState(handle, status string) routeLoadState { @@ -248,13 +248,13 @@ func projectSubstrateReasonWithFollowUp(reason string, followUp []string) string if len(followUp) == 0 { return reason } - return reason + " • follow_up=" + joinCSV(followUp) + return reason + " • next broker steps=" + joinCSV(followUp) } func projectSubstrateActionReason(reasons []string, posture brokerapi.ProjectSubstratePostureGetResponse) string { parts := []string{} if len(reasons) > 0 { - parts = append(parts, "reasons="+joinCSV(reasons)) + parts = append(parts, "broker reasons="+joinCSV(reasons)) } if blocking := strings.TrimSpace(projectSubstrateBlockingReason(posture)); blocking != "" { parts = append(parts, blocking) @@ -264,13 +264,24 @@ func projectSubstrateActionReason(reasons []string, posture brokerapi.ProjectSub func projectSubstrateBlockingReason(posture brokerapi.ProjectSubstratePostureGetResponse) string { if explanation := strings.TrimSpace(posture.BlockedExplanation); explanation != "" { - return "normal_work_blocked=" + sanitizeUIText(explanation) + return "normal work blocked: " + sanitizeUIText(explanation) } if len(posture.PostureSummary.BlockedReasonCodes) > 0 { - return "normal_work_blocked=" + joinCSV(posture.PostureSummary.BlockedReasonCodes) + return "normal work blocked: " + joinCSV(posture.PostureSummary.BlockedReasonCodes) } if len(posture.PostureSummary.ReasonCodes) > 0 { - return "broker_guidance=" + joinCSV(posture.PostureSummary.ReasonCodes) + return "broker guidance: " + joinCSV(posture.PostureSummary.ReasonCodes) } - return "normal_work_blocked=not_reported" + 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 index c81280eb..b23d2872 100644 --- a/cmd/runecode-tui/route_status_render.go +++ b/cmd/runecode-tui/route_status_render.go @@ -13,7 +13,7 @@ func renderLifecycleOperationCard(posture brokerapi.BrokerProductLifecyclePostur } state := routeLoadStateReady message := "Attach and normal managed operation are available." - reason := fmt.Sprintf("Broker lifecycle posture=%s attach_mode=%s", valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode)) + reason := lifecycleReasonSummary(posture) nextAction := "Normal managed work can continue; use Chat or Runs for active execution details." routeCue := "Chat or Runs" if !posture.Attachable { @@ -38,29 +38,43 @@ func renderLifecycleOperationCard(posture brokerapi.BrokerProductLifecyclePostur func renderLifecycleStatusLine(posture brokerapi.BrokerProductLifecyclePosture) string { if strings.TrimSpace(posture.SchemaID) == "" { - return "Managed-operation details: unavailable" + return "Managed-operation summary: unavailable" } - return fmt.Sprintf("Managed-operation details: instance=%s generation=%s posture=%s attach_mode=%s attachable=%t normal_operation_allowed=%t", valueOrNA(posture.ProductInstanceID), valueOrNA(posture.LifecycleGeneration), valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode), posture.Attachable, posture.NormalOperationAllowed) + 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 blocking reasons: unavailable" + return "Managed-operation blockers: unavailable" } if len(posture.BlockedReasonCodes) == 0 { - return "Managed-operation blocking reasons: none" + return "Managed-operation blockers: none reported" } - return "Managed-operation blocking reasons: " + joinCSV(posture.BlockedReasonCodes) + return "Managed-operation blockers: " + joinCSV(posture.BlockedReasonCodes) } func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { if strings.TrimSpace(posture.SchemaID) == "" { - return "Managed-operation degraded reasons: unavailable" + return "Managed-operation watchouts: unavailable" } if len(posture.DegradedReasonCodes) == 0 { - return "Managed-operation degraded reasons: none" + return "Managed-operation watchouts: none reported" } - return "Managed-operation degraded reasons: " + joinCSV(posture.DegradedReasonCodes) + return "Managed-operation watchouts: " + joinCSV(posture.DegradedReasonCodes) } func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) string { @@ -72,7 +86,7 @@ func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) str parts = append(parts, "degraded="+joinCSV(posture.DegradedReasonCodes)) } if len(parts) == 0 { - return fmt.Sprintf("Broker lifecycle posture=%s attach_mode=%s", valueOrNA(posture.LifecyclePosture), valueOrNA(posture.AttachMode)) + return fmt.Sprintf("Broker reports %s operation with %s access.", lifecyclePostureSummary(posture.LifecyclePosture), attachModeSummary(posture.AttachMode)) } return strings.Join(parts, " • ") } @@ -81,7 +95,14 @@ 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) + parts := []string{fmt.Sprintf("Backend posture: %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 { @@ -105,32 +126,38 @@ func renderStatusSafetyStrip(r brokerapi.BrokerReadiness) string { func renderReadinessDiagnostics(r brokerapi.BrokerReadiness) string { issues := []string{} if !r.RecoveryComplete { - issues = append(issues, "recovery=incomplete") + issues = append(issues, "recovery still in progress") } if !r.AppendPositionStable { - issues = append(issues, "ledger_append=unstable") + issues = append(issues, "audit ledger append is unstable") } if !r.CurrentSegmentWritable { - issues = append(issues, "current_segment=read_only_or_unavailable") + issues = append(issues, "current audit segment is not writable") } if !r.VerifierMaterialAvailable { - issues = append(issues, "verifier_material=missing") + issues = append(issues, "runtime verification material is unavailable") } if !r.DerivedIndexCaughtUp { - issues = append(issues, "derived_index=lagging") + issues = append(issues, "derived index still catching up") } if len(issues) == 0 { - return "Diagnostics: all readiness subsystems report nominal posture." + return "Broker health: ready for local work; no runtime or audit warnings reported." } - return fmt.Sprintf("Diagnostics: degraded subsystems=%s", joinCSV(issues)) + return fmt.Sprintf("Broker health: attention needed for %s.", joinCSV(issues)) } func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { summary := posture.PostureSummary if strings.TrimSpace(summary.SchemaID) == "" { - return "Project setup details: unavailable" + return "Project setup summary: unavailable" } - return fmt.Sprintf("Project setup details: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed) + parts := []string{projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)} + if summary.NormalOperationAllowed { + parts = append(parts, "Normal work is allowed") + } else { + parts = append(parts, "Normal work stays blocked until setup is fixed") + } + return "Project setup summary: " + strings.Join(parts, " • ") } func renderProjectSubstrateActionCard(spec *stateCardSpec) string { @@ -147,7 +174,7 @@ func renderProjectSubstrateStatusCard(posture brokerapi.ProjectSubstratePostureG } state := routeLoadStateReady message := "Project setup supports normal managed work." - reason := fmt.Sprintf("validation=%s compatibility=%s", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture)) + reason := projectSubstrateReasonSummary(posture) nextAction := "Review upgrade guidance if recommended; otherwise continue normal work." if !summary.NormalOperationAllowed { state = routeLoadStateBlocked @@ -165,29 +192,22 @@ func renderProjectSubstrateStatusCard(posture brokerapi.ProjectSubstratePostureG func renderProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGetResponse) string { summary := posture.PostureSummary - parts := []string{tableHeader("Guided setup/remediation flow")} - parts = append(parts, fmt.Sprintf("Inspect current posture: validation=%s compatibility=%s normal_operation_allowed=%t", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture), summary.NormalOperationAllowed)) - parts = append(parts, fmt.Sprintf("Compatible adoption (a): status=%s mutation=none; read-only recognition of compatible existing substrate", projectSubstrateStepStatus(posture.Adoption.Status))) - parts = append(parts, fmt.Sprintf("Init preview/apply (i/I): preview status=%s mutation=%s handle=%s", projectSubstrateStepStatus(posture.InitPreview.Status), projectSubstrateMutationLabel(posture.InitPreview.Status, posture.InitPreview.PreviewToken), projectSubstrateHandleDisplay(posture.InitPreview.PreviewToken))) - parts = append(parts, fmt.Sprintf("Upgrade preview/apply (u/U): preview status=%s mutation=%s digest=%s", projectSubstrateStepStatus(posture.UpgradePreview.Status), projectSubstrateMutationLabel(posture.UpgradePreview.Status, posture.UpgradePreview.PreviewDigest), projectSubstrateHandleDisplay(posture.UpgradePreview.PreviewDigest))) + 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 strings.TrimSpace(posture.InitPreview.Status) == "" { - parts = append(parts, "If init preview/apply is unavailable: reload current posture, then run init preview before any init apply.") - } - if strings.TrimSpace(posture.UpgradePreview.Status) == "" { - parts = append(parts, "If upgrade preview/apply is unavailable: reload current posture, then run upgrade preview before any upgrade apply.") - } if len(posture.RemediationGuidance) > 0 { parts = append(parts, "Broker guidance: "+joinCSV(posture.RemediationGuidance)) } if !summary.NormalOperationAllowed { - parts = append(parts, "Next action: use adopt only for read-only compatible recognition; otherwise run preview before any apply, then reload to validate resulting posture.") + 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 is allowed, but review the broker-owned upgrade preview and revalidate after any apply.") + 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; reload after any external change if you want fresh broker validation.") + 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.") @@ -208,11 +228,164 @@ func projectSubstrateReasonSummary(posture brokerapi.ProjectSubstratePostureGetR parts = append(parts, sanitizeUIText(explanation)) } if len(parts) == 0 { - return fmt.Sprintf("validation=%s compatibility=%s", valueOrNA(summary.ValidationState), valueOrNA(summary.CompatibilityPosture)) + 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 reachable") + } 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 broker mode") + } + return strings.Join(parts, " • ") +} + +func renderStatusSetupLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { + summary := posture.PostureSummary + if strings.TrimSpace(summary.SchemaID) == "" { + return "Setup posture: unavailable" + } + return fmt.Sprintf("Setup posture: %s • %s", projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)) +} + +func renderVersionPostureLine(v brokerapi.BrokerVersionInfo) string { + parts := []string{fmt.Sprintf("Version posture: RuneCode %s", valueOrNA(v.ProductVersion))} + if strings.TrimSpace(v.ProtocolBundleVersion) != "" { + parts = append(parts, fmt.Sprintf("protocol bundle %s", sanitizeUIText(v.ProtocolBundleVersion))) + } + if strings.TrimSpace(v.APIFamily) != "" || strings.TrimSpace(v.APIVersion) != "" { + parts = append(parts, fmt.Sprintf("broker API available (%s)", versionAPIShortLabel(v.APIFamily, v.APIVersion))) } 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 "supported with an 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 == "" { diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index 5114abfd..0c48d3b9 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -20,20 +20,20 @@ func TestStatusRouteRendersProjectSubstratePostureAndGuidance(t *testing.T) { mustContainAll(t, view, "Managed operation", "Attach and normal managed operation are available.", - "Managed-operation details:", - "attach_mode=full", - "attachable=true", - "normal_operation_allowed=true", - "Managed-operation blocking reasons: none", - "Managed-operation degraded reasons: none", + "Overview: • broker reachable • normal work available • local broker mode", + "Setup posture: setup validated • supported with an 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.", - "Compatible adoption (a): status=unavailable mutation=none; read-only recognition of compatible existing substrate", - "Init preview/apply (i/I): preview status=ready_for_apply mutation=apply will mutate the repository through the broker-owned flow handle=", - "Upgrade preview/apply (u/U): preview status=ready_for_apply mutation=apply will mutate the repository through the broker-owned flow digest=", - "Project setup details:", - "compatibility=supported_with_upgrade_available", - "Guided setup/remediation flow", + "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", "Keys: r reload", ) } @@ -62,11 +62,11 @@ type statusRouteActionCase struct { func statusRouteActionCases() []statusRouteActionCase { return []statusRouteActionCase{ - {key: 'a', expectedStatus: "Project setup adoption refreshed: status=compatible_existing", expectedSnippets: []string{"Compatible adoption", "Adoption is read-only and does not mutate the repository.", "read-only recognition only"}, expectedRPCCall: []string{"ProjectSubstrateAdopt"}}, - {key: 'i', expectedStatus: "Project setup init preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Init preview", "Mutation=no mutation yet; apply is available if you explicitly choose it.", "Handle=."}, expectedRPCCall: []string{"ProjectSubstrateInitPreview"}}, - {key: 'I', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Init apply", "Mutation occurred through the broker-owned init flow. Handle=.", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateInitApply"}, expectReload: true}, - {key: 'u', expectedStatus: "Project setup upgrade preview refreshed: status=ready_for_apply", expectedSnippets: []string{"Upgrade preview", "Mutation=no mutation yet; apply is available if you explicitly choose it.", "Digest=."}, expectedRPCCall: []string{"ProjectSubstrateUpgradePreview"}}, - {key: 'U', expectedStatus: "Project setup validation refreshed. Review the updated managed-operation and setup posture below.", expectedSnippets: []string{"Upgrade apply", "Mutation occurred through the broker-owned upgrade flow. Digest=.", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}, expectReload: true}, + {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{"Init apply", "applied the broker-owned project setup initialization", "Project setup validation refreshed."}, 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{"Upgrade apply", "applied the broker-owned project setup upgrade", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}, expectReload: true}, } } @@ -144,7 +144,7 @@ func TestStatusRouteProjectSubstratePreviewFailureGuidesRetry(t *testing.T) { "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", + "normal work blocked: project substrate posture blocks execution while validation is incompatible", ) } @@ -280,12 +280,10 @@ func TestStatusRouteRendersDiagnosticsOnlyAttachGuidanceWhenNormalOperationBlock mustContainAll(t, view, "Managed operation", "You can attach for diagnostics and remediation only; managed work stays blocked.", - "attach_mode=diagnostics_only", - "posture=blocked", - "attachable=true", - "normal_operation_allowed=false", - "Managed-operation blocking reasons: project_substrate_unsupported_too_new", - "Managed-operation degraded reasons: project_substrate_upgrade_available", + "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", ) } @@ -341,10 +339,10 @@ func TestStatusRouteRendersBlockedProjectSubstrateGuidance(t *testing.T) { mustContainAll(t, view, "Project setup", "Project setup needs attention before managed work can continue.", - "Project setup details:", - "validation=missing", - "compatibility=missing", - "normal_operation_allowed=false", + "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", ) From 3dde7941925139c23bbb70eb08deed914a516c8f Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:33:42 -0500 Subject: [PATCH 22/39] Improves TUI Artifacts route evidence workspace clarity. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_artifacts.go | 7 +- cmd/runecode-tui/route_artifacts_detail.go | 88 ++++++++++++++++--- .../route_runs_approvals_artifacts_test.go | 17 +++- cmd/runecode-tui/snapshot_bundles.go | 5 +- cmd/runecode-tui/snapshot_scenarios.go | 30 +++++++ 5 files changed, 123 insertions(+), 24 deletions(-) diff --git a/cmd/runecode-tui/route_artifacts.go b/cmd/runecode-tui/route_artifacts.go index 340566b6..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" @@ -157,14 +156,12 @@ func (m artifactsRouteModel) View(width, height int, focus focusArea) string { sectionTitle("Artifacts") + " " + focusBadge(focus), renderArtifactOverviewCard(m.active, artifactClassFilters[m.classIndex]), renderArtifactEvidenceTrail(m.active), - fmt.Sprintf("Filter: evidence class=%q", artifactClassFilters[m.classIndex]), - renderModeSwitchTabs([]string{string(presentationRendered), string(presentationRaw), string(presentationStructured)}, string(normalizePresentationMode(m.presentation))), + 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("Keys: j/k move, enter detail, [/] filter, m detail view, v mode, r reload")) return compactLines(body...) } @@ -195,8 +192,6 @@ func (m artifactsRouteModel) ShellSurface(ctx routeShellContext) routeSurface { 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(), diff --git a/cmd/runecode-tui/route_artifacts_detail.go b/cmd/runecode-tui/route_artifacts_detail.go index 4469995d..2210d209 100644 --- a/cmd/runecode-tui/route_artifacts_detail.go +++ b/cmd/runecode-tui/route_artifacts_detail.go @@ -20,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 } @@ -28,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 %s class=%s bytes=%d %s", artifactDisplayLabel(item), shortIdentity(item.Reference.Digest), item.Reference.DataClass, item.Reference.SizeBytes, artifactRunLabel(item.RunID))) + 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." @@ -49,7 +61,7 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art 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("Evidence trail: workflow result/run %s -> artifact %s -> audit records -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring context", valueOrNA(a.RunID), artifactDisplayLabel(a)), fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), @@ -237,7 +249,7 @@ func (m *artifactsRouteModel) syncDetailDocument() { content := compactLines( fmt.Sprintf("Evidence label: %s", artifactDisplayLabel(a)), fmt.Sprintf("Data class: %s", a.Reference.DataClass), - fmt.Sprintf("Evidence trail: workflow result/run %s -> artifact %s -> audit records -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a)), + fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring context", valueOrNA(a.RunID), artifactDisplayLabel(a)), fmt.Sprintf("Primary digest display: %s (copy raw digest below)", shortIdentity(a.Reference.Digest)), fmt.Sprintf("Typed detail mode: %s (metadata remains control-plane truth)", mode), fmt.Sprintf("Presentation mode: %s", presentation), @@ -251,26 +263,26 @@ func (m *artifactsRouteModel) syncDetailDocument() { func renderArtifactOverviewCard(head *brokerapi.LocalArtifactHeadResponse, classFilter string) string { if head == nil { - message := "Select an artifact to inspect the evidence trail from workflow result to audit verification." + message := "Select evidence to review what the run produced before you continue to Audit." if strings.TrimSpace(classFilter) != "" { - message = fmt.Sprintf("Select an artifact in class %q to inspect the evidence trail from workflow result to audit verification.", 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 trail", Message: message, Reason: "No artifact detail is loaded yet.", NextAction: "Choose an artifact, then continue to Audit for verification posture and anchoring.", ShortcutCue: "enter", RouteCue: "Artifacts → Audit"}) + 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 verification posture and anchoring.", ShortcutCue: "enter", RouteCue: "Artifacts → Audit"}) } a := head.Artifact - return renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Evidence trail", Message: fmt.Sprintf("%s is ready for inspection.", artifactDisplayLabel(a)), Reason: fmt.Sprintf("This artifact belongs to run %s and carries broker metadata plus copyable raw digests.", valueOrNA(a.RunID)), NextAction: "Inspect content here, then open Audit to review verification posture, export, or anchoring actions.", ShortcutCue: "enter / m", RouteCue: "Audit"}) + return renderStateCardSpec(stateCardSpec{State: routeLoadStateReady, Title: "Evidence workspace", Message: fmt.Sprintf("%s is ready to inspect.", artifactDisplayLabel(a)), Reason: fmt.Sprintf("Artifact class %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 verification posture, export, or anchoring actions.", ShortcutCue: "enter / m", RouteCue: "Audit"}) } func renderArtifactEvidenceTrail(head *brokerapi.LocalArtifactHeadResponse) string { if head == nil { - return "Evidence path: workflow result -> artifact -> audit record -> verification posture -> export/offline verification or anchoring where available" + return "Evidence path: run result → artifact evidence → Audit verification and anchoring." } a := head.Artifact - return fmt.Sprintf("Evidence path: workflow result/run %s -> artifact %s (%s) -> Audit -> verification posture -> export/offline verification or anchoring where available", valueOrNA(a.RunID), artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)) + return fmt.Sprintf("Evidence path: run %s → %s → Audit for verification posture and anchoring.", valueOrNA(a.RunID), artifactDisplayLabel(a)) } func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { - class := strings.TrimSpace(fmt.Sprintf("%v", item.Reference.DataClass)) + class := artifactClassLabel(item.Reference.DataClass) run := strings.TrimSpace(item.RunID) if class != "" && run != "" { return fmt.Sprintf("%s for %s", class, run) @@ -281,10 +293,60 @@ func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { return shortIdentity(item.Reference.Digest) } -func artifactRunLabel(runID string) string { +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 "run=" + runID + 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_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index a8fc722c..c9de0949 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -117,16 +117,27 @@ func TestArtifactsRouteUsesTypedReadAndInspectableModes(t *testing.T) { "Local actions: jump:runs | jump:audit | copy:digest | copy:provenance_receipt | copy:artifact_preview", "Copy actions: artifact digest | provenance receipt | artifact preview", "Evidence label: diffs for run-1", - "Evidence trail: workflow result/run run-1 -> artifact diffs for run-1 -> audit records -> verifi", + "Evidence trail: run run-1 -> artifact diffs for run-1 -> Audit for verification posture and anch", "Primary digest display: sha256:bbbbbbbbbbbb (copy raw digest below)", "Typed detail mode:", "Inspectable content is supplemental evidence, not authoritative run/approval truth.", "diff preview (secrets redacted):", "token=[REDACTED]", ) + mustContainAll(t, view, + "Evidence workspace", + "Evidence path: run run-1 → diffs for run-1 → Audit for verification posture and anchoring.", + "Filter: all artifact classes", + "diffs for run-1 sha256:bbbbbbbbbbbb • 128 bytes plain text preview", + ) if strings.Contains(view, "Summary: artifact=diffs for run-1") { 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}) @@ -354,7 +365,7 @@ func TestArtifactsReloadKeepsSelectedDetailAligned(t *testing.T) { updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - if !strings.Contains(view, "> build_logs for run-2 sha256:cccccccccccc") { + 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}) @@ -430,7 +441,7 @@ 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, "> diffs for run-1 sha256:bbbbbbbbbbbb") { + 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=diffs for run-1") { diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index 2fd2cd24..210f9d21 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -54,20 +54,21 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { "runs-active-detail", "approvals-pending-detail", "action-center-triage", + "artifacts-evidence-detail", "audit-degraded-detail", "status-ready-overview", "model-providers-credential-needed", "git-setup-identity-needed", "git-remote-approval-ready", }, - Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, + Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeArtifacts, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, }, nil case snapshotBundleDashboard: return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded"}, Routes: []routeID{routeDashboard}}, nil case snapshotBundleActionCenter: return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil case snapshotBundleRuns: - return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail"}, Routes: []routeID{routeRuns}}, nil + return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail", "artifacts-evidence-detail"}, Routes: []routeID{routeRuns, routeArtifacts}}, nil case snapshotBundleApprovals: return snapshotBundleSpec{Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail"}, Routes: []routeID{routeApprovals}}, nil case snapshotBundleAudit: diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index c6efc215..93ded67b 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -31,6 +31,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildRunsSnapshot(), buildApprovalsSnapshot(), buildActionCenterSnapshot(), + buildArtifactsSnapshot(), buildAuditSnapshot(), buildStatusSnapshot(), buildProviderSetupSnapshot(), @@ -231,6 +232,35 @@ func buildApprovalsSnapshot() snapshotScenarioState { return snapshotScenarioState{Name: "approvals-pending-detail", RouteID: routeApprovals, Surface: approvals, Sessions: defaultSnapshotSessions(), Watch: watch, Theme: themePresetDark, Focus: focusContent} } +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 buildAuditSnapshot() snapshotScenarioState { watch := snapshotWatchActionCenter() verify := &brokerapi.AuditVerificationGetResponse{Summary: trustpolicy.DerivedRunAuditVerificationSummary{IntegrityStatus: "failed", AnchoringStatus: "degraded", CurrentlyDegraded: true, HardFailures: []string{"anchor_receipt_invalid"}}} From 882afbb9afced4e53915d527f70adae0e708f1c6 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:36:08 -0500 Subject: [PATCH 23/39] Improves TUI Model Providers setup guidance. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_provider_setup.go | 58 ++++++++++++------- cmd/runecode-tui/route_provider_setup_test.go | 11 ++-- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/cmd/runecode-tui/route_provider_setup.go b/cmd/runecode-tui/route_provider_setup.go index e37c61b9..2aca30a2 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,45 +212,38 @@ 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), renderStateCardSpec(providerSetupStateCard(m, current)), - "Credential safety: raw secrets are masked locally, submitted through trusted secret ingress, and never displayed in ordinary route output.", - fmt.Sprintf("Provider: %s", valueOrNA(m.selected.DisplayLabel)), - fmt.Sprintf("Readiness: %s", valueOrNA(current.ReadinessPosture.EffectiveReadiness)), - fmt.Sprintf("Credential setup: %s", providerCredentialSetupState(current, m.entryActive)), - fmt.Sprintf("Masked credential input: %s", masked), - fmt.Sprintf("Profiles discovered: %d", len(m.profiles)), - muted(fmt.Sprintf("Structured/raw detail can show provider_family=%s endpoint=%s%s profile=%s auth_modes=[%s].", valueOrNA(m.selected.ProviderFamily), valueOrNA(m.selected.CanonicalHost), valueOrNA(m.selected.CanonicalPathPrefix), valueOrNA(current.ProviderProfileID), supportedAuthModes)), + fmt.Sprintf("Selected provider: %s", valueOrNA(m.selected.DisplayLabel)), + providerSetupSummary(current, m.entryActive, len(m.profiles)), + providerMaskedEntryLine(masked, m.entryActive), + "Credential safety: entry stays masked locally, moves through trusted secret ingress, and never appears in ordinary route output.", 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 := "Model provider setup is ready for a credential." - next := "Press s to start trusted secret ingress." + message := fmt.Sprintf("%s is selected for broker-managed credential setup.", providerLabel) + next := "Press s to add a credential through trusted secret ingress." if strings.TrimSpace(current.ProviderProfileID) != "" && strings.EqualFold(strings.TrimSpace(current.ReadinessPosture.CredentialState), "present") { state = routeLoadStateReady - message = "A broker-managed model provider credential is present." + message = fmt.Sprintf("%s already has a stored broker-managed credential.", providerLabel) next = "Refresh readiness or continue with Chat when workflow execution needs a model." } if m.entryActive { state = routeLoadStateWaiting - message = "Secret entry is active and masked." + 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 uses broker-owned direct-credential setup and trusted secret ingress.", NextAction: next, ShortcutCue: "s setup • f provider • r refresh", EvidenceCue: "broker provider profiles and secret-ingress session"} + return stateCardSpec{State: state, Title: "Provider setup", Message: message, Reason: "RuneCode keeps direct credentials in broker-owned storage and accepts entry only through trusted secret ingress.", NextAction: next, ShortcutCue: "s setup • f provider • r refresh", EvidenceCue: "broker provider profiles and secret-ingress session"} } func providerCredentialSetupState(profile brokerapi.ProviderProfile, entryActive bool) string { if entryActive { - return "masked entry active" + return "credential entry in progress" } if strings.EqualFold(strings.TrimSpace(profile.ReadinessPosture.CredentialState), "present") { return "credential stored" @@ -258,6 +251,29 @@ func providerCredentialSetupState(profile brokerapi.ProviderProfile, entryActive return "credential needed" } +func providerSetupSummary(profile brokerapi.ProviderProfile, entryActive bool, profileCount int) string { + return fmt.Sprintf( + "Current setup: %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 44bd0e5e..50c87594 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, "Credential setup: credential stored") { - 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, "Structured/raw detail can show provider_family=openai_compatible") { - t.Fatalf("expected compatibility posture in provider view, got %q", view) + if !strings.Contains(view, "Current setup: 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"}) } From f3401eba161fd599717e795274474fba39a1090c Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:38:47 -0500 Subject: [PATCH 24/39] Improves TUI Git Setup readiness guidance. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_git_setup.go | 80 +++++++++++++----------- cmd/runecode-tui/route_git_setup_test.go | 57 +++++++++++++++++ 2 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 cmd/runecode-tui/route_git_setup_test.go diff --git a/cmd/runecode-tui/route_git_setup.go b/cmd/runecode-tui/route_git_setup.go index 5e2d98e5..b5cb9a80 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,68 +186,76 @@ 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), renderStateCardSpec(gitSetupStateCard(account, auth, profiles)), - fmt.Sprintf("Provider account: %s", gitProviderAccountSummary(account)), - fmt.Sprintf("Authentication: %s", gitAuthSummary(auth)), + fmt.Sprintf("Provider link: %s", gitProviderAccountSummary(account, auth)), fmt.Sprintf("Commit identity: %s", gitIdentitySummary(profiles, control.DefaultIdentityProfileID)), - fmt.Sprintf("Policy safety: %s", gitPolicySafetySummary(m.data.PolicySurface)), - muted(fmt.Sprintf("Structured/raw detail can show bootstrap_mode=%s headless=%t token_fallback=%t profiles=%s last_view=%s recent_repositories=%d.", valueOrNA(auth.BootstrapMode), auth.HeadlessBootstrapSupported, auth.InteractiveTokenFallbackSupport, profileSummary, valueOrNA(control.LastSetupView), len(control.RecentRepositories))), + fmt.Sprintf("Review safety: %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 := "Git account setup is not linked yet." - next := "Press a for browser auth or d for device-code auth." + 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 refresh if provider state changed." + next = "Continue to review flows, or reload here if provider state changed." } else if account.Linked { - message = "Git account is linked; commit identity needs setup." - next = "Press i to upsert the default commit identity profile." + 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: "Remote mutation stays broker-gated and artifact-managed; this route only prepares account and identity state.", NextAction: next, ShortcutCue: "a browser • d device • i identity", EvidenceCue: "broker git setup profile"} + return stateCardSpec{State: state, Title: "Git setup", Message: message, Reason: "This route prepares the broker-owned account and identity state used by artifact-managed review flows; remote mutation remains broker-gated.", NextAction: next, ShortcutCue: "browser auth • device auth • identity upsert", EvidenceCue: "broker git setup posture"} } -func gitProviderAccountSummary(account brokerapi.GitProviderAccountState) string { +func gitProviderAccountSummary(account brokerapi.GitProviderAccountState, auth brokerapi.GitAuthPostureState) string { if account.Linked { - return fmt.Sprintf("%s linked as %s", valueOrNA(account.Provider), valueOrNA(account.AccountUsername)) + return fmt.Sprintf("%s is linked as %s.", valueOrNA(account.Provider), valueOrNA(account.AccountUsername)) } - return fmt.Sprintf("%s not linked", valueOrNA(account.Provider)) -} - -func gitAuthSummary(auth brokerapi.GitAuthPostureState) string { - if strings.TrimSpace(auth.AuthStatus) == "" { - return "not started" + if strings.TrimSpace(auth.AuthStatus) == "pending" { + return fmt.Sprintf("%s sign-in is in progress.", valueOrNA(account.Provider)) } - return fmt.Sprintf("%s via %s", valueOrNA(auth.AuthStatus), valueOrNA(auth.BootstrapMode)) + 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 identity profile yet" + return "No broker-managed commit identity is ready yet." + } + if strings.TrimSpace(defaultID) != "" { + return fmt.Sprintf("Broker-managed identity is ready with default profile %s.", valueOrNA(defaultID)) } - return fmt.Sprintf("%d profile(s), default %s", len(profiles), valueOrNA(defaultID)) + return fmt.Sprintf("%d broker-managed identity profile(s) are available.", len(profiles)) } func gitPolicySafetySummary(policy brokerapi.GitPolicySurfaceState) string { if policy.ArtifactManagedOnly && !policy.DirectMutationSupport { - return "artifact-managed review only; direct mutation disabled" + 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 fmt.Sprintf("artifact_managed_only=%t direct_mutation=%t", policy.ArtifactManagedOnly, policy.DirectMutationSupport) + return "Move on to review flows, and reload here if provider state changes." } func (m gitSetupRouteModel) ShellSurface(ctx routeShellContext) routeSurface { 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..899e09fe --- /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 safety:", + "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) + } + } +} From bf2aa859d67bd7928bf18034348f1d65088e712a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 19:41:10 -0500 Subject: [PATCH 25/39] Improves TUI Git Remote execution guidance. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_git_remote_mutation.go | 92 ++++++++++--------- .../route_git_remote_mutation_test.go | 17 +++- 2 files changed, 62 insertions(+), 47 deletions(-) diff --git a/cmd/runecode-tui/route_git_remote_mutation.go b/cmd/runecode-tui/route_git_remote_mutation.go index 8214ad6d..859bfd0c 100644 --- a/cmd/runecode-tui/route_git_remote_mutation.go +++ b/cmd/runecode-tui/route_git_remote_mutation.go @@ -6,7 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/runecode-ai/runecode/internal/brokerapi" - "github.com/runecode-ai/runecode/internal/trustpolicy" ) type gitRemoteMutationLoadedMsg struct { @@ -108,7 +107,7 @@ func (m gitRemoteMutationRouteModel) handleLoaded(msg gitRemoteMutationLoadedMsg m.errText = "" 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 } @@ -122,7 +121,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 +134,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 @@ -169,37 +168,24 @@ 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), renderStateCardSpec(gitRemoteStateCard(m.prepared, m.providerAuthLeaseID)), fmt.Sprintf("Prepared change: %s", gitRemotePreparedSummary(summary)), - fmt.Sprintf("Target: repository=%s refs=%s", valueOrNA(summary.RepositoryIdentity), joinCSV(summary.TargetRefs)), - fmt.Sprintf("Approval: %s", gitRemoteApprovalSummary(m.prepared)), + fmt.Sprintf("Target: %s", gitRemoteTargetSummary(summary)), + fmt.Sprintf("Approval state: %s", gitRemoteApprovalSummary(m.prepared)), fmt.Sprintf("Credential lease: %s", gitRemoteLeaseSummary(m.providerAuthLeaseID)), fmt.Sprintf("Execution: %s", gitRemoteExecutionSummary(m.prepared)), + fmt.Sprintf("Next safe action: %s", gitRemoteNextSafeAction(m.prepared, m.providerAuthLeaseID)), "Safety: execute remains fail-closed until approval bindings and a broker-issued provider credential lease match this prepared mutation.", - muted(fmt.Sprintf("Structured/raw detail can show prepared=%s typed_request=%s action=%s policy=%s approval_request=%s approval_decision=%s tree=%s patches=%s.", valueOrNA(m.prepared.PreparedMutationID), requestHash, actionHash, decisionHash, approvalReqHash, approvalDecisionHash, digestIdentityOrNA(summary.ExpectedResultTreeHash), joinCSV(patches))), m.status, - keyHint("Route keys: r reload prepared state, e execute prepared mutation"), ) } func gitRemoteStateCard(prepared brokerapi.GitRemoteMutationPreparedState, leaseID string) stateCardSpec { state := routeLoadStateWaiting message := "Prepared remote mutation is ready for review." - next := "Review the target, approval, and lease state before executing." + next := "Confirm the prepared change, target, and approval evidence before executing." if strings.TrimSpace(prepared.RequiredApprovalID) != "" && prepared.RequiredApprovalDecisionHash == nil { state = routeLoadStateApprovalRequired message = "Remote execution is waiting for approval." @@ -228,21 +214,30 @@ func gitRemotePreparedSummary(summary brokerapi.GitRemoteMutationDerivedSummary) return "review prepared repository mutation" } +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 "no approval binding reported" + return "approval evidence is missing, so execution stays blocked" } if prepared.RequiredApprovalDecisionHash != nil { - return fmt.Sprintf("approval %s has decision evidence", prepared.RequiredApprovalID) + return "bound approval evidence is present for execution review" } - return fmt.Sprintf("approval %s required before execution", prepared.RequiredApprovalID) + return "waiting for approval decision evidence before execution" } func gitRemoteLeaseSummary(leaseID string) string { if strings.TrimSpace(leaseID) == "" { - return "not issued yet" + return "not issued yet; RuneCode will request one when execution starts" } - return "issued for this prepared mutation" + return "ready for this prepared mutation" } func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { @@ -250,9 +245,35 @@ func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState if state == "" { state = strings.TrimSpace(prepared.LifecycleState) } + switch strings.ToLower(state) { + case "", "prepared", "not_started": + return "not started; broker checks still gate execution" + case "completed", "complete", "succeeded", "success": + return "completed" + } + if strings.Contains(strings.ToLower(state), "fail") { + return "failed; review broker 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 strings.TrimSpace(prepared.RequiredApprovalID) == "" || prepared.RequiredApprovalRequestHash == nil || prepared.RequiredApprovalDecisionHash == nil { + 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 (m gitRemoteMutationRouteModel) ShellSurface(ctx routeShellContext) routeSurface { mainWidth := routeRegionWidth(ctx.Regions.Main, ctx.Width) mainHeight := routeRegionHeight(ctx.Regions.Main, ctx.Height) @@ -336,22 +357,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 831ef7c2..1f01b117 100644 --- a/cmd/runecode-tui/route_git_remote_mutation_test.go +++ b/cmd/runecode-tui/route_git_remote_mutation_test.go @@ -20,13 +20,26 @@ func TestGitRemoteMutationRouteLoadsPreparedReviewState(t *testing.T) { "Git Remote Mutation", "Guarded remote review", "Prepared change:", - "Approval:", + "Target:", + "Approval state:", + "Credential lease:", + "Next safe action:", "Safety: execute remains fail-closed", } { 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"}) From 1d35087648d5c5b013893eb45e89306da0fe577a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 20:10:35 -0500 Subject: [PATCH 26/39] Updates from an additional pass over the TUI. Updates TUI tests to match. Signed-off-by: Zebulon Rouse-Frantzich --- .../local_rpc_integration_linux_test.go | 10 +- cmd/runecode-tui/route_action_center.go | 26 +-- .../route_action_center_render.go | 22 ++- cmd/runecode-tui/route_audit_render.go | 146 ---------------- .../route_audit_timeline_labels.go | 78 +++++++++ .../route_audit_workbench_helpers.go | 81 +++++++++ cmd/runecode-tui/route_chat_rendering.go | 3 +- cmd/runecode-tui/route_dashboard_helpers.go | 144 ---------------- cmd/runecode-tui/route_dashboard_metrics.go | 146 ++++++++++++++++ cmd/runecode-tui/route_runs.go | 162 ------------------ cmd/runecode-tui/route_runs_detail.go | 155 ----------------- cmd/runecode-tui/route_runs_evidence.go | 141 +++++++++++++++ cmd/runecode-tui/route_runs_render.go | 160 +++++++++++++++++ tools/tuisnapshotci/main.go | 3 +- tools/tuisnapshotci/validate.go | 2 +- 15 files changed, 647 insertions(+), 632 deletions(-) create mode 100644 cmd/runecode-tui/route_audit_timeline_labels.go create mode 100644 cmd/runecode-tui/route_audit_workbench_helpers.go create mode 100644 cmd/runecode-tui/route_dashboard_metrics.go create mode 100644 cmd/runecode-tui/route_runs_evidence.go create mode 100644 cmd/runecode-tui/route_runs_render.go diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 3238c9df..5f83336b 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -252,7 +252,8 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { t.Helper() runs := newRunsRouteModel(routeDefinition{ID: routeRuns, Label: "Runs"}, recording) assertRouteOutputContainsAll(t, runs, routeRuns, - "Backend: unknown", + "Run overview", + "Safety and evidence", ) assertRouteInspectorContainsAll(t, runs, routeRuns, "Workflow operation:", @@ -261,7 +262,8 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { approvals := newApprovalsRouteModel(routeDefinition{ID: routeApprovals, Label: "Approvals"}, recording) assertRouteOutputContainsAll(t, approvals, routeApprovals, - "Approval posture", + "Approval review", + "Decision workbench", ) assertRouteInspectorContainsAll(t, approvals, routeApprovals, "Why this approval exists:", @@ -349,8 +351,8 @@ func assertAuditRouteSupportsDrillDown(t *testing.T, model routeModel) { t.Fatalf("audit view missing %q: %s", needle, view) } } - if !strings.Contains(view, "Verification findings") { - 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 { diff --git a/cmd/runecode-tui/route_action_center.go b/cmd/runecode-tui/route_action_center.go index 25dd790c..be2e0fb5 100644 --- a/cmd/runecode-tui/route_action_center.go +++ b/cmd/runecode-tui/route_action_center.go @@ -174,19 +174,19 @@ func (m actionCenterRouteModel) View(width, height int, focus focusArea) string vm := m.snapshot() return compactLines( sectionTitle("Action Center")+" "+focusBadge(focus), - 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"), + 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"), ) } diff --git a/cmd/runecode-tui/route_action_center_render.go b/cmd/runecode-tui/route_action_center_render.go index e9bd07c4..a85a7e89 100644 --- a/cmd/runecode-tui/route_action_center_render.go +++ b/cmd/runecode-tui/route_action_center_render.go @@ -94,13 +94,25 @@ func actionCenterShortReason(reason string) string { if reason == "" || strings.EqualFold(reason, "n/a") { return "" } - for _, sep := range []string{";", "."} { - if idx := strings.Index(reason, sep); idx > 0 { - reason = reason[:idx] - break + 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 strings.TrimSpace(reason) + return base } func actionCenterContinueCue(target string) string { diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index 0faf587f..2fa662d6 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -360,149 +360,3 @@ func renderAuditEvidenceTrail(verify *brokerapi.AuditVerificationGetResponse, re } return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> verification posture -> export/offline verification -> anchoring", recordLabel) } - -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, ", ")) -} - -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": - if len(reasonCodes) > 0 { - return "verification 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_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_rendering.go b/cmd/runecode-tui/route_chat_rendering.go index 2eda33c5..077a6969 100644 --- a/cmd/runecode-tui/route_chat_rendering.go +++ b/cmd/runecode-tui/route_chat_rendering.go @@ -96,6 +96,7 @@ func renderActiveSessionSummaryBlock(detail *brokerapi.SessionDetail, sessionCou } 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, "— "))) @@ -160,7 +161,7 @@ func flattenRelatedLinks(links brokerapi.SessionTranscriptLinks) string { func renderComposer(on bool, draft string, textareaView string) string { if !on { - return muted("Composer closed. Press c to continue the conversation.") + return muted("Composer closed. Press c to continue the conversation. Composer is idle.") } if strings.TrimSpace(textareaView) != "" { return compactLines("Compose draft:", textareaView) diff --git a/cmd/runecode-tui/route_dashboard_helpers.go b/cmd/runecode-tui/route_dashboard_helpers.go index 5983ac22..06dc8780 100644 --- a/cmd/runecode-tui/route_dashboard_helpers.go +++ b/cmd/runecode-tui/route_dashboard_helpers.go @@ -358,147 +358,3 @@ func renderDashboardLiveActivitySummary(live dashboardLiveActivity) string { } return "Live updates are quiet; RuneCode will show broker-owned activity here when work changes." } - -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 - } - if !posture.PostureSummary.NormalOperationAllowed { - return true - } - return false -} - -func dashboardProjectSubstrateNeedsAttention(posture brokerapi.ProjectSubstratePostureGetResponse) bool { - if strings.TrimSpace(posture.PostureSummary.SchemaID) == "" { - return false - } - if dashboardProjectSubstrateBlocked(posture) { - return true - } - if 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_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_runs.go b/cmd/runecode-tui/route_runs.go index 93915f17..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" @@ -279,164 +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, 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 operating in an isolated environment" - 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_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index 7846c1b8..d8d7a6e3 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "sort" "strings" "github.com/runecode-ai/runecode/internal/brokerapi" @@ -390,157 +389,3 @@ func runBlockingReasonSummary(summary brokerapi.RunSummary, detail *brokerapi.Ru } return humanizeExecutionToken(reason) } - -func runEvidenceSummary(detail *brokerapi.RunDetail) string { - 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 == "" { - continue - } - labels = append(labels, "manifest "+shortIdentity(digest)) - } - for _, digest := range detail.LatestPolicyDecisionRefs { - digest = strings.TrimSpace(digest) - if digest == "" { - continue - } - 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 attested posture earned from verified post-handshake evidence" - } - 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 posture, reasons - } - reasons := make([]string, 0, len(reasonsAny)) - for _, value := range reasonsAny { - if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { - reasons = append(reasons, s) - } - } - return 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_evidence.go b/cmd/runecode-tui/route_runs_evidence.go new file mode 100644 index 00000000..f271acd9 --- /dev/null +++ b/cmd/runecode-tui/route_runs_evidence.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func runEvidenceSummary(detail *brokerapi.RunDetail) string { + 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 attested posture earned from verified post-handshake evidence" + } + 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 posture, reasons + } + reasons := make([]string, 0, len(reasonsAny)) + for _, value := range reasonsAny { + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { + reasons = append(reasons, s) + } + } + return 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..3db9cd99 --- /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 operating in an isolated environment" + 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/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index a7060cc5..5c67c368 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -27,6 +27,7 @@ var expectedScenarioNames = []string{ "runs-active-detail", "approvals-pending-detail", "action-center-triage", + "artifacts-evidence-detail", "audit-degraded-detail", "status-ready-overview", "model-providers-credential-needed", @@ -195,7 +196,7 @@ func buildSnapshotBinary(workspaceDir string, binaryPath string) error { } func generateSnapshotArtifacts(workspaceDir string, binaryPath string, outputDir string) error { - if err := runCommand(workspaceDir, binaryPath, "--snapshot-scenario", "all", "--snapshot-viewport", snapshotViewport, "--snapshot-output-dir", outputDir); err != nil { + 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 diff --git a/tools/tuisnapshotci/validate.go b/tools/tuisnapshotci/validate.go index a055c363..d2dc0ba3 100644 --- a/tools/tuisnapshotci/validate.go +++ b/tools/tuisnapshotci/validate.go @@ -96,7 +96,7 @@ func validateCoverage(coverage snapshotCoverage) error { } func expectedCoverageRoutes() []string { - routes := []string{"action-center", "approvals", "audit", "chat", "dashboard", "git-remote-mutation", "git-setup", "model-providers", "runs", "status"} + routes := []string{"action-center", "approvals", "artifacts", "audit", "chat", "dashboard", "git-remote-mutation", "git-setup", "model-providers", "runs", "status"} slices.Sort(routes) return routes } From d3537f6714ef0e08c0dd1a86ef82a1c0b725f3e9 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 21:13:15 -0500 Subject: [PATCH 27/39] Improves the footer area to be more compact and give more room for the center panes. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/help.go | 41 ++--------- cmd/runecode-tui/help_test.go | 30 ++++++-- .../shell_action_metadata_test.go | 16 ++++- cmd/runecode-tui/shell_chrome_test.go | 4 +- cmd/runecode-tui/shell_footer_view.go | 69 +------------------ cmd/runecode-tui/shell_layout.go | 8 +-- cmd/runecode-tui/shell_layout_test.go | 4 +- cmd/runecode-tui/shell_modes_quit_test.go | 8 +-- cmd/runecode-tui/shell_render_helpers.go | 4 ++ cmd/runecode-tui/shell_shortcuts_test.go | 17 +++-- cmd/runecode-tui/shell_test.go | 2 +- cmd/runecode-tui/shell_view.go | 9 +-- cmd/runecode-tui/snapshot_bundles.go | 3 +- cmd/runecode-tui/snapshot_scenarios.go | 27 +++++++- tools/tuisnapshotci/main.go | 1 + 15 files changed, 104 insertions(+), 139 deletions(-) diff --git a/cmd/runecode-tui/help.go b/cmd/runecode-tui/help.go index 7647e134..0ef06a49 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,34 +8,8 @@ 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 !paletteOpen { - view = "ctrl+p commands • " + view - } - if entries := compactActionHelpEntries(actions.helpEntries(4)); len(entries) > 0 { - view += " | Actions: " + strings.Join(entries, " · ") - } - return view -} - -func compactActionHelpEntries(entries []string) []string { - out := make([]string, 0, len(entries)) - for _, entry := range entries { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - if before, _, ok := strings.Cut(entry, " — "); ok { - entry = strings.TrimSpace(before) - } - out = append(out, entry) - } - return out + _ = keys + _ = paletteOpen + _ = actions + return "" } diff --git a/cmd/runecode-tui/help_test.go b/cmd/runecode-tui/help_test.go index 0cd91a4d..62b27c64 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.TrimSpace(help) != "" { + t.Fatalf("expected footer help removed in favor of leader overlay 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,6 +44,9 @@ 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) } @@ -36,10 +55,7 @@ func TestShellViewLeaderOverlayKeepsBaseFooterHelp(t *testing.T) { func TestHelpFooterStaysCompactWhilePreservingDiscovery(t *testing.T) { m := newShellModel() help := renderHelp(m.keys, false, m.actions) - if !strings.Contains(help, "Help:") || !strings.Contains(help, "Actions:") { - t.Fatalf("expected compact footer help with actions discovery, got %q", help) - } - if strings.Contains(help, "copy current object identity") { - t.Fatalf("expected footer actions summary not to expand into long-form descriptions, got %q", help) + if strings.TrimSpace(help) != "" { + t.Fatalf("expected footer help removed to reclaim pane height, got %q", help) } } diff --git a/cmd/runecode-tui/shell_action_metadata_test.go b/cmd/runecode-tui/shell_action_metadata_test.go index 36ef1429..89c50012 100644 --- a/cmd/runecode-tui/shell_action_metadata_test.go +++ b/cmd/runecode-tui/shell_action_metadata_test.go @@ -3,17 +3,29 @@ 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, "focus main pane") || strings.Contains(help, "custom help text") { - t.Fatalf("expected help to include action-metadata help text, got %q", help) + if strings.TrimSpace(help) != "" { + t.Fatalf("expected footer help removed, 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_chrome_test.go b/cmd/runecode-tui/shell_chrome_test.go index f7ed035b..202d2376 100644 --- a/cmd/runecode-tui/shell_chrome_test.go +++ b/cmd/runecode-tui/shell_chrome_test.go @@ -9,8 +9,8 @@ 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 calm diagnostic hint in bottom strip, got %q", v) + 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) } } diff --git a/cmd/runecode-tui/shell_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index 6930de60..ef19bc25 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -1,9 +1,6 @@ package main -import ( - "fmt" - "strings" -) +import "strings" func (m shellModel) renderBottomStrip(surface routeSurface) string { if m.emergencyQuit.pending { @@ -12,16 +9,10 @@ func (m shellModel) renderBottomStrip(surface routeSurface) string { if prompt := strings.TrimSpace(m.commandMode.RenderPrompt()); prompt != "" { return prompt } - parts := []string{"ctrl+p commands", ": command", "tab focus"} + parts := []string{"ctrl+p commands", ": command", m.keys.LeaderStart.label() + " leader", "tab focus"} if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { parts = append(parts, quit) } - if actions := strings.TrimSpace(m.renderRouteActionHints(surface)); actions != "" { - parts = append(parts, actions) - } - if diagnostic := strings.TrimSpace(m.renderBottomDiagnosticLine(surface)); diagnostic != "" && !strings.Contains(diagnostic, "Broker-owned truth stays") { - parts = append(parts, diagnostic) - } if bottom := strings.TrimSpace(surface.Regions.Bottom.Body); bottom != "" { parts = append(parts, bottom) } @@ -39,59 +30,3 @@ func (m shellModel) renderQuitDiscoverabilityHint() string { } return "Quick action: " + label + " (:quit)" } - -func (m shellModel) renderBottomActionLine(surface routeSurface) string { - parts := make([]string, 0, 3) - if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { - parts = append(parts, quit) - } - if actions := strings.TrimSpace(m.renderRouteActionHints(surface)); actions != "" { - parts = append(parts, actions) - } - if len(parts) == 0 { - return muted("ctrl+p opens command discovery") - } - return strings.Join(parts, " • ") -} - -func (m shellModel) renderRouteActionHints(surface routeSurface) string { - parts := []string{} - if len(surface.Actions.LocalActions) > 0 { - parts = append(parts, fmt.Sprintf("%d route actions", len(surface.Actions.LocalActions))) - } - if len(surface.Actions.ReferenceActions) > 0 { - parts = append(parts, fmt.Sprintf("%d linked refs", len(surface.Actions.ReferenceActions))) - } - if len(surface.Actions.CopyActions) > 0 { - parts = append(parts, fmt.Sprintf("%d copy actions", len(surface.Actions.CopyActions))) - } - if len(parts) == 0 { - return "" - } - return "Route actions: " + strings.Join(parts, " · ") -} - -func (m shellModel) renderStatusSurface(surface routeSurface) string { - status := strings.TrimSpace(surface.Regions.Status.Body) - if status == "" { - status = fmt.Sprintf("%s ready", m.routeLabel(m.currentRouteID())) - } - return "Status: " + status -} - -func (m shellModel) renderBottomDiagnosticLine(surface routeSurface) string { - parts := []string{} - if m.selectionMode { - parts = append(parts, "Selection mode on") - } - if actions := len(surface.Actions.CopyActions); actions > 0 { - parts = append(parts, fmt.Sprintf("Copy via action entry (%d)", actions)) - } - if m.commandMode.Active() { - parts = append(parts, "Command mode") - } - if len(parts) == 0 { - parts = append(parts, muted("Broker-owned truth stays in Status, inspectors, and command discovery")) - } - return strings.Join(parts, " • ") -} diff --git a/cmd/runecode-tui/shell_layout.go b/cmd/runecode-tui/shell_layout.go index 20e7b6ec..f87d2887 100644 --- a/cmd/runecode-tui/shell_layout.go +++ b/cmd/runecode-tui/shell_layout.go @@ -13,9 +13,9 @@ const ( shellBreadcrumbHeight = 0 shellHistoryHeight = 0 shellPaneSpacerHeight = 1 - shellBottomStripHeight = 4 - shellStatusHeight = 1 - shellFooterHeight = 2 + shellBottomStripHeight = 1 + shellStatusHeight = 0 + shellFooterHeight = 0 ) 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() - 1 if modeTabsVisible { paneFrameHeight-- } diff --git a/cmd/runecode-tui/shell_layout_test.go b/cmd/runecode-tui/shell_layout_test.go index 344ce9d0..6c510b3d 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() - 1 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 - 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_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index 230a9abe..8b5ef25e 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -393,8 +393,8 @@ 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 via action entry (3)") { - 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) @@ -405,8 +405,8 @@ func TestShellBottomStripSummarizesRouteActionsCalmly(t *testing.T) { m := newShellModel() m.width = 150 bottom := m.renderBottomStrip(m.activeShellSurface()) - if !strings.Contains(bottom, "Route actions:") { - t.Fatalf("expected calm route action summary, got %q", bottom) + 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 one-line route action summary, got %q", bottom) diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 75fb2994..6d723da3 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -103,6 +103,10 @@ func (m shellModel) renderLeaderWhichKey() string { 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.")) diff --git a/cmd/runecode-tui/shell_shortcuts_test.go b/cmd/runecode-tui/shell_shortcuts_test.go index dbee4ddf..3f85d154 100644 --- a/cmd/runecode-tui/shell_shortcuts_test.go +++ b/cmd/runecode-tui/shell_shortcuts_test.go @@ -9,15 +9,22 @@ 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) + if strings.TrimSpace(help) != "" { + t.Fatalf("expected footer help removed, 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) } } } diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index a8826bf4..85b822cc 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -349,7 +349,7 @@ func TestShellViewRendersShellSurfaces(t *testing.T) { m := newShellModel() m.width = 150 v := m.View() - for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "ctrl+p commands", "Status:"} { + for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "ctrl+p commands"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in view, got %q", want, v) } diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 8a810792..092d74d0 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -46,16 +46,11 @@ func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, la 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") } func (m shellModel) writeShellFooter(b *strings.Builder, viewportWidth int) { - b.WriteString(constrainShellBlock(renderHelp(m.keys, m.palette.IsOpen() || m.sessions.IsOpen(), m.actions), viewportWidth, 1)) - b.WriteString("\n") - b.WriteString(constrainShellBlock(muted("Broker-owned truth stays in Status, inspectors, and command discovery."), viewportWidth, 1)) - b.WriteString("\n") + _ = b + _ = viewportWidth } func (m shellModel) overlayBodyWithHeight(surface routeSurface, layout shellLayoutPlan, viewportHeight int) (string, int) { diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index 210f9d21..006ed8cc 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -50,6 +50,7 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", + "leader-help-root", "chat-active-session", "runs-active-detail", "approvals-pending-detail", @@ -64,7 +65,7 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeArtifacts, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, }, nil case snapshotBundleDashboard: - return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded"}, Routes: []routeID{routeDashboard}}, nil + return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "leader-help-root"}, Routes: []routeID{routeDashboard}}, nil case snapshotBundleActionCenter: return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil case snapshotBundleRuns: diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 93ded67b..095e9f66 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -27,6 +27,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildApprovalWaitingSnapshot(), buildBlockedSnapshot(), buildDegradedSnapshot(), + buildLeaderHelpSnapshot(), buildChatSnapshot(), buildRunsSnapshot(), buildApprovalsSnapshot(), @@ -42,7 +43,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { case "", "all": return scenarios, nil case "dashboard": - return scenarios[:4], nil + return scenarios[:5], nil case "action-center": return []snapshotScenarioState{buildActionCenterSnapshot()}, nil default: @@ -94,6 +95,13 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) m.applySessionWorkspaceLoaded(sessionWorkspaceLoadedMsg{sessions: append([]brokerapi.SessionSummary(nil), state.Sessions...)}) m.applyWatchTransport(state.Watch) m.publishWatchStateToRoutes() + if state.Name == "leader-help-root" { + m.beginOverlaySession() + m.leader.Rebind(m.actions.leaderBindings(m)) + m.leader.Start() + m.setFocus(focusPalette) + m.syncOverlayStack() + } m.setFocus(state.Focus) m.syncSidebarCursorToLocation() return m.View(), state.RouteID, nil @@ -205,6 +213,23 @@ func buildActionCenterSnapshot() snapshotScenarioState { 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} +} + func buildChatSnapshot() snapshotScenarioState { watch := snapshotWatchApprovalWaiting() active := &brokerapi.SessionDetail{ diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index 5c67c368..ca0616f2 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -23,6 +23,7 @@ var expectedScenarioNames = []string{ "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", + "leader-help-root", "chat-active-session", "runs-active-detail", "approvals-pending-detail", From 3d2655912647554e092b117d48a23e17a0f54d80 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 22:42:47 -0500 Subject: [PATCH 28/39] Polish for footer Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/route_status_test.go | 2 +- cmd/runecode-tui/shell_chrome_test.go | 2 +- cmd/runecode-tui/shell_footer_view.go | 24 ++++++++++++++-- cmd/runecode-tui/shell_layout.go | 6 ++-- cmd/runecode-tui/shell_layout_test.go | 4 +-- cmd/runecode-tui/shell_modes_quit_test.go | 8 +++--- cmd/runecode-tui/shell_test.go | 8 +++++- cmd/runecode-tui/shell_view.go | 35 +++++++++++++++++++---- cmd/runecode-tui/snapshot_bundles.go | 3 +- cmd/runecode-tui/snapshot_scenarios.go | 24 +++++++++++++++- cmd/runecode-tui/theme.go | 7 +++++ tools/tuisnapshotci/main.go | 1 + 12 files changed, 101 insertions(+), 23 deletions(-) diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index 0c48d3b9..84374e71 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -34,7 +34,7 @@ func TestStatusRouteRendersProjectSubstratePostureAndGuidance(t *testing.T) { "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", - "Keys: r reload", + "r reload", ) } diff --git a/cmd/runecode-tui/shell_chrome_test.go b/cmd/runecode-tui/shell_chrome_test.go index 202d2376..997e5af1 100644 --- a/cmd/runecode-tui/shell_chrome_test.go +++ b/cmd/runecode-tui/shell_chrome_test.go @@ -22,7 +22,7 @@ func TestShellChromeShowsCalmWorkbenchSummary(t *testing.T) { 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"} { + 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) } diff --git a/cmd/runecode-tui/shell_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index ef19bc25..5ec2bfff 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -3,6 +3,27 @@ package main import "strings" func (m shellModel) renderBottomStrip(surface routeSurface) string { + 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 { return "Emergency quit armed: press ctrl+c once more to quit." } @@ -13,9 +34,6 @@ func (m shellModel) renderBottomStrip(surface routeSurface) string { if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { parts = append(parts, quit) } - if bottom := strings.TrimSpace(surface.Regions.Bottom.Body); bottom != "" { - parts = append(parts, bottom) - } return strings.Join(parts, " • ") } diff --git a/cmd/runecode-tui/shell_layout.go b/cmd/runecode-tui/shell_layout.go index f87d2887..9f83e9d8 100644 --- a/cmd/runecode-tui/shell_layout.go +++ b/cmd/runecode-tui/shell_layout.go @@ -13,8 +13,8 @@ const ( shellBreadcrumbHeight = 0 shellHistoryHeight = 0 shellPaneSpacerHeight = 1 - shellBottomStripHeight = 1 - shellStatusHeight = 0 + shellBottomStripHeight = 2 + shellStatusHeight = 1 shellFooterHeight = 0 ) @@ -66,7 +66,7 @@ func (m shellModel) planShellRegions(viewportWidth int, viewportHeight int, brea mainWidth = viewportWidth } - paneFrameHeight := viewportHeight - shellChromeReservedHeight() - 1 + 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 6c510b3d..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() - 1 + 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() - 1 - 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_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index 8b5ef25e..e14b4a06 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -354,8 +354,8 @@ func TestShellBottomStripShowsQuitDiscoverabilityWhenSidebarHiddenNarrow(t *test if !strings.Contains(bottom, "Quick action: Quit RuneCode") || !strings.Contains(bottom, "(: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 one-line bottom strip, got %q", bottom) + if !strings.Contains(bottom, "\n") { + t.Fatalf("expected two-line bottom strip with route keys above command line, got %q", bottom) } } @@ -408,8 +408,8 @@ func TestShellBottomStripSummarizesRouteActionsCalmly(t *testing.T) { 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 one-line route action summary, 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) diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 85b822cc..633a3420 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -349,7 +349,7 @@ func TestShellViewRendersShellSurfaces(t *testing.T) { m := newShellModel() m.width = 150 v := m.View() - for _, want := range []string{"RuneCode Workbench", "ROUTE", "Product truth:", "Main pane", "Sidebar", "ctrl+p commands"} { + 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) } @@ -520,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) { diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 092d74d0..ac58407d 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -29,7 +29,7 @@ func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayou b := strings.Builder{} m.writeShellFrame(&b, surface, layout, viewportWidth) m.writeShellFooter(&b, viewportWidth) - frame := padShellBlock(strings.TrimRight(b.String(), "\n"), viewportWidth, viewportHeight) + frame := padShellFrame(strings.TrimRight(b.String(), "\n"), viewportWidth, viewportHeight) if strings.TrimSpace(overlayBody) == "" { return lipgloss.JoinVertical(lipgloss.Left, frame) } @@ -45,6 +45,8 @@ func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, la b.WriteString("\n") b.WriteString(m.renderShellPanes(surface, layout)) 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)) } @@ -74,11 +76,7 @@ func (m shellModel) overlayBodyWithHeight(surface routeSurface, layout shellLayo } func (m shellModel) overlayStaticParts() []string { - parts := []string{} - if toast := strings.TrimSpace(m.toasts.Latest()); toast != "" { - parts = append(parts, "Toast: "+sanitizeUIText(toast)) - } - return parts + return nil } func overlayHeightBudget(viewportHeight int) int { @@ -413,3 +411,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/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index 006ed8cc..e9a0e3c8 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -50,6 +50,7 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", + "dashboard-toast-info", "leader-help-root", "chat-active-session", "runs-active-detail", @@ -65,7 +66,7 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { Routes: []routeID{routeDashboard, routeChat, routeRuns, routeApprovals, routeAction, routeArtifacts, routeAudit, routeStatus, routeProviders, routeGitSetup, routeGitRemote}, }, nil case snapshotBundleDashboard: - return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "leader-help-root"}, Routes: []routeID{routeDashboard}}, nil + return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "dashboard-toast-info", "leader-help-root"}, Routes: []routeID{routeDashboard}}, nil case snapshotBundleActionCenter: return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil case snapshotBundleRuns: diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 095e9f66..c8677272 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -19,6 +19,7 @@ type snapshotScenarioState struct { Watch shellWatchTransportLoadedMsg Theme themePreset Focus focusArea + Toast string } func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { @@ -27,6 +28,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildApprovalWaitingSnapshot(), buildBlockedSnapshot(), buildDegradedSnapshot(), + buildToastSnapshot(), buildLeaderHelpSnapshot(), buildChatSnapshot(), buildRunsSnapshot(), @@ -43,7 +45,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { case "", "all": return scenarios, nil case "dashboard": - return scenarios[:5], nil + return scenarios[:6], nil case "action-center": return []snapshotScenarioState{buildActionCenterSnapshot()}, nil default: @@ -95,6 +97,9 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) 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.Name == "leader-help-root" { m.beginOverlaySession() m.leader.Rebind(m.actions.leaderBindings(m)) @@ -185,6 +190,23 @@ func buildDegradedSnapshot() snapshotScenarioState { 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 buildActionCenterSnapshot() snapshotScenarioState { watch := snapshotWatchActionCenter() now := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) diff --git a/cmd/runecode-tui/theme.go b/cmd/runecode-tui/theme.go index 7f3589c6..b3d0d088 100644 --- a/cmd/runecode-tui/theme.go +++ b/cmd/runecode-tui/theme.go @@ -236,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) } diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index ca0616f2..9adfb81f 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -23,6 +23,7 @@ var expectedScenarioNames = []string{ "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", + "dashboard-toast-info", "leader-help-root", "chat-active-session", "runs-active-detail", From ce02a62fe4e3849c36a426a977de4c0884802c71 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 23:04:50 -0500 Subject: [PATCH 29/39] Adds more of TUI to be accessible to the snapshot tool Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/shell_view.go | 8 +- cmd/runecode-tui/snapshot_bundles.go | 62 ++++-- cmd/runecode-tui/snapshot_scenarios.go | 265 +++++++++++++++++++++++-- justfile | 33 +++ tools/tuisnapshotci/main.go | 19 ++ 5 files changed, 353 insertions(+), 34 deletions(-) diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index ac58407d..03068cfd 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -397,7 +397,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 } diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index e9a0e3c8..351c103c 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -11,16 +11,19 @@ import ( 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" + 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 { @@ -51,36 +54,61 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { "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}, }, nil case snapshotBundleDashboard: - return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "dashboard-toast-info", "leader-help-root"}, Routes: []routeID{routeDashboard}}, nil + return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "dashboard-toast-info", "dashboard-focus-nav"}, Routes: []routeID{routeDashboard}}, nil case snapshotBundleActionCenter: return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil case snapshotBundleRuns: - return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail", "artifacts-evidence-detail"}, Routes: []routeID{routeRuns, routeArtifacts}}, nil + return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail", "runs-focus-inspector", "runs-raw-detail", "artifacts-evidence-detail", "artifacts-structured-detail"}, Routes: []routeID{routeRuns, routeArtifacts}}, nil case snapshotBundleApprovals: - return snapshotBundleSpec{Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail"}, Routes: []routeID{routeApprovals}}, nil + return snapshotBundleSpec{Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail", "approvals-structured-detail"}, Routes: []routeID{routeApprovals}}, nil case snapshotBundleAudit: - return snapshotBundleSpec{Name: snapshotBundleAudit, Scenarios: []string{"audit-degraded-detail"}, Routes: []routeID{routeAudit}}, nil + return snapshotBundleSpec{Name: snapshotBundleAudit, Scenarios: []string{"audit-degraded-detail", "audit-raw-detail"}, Routes: []routeID{routeAudit}}, nil case snapshotBundleStatus: - return snapshotBundleSpec{Name: snapshotBundleStatus, Scenarios: []string{"status-ready-overview"}, Routes: []routeID{routeStatus}}, nil + return snapshotBundleSpec{Name: snapshotBundleStatus, Scenarios: []string{"status-ready-overview", "status-degraded-overview"}, Routes: []routeID{routeStatus}}, nil case snapshotBundleSetup: - return snapshotBundleSpec{Name: snapshotBundleSetup, Scenarios: []string{"model-providers-credential-needed", "git-setup-identity-needed", "git-remote-approval-ready"}, Routes: []routeID{routeProviders, routeGitSetup, routeGitRemote}}, nil + return snapshotBundleSpec{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}}, nil case snapshotBundleChat: - return snapshotBundleSpec{Name: snapshotBundleChat, Scenarios: []string{"chat-active-session"}, Routes: []routeID{routeChat}}, nil + return snapshotBundleSpec{Name: snapshotBundleChat, Scenarios: []string{"chat-active-session", "chat-compose-open"}, Routes: []routeID{routeChat}}, nil + case snapshotBundleShell: + return snapshotBundleSpec{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}}, nil + case snapshotBundleNarrowMobile: + return snapshotBundleSpec{Name: snapshotBundleNarrowMobile, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, nil + case snapshotBundleNarrowCompact: + return snapshotBundleSpec{Name: snapshotBundleNarrowCompact, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, nil default: return snapshotBundleSpec{}, fmt.Errorf("unknown snapshot bundle %q", name) } diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index c8677272..8b16116c 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -12,14 +12,17 @@ import ( ) type snapshotScenarioState struct { - Name string - RouteID routeID - Surface routeModel - Sessions []brokerapi.SessionSummary - Watch shellWatchTransportLoadedMsg - Theme themePreset - Focus focusArea - Toast string + Name string + RouteID routeID + Surface routeModel + Sessions []brokerapi.SessionSummary + Watch shellWatchTransportLoadedMsg + Theme themePreset + Focus focusArea + Toast string + Viewport snapshotViewportPreset + Prepare func(*shellModel) + PostRender func(*shellModel) } func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { @@ -29,23 +32,52 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildBlockedSnapshot(), buildDegradedSnapshot(), buildToastSnapshot(), + buildCommandModeDraftSnapshot(), + buildCommandModeErrorSnapshot(), + buildCommandPaletteSnapshot(), + buildSessionSwitcherSnapshot(), + buildQuitConfirmChatComposeSnapshot(), + buildQuitConfirmCommandEntrySnapshot(), + buildQuitConfirmProviderSecretSnapshot(), + buildNarrowSidebarOverlaySnapshot(), + buildNarrowInspectorOverlaySnapshot(), + buildDashboardFocusNavSnapshot(), buildLeaderHelpSnapshot(), buildChatSnapshot(), + buildChatComposeOpenSnapshot(), buildRunsSnapshot(), + buildRunsFocusInspectorSnapshot(), + buildRunsRawSnapshot(), buildApprovalsSnapshot(), + buildApprovalsStructuredSnapshot(), buildActionCenterSnapshot(), buildArtifactsSnapshot(), + buildArtifactsStructuredSnapshot(), buildAuditSnapshot(), + buildAuditRawSnapshot(), buildStatusSnapshot(), + buildStatusDegradedSnapshot(), buildProviderSetupSnapshot(), + buildProviderSecretEntrySnapshot(), buildGitSetupSnapshot(), + buildGitSetupProviderLinkedSnapshot(), buildGitRemoteSnapshot(), + buildGitRemoteExecutedSnapshot(), + buildGitRemoteFailClosedSnapshot(), } switch strings.TrimSpace(name) { case "", "all": return scenarios, nil case "dashboard": - return scenarios[:6], nil + return []snapshotScenarioState{ + buildHealthyEmptySnapshot(), + buildApprovalWaitingSnapshot(), + buildBlockedSnapshot(), + buildDegradedSnapshot(), + buildToastSnapshot(), + buildDashboardFocusNavSnapshot(), + buildLeaderHelpSnapshot(), + }, nil case "action-center": return []snapshotScenarioState{buildActionCenterSnapshot()}, nil default: @@ -100,15 +132,14 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) if strings.TrimSpace(state.Toast) != "" { m.toasts.Push(toastInfo, state.Toast) } - if state.Name == "leader-help-root" { - m.beginOverlaySession() - m.leader.Rebind(m.actions.leaderBindings(m)) - m.leader.Start() - m.setFocus(focusPalette) - m.syncOverlayStack() + 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 }) } @@ -207,6 +238,98 @@ func buildToastSnapshot() snapshotScenarioState { 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" + m.palette.rebuildMatches() + 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 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) @@ -249,7 +372,13 @@ func buildLeaderHelpSnapshot() snapshotScenarioState { 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} + 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() + }} } func buildChatSnapshot() snapshotScenarioState { @@ -265,6 +394,17 @@ func buildChatSnapshot() snapshotScenarioState { 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"}} @@ -272,6 +412,23 @@ func buildRunsSnapshot() snapshotScenarioState { 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"}}} @@ -279,6 +436,16 @@ func buildApprovalsSnapshot() snapshotScenarioState { 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"} @@ -308,6 +475,16 @@ func buildArtifactsSnapshot() snapshotScenarioState { 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"}}} @@ -317,12 +494,28 @@ func buildAuditSnapshot() snapshotScenarioState { 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"}} @@ -330,12 +523,29 @@ func buildProviderSetupSnapshot() snapshotScenarioState { 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)} @@ -350,6 +560,29 @@ func buildGitRemoteSnapshot() snapshotScenarioState { 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 +} + func watchProjection(msg shellWatchTransportLoadedMsg) shellWatchProjectionState { manager := newShellWatchManager() manager.applyTransport(msg) diff --git a/justfile b/justfile index 98948bf4..0999dce3 100644 --- a/justfile +++ b/justfile @@ -153,6 +153,15 @@ tui-snapshot-audit-setup: 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 @@ -165,6 +174,30 @@ tui-snapshot-review-dashboard: tui-dev-snapshot-build 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 diff --git a/tools/tuisnapshotci/main.go b/tools/tuisnapshotci/main.go index 9adfb81f..99bf6d89 100644 --- a/tools/tuisnapshotci/main.go +++ b/tools/tuisnapshotci/main.go @@ -24,17 +24,36 @@ var expectedScenarioNames = []string{ "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 { From d6bc68f964070f3d58403a2dfae50b348bff1656 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 23:36:25 -0500 Subject: [PATCH 30/39] Updates a flaky issue with snapshot data when writing to same folder repeatedly. More polish. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/palette.go | 18 ++++- cmd/runecode-tui/route_audit_render.go | 8 +-- cmd/runecode-tui/route_git_remote_mutation.go | 38 +++++------ cmd/runecode-tui/route_git_setup.go | 10 +-- cmd/runecode-tui/route_provider_setup.go | 12 ++-- cmd/runecode-tui/route_runs_render.go | 2 +- cmd/runecode-tui/session_workspace_test.go | 2 +- cmd/runecode-tui/shell_command_mode.go | 13 +++- cmd/runecode-tui/shell_footer_view.go | 6 ++ cmd/runecode-tui/shell_modes_quit_test.go | 2 +- cmd/runecode-tui/shell_overlay_render.go | 41 +++++++++-- cmd/runecode-tui/shell_render_helpers.go | 55 +++++++++++---- cmd/runecode-tui/shell_test.go | 14 ++-- cmd/runecode-tui/shell_view.go | 68 ++++++++++++++++--- cmd/runecode-tui/snapshot_bundles.go | 2 +- cmd/runecode-tui/snapshot_enabled_test.go | 4 +- cmd/runecode-tui/snapshot_scenarios.go | 37 +++++++++- cmd/runecode-tui/snapshot_svg.go | 7 +- tools/test_tui_snapshot_review.py | 25 +++++-- tools/tui_snapshot_review.py | 22 +++++- 20 files changed, 292 insertions(+), 94 deletions(-) diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index ee53545f..ba74ced2 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -206,11 +206,23 @@ func paletteMatchLineBounded(entry paletteEntry, width int) string { } func paletteEntryShortcut(entry paletteEntry) string { - if commandID := strings.TrimSpace(entry.Action.Target.CommandID); commandID != "" { - return commandID + if strings.TrimSpace(entry.Action.Target.CommandID) != "" { + return "Quick action" } if route := strings.TrimSpace(string(entry.Action.Target.RouteID)); route != "" { - return 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 diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index 2fa662d6..edb2b7d1 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -13,7 +13,7 @@ func renderAuditSummary(verify *brokerapi.AuditVerificationGetResponse) string { } s := verify.Summary anchorLabel := renderAnchoringPostureLabel(s.AnchoringStatus) - return fmt.Sprintf("Verification posture: integrity=%s %s • anchoring=%s (%s) • storage=%s • lifecycle=%s • %s • findings=%d", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.StoragePostureStatus, s.SegmentLifecycleStatus, boolBadge("degraded", s.CurrentlyDegraded), s.FindingCount) + return fmt.Sprintf("Verification posture: integrity=%s %s • anchoring=%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 { @@ -62,7 +62,7 @@ func renderAuditFindings(verify *brokerapi.AuditVerificationGetResponse, present return "Verification findings: unavailable" } if len(verify.Report.Findings) == 0 { - return "Verification findings: none; current audit posture does not report degraded or failed reasons" + 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)) @@ -205,8 +205,8 @@ func auditInspectorContent(record brokerapi.AuditRecordDetail, status string, re fmt.Sprintf("Primary digest display: %s (copy raw digest below)", auditRecordDigestDetail(record)), fmt.Sprintf("Verification posture: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), fmt.Sprintf("Linked references: %d", len(record.LinkedReferences)), - "Evidence trail: workflow result -> artifacts -> audit records -> verification posture -> export/offline verification -> anchoring where available.", - "Trust posture: broker-linked references stay authoritative; copied digests and any raw content are supplemental proof material.", + "Evidence trail: workflow result -> artifacts -> audit record -> verification posture -> offline review or anchoring.", + "Trust posture: linked records stay authoritative; copied digests and raw content are supplemental proof material.", ) } diff --git a/cmd/runecode-tui/route_git_remote_mutation.go b/cmd/runecode-tui/route_git_remote_mutation.go index 859bfd0c..13d53e13 100644 --- a/cmd/runecode-tui/route_git_remote_mutation.go +++ b/cmd/runecode-tui/route_git_remote_mutation.go @@ -171,37 +171,37 @@ func (m gitRemoteMutationRouteModel) View(width, height int, focus focusArea) st return compactLines( sectionTitle("Git Remote Mutation")+" "+focusBadge(focus), renderStateCardSpec(gitRemoteStateCard(m.prepared, m.providerAuthLeaseID)), - fmt.Sprintf("Prepared change: %s", gitRemotePreparedSummary(summary)), + fmt.Sprintf("Planned change: %s", gitRemotePreparedSummary(summary)), fmt.Sprintf("Target: %s", gitRemoteTargetSummary(summary)), - fmt.Sprintf("Approval state: %s", gitRemoteApprovalSummary(m.prepared)), - fmt.Sprintf("Credential lease: %s", gitRemoteLeaseSummary(m.providerAuthLeaseID)), + 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: execute remains fail-closed until approval bindings and a broker-issued provider credential lease match this prepared mutation.", + "Safety: RuneCode keeps execution blocked until approval evidence and broker-issued execution access both match this prepared change.", m.status, ) } func gitRemoteStateCard(prepared brokerapi.GitRemoteMutationPreparedState, leaseID string) stateCardSpec { state := routeLoadStateWaiting - message := "Prepared remote mutation is ready for review." - next := "Confirm the prepared change, target, and approval evidence before executing." + 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 prepared mutation." + next = "Open Approvals before executing this remote change." } if strings.TrimSpace(leaseID) != "" { state = routeLoadStateReady - message = "Credential lease is present for execution." - next = "Press e only after confirming the prepared change and approval state." + 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 mutation execution failed." - next = "Review the broker execution reason before retrying." + message = "Remote change execution failed." + next = "Review the failure reason before retrying." } - return stateCardSpec{State: state, Title: "Guarded remote review", Message: message, Reason: "Git remote mutation stays behind broker prepare/get/execute contracts and fail-closed approval/lease checks.", NextAction: next, ShortcutCue: "r reload • e execute", EvidenceCue: "prepared mutation, approval binding, provider lease"} + 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 { @@ -211,7 +211,7 @@ func gitRemotePreparedSummary(summary brokerapi.GitRemoteMutationDerivedSummary) if strings.TrimSpace(summary.PullRequestTitle) != "" { return summary.PullRequestTitle } - return "review prepared repository mutation" + return "review prepared repository change" } func gitRemoteTargetSummary(summary brokerapi.GitRemoteMutationDerivedSummary) string { @@ -228,16 +228,16 @@ func gitRemoteApprovalSummary(prepared brokerapi.GitRemoteMutationPreparedState) return "approval evidence is missing, so execution stays blocked" } if prepared.RequiredApprovalDecisionHash != nil { - return "bound approval evidence is present for execution review" + return "approval evidence is ready for execution review" } - return "waiting for approval decision evidence before execution" + return "waiting for approval decision before execution" } func gitRemoteLeaseSummary(leaseID string) string { if strings.TrimSpace(leaseID) == "" { - return "not issued yet; RuneCode will request one when execution starts" + return "not ready yet; RuneCode will request execution access when you start the change" } - return "ready for this prepared mutation" + return "ready for this prepared change" } func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState) string { @@ -247,12 +247,12 @@ func gitRemoteExecutionSummary(prepared brokerapi.GitRemoteMutationPreparedState } switch strings.ToLower(state) { case "", "prepared", "not_started": - return "not started; broker checks still gate execution" + 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 broker execution details before retrying" + return "failed; review execution details before retrying" } return valueOrNA(state) } diff --git a/cmd/runecode-tui/route_git_setup.go b/cmd/runecode-tui/route_git_setup.go index b5cb9a80..3d2903be 100644 --- a/cmd/runecode-tui/route_git_setup.go +++ b/cmd/runecode-tui/route_git_setup.go @@ -191,7 +191,7 @@ func (m gitSetupRouteModel) View(width, height int, focus focusArea) string { 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 safety: %s", gitPolicySafetySummary(m.data.PolicySurface)), + fmt.Sprintf("Review posture: %s", gitPolicySafetySummary(m.data.PolicySurface)), fmt.Sprintf("Next safe action: %s", gitNextSafeAction(account, profiles)), m.status, ) @@ -212,7 +212,7 @@ func gitSetupStateCard(account brokerapi.GitProviderAccountState, auth brokerapi 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: "This route prepares the broker-owned account and identity state used by artifact-managed review flows; remote mutation remains broker-gated.", NextAction: next, ShortcutCue: "browser auth • device auth • identity upsert", EvidenceCue: "broker git setup posture"} + 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 { @@ -227,12 +227,12 @@ func gitProviderAccountSummary(account brokerapi.GitProviderAccountState, auth b func gitIdentitySummary(profiles []brokerapi.GitCommitIdentityProfile, defaultID string) string { if len(profiles) == 0 { - return "No broker-managed commit identity is ready yet." + return "No commit identity is ready yet." } if strings.TrimSpace(defaultID) != "" { - return fmt.Sprintf("Broker-managed identity is ready with default profile %s.", valueOrNA(defaultID)) + return fmt.Sprintf("Default commit identity %s is ready.", valueOrNA(defaultID)) } - return fmt.Sprintf("%d broker-managed identity profile(s) are available.", len(profiles)) + return fmt.Sprintf("%d commit identity profile(s) are available.", len(profiles)) } func gitPolicySafetySummary(policy brokerapi.GitPolicySurfaceState) string { diff --git a/cmd/runecode-tui/route_provider_setup.go b/cmd/runecode-tui/route_provider_setup.go index 2aca30a2..338ceb03 100644 --- a/cmd/runecode-tui/route_provider_setup.go +++ b/cmd/runecode-tui/route_provider_setup.go @@ -218,7 +218,7 @@ func (m providerSetupRouteModel) View(width, height int, focus focusArea) string fmt.Sprintf("Selected provider: %s", valueOrNA(m.selected.DisplayLabel)), providerSetupSummary(current, m.entryActive, len(m.profiles)), providerMaskedEntryLine(masked, m.entryActive), - "Credential safety: entry stays masked locally, moves through trusted secret ingress, and never appears in ordinary route output.", + "Credential safety: secrets stay masked in the TUI and move through trusted broker-owned entry flow.", m.status, ) } @@ -226,11 +226,11 @@ func (m providerSetupRouteModel) View(width, height int, focus focusArea) string func providerSetupStateCard(m providerSetupRouteModel, current brokerapi.ProviderProfile) stateCardSpec { providerLabel := valueOrNA(m.selected.DisplayLabel) state := routeLoadStateWaiting - message := fmt.Sprintf("%s is selected for broker-managed credential setup.", providerLabel) - next := "Press s to add a credential through trusted secret ingress." + 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 broker-managed credential.", providerLabel) + 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 { @@ -238,7 +238,7 @@ func providerSetupStateCard(m providerSetupRouteModel, current brokerapi.Provide 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 keeps direct credentials in broker-owned storage and accepts entry only through trusted secret ingress.", NextAction: next, ShortcutCue: "s setup • f provider • r refresh", EvidenceCue: "broker provider profiles and secret-ingress session"} + 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 { @@ -253,7 +253,7 @@ func providerCredentialSetupState(profile brokerapi.ProviderProfile, entryActive func providerSetupSummary(profile brokerapi.ProviderProfile, entryActive bool, profileCount int) string { return fmt.Sprintf( - "Current setup: %s • %s • %s.", + "Setup status: %s • %s • %s.", valueOrNA(strings.TrimSpace(profile.ReadinessPosture.EffectiveReadiness)), providerCredentialSetupState(profile, entryActive), providerProfileCountSummary(profileCount), diff --git a/cmd/runecode-tui/route_runs_render.go b/cmd/runecode-tui/route_runs_render.go index 3db9cd99..b7dfecd6 100644 --- a/cmd/runecode-tui/route_runs_render.go +++ b/cmd/runecode-tui/route_runs_render.go @@ -135,7 +135,7 @@ func runMainPaneRuntimeCue(summary brokerapi.RunSummary) string { case nBackend == "container" || nIsolation == "reduced": return "runtime uses reduced assurance" case nIsolation == "sandboxed" || nIsolation == "isolated" || nIsolation == "microvm": - return "runtime is operating in an isolated environment" + return "runtime is isolated" default: return "runtime posture reported" } diff --git a/cmd/runecode-tui/session_workspace_test.go b/cmd/runecode-tui/session_workspace_test.go index 1ada7ee3..50ce8713 100644 --- a/cmd/runecode-tui/session_workspace_test.go +++ b/cmd/runecode-tui/session_workspace_test.go @@ -58,7 +58,7 @@ func TestSessionQuickSwitcherIncludesCanonicalSessionMetadata(t *testing.T) { }}}) 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{"session-1", "Workspace ws-1", "Recent activity run_progress", "1 run"} { if !strings.Contains(v, want) { t.Fatalf("expected %q in switcher view %q", want, 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_footer_view.go b/cmd/runecode-tui/shell_footer_view.go index 5ec2bfff..e03e73d3 100644 --- a/cmd/runecode-tui/shell_footer_view.go +++ b/cmd/runecode-tui/shell_footer_view.go @@ -31,6 +31,9 @@ func (m shellModel) renderBottomCommandLine() string { return prompt } 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"} + } if quit := strings.TrimSpace(m.renderQuitDiscoverabilityHint()); quit != "" { parts = append(parts, quit) } @@ -46,5 +49,8 @@ func (m shellModel) renderQuitDiscoverabilityHint() string { if title := strings.TrimSpace(action.Title); title != "" { label = title } + if m.width > 0 && m.width < shellMediumMinWidth { + return "Quit :quit" + } return "Quick action: " + label + " (:quit)" } diff --git a/cmd/runecode-tui/shell_modes_quit_test.go b/cmd/runecode-tui/shell_modes_quit_test.go index e14b4a06..7265dc55 100644 --- a/cmd/runecode-tui/shell_modes_quit_test.go +++ b/cmd/runecode-tui/shell_modes_quit_test.go @@ -351,7 +351,7 @@ 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") { diff --git a/cmd/runecode-tui/shell_overlay_render.go b/cmd/runecode-tui/shell_overlay_render.go index c7434e20..61b024e5 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" @@ -29,31 +30,31 @@ func centeredOverlayBlockBounded(title shellOverlayID, body string, viewportWidt 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()). + Padding(0, 0). Width(innerWidth). MaxWidth(innerWidth). Render(content) - rendered := compactLines( - lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(tableHeader("Overlay")+" "+neutralBadge(strings.ToUpper(string(title)))+" "+muted("esc close")), - lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(frame), - ) + rendered := lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(frame) if maxHeight <= 0 { return rendered } @@ -90,3 +91,29 @@ func centeredOverlayContentBounds(viewportWidth int) (int, int) { } 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_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 6d723da3..37e7bbc4 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 { @@ -19,14 +21,13 @@ func (m shellModel) renderOverlayStack() string { func (m shellModel) renderPalette() string { b := strings.Builder{} width := boundedOverlayListWidth(m.width) - b.WriteString(rightAlignMetadata(tableHeader("Commands")+" "+muted("Workbench Command Surface"), "esc", width) + "\n") b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(m.palette.query)) + "\n") if len(m.palette.matches) == 0 { b.WriteString(muted("No matches. Keep typing or press esc to close.")) b.WriteString("\n") return b.String() } - b.WriteString(tableHeader("Suggested") + " " + muted("Matches")) + b.WriteString(tableHeader("Suggested actions")) b.WriteString("\n") rows := make([]boundedListRow, 0, len(m.palette.matches)) for _, entry := range m.palette.matches { @@ -49,7 +50,6 @@ func (m shellModel) renderPalette() string { func (m shellModel) renderSessionQuickSwitcher() string { b := strings.Builder{} width := boundedOverlayListWidth(m.width) - b.WriteString(rightAlignMetadata(tableHeader("Switch Session"), "esc", width) + "\n") b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(m.sessions.query)) + "\n") if len(m.sessions.matches) == 0 { b.WriteString(muted("No matches. Press esc to close.")) @@ -68,18 +68,26 @@ func (m shellModel) renderSessionQuickSwitcher() string { 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 } - preview := truncateText(sanitizeUIText(s.LastActivityPreview), 50) + preview := truncateText(sanitizeUIText(s.LastActivityPreview), 58) left := fmt.Sprintf("%s %s %s", marker, sessionLabel, sessionHighLevelCue(s)) - right := fmt.Sprintf("runs=%d approvals=%d", s.LinkedRunCount, s.LinkedApprovalCount) + right := compactSessionSwitcherCounts(s) line := rightAlignMetadata(left, right, width) - detail := fmt.Sprintf(" activity=%s/%s ws=%s preview=%q", - defaultPlaceholder(s.LastActivityAt, "n/a"), - defaultPlaceholder(s.LastActivityKind, "n/a"), - s.Identity.WorkspaceID, - preview, - ) + detailParts := []string{} + if workspace := strings.TrimSpace(s.Identity.WorkspaceID); workspace != "" { + detailParts = append(detailParts, "Workspace "+workspace) + } + if activityKind := strings.TrimSpace(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 += " waiting" + detail += " • Needs follow-up" } line = compactLines(line, muted(clipDisplayText(detail, width))) rows = append(rows, boundedListRow{Text: line, Selectable: true}) @@ -98,6 +106,20 @@ func (m shellModel) renderSessionQuickSwitcher() string { return b.String() } +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") @@ -233,7 +255,14 @@ func (m shellModel) renderPrimaryWorkbenchActions() string { parts = append(parts, "Approvals") } if _, ok := m.actions.definitionByID("shell.open_palette"); ok { - parts = append(parts, "Commands ctrl+p/:") + 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, " · ") } diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index 633a3420..fcaba707 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -391,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", "Suggested"} { + 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) } @@ -421,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, "Commands") { + if !strings.Contains(v, "Command Palette") { t.Fatalf("expected palette overlay content in narrow viewport, got %q", v) } } @@ -437,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 { diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 03068cfd..c1e697c8 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) func (m shellModel) View() string { @@ -33,7 +34,7 @@ func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayou if strings.TrimSpace(overlayBody) == "" { return lipgloss.JoinVertical(lipgloss.Left, frame) } - return applyModalOverlay(frame, overlayBody, viewportWidth, viewportHeight) + return applyModalOverlay(scrubShellFrameForOverlay(frame, viewportWidth, viewportHeight), overlayBody, viewportWidth, viewportHeight) } func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, layout shellLayoutPlan, viewportWidth int) { @@ -101,15 +102,15 @@ func overlayRemainingHeight(parts []string, maxOverlayHeight int) int { 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)) + return centeredOverlayBlockBounded(overlayIDQuickJump, m.renderPalette(), overlayWidth, remainingHeight) case m.sessions.IsOpen(): - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDSessions, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderSessionQuickSwitcher()), overlayWidth, remainingHeight)) + return centeredOverlayBlockBounded(overlayIDSessions, m.renderSessionQuickSwitcher(), overlayWidth, remainingHeight) case m.leader.Active(): - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDLeader, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderLeaderWhichKey()), overlayWidth, remainingHeight)) + return centeredOverlayBlockBounded(overlayIDLeader, m.renderLeaderWhichKey(), overlayWidth, remainingHeight) case m.quitConfirm.active: - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDQuitConfirm, compactLines("Return focus: "+m.overlayReturn.Label(), m.renderQuitConfirmDialog()), overlayWidth, remainingHeight)) + return centeredOverlayBlockBounded(overlayIDQuitConfirm, 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)) + return centeredOverlayBlockBounded(overlayIDSidebar, m.renderSidebar(), overlayWidth, remainingHeight) case m.narrowInspectOn && m.breakpoint() == shellBreakpointNarrow: return m.narrowInspectorOverlayBody(surface, layout, overlayWidth, remainingHeight) default: @@ -124,12 +125,12 @@ func (m shellModel) narrowInspectorOverlayBody(surface routeSurface, layout shel title = "Inspector" } if inspector != "" && routeInspectorAvailable(surface) && m.inspectorOn { - return compactLines(m.renderOverlayStack(), centeredOverlayBlockBounded(overlayIDInspector, compactLines("Return focus: "+m.overlayReturn.Label(), title, inspector), overlayWidth, remainingHeight)) + return centeredOverlayBlockBounded(overlayIDInspector, compactLines(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 centeredOverlayBlockBounded(overlayIDInspector, "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)) + return centeredOverlayBlockBounded(overlayIDInspector, compactLines(title, "No inspector item selected."), overlayWidth, remainingHeight) } func (m shellModel) activeOverlayHeight(viewportHeight int) int { @@ -214,7 +215,11 @@ func renderRunningSuffix(indicator string) string { } func (m shellModel) renderSyncHealth() string { - text := "Product truth: " + renderShellSyncState(m.watch.projection.Health.State) + 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("Status has detail: "+sanitizeUIText(m.watch.projection.Health.ErrorText)) } @@ -224,6 +229,49 @@ func (m shellModel) renderSyncHealth() string { return text } +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 diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index 351c103c..bafa3cd7 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -108,7 +108,7 @@ func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { case snapshotBundleNarrowMobile: return snapshotBundleSpec{Name: snapshotBundleNarrowMobile, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, nil case snapshotBundleNarrowCompact: - return snapshotBundleSpec{Name: snapshotBundleNarrowCompact, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, nil + return snapshotBundleSpec{Name: snapshotBundleNarrowCompact, Scenarios: []string{"narrow-sidebar-overlay-compact", "narrow-inspector-overlay-compact"}, Routes: []routeID{routeDashboard, routeRuns}}, nil default: return snapshotBundleSpec{}, fmt.Errorf("unknown snapshot bundle %q", name) } diff --git a/cmd/runecode-tui/snapshot_enabled_test.go b/cmd/runecode-tui/snapshot_enabled_test.go index efa1f80e..efdfa3eb 100644 --- a/cmd/runecode-tui/snapshot_enabled_test.go +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -218,8 +218,8 @@ func TestWriteSnapshotArtifactsIncludesBundleCoverageMetadata(t *testing.T) { 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) != 4 { - t.Fatalf("manifest coverage scenarios = %+v, want 4 dashboard scenarios", manifest.Coverage) + if len(manifest.Coverage.Scenarios) != 6 { + t.Fatalf("manifest coverage scenarios = %+v, want 6 dashboard scenarios", manifest.Coverage) } } diff --git a/cmd/runecode-tui/snapshot_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 8b16116c..c2e8bdf1 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -21,6 +21,8 @@ type snapshotScenarioState struct { Focus focusArea Toast string Viewport snapshotViewportPreset + Width int + Height int Prepare func(*shellModel) PostRender func(*shellModel) } @@ -41,6 +43,8 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { buildQuitConfirmProviderSecretSnapshot(), buildNarrowSidebarOverlaySnapshot(), buildNarrowInspectorOverlaySnapshot(), + buildNarrowSidebarOverlayCompactSnapshot(), + buildNarrowInspectorOverlayCompactSnapshot(), buildDashboardFocusNavSnapshot(), buildLeaderHelpSnapshot(), buildChatSnapshot(), @@ -117,10 +121,11 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) if theme == "" { theme = state.Theme } + width, height := snapshotScenarioDimensions(state, cfg) appTheme = newTheme(theme) m := newShellModel() - m.width = cfg.width - m.height = cfg.height + 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)}} @@ -144,6 +149,18 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) }) } +func snapshotScenarioDimensions(state snapshotScenarioState, cfg tuiSnapshotConfig) (int, int) { + width := cfg.width + if state.Width > 0 { + width = state.Width + } + height := cfg.height + 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}, @@ -323,6 +340,22 @@ func buildNarrowInspectorOverlaySnapshot() snapshotScenarioState { }} } +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" diff --git a/cmd/runecode-tui/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go index addacd87..fc6b8f6e 100644 --- a/cmd/runecode-tui/snapshot_svg.go +++ b/cmd/runecode-tui/snapshot_svg.go @@ -154,6 +154,7 @@ func writeSnapshotScenarioArtifacts(cfg tuiSnapshotConfig, scenarios []snapshotS } func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScenarioState) (snapshotManifestEntry, error) { + width, height := snapshotScenarioDimensions(scenario, cfg) paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name, cfg.viewport) ansiManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.ANSI) if err != nil { @@ -186,7 +187,7 @@ func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScena if err != nil { return snapshotManifestEntry{}, err } - svgArtifact, err := writeSnapshotHashedArtifact(paths.SVG, []byte(renderSnapshotSVG(view, cfg.width, cfg.height))) + svgArtifact, err := writeSnapshotHashedArtifact(paths.SVG, []byte(renderSnapshotSVG(view, width, height))) if err != nil { return snapshotManifestEntry{}, err } @@ -196,8 +197,8 @@ func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScena return snapshotManifestEntry{ Name: scenario.Name, Viewport: string(cfg.viewport), - Width: cfg.width, - Height: cfg.height, + Width: width, + Height: height, Route: string(routeID), Artifacts: snapshotManifestArtifacts{ ANSI: ansiArtifact, diff --git a/tools/test_tui_snapshot_review.py b/tools/test_tui_snapshot_review.py index 847778f8..ea1109e7 100644 --- a/tools/test_tui_snapshot_review.py +++ b/tools/test_tui_snapshot_review.py @@ -220,13 +220,24 @@ def test_open_mode_uses_desktop_opener(self) -> None: self.assertEqual(exit_code, 0) self.assertEqual(stdout, "") self.assertEqual(stderr, "") - self.assertEqual( - run_mock.call_args_list, - [ - mock.call(["xdg-open", str(png_path.resolve())], check=True), - mock.call(["xdg-open", str(svg_path.resolve())], check=True), - ], - ) + 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() diff --git a/tools/tui_snapshot_review.py b/tools/tui_snapshot_review.py index af8be915..1be7298e 100644 --- a/tools/tui_snapshot_review.py +++ b/tools/tui_snapshot_review.py @@ -2,6 +2,7 @@ import argparse import json +import atexit import ntpath import os import platform @@ -18,6 +19,21 @@ 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]: @@ -377,14 +393,18 @@ def open_review_artifacts(entries: List[Dict[str, str]]) -> int: 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: - subprocess.run([opener, artifact_path], check=True) + shutil.copy2(artifact_path, staged_path) + subprocess.run([opener, staged_path], check=True) except OSError: failed_paths.append(artifact_path) except subprocess.CalledProcessError: From 83d8294fbe49d2fed6155dec65f0308d254e2e9e Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sat, 9 May 2026 23:52:28 -0500 Subject: [PATCH 31/39] Makes more content in the TUI human-friendly. Polishes more. Signed-off-by: Zebulon Rouse-Frantzich --- .../local_rpc_integration_linux_test.go | 12 ++-- .../route_action_center_blocked_helpers.go | 2 +- .../route_action_center_helpers.go | 15 +++-- .../route_action_center_render.go | 32 +++++++-- cmd/runecode-tui/route_action_center_test.go | 2 +- .../route_approvals_followup_helpers.go | 4 +- cmd/runecode-tui/route_approvals_helpers.go | 16 ++--- .../route_approvals_state_helpers.go | 6 +- cmd/runecode-tui/route_artifacts_detail.go | 24 +++---- cmd/runecode-tui/route_audit_render.go | 34 +++++----- cmd/runecode-tui/route_audit_status_test.go | 26 +++---- .../route_git_remote_mutation_test.go | 8 +-- cmd/runecode-tui/route_git_setup_test.go | 2 +- cmd/runecode-tui/route_provider_setup_test.go | 2 +- .../route_runs_approvals_artifacts_test.go | 38 +++++------ cmd/runecode-tui/route_status_render.go | 67 ++++++++++++------- cmd/runecode-tui/route_status_test.go | 10 +-- 17 files changed, 171 insertions(+), 129 deletions(-) diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 5f83336b..85624299 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -267,7 +267,7 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { ) assertRouteInspectorContainsAll(t, approvals, routeApprovals, "Why this approval exists:", - "Exact gated object/action:", + "What is gated:", ) artifacts := newArtifactsRouteModel(routeDefinition{ID: routeArtifacts, Label: "Artifacts"}, recording) @@ -278,9 +278,9 @@ func assertWorkAndSetupBackedRoutes(t *testing.T, recording localBrokerClient) { status := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, recording) assertRouteOutputContainsAll(t, status, routeStatus, - "Runtime/audit readiness strip", + "System health", "Overview:", - "Version posture:", + "Version:", "Project setup", ) } @@ -331,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) } @@ -346,7 +346,7 @@ func assertAuditRouteSupportsDrillDown(t *testing.T, model routeModel) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, needle := range []string{"Audit posture", "Timeline: page 1 • 1 records • no more pages", "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) } @@ -360,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/route_action_center_blocked_helpers.go b/cmd/runecode-tui/route_action_center_blocked_helpers.go index f0554395..ca914ac8 100644 --- a/cmd/runecode-tui/route_action_center_blocked_helpers.go +++ b/cmd/runecode-tui/route_action_center_blocked_helpers.go @@ -56,7 +56,7 @@ func blockedImpactDisposition(run brokerapi.RunSummary) (string, routeLoadState, 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, fmt.Sprintf("reason is %s", humanizeExecutionToken(run.BlockingReasonCode))) + reasonParts = append(reasonParts, humanizeExecutionToken(run.BlockingReasonCode)) } if blockedCount > 0 { reasonParts = append(reasonParts, fmt.Sprintf("%d approval queue item(s) are linked", blockedCount)) diff --git a/cmd/runecode-tui/route_action_center_helpers.go b/cmd/runecode-tui/route_action_center_helpers.go index f9032e03..afdf2890 100644 --- a/cmd/runecode-tui/route_action_center_helpers.go +++ b/cmd/runecode-tui/route_action_center_helpers.go @@ -51,14 +51,17 @@ func buildApprovalActionItems(items []brokerapi.ApprovalSummary, now time.Time) if urgency == "critical" { state = routeLoadStateBlocked } - reasons := []string{fmt.Sprintf("Approval is %s", humanizeExecutionToken(ap.Status)), fmt.Sprintf("triggered by %s", humanizeExecutionToken(ap.ApprovalTriggerCode))} + 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 action %s until this decision is resolved.", valueOrNA(ap.BoundScope.RunID), valueOrNA(ap.BoundScope.StageID), valueOrNA(ap.BoundScope.ActionKind)) + 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{ @@ -150,7 +153,7 @@ func operationalSyncHealthItem(health shellSyncHealth) []actionCenterItem { Title: "shell watch sync health", State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("Watch sync is %s; last error: %s.", humanizeExecutionToken(string(health.State)), defaultPlaceholder(health.ErrorText, "n/a")), + 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.", @@ -174,7 +177,7 @@ func operationalWatchFamilyItems(watch dashboardLiveActivity) []actionCenterItem Title: fmt.Sprintf("watch family %s", valueOrNA(family.family)), State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("%s reported %d error(s); last status %s; subject %s.", humanizeExecutionToken(family.family), family.errorCount, humanizeExecutionToken(family.lastStatus), valueOrNA(family.lastSubject)), + 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.", @@ -192,7 +195,7 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Title: "audit verification unavailable", State: routeLoadStateDegraded, Urgency: "high", - Reason: fmt.Sprintf("Audit verification load failed: %s", auditErr), + 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.", @@ -216,7 +219,7 @@ func operationalAuditItems(audit *brokerapi.AuditVerificationGetResponse, auditE Title: "audit verification posture", State: routeLoadStateDegraded, Urgency: urgency, - Reason: fmt.Sprintf("Integrity is %s, anchoring is %s, and evidence currently needs review: %t.", humanizeExecutionToken(s.IntegrityStatus), humanizeExecutionToken(s.AnchoringStatus), s.CurrentlyDegraded), + 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.", diff --git a/cmd/runecode-tui/route_action_center_render.go b/cmd/runecode-tui/route_action_center_render.go index a85a7e89..8c08e077 100644 --- a/cmd/runecode-tui/route_action_center_render.go +++ b/cmd/runecode-tui/route_action_center_render.go @@ -141,12 +141,12 @@ func renderActionCenterInspector(family actionCenterFamily, items []actionCenter " "+valueOrNA(item.Reason), " What it affects", " "+valueOrNA(item.Impact), - " What to do", - " "+valueOrNA(strings.TrimSpace(strings.Join([]string{item.Owner, item.RequiredAction}, " • "))), + " What to do now", + " "+valueOrNA(strings.TrimSpace(item.RequiredAction)), " Continue in", " "+drillDown, " Evidence", - " "+valueOrNA(item.EvidenceCue), + " "+actionCenterEvidenceSummary(item.EvidenceCue), ) } @@ -156,7 +156,7 @@ func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem blocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateBlocked) approvalBlocked := countActionCenterItemsByState(families[actionCenterFamilyBlocked], routeLoadStateApprovalRequired) if blocked > 0 { - return actionCenterSummary{State: routeLoadStateBlocked, Title: "Action Center", Message: "Workflow progress 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."} + 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."} @@ -167,6 +167,30 @@ func buildActionCenterSummary(families map[actionCenterFamily][]actionCenterItem 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 { diff --git a/cmd/runecode-tui/route_action_center_test.go b/cmd/runecode-tui/route_action_center_test.go index 1cb0ea58..4fa25f2c 100644 --- a/cmd/runecode-tui/route_action_center_test.go +++ b/cmd/runecode-tui/route_action_center_test.go @@ -128,7 +128,7 @@ func TestBuildOperationalAttentionItemsIncludesAuditAndWatchDisconnect(t *testin "shell watch sync health", "Watch sync is disconnected", "audit verification posture", - "anchoring is degraded", + "receipts are degraded", "run run-1 operational posture", "Continue in", ) diff --git a/cmd/runecode-tui/route_approvals_followup_helpers.go b/cmd/runecode-tui/route_approvals_followup_helpers.go index 3374d7a8..85d60afe 100644 --- a/cmd/runecode-tui/route_approvals_followup_helpers.go +++ b/cmd/runecode-tui/route_approvals_followup_helpers.go @@ -35,7 +35,7 @@ func approvalResolveSummary(summary brokerapi.ApprovalSummary, detail brokerapi. } switch strings.TrimSpace(summary.BoundScope.ActionKind) { case "promotion": - return "unavailable here because promotion approvals must be completed in the promotion flow to preserve exact binding" + 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" } @@ -167,7 +167,7 @@ func approvalNextAction(summary brokerapi.ApprovalSummary, detail brokerapi.Appr if approvalResolveSupported(summary, detail) { return "resolve it here if the evidence supports the requested change" } - return "follow the linked workflow-specific route because resolve is unavailable here" + return "continue in the linked route after review" } func approvalFollowUpRoute(summary brokerapi.ApprovalSummary) string { diff --git a/cmd/runecode-tui/route_approvals_helpers.go b/cmd/runecode-tui/route_approvals_helpers.go index 0efe2b21..d01d4793 100644 --- a/cmd/runecode-tui/route_approvals_helpers.go +++ b/cmd/runecode-tui/route_approvals_helpers.go @@ -60,10 +60,10 @@ 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 state=%s reason=%s", summary.ApprovalID, approvalDisplayState(summary, detail), approvalPrimaryReason(summary, detail)), - Identity: fmt.Sprintf("approval=%s run=%s action=%s", summary.ApprovalID, valueOrNA(boundScope.RunID), valueOrNA(boundScope.ActionKind)), - Status: fmt.Sprintf("workflow_state=%s resolve=%s", workflowApprovalState(summary, detail), approvalResolveStatus(summary, detail)), - 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}} @@ -156,13 +156,13 @@ func approvalInspectorContent(summary brokerapi.ApprovalSummary, detail brokerap return compactLines( 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("Exact gated object/action: %s", approvalUIValue(approvalExactObjectAction(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("After approval route: %s", approvalUIValue(approvalFollowUpRoute(summary))), + 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 posture: %s; lifecycle=%s (%s)", approvalUIValue(workflowApprovalState(summary, detail)), approvalUIValue(lifecycleState), approvalUIValue(lifecycleFlags)), + 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))), ) @@ -255,7 +255,7 @@ func renderApprovalSafetyStrip(resp *brokerapi.ApprovalGetResponse) string { return compactLines( tableHeader("Approval posture")+" "+approvalPrimaryStateBadge(s)+" "+approvalSupportBadge(s, d), fmt.Sprintf("Needs attention because %s", approvalPrimaryReason(s, d)), - fmt.Sprintf("Broker cues: policy_reason_code=%s %s | approval_trigger_code=%s %s", approvalUIValue(d.PolicyReasonCode), stateCue, approvalUIValue(s.ApprovalTriggerCode), triggerCue), + fmt.Sprintf("Broker cues: policy %s %s | trigger %s %s", approvalUIValue(d.PolicyReasonCode), stateCue, approvalUIValue(s.ApprovalTriggerCode), triggerCue), ) } diff --git a/cmd/runecode-tui/route_approvals_state_helpers.go b/cmd/runecode-tui/route_approvals_state_helpers.go index 13f80406..2e24fd72 100644 --- a/cmd/runecode-tui/route_approvals_state_helpers.go +++ b/cmd/runecode-tui/route_approvals_state_helpers.go @@ -100,7 +100,7 @@ func approvalPrimaryReason(summary brokerapi.ApprovalSummary, detail brokerapi.A 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 (%s)", approvalDisplayLabel(summary), 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) @@ -170,7 +170,7 @@ func approvalExactObjectAction(summary brokerapi.ApprovalSummary, detail brokera } action := valueOrNA(strings.TrimSpace(scope.ActionKind)) if len(parts) == 0 { - return "action=" + action + return humanizeExecutionToken(action) } - return strings.Join(parts, " • ") + " • action=" + action + return strings.Join(parts, " • ") + " • " + humanizeExecutionToken(action) } diff --git a/cmd/runecode-tui/route_artifacts_detail.go b/cmd/runecode-tui/route_artifacts_detail.go index 2210d209..85c2422c 100644 --- a/cmd/runecode-tui/route_artifacts_detail.go +++ b/cmd/runecode-tui/route_artifacts_detail.go @@ -61,9 +61,9 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art 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("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring context", valueOrNA(a.RunID), artifactDisplayLabel(a)), + 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("Typed detail mode: %s (metadata remains control-plane truth)", mode), + 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.", @@ -71,10 +71,10 @@ func renderArtifactInspector(head *brokerapi.LocalArtifactHeadResponse, mode art )) return renderInspectorShell(inspectorShellSpec{ Title: "Artifact inspector", - Summary: fmt.Sprintf("artifact=%s class=%s bytes=%d", artifactDisplayLabel(a), a.Reference.DataClass, a.Reference.SizeBytes), - Identity: fmt.Sprintf("artifact=%s digest=%s", artifactDisplayLabel(a), shortIdentity(a.Reference.Digest)), - Status: fmt.Sprintf("evidence_trail=run:%s -> artifact:%s -> audit -> verification posture", valueOrNA(a.RunID), shortIdentity(a.Reference.Digest)), - 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 { @@ -249,9 +249,9 @@ func (m *artifactsRouteModel) syncDetailDocument() { content := compactLines( fmt.Sprintf("Evidence label: %s", artifactDisplayLabel(a)), fmt.Sprintf("Data class: %s", a.Reference.DataClass), - fmt.Sprintf("Evidence trail: run %s -> artifact %s -> Audit for verification posture and anchoring context", valueOrNA(a.RunID), artifactDisplayLabel(a)), + 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("Typed detail mode: %s (metadata remains control-plane truth)", mode), + 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.", @@ -267,18 +267,18 @@ func renderArtifactOverviewCard(head *brokerapi.LocalArtifactHeadResponse, class 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 verification posture and anchoring.", ShortcutCue: "enter", RouteCue: "Artifacts → Audit"}) + 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("Artifact class %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 verification posture, export, or anchoring actions.", ShortcutCue: "enter / m", RouteCue: "Audit"}) + 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 verification and anchoring." + return "Evidence path: run result -> artifact evidence -> Audit review and receipts." } a := head.Artifact - return fmt.Sprintf("Evidence path: run %s → %s → Audit for verification posture and anchoring.", valueOrNA(a.RunID), artifactDisplayLabel(a)) + return fmt.Sprintf("Evidence path: run %s -> %s -> Audit review and receipts.", valueOrNA(a.RunID), artifactDisplayLabel(a)) } func artifactDisplayLabel(item brokerapi.ArtifactSummary) string { diff --git a/cmd/runecode-tui/route_audit_render.go b/cmd/runecode-tui/route_audit_render.go index edb2b7d1..18ca475b 100644 --- a/cmd/runecode-tui/route_audit_render.go +++ b/cmd/runecode-tui/route_audit_render.go @@ -9,11 +9,11 @@ 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) • findings=%d • %s", s.IntegrityStatus, postureBadge(s.IntegrityStatus), s.AnchoringStatus, anchorLabel, s.FindingCount, boolBadge("degraded", s.CurrentlyDegraded)) + 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 { @@ -131,7 +131,7 @@ func renderAuditWorkbenchSummary(verify *brokerapi.AuditVerificationGetResponse, fmt.Sprintf("Record in focus: %s", recordLabel), evidence, fmt.Sprintf("Next actions: %s", renderAuditNextActionCue(verify, finalize)), - fmt.Sprintf("Verification: %s", renderAuditSummary(verify)), + fmt.Sprintf("Audit summary: %s", renderAuditSummary(verify)), finalizeLine, anchorLine, paging, @@ -159,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("record=%s event=%s linked_refs=%d", shortIdentity(identity), valueOrNA(r.EventType), len(r.LinkedReferences)), - Identity: fmt.Sprintf("record_family=%s event_type=%s digest=%s", valueOrNA(r.RecordFamily), valueOrNA(r.EventType), shortIdentity(identity)), - 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), @@ -203,9 +203,9 @@ func auditInspectorContent(record brokerapi.AuditRecordDetail, status string, re fmt.Sprintf("Record family: %s event=%s", record.RecordFamily, record.EventType), fmt.Sprintf("Occurred at: %s", record.OccurredAt), fmt.Sprintf("Primary digest display: %s (copy raw digest below)", auditRecordDigestDetail(record)), - fmt.Sprintf("Verification posture: %s (%s) reasons=%d", status, renderAnchoringPostureLabel(status), reasons), + 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 -> verification posture -> offline review or anchoring.", + "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.", ) } @@ -314,18 +314,18 @@ func renderAnchoringPostureLabel(status string) string { func renderAuditSafetyAlertStrip(verify *brokerapi.AuditVerificationGetResponse) string { if verify == nil { - return tableHeader("Audit posture") + " " + dangerBadge("AUDIT_POSTURE_UNAVAILABLE") + " audit verification unavailable" + return tableHeader("Audit health") + " " + dangerBadge("Audit unavailable") + " audit verification unavailable" } s := verify.Summary - parts := []string{tableHeader("Audit posture")} + 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, " ") } @@ -346,7 +346,7 @@ func renderAuditOverviewCard(verify *brokerapi.AuditVerificationGetResponse, rec identity, _ := record.Record.RecordDigest.Identity() recordLabel = "record " + shortIdentity(identity) } - return renderStateCardSpec(stateCardSpec{State: state, Title: "Verification trail", Message: fmt.Sprintf("Audit explains the current verification posture for %s.", recordLabel), Reason: renderAuditSummary(verify), NextAction: "Review findings, then use finalize/verify, export copy, or anchoring actions as needed.", ShortcutCue: "f / a / x", RouteCue: "Audit"}) + 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 { @@ -356,7 +356,7 @@ func renderAuditEvidenceTrail(verify *brokerapi.AuditVerificationGetResponse, re recordLabel = "record " + shortIdentity(identity) } if verify == nil { - return fmt.Sprintf("Evidence trail: workflow result -> artifacts -> audit records (%s) -> verification posture unavailable until broker verification data loads", recordLabel) + 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) -> verification posture -> export/offline verification -> anchoring", 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 917a1532..226043e6 100644 --- a/cmd/runecode-tui/route_audit_status_test.go +++ b/cmd/runecode-tui/route_audit_status_test.go @@ -22,12 +22,12 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { mustContainAll(t, view, "Verification trail", - "Audit posture", + "Audit health", "Evidence workbench", "Finalize/verify: unavailable", - "UNANCHORED_OR_DEGRADED_AUDIT", + "Receipts need review", "Timeline: page 1 • 1 records • next page available • back 0", - "anchoring=degraded (unanchored/degraded)", + "receipts=degraded (unanchored/degraded)", "Evidence trail:", "Findings to review:", "anchor_receipt_missing • warning • anchoring", @@ -59,10 +59,10 @@ func TestAuditRouteShowsPagedTimelineAndVerificationReasonCodes(t *testing.T) { 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 records -> verification posture -> export/") { + 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) } } @@ -73,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) } @@ -97,13 +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", - "Overview: • broker reachable • normal work available • local broker mode", - "Broker health: attention needed for runtime verification material is unavailable.", - "Version posture: RuneCode 0.1.0", - "protocol 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", ) } diff --git a/cmd/runecode-tui/route_git_remote_mutation_test.go b/cmd/runecode-tui/route_git_remote_mutation_test.go index 1f01b117..5132aa1c 100644 --- a/cmd/runecode-tui/route_git_remote_mutation_test.go +++ b/cmd/runecode-tui/route_git_remote_mutation_test.go @@ -19,12 +19,12 @@ func TestGitRemoteMutationRouteLoadsPreparedReviewState(t *testing.T) { for _, want := range []string{ "Git Remote Mutation", "Guarded remote review", - "Prepared change:", + "Planned change:", "Target:", - "Approval state:", - "Credential lease:", + "Approval check:", + "Execution access:", "Next safe action:", - "Safety: execute remains fail-closed", + "Safety: RuneCode keeps execution blocked", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q in %q", want, view) diff --git a/cmd/runecode-tui/route_git_setup_test.go b/cmd/runecode-tui/route_git_setup_test.go index 899e09fe..4b36b3ec 100644 --- a/cmd/runecode-tui/route_git_setup_test.go +++ b/cmd/runecode-tui/route_git_setup_test.go @@ -19,7 +19,7 @@ func TestGitSetupRouteViewUsesGuidedSummaries(t *testing.T) { for _, want := range []string{ "Provider link:", "Commit identity:", - "Review safety:", + "Review posture:", "Next safe action:", } { if !strings.Contains(view, want) { diff --git a/cmd/runecode-tui/route_provider_setup_test.go b/cmd/runecode-tui/route_provider_setup_test.go index 50c87594..9e431741 100644 --- a/cmd/runecode-tui/route_provider_setup_test.go +++ b/cmd/runecode-tui/route_provider_setup_test.go @@ -20,7 +20,7 @@ func TestProviderSetupRouteActivationLoadsBrokerProjectedProfilePosture(t *testi if !strings.Contains(view, "Selected provider: OpenAI default") { t.Fatalf("expected selected provider summary in provider view, got %q", view) } - if !strings.Contains(view, "Current setup: not_ready • credential stored • 1 broker profile discovered.") { + 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") { diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index c9de0949..0940a52c 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -67,19 +67,19 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: approval=ap-1 state=approval required reason=policy requires operator review before promotion for run-1 can continue (requires_human_review)", - "Identity: approval=ap-1 run=run-1 action=promotion", + "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", - "Exact gated object/action: run run-1 • stage stage-1 • action=promotion", + "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)", - "After approval route: Artifacts → Audit", - "Resolve availability: unavailable here because promotion approvals must be completed in the prom", + "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 posture: approval required; lifecycle=pending (stale)", + "Workflow state: approval required; lifecycle=pending (stale)", "Structured/raw modes expose exact-action approval", ) if !strings.Contains(view, "Approval review") { @@ -89,7 +89,7 @@ func TestApprovalsRouteDistinguishesCodesLifecycleAndBinding(t *testing.T) { "Decision workbench", "Approval queue", ) - if strings.Contains(view, "Summary: approval=ap-1 state=approval required") { + 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="} { @@ -111,26 +111,26 @@ func TestArtifactsRouteUsesTypedReadAndInspectableModes(t *testing.T) { inspector := surface.Regions.Inspector.Body mustContainAll(t, inspector, - "Summary: artifact=diffs for run-1 class=diffs bytes=128", - "Identity: artifact=diffs for run-1 digest=sha256:bbbbbbbbbbbb", - "Status: evidence_trail=run:run-1 -> artifact:sha256:bbbbbbbbbbbb -> audit -> verification posture", + "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", "Evidence label: diffs for run-1", - "Evidence trail: run run-1 -> artifact diffs for run-1 -> Audit for verification posture and anch", + "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)", - "Typed detail mode:", + "Detail mode:", "Inspectable content is supplemental evidence, not authoritative run/approval truth.", "diff preview (secrets redacted):", "token=[REDACTED]", ) mustContainAll(t, view, "Evidence workspace", - "Evidence path: run run-1 → diffs for run-1 → Audit for verification posture and anchoring.", + "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: artifact=diffs for run-1") { + 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="} { @@ -262,8 +262,8 @@ func TestAuditRouteInspectorShowsBoundedTrailAndCopyableDigest(t *testing.T) { 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 records -> verification posture", - "Trust posture: broker-linked references stay authoritative", + "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") { @@ -344,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, "Exact gated object/action: run run-2 • stage stage-2 • action=stage_summary_sign_off") { + 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) } } @@ -419,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) } } @@ -444,7 +444,7 @@ func TestArtifactsReloadFallsBackWhenSelectedArtifactDisappears(t *testing.T) { 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=diffs for run-1") { + 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) } } diff --git a/cmd/runecode-tui/route_status_render.go b/cmd/runecode-tui/route_status_render.go index b23d2872..bc716ff0 100644 --- a/cmd/runecode-tui/route_status_render.go +++ b/cmd/runecode-tui/route_status_render.go @@ -64,7 +64,7 @@ func renderLifecycleBlockedReasonLine(posture brokerapi.BrokerProductLifecyclePo if len(posture.BlockedReasonCodes) == 0 { return "Managed-operation blockers: none reported" } - return "Managed-operation blockers: " + joinCSV(posture.BlockedReasonCodes) + return "Managed-operation blockers: " + humanizeStatusCodes(posture.BlockedReasonCodes) } func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecyclePosture) string { @@ -74,16 +74,16 @@ func renderLifecycleDegradedReasonLine(posture brokerapi.BrokerProductLifecycleP if len(posture.DegradedReasonCodes) == 0 { return "Managed-operation watchouts: none reported" } - return "Managed-operation watchouts: " + joinCSV(posture.DegradedReasonCodes) + return "Managed-operation watchouts: " + humanizeStatusCodes(posture.DegradedReasonCodes) } func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) string { parts := []string{} if len(posture.BlockedReasonCodes) > 0 { - parts = append(parts, "blocked="+joinCSV(posture.BlockedReasonCodes)) + parts = append(parts, "blocked because "+humanizeStatusCodes(posture.BlockedReasonCodes)) } if len(posture.DegradedReasonCodes) > 0 { - parts = append(parts, "degraded="+joinCSV(posture.DegradedReasonCodes)) + 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)) @@ -93,9 +93,9 @@ func lifecycleReasonSummary(posture brokerapi.BrokerProductLifecyclePosture) str func renderBackendPostureLine(posture brokerapi.BackendPostureState) string { if strings.TrimSpace(posture.InstanceID) == "" { - return "Backend posture: unavailable" + return "Backend: unavailable" } - parts := []string{fmt.Sprintf("Backend posture: %s", valueOrNA(posture.BackendKind))} + parts := []string{fmt.Sprintf("Backend: %s", valueOrNA(posture.BackendKind))} if posture.PendingApproval { parts = append(parts, "approval pending") } @@ -106,19 +106,19 @@ func renderBackendPostureLine(posture brokerapi.BackendPostureState) string { } func renderStatusSafetyStrip(r brokerapi.BrokerReadiness) string { - parts := []string{tableHeader("Runtime/audit readiness strip")} + parts := []string{tableHeader("System health")} if !r.VerifierMaterialAvailable { - parts = append(parts, dangerBadge("RUNTIME_POSTURE_AUTH_UNAVAILABLE")) + parts = append(parts, dangerBadge("Runtime proof missing")) } else { - parts = append(parts, successBadge("RUNTIME_POSTURE_AUTH_AVAILABLE")) + parts = append(parts, successBadge("Runtime proof ready")) } if !r.RecoveryComplete || !r.AppendPositionStable || !r.CurrentSegmentWritable { - parts = append(parts, auditDegradedBadge("AUDIT_POSTURE_DEGRADED_OR_UNAVAILABLE")) + parts = append(parts, auditDegradedBadge("Audit needs review")) } else { - parts = append(parts, successBadge("AUDIT_STORAGE_NOMINAL")) + parts = append(parts, successBadge("Audit ready")) } if !r.Ready { - parts = append(parts, systemFailureBadge("SYSTEM_FAILURE_BROKER_NOT_READY")) + parts = append(parts, systemFailureBadge("Broker not ready")) } return compactLines(parts...) } @@ -141,9 +141,9 @@ func renderReadinessDiagnostics(r brokerapi.BrokerReadiness) string { issues = append(issues, "derived index still catching up") } if len(issues) == 0 { - return "Broker health: ready for local work; no runtime or audit warnings reported." + return "Broker health: ready for normal work." } - return fmt.Sprintf("Broker health: attention needed for %s.", joinCSV(issues)) + return fmt.Sprintf("Broker health: %s.", strings.Join(issues, "; ")) } func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { @@ -153,7 +153,7 @@ func renderProjectSubstrateStatusLine(posture brokerapi.ProjectSubstratePostureG } parts := []string{projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)} if summary.NormalOperationAllowed { - parts = append(parts, "Normal work is allowed") + parts = append(parts, "Normal work can continue") } else { parts = append(parts, "Normal work stays blocked until setup is fixed") } @@ -200,7 +200,7 @@ func renderProjectSubstrateGuidance(posture brokerapi.ProjectSubstratePostureGet parts = append(parts, "What blocks normal work: "+sanitizeUIText(posture.BlockedExplanation)) } if len(posture.RemediationGuidance) > 0 { - parts = append(parts, "Broker guidance: "+joinCSV(posture.RemediationGuidance)) + 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.") @@ -219,10 +219,10 @@ func projectSubstrateReasonSummary(posture brokerapi.ProjectSubstratePostureGetR summary := posture.PostureSummary parts := []string{} if len(summary.BlockedReasonCodes) > 0 { - parts = append(parts, "blocked="+joinCSV(summary.BlockedReasonCodes)) + parts = append(parts, "blocked because "+humanizeStatusCodes(summary.BlockedReasonCodes)) } if len(summary.ReasonCodes) > 0 { - parts = append(parts, "reasons="+joinCSV(summary.ReasonCodes)) + parts = append(parts, "notes: "+humanizeStatusCodes(summary.ReasonCodes)) } if explanation := strings.TrimSpace(posture.BlockedExplanation); explanation != "" { parts = append(parts, sanitizeUIText(explanation)) @@ -236,7 +236,7 @@ func projectSubstrateReasonSummary(posture brokerapi.ProjectSubstratePostureGetR func renderStatusOverviewLine(r brokerapi.BrokerReadiness, lifecycle brokerapi.BrokerProductLifecyclePosture, posture brokerapi.ProjectSubstratePostureGetResponse) string { parts := []string{"Overview:"} if r.Ready { - parts = append(parts, "broker reachable") + parts = append(parts, "broker connected") } else { parts = append(parts, "broker not ready") } @@ -246,7 +246,7 @@ func renderStatusOverviewLine(r brokerapi.BrokerReadiness, lifecycle brokerapi.B parts = append(parts, "normal work blocked") } if r.LocalOnly { - parts = append(parts, "local broker mode") + parts = append(parts, "local workspace mode") } return strings.Join(parts, " • ") } @@ -254,22 +254,37 @@ func renderStatusOverviewLine(r brokerapi.BrokerReadiness, lifecycle brokerapi.B func renderStatusSetupLine(posture brokerapi.ProjectSubstratePostureGetResponse) string { summary := posture.PostureSummary if strings.TrimSpace(summary.SchemaID) == "" { - return "Setup posture: unavailable" + return "Setup: unavailable" } - return fmt.Sprintf("Setup posture: %s • %s", projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)) + return fmt.Sprintf("Setup: %s • %s", projectSetupValidationSummary(summary.ValidationState), projectSetupCompatibilitySummary(summary.CompatibilityPosture)) } func renderVersionPostureLine(v brokerapi.BrokerVersionInfo) string { - parts := []string{fmt.Sprintf("Version posture: RuneCode %s", valueOrNA(v.ProductVersion))} + parts := []string{fmt.Sprintf("Version: RuneCode %s", valueOrNA(v.ProductVersion))} if strings.TrimSpace(v.ProtocolBundleVersion) != "" { - parts = append(parts, fmt.Sprintf("protocol bundle %s", sanitizeUIText(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 available (%s)", versionAPIShortLabel(v.APIFamily, 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) } @@ -328,7 +343,7 @@ func projectSetupCompatibilitySummary(value string) string { case "supported": return "fully supported" case "supported_with_upgrade_available": - return "supported with an upgrade available" + return "upgrade available" case "missing": return "no compatible setup detected" case "unsupported": diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index 84374e71..614ac3a2 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -20,8 +20,8 @@ func TestStatusRouteRendersProjectSubstratePostureAndGuidance(t *testing.T) { mustContainAll(t, view, "Managed operation", "Attach and normal managed operation are available.", - "Overview: • broker reachable • normal work available • local broker mode", - "Setup posture: setup validated • supported with an upgrade 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", @@ -282,8 +282,8 @@ func TestStatusRouteRendersDiagnosticsOnlyAttachGuidanceWhenNormalOperationBlock "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", + "Managed-operation blockers: project substrate unsupported too new", + "Managed-operation watchouts: project substrate upgrade available", ) } @@ -344,6 +344,6 @@ func TestStatusRouteRendersBlockedProjectSubstrateGuidance(t *testing.T) { "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", + "Broker guidance: inspect project substrate posture, initialize canonical runecontext substrate, revalidate project substrate", ) } From 14b077d3eec78a8f82c9a6ee896c1e920eab85ff Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 00:56:25 -0500 Subject: [PATCH 32/39] Polish for status indicator lines. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/palette.go | 23 +++++++++++++++++------ cmd/runecode-tui/route_dashboard.go | 4 ++-- cmd/runecode-tui/route_dashboard_test.go | 4 ++-- cmd/runecode-tui/routes_model.go | 10 +++++++--- cmd/runecode-tui/shared_state_card.go | 2 +- cmd/runecode-tui/shared_visual.go | 21 +++++++++++---------- cmd/runecode-tui/shell_compositor.go | 4 ++-- cmd/runecode-tui/shell_overlay_render.go | 12 ++++-------- cmd/runecode-tui/shell_render_helpers.go | 16 +++++++++++----- cmd/runecode-tui/shell_services_test.go | 19 +++++++++++++++++-- cmd/runecode-tui/shell_test.go | 8 ++++---- cmd/runecode-tui/shell_view.go | 4 ++-- 12 files changed, 80 insertions(+), 47 deletions(-) diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index ba74ced2..a1d936eb 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -197,17 +197,28 @@ func paletteMatchLine(entry paletteEntry, selected bool) string { } func paletteMatchLineBounded(entry paletteEntry, width int) string { - left := fmt.Sprintf("%s", entry.Label) - if strings.TrimSpace(entry.Description) != "" { - left += " " + muted(entry.Description) + 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+"]") } - right := paletteEntryShortcut(entry) - return rightAlignMetadata(left, right, width) + return clipDisplayText(strings.Join(parts, " "), width) } func paletteEntryShortcut(entry paletteEntry) string { if strings.TrimSpace(entry.Action.Target.CommandID) != "" { - return "Quick action" + return "Cmd" } if route := strings.TrimSpace(string(entry.Action.Target.RouteID)); route != "" { return "Route" diff --git a/cmd/runecode-tui/route_dashboard.go b/cmd/runecode-tui/route_dashboard.go index 39afc4df..c41ae3c9 100644 --- a/cmd/runecode-tui/route_dashboard.go +++ b/cmd/runecode-tui/route_dashboard.go @@ -159,8 +159,8 @@ func (m dashboardRouteModel) View(width, height int, focus focusArea) string { 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) diff --git a/cmd/runecode-tui/route_dashboard_test.go b/cmd/runecode-tui/route_dashboard_test.go index 1615b318..66a36cbb 100644 --- a/cmd/runecode-tui/route_dashboard_test.go +++ b/cmd/runecode-tui/route_dashboard_test.go @@ -126,7 +126,7 @@ func TestDashboardViewPreservesSectionGaps(t *testing.T) { } updated, _ = updated.Update(cmd()) view := updated.View(120, 40, focusContent) - for _, want := range []string{"Current work", "\n\n| At a glance", "Review 1\n\n| Next action"} { + 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) } @@ -179,7 +179,7 @@ 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\n| At a glance") { + 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, 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/shared_state_card.go b/cmd/runecode-tui/shared_state_card.go index 51594021..7b23849f 100644 --- a/cmd/runecode-tui/shared_state_card.go +++ b/cmd/runecode-tui/shared_state_card.go @@ -36,7 +36,7 @@ func renderStateCardSpec(spec stateCardSpec) string { nextAction = stateCardNextStep(spec.State) } cues := stateCardCueLine(spec.ShortcutCue, spec.RouteCue) - lines := []string{appTheme.SurfaceCard.Padding(0, 1).Render(compactLines("Message: "+message, "Reason: "+reason))} + lines := []string{compactLines("Message: " + message, "Reason: " + reason)} if nextAction != "" { lines = append(lines, "Next: "+nextAction) } diff --git a/cmd/runecode-tui/shared_visual.go b/cmd/runecode-tui/shared_visual.go index a81ff4e0..2a539b2e 100644 --- a/cmd/runecode-tui/shared_visual.go +++ b/cmd/runecode-tui/shared_visual.go @@ -49,10 +49,10 @@ func renderAccentRail(tone visualTone, body string) string { if strings.TrimSpace(body) == "" { return "" } - rail := toneAccentStyle(tone).Render("|") + rail := toneAccentStyle(tone).Render("│") lines := strings.Split(body, "\n") for i, line := range lines { - lines[i] = rail + " " + line + lines[i] = " " + rail + " " + line } return strings.Join(lines, "\n") } @@ -60,17 +60,17 @@ func renderAccentRail(tone visualTone, body string) string { func toneAccentStyle(tone visualTone) lipgloss.Style { switch tone { case visualToneSuccess: - return appTheme.StateSuccess.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.StateSuccess.GetBackground()) case visualToneAttention: - return appTheme.StateWarn.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.StateWarn.GetBackground()) case visualToneDanger: - return appTheme.StateDanger.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.StateDanger.GetBackground()) case visualToneCommand: - return appTheme.Selected.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.Selected.GetForeground()) case visualToneInfo: - return appTheme.StateInfo.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.StateInfo.GetBackground()) default: - return appTheme.BorderStrong.Bold(true) + return lipgloss.NewStyle().Foreground(appTheme.BorderStrong.GetForeground()) } } @@ -127,8 +127,9 @@ func rightAlignMetadata(left, right string, width int) string { if right == "" || width <= 0 { return left } + renderedRight := muted(right) leftWidth := lipgloss.Width(left) - rightWidth := lipgloss.Width(right) + rightWidth := lipgloss.Width(renderedRight) if leftWidth+rightWidth+2 > width { left = clipDisplayText(left, width-rightWidth-2) leftWidth = lipgloss.Width(left) @@ -137,5 +138,5 @@ func rightAlignMetadata(left, right string, width int) string { if gap < 1 { gap = 1 } - return left + strings.Repeat(" ", gap) + muted(right) + return left + strings.Repeat(" ", gap) + renderedRight } 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_overlay_render.go b/cmd/runecode-tui/shell_overlay_render.go index 61b024e5..e490389e 100644 --- a/cmd/runecode-tui/shell_overlay_render.go +++ b/cmd/runecode-tui/shell_overlay_render.go @@ -17,14 +17,12 @@ 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 @@ -50,8 +48,6 @@ func centeredOverlayBlockBounded(title shellOverlayID, body string, viewportWidt Border(lipgloss.NormalBorder()). BorderForeground(appTheme.BorderStrong.GetForeground()). Padding(0, 0). - Width(innerWidth). - MaxWidth(innerWidth). Render(content) rendered := lipgloss.NewStyle().Width(viewportWidth).Align(lipgloss.Center).Render(frame) @@ -85,7 +81,7 @@ func centeredOverlayContentBounds(viewportWidth int) (int, int) { startX = 0 } contentStartX := startX + 2 // border + left padding - contentEndX := startX + outerWidth - 3 + contentEndX := startX + outerWidth - 5 if contentEndX < contentStartX { contentEndX = contentStartX } diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 37e7bbc4..f5a8cdcd 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -21,7 +21,7 @@ func (m shellModel) renderOverlayStack() string { func (m shellModel) renderPalette() string { b := strings.Builder{} width := boundedOverlayListWidth(m.width) - b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(m.palette.query)) + "\n") + b.WriteString(renderOverlaySearchPrompt(m.palette.query) + "\n") if len(m.palette.matches) == 0 { b.WriteString(muted("No matches. Keep typing or press esc to close.")) b.WriteString("\n") @@ -50,7 +50,7 @@ func (m shellModel) renderPalette() string { func (m shellModel) renderSessionQuickSwitcher() string { b := strings.Builder{} width := boundedOverlayListWidth(m.width) - b.WriteString(renderAccentRail(visualToneCommand, "Search "+paletteSearchText(m.sessions.query)) + "\n") + 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") @@ -69,9 +69,11 @@ func (m shellModel) renderSessionQuickSwitcher() string { sessionLabel = "● " + sessionLabel } preview := truncateText(sanitizeUIText(s.LastActivityPreview), 58) - left := fmt.Sprintf("%s %s %s", marker, sessionLabel, sessionHighLevelCue(s)) - right := compactSessionSwitcherCounts(s) - line := rightAlignMetadata(left, right, width) + 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 := []string{} if workspace := strings.TrimSpace(s.Identity.WorkspaceID); workspace != "" { detailParts = append(detailParts, "Workspace "+workspace) @@ -266,3 +268,7 @@ func (m shellModel) renderPrimaryWorkbenchActions() string { } return strings.Join(parts, " · ") } + +func renderOverlaySearchPrompt(query string) string { + return appTheme.TextSecondary.Render("› Search") + " " + paletteSearchText(query) +} diff --git a/cmd/runecode-tui/shell_services_test.go b/cmd/runecode-tui/shell_services_test.go index cf02302a..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 != 6 || end != 73 { - t.Fatalf("expected centered content bounds [6,73], 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_test.go b/cmd/runecode-tui/shell_test.go index fcaba707..d04893e2 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) diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index c1e697c8..5dbd7c3d 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -385,7 +385,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) } @@ -408,7 +408,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) From b35a5c60f815e84a2a1773ade913611a4d9ae1a6 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 09:19:34 -0500 Subject: [PATCH 33/39] Implements a TUI user interaction performance pass. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/main.go | 62 ++-- cmd/runecode-tui/main_test.go | 67 ++++ cmd/runecode-tui/palette.go | 46 ++- cmd/runecode-tui/palette_test.go | 23 ++ cmd/runecode-tui/session_workspace.go | 58 +++- cmd/runecode-tui/session_workspace_test.go | 23 ++ cmd/runecode-tui/shared_components.go | 49 ++- cmd/runecode-tui/shared_components_test.go | 22 ++ cmd/runecode-tui/shared_state_card.go | 2 +- cmd/runecode-tui/shell_commands.go | 5 +- cmd/runecode-tui/shell_focus_overlay.go | 4 +- cmd/runecode-tui/shell_input.go | 30 +- cmd/runecode-tui/shell_leader.go | 200 +++++++---- cmd/runecode-tui/shell_model.go | 152 +++++++-- .../shell_overlay_behavior_test.go | 34 ++ cmd/runecode-tui/shell_overlay_view.go | 234 +++++++++++++ cmd/runecode-tui/shell_perf_test.go | 300 +++++++++++++++++ cmd/runecode-tui/shell_persistence.go | 94 +++++- cmd/runecode-tui/shell_render_helpers.go | 123 +++---- cmd/runecode-tui/shell_services.go | 131 -------- cmd/runecode-tui/shell_shortcuts_test.go | 41 +++ cmd/runecode-tui/shell_update.go | 40 +++ cmd/runecode-tui/shell_view.go | 207 ++---------- cmd/runecode-tui/shell_workbench_store.go | 315 ++++++++++++++++++ cmd/runecode-tui/snapshot_bundle_specs.go | 60 ++++ cmd/runecode-tui/snapshot_bundles.go | 69 +--- cmd/runecode-tui/snapshot_scenario_catalog.go | 59 ++++ cmd/runecode-tui/snapshot_scenarios.go | 296 +--------------- cmd/runecode-tui/snapshot_scenarios_routes.go | 213 ++++++++++++ cmd/runecode-tui/snapshot_watch_helpers.go | 51 +++ justfile | 4 + .../design.md | 51 ++- .../proposal.md | 3 + .../tasks.md | 9 + .../verification.md | 11 + 35 files changed, 2178 insertions(+), 910 deletions(-) create mode 100644 cmd/runecode-tui/shell_overlay_view.go create mode 100644 cmd/runecode-tui/shell_workbench_store.go create mode 100644 cmd/runecode-tui/snapshot_bundle_specs.go create mode 100644 cmd/runecode-tui/snapshot_scenario_catalog.go create mode 100644 cmd/runecode-tui/snapshot_scenarios_routes.go create mode 100644 cmd/runecode-tui/snapshot_watch_helpers.go diff --git a/cmd/runecode-tui/main.go b/cmd/runecode-tui/main.go index b6a3dbf2..737dea9d 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -16,47 +16,67 @@ type usageError struct{ message string } func (e *usageError) Error() string { return e.message } +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 + return 0 } if cfg.snapshot.enabled { if err := writeSnapshotArtifacts(cfg.snapshot); err != nil { - fmt.Fprintf(os.Stderr, "runecode-tui snapshot failed: %v\n", err) - os.Exit(1) + fmt.Fprintf(stderr, "runecode-tui snapshot failed: %v\n", err) + return 1 } - return + return 0 } - 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() - 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) + finalModel, err := runShellProgram(model) + finalModel.flushWorkbenchState() + if err == nil { + err = finalModel.workbenchFlushError() + } + if err != nil { + fmt.Fprintf(stderr, "runecode-tui failed: %v\n", err) + return 1 } + return 0 } func isHelpArg(arg string) bool { diff --git a/cmd/runecode-tui/main_test.go b/cmd/runecode-tui/main_test.go index a6feeddb..31ab8dd7 100644 --- a/cmd/runecode-tui/main_test.go +++ b/cmd/runecode-tui/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "os" "strings" "testing" ) @@ -79,3 +80,69 @@ 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: errForcedFlushFailure{}} + 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()) + } +} + +type errForcedFlushFailure struct{} + +func (errForcedFlushFailure) Error() string { return "forced flush failure" } diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index a1d936eb..fc3090ca 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -9,21 +9,21 @@ import ( ) type paletteModel struct { - open bool - query string - selectedIndex int - matches []paletteEntry - entries []paletteEntry + open bool + query string + selectedIndex int + matches []paletteEntry + entries []paletteEntry + normalizedEntries []string } 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.normalizedEntries = buildPaletteNormalizedEntries(m.entries) m.rebuildMatches() return m } @@ -146,13 +146,13 @@ func (m paletteModel) deleteQueryRune() paletteModel { func (m *paletteModel) rebuildMatches() { needle := strings.ToLower(strings.TrimSpace(m.query)) - m.matches = m.matches[:0:0] + m.matches = m.matches[:0] 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) + for i, entry := range m.entries { + if strings.Contains(m.normalizedEntries[i], needle) { + m.matches = append(m.matches, entry) } } } @@ -165,6 +165,28 @@ func (m *paletteModel) rebuildMatches() { } } +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 { return 0, false diff --git a/cmd/runecode-tui/palette_test.go b/cmd/runecode-tui/palette_test.go index e5773c5d..dc6cc66e 100644 --- a/cmd/runecode-tui/palette_test.go +++ b/cmd/runecode-tui/palette_test.go @@ -103,3 +103,26 @@ 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" + m.rebuildMatches() + if len(m.matches) != 1 || m.matches[0].Index != 2 { + t.Fatalf("expected search text match for second entry, got %+v", m.matches) + } + + m.query = "FOLLOW-UP" + m.rebuildMatches() + if len(m.matches) != 1 || m.matches[0].Index != 2 { + t.Fatalf("expected description match for second entry, got %+v", m.matches) + } +} diff --git a/cmd/runecode-tui/session_workspace.go b/cmd/runecode-tui/session_workspace.go index d1e65821..0f1156c4 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,11 +205,12 @@ 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 + sid := sanitizeUIText(summary.Identity.SessionID) + workspaceID := sanitizeUIText(summary.Identity.WorkspaceID) markerText := formatSessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewed[sid], active) parts := []string{ fmt.Sprintf("%s%s", sid, markerText), - fmt.Sprintf("workspace %s", valueOrNA(summary.Identity.WorkspaceID)), + fmt.Sprintf("workspace %s", valueOrNA(workspaceID)), sessionHighLevelCue(summary), } if summary.LinkedRunCount > 0 { diff --git a/cmd/runecode-tui/session_workspace_test.go b/cmd/runecode-tui/session_workspace_test.go index 50ce8713..efd159fc 100644 --- a/cmd/runecode-tui/session_workspace_test.go +++ b/cmd/runecode-tui/session_workspace_test.go @@ -90,3 +90,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 4ee2eaaa..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 { diff --git a/cmd/runecode-tui/shared_components_test.go b/cmd/runecode-tui/shared_components_test.go index 8eb65b5a..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" @@ -248,3 +249,24 @@ func TestRenderStateCardProvidesDefaultNextStepForBlockedAndCompleted(t *testing "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 index 7b23849f..17e51cac 100644 --- a/cmd/runecode-tui/shared_state_card.go +++ b/cmd/runecode-tui/shared_state_card.go @@ -36,7 +36,7 @@ func renderStateCardSpec(spec stateCardSpec) string { nextAction = stateCardNextStep(spec.State) } cues := stateCardCueLine(spec.ShortcutCue, spec.RouteCue) - lines := []string{compactLines("Message: " + message, "Reason: " + reason)} + lines := []string{compactLines("Message: "+message, "Reason: "+reason)} if nextAction != "" { lines = append(lines, "Next: "+nextAction) } diff --git a/cmd/runecode-tui/shell_commands.go b/cmd/runecode-tui/shell_commands.go index 03353c6e..57bf0055 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() } 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_input.go b/cmd/runecode-tui/shell_input.go index d835b2c2..e34d2b0b 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,9 +106,11 @@ 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() m.setFocus(focusPalette) @@ -108,9 +125,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 +152,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 +164,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 +257,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 +292,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 +324,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 +336,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_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 c41b6f9f..bce06bbe 100644 --- a/cmd/runecode-tui/shell_model.go +++ b/cmd/runecode-tui/shell_model.go @@ -118,12 +118,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 { @@ -137,23 +146,47 @@ type shellQuitConfirmState struct { } func newShellModel() shellModel { + return newShellModelWithWorkbenchStore(nil) +} + +func newShellModelWithWorkbenchStore(store shellWorkbenchStateStore) shellModel { routes := shellRoutes() models := newRouteModels(routes) defaultRoute := routeChat commands := defaultShellCommandRegistry() actions := newShellActionGraph(routes, commands) - workbench := newDefaultWorkbenchStateStore() + 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") { - workbench = &memoryWorkbenchStateStore{} + return &memoryWorkbenchStateStore{} } - scope := logicalBrokerTargetKey() + 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) } - appTheme = newTheme(themePresetDark) - m := shellModel{ +} + +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), @@ -173,28 +206,26 @@ func newShellModel() shellModel { 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", + 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{}, } - _ = m.setLeaderKey(m.leaderKeyConfig) - m.restoreWorkbenchState() - m.syncSidebarCursorToLocation() - return m } func (m shellModel) Init() tea.Cmd { @@ -202,6 +233,7 @@ func (m shellModel) Init() tea.Cmd { } 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) @@ -267,15 +299,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) @@ -283,8 +321,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 { @@ -302,6 +340,50 @@ 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) focusedRouteRegion() routeRegionFocus { if m.palette.IsOpen() || m.sessions.IsOpen() || m.leader.Active() || m.quitConfirm.active { return routeRegionOverlay diff --git a/cmd/runecode-tui/shell_overlay_behavior_test.go b/cmd/runecode-tui/shell_overlay_behavior_test.go index 053d1c4c..70098855 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,35 @@ 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) + } +} 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..9c74e474 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,297 @@ 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) { + entries := makePaletteBenchmarkEntries(2000) + keys := defaultShellKeyMap() + base := newPaletteModel(entries) + query := []rune("session-1999") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + state := base.Open() + for _, r := range query { + state, _, _ = state.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}, keys) + } + } +} + +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 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..c18cc81e 100644 --- a/cmd/runecode-tui/shell_persistence.go +++ b/cmd/runecode-tui/shell_persistence.go @@ -1,12 +1,39 @@ 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 (m *shellModel) currentWorkbenchState() workbenchLocalState { + return workbenchLocalState{ SidebarVisible: m.sidebarVisible, InspectorVisible: m.inspectorOn, InspectorMode: normalizePresentationMode(m.preferredMode), @@ -14,16 +41,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 +126,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 +136,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 +158,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 +176,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 +192,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 f5a8cdcd..8e4706c5 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -29,19 +29,17 @@ func (m shellModel) renderPalette() string { } 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: paletteMatchLineBounded(entry, width), Selectable: true}) - } - b.WriteString(renderBoundedList(boundedListSpec{ - Rows: rows, - Selected: m.palette.selectedIndex, + b.WriteString(renderBoundedListWindowed(boundedListWindowedSpec{ + TotalRows: len(m.palette.matches), + SelectedRow: m.palette.selectedIndex, Width: width, Height: 10, GapMarker: "...", - PreserveGaps: true, ApplySelected: true, ActiveFill: true, + RenderRow: func(index int) boundedListRow { + return boundedListRow{Text: paletteMatchLineBounded(m.palette.matches[index], width), Selectable: true} + }, })) b.WriteString("\n") return b.String() @@ -58,56 +56,58 @@ func (m shellModel) renderSessionQuickSwitcher() string { } 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 - } - 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 := []string{} - if workspace := strings.TrimSpace(s.Identity.WorkspaceID); workspace != "" { - detailParts = append(detailParts, "Workspace "+workspace) - } - if activityKind := strings.TrimSpace(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" - } - line = compactLines(line, muted(clipDisplayText(detail, width))) - rows = append(rows, boundedListRow{Text: line, Selectable: true}) - } - b.WriteString(renderBoundedList(boundedListSpec{ - Rows: rows, - Selected: m.sessions.selectedIndex, + 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 = "▶" + } + 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 + } + 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 := strings.TrimSpace(s.Identity.WorkspaceID); workspace != "" { + detailParts = append(detailParts, "Workspace "+workspace) + } + if activityKind := strings.TrimSpace(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 { @@ -139,18 +139,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 == "" { 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_shortcuts_test.go b/cmd/runecode-tui/shell_shortcuts_test.go index 3f85d154..64131ceb 100644 --- a/cmd/runecode-tui/shell_shortcuts_test.go +++ b/cmd/runecode-tui/shell_shortcuts_test.go @@ -781,3 +781,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_update.go b/cmd/runecode-tui/shell_update.go index 6b2e211f..171bcd0e 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 @@ -211,6 +242,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 +289,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 +324,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,6 +338,9 @@ 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 + if !m.palette.IsOpen() { + m.invalidateOverlayFrameCache() + } m.syncOverlayStack() m.restoreFocusAfterOverlayClose() if changed { diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 5dbd7c3d..02d586de 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" ) func (m shellModel) View() string { @@ -13,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). @@ -27,14 +28,30 @@ func (m shellModel) View() string { func (m shellModel) renderShellWorkbench(surface routeSurface, layout shellLayoutPlan, viewportWidth int, viewportHeight int) string { overlayBody, _ := m.overlayBodyWithHeight(surface, layout, viewportHeight) + 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 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 strings.TrimSpace(overlayBody) == "" { - return lipgloss.JoinVertical(lipgloss.Left, frame) + if m.overlayFrameCacheable() && m.overlayFrameCache != nil { + *m.overlayFrameCache = shellOverlayFrameCache{overlay: m.activeOverlayID(), width: viewportWidth, height: viewportHeight, frame: frame} } - return applyModalOverlay(scrubShellFrameForOverlay(frame, viewportWidth, viewportHeight), overlayBody, viewportWidth, viewportHeight) + return frame } func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, layout shellLayoutPlan, viewportWidth int) { @@ -56,110 +73,6 @@ func (m shellModel) writeShellFooter(b *strings.Builder, viewportWidth int) { _ = viewportWidth } -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() - _, height := m.overlayBodyWithHeight(surface, m.planShellLayout(surface), 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 constrainShellBlock(block string, width int, height int) string { if height <= 0 { return "" @@ -229,78 +142,6 @@ func (m shellModel) renderSyncHealth() string { return text } -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") -} - func (m shellModel) renderBreadcrumbs(surface routeSurface) string { breadcrumbs := surface.Chrome.Breadcrumbs if len(breadcrumbs) == 0 { 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/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 index bafa3cd7..57aa6285 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -44,74 +44,11 @@ func normalizeSnapshotBundle(name string) snapshotAuditBundle { } func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { - switch normalizeSnapshotBundle(name) { - case snapshotBundleFullAudit: - return snapshotBundleSpec{ - 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}, - }, nil - case snapshotBundleDashboard: - return snapshotBundleSpec{Name: snapshotBundleDashboard, Scenarios: []string{"dashboard-healthy-empty", "dashboard-approval-waiting", "dashboard-blocked", "dashboard-degraded", "dashboard-toast-info", "dashboard-focus-nav"}, Routes: []routeID{routeDashboard}}, nil - case snapshotBundleActionCenter: - return snapshotBundleSpec{Name: snapshotBundleActionCenter, Scenarios: []string{"action-center-triage"}, Routes: []routeID{routeAction}}, nil - case snapshotBundleRuns: - return snapshotBundleSpec{Name: snapshotBundleRuns, Scenarios: []string{"runs-active-detail", "runs-focus-inspector", "runs-raw-detail", "artifacts-evidence-detail", "artifacts-structured-detail"}, Routes: []routeID{routeRuns, routeArtifacts}}, nil - case snapshotBundleApprovals: - return snapshotBundleSpec{Name: snapshotBundleApprovals, Scenarios: []string{"approvals-pending-detail", "approvals-structured-detail"}, Routes: []routeID{routeApprovals}}, nil - case snapshotBundleAudit: - return snapshotBundleSpec{Name: snapshotBundleAudit, Scenarios: []string{"audit-degraded-detail", "audit-raw-detail"}, Routes: []routeID{routeAudit}}, nil - case snapshotBundleStatus: - return snapshotBundleSpec{Name: snapshotBundleStatus, Scenarios: []string{"status-ready-overview", "status-degraded-overview"}, Routes: []routeID{routeStatus}}, nil - case snapshotBundleSetup: - return snapshotBundleSpec{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}}, nil - case snapshotBundleChat: - return snapshotBundleSpec{Name: snapshotBundleChat, Scenarios: []string{"chat-active-session", "chat-compose-open"}, Routes: []routeID{routeChat}}, nil - case snapshotBundleShell: - return snapshotBundleSpec{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}}, nil - case snapshotBundleNarrowMobile: - return snapshotBundleSpec{Name: snapshotBundleNarrowMobile, Scenarios: []string{"narrow-sidebar-overlay", "narrow-inspector-overlay"}, Routes: []routeID{routeDashboard, routeRuns}}, nil - case snapshotBundleNarrowCompact: - return snapshotBundleSpec{Name: snapshotBundleNarrowCompact, Scenarios: []string{"narrow-sidebar-overlay-compact", "narrow-inspector-overlay-compact"}, Routes: []routeID{routeDashboard, routeRuns}}, nil - default: + bundle, ok := snapshotBundleSpecs()[normalizeSnapshotBundle(name)] + if !ok { return snapshotBundleSpec{}, fmt.Errorf("unknown snapshot bundle %q", name) } + return bundle, nil } func snapshotCoverageForBundle(bundle snapshotAuditBundle, scenarios []snapshotScenarioState, viewport snapshotViewportPreset) snapshotCoverage { 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 index c2e8bdf1..9f7bb71e 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -28,60 +28,12 @@ type snapshotScenarioState struct { } func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { - scenarios := []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(), - } + scenarios := allSnapshotScenarios() switch strings.TrimSpace(name) { case "", "all": return scenarios, nil case "dashboard": - return []snapshotScenarioState{ - buildHealthyEmptySnapshot(), - buildApprovalWaitingSnapshot(), - buildBlockedSnapshot(), - buildDegradedSnapshot(), - buildToastSnapshot(), - buildDashboardFocusNavSnapshot(), - buildLeaderHelpSnapshot(), - }, nil + return dashboardSnapshotScenarios(), nil case "action-center": return []snapshotScenarioState{buildActionCenterSnapshot()}, nil default: @@ -413,247 +365,3 @@ func buildLeaderHelpSnapshot() snapshotScenarioState { m.syncOverlayStack() }} } - -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 -} - -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/snapshot_scenarios_routes.go b/cmd/runecode-tui/snapshot_scenarios_routes.go new file mode 100644 index 00000000..4a4b095a --- /dev/null +++ b/cmd/runecode-tui/snapshot_scenarios_routes.go @@ -0,0 +1,213 @@ +//go:build runecode_tui_snapshot + +package main + +import ( + "strings" + "time" + + "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_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/justfile b/justfile index 0999dce3..fb1a6c90 100644 --- a/justfile +++ b/justfile @@ -23,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 @@ -39,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 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 998a793c..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 @@ -183,6 +183,46 @@ The first once-over identified these concrete polish directions: ### 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. @@ -286,7 +326,10 @@ To reach a polished and professional TUI experience, close the gaps in this orde 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. + 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. @@ -312,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 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 322431db..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,7 +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. @@ -58,6 +60,7 @@ That split is easier to reason about than continuing to leave integration and po - `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. 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 5c07c83a..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 @@ -49,6 +49,12 @@ - [ ] 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. @@ -70,6 +76,8 @@ - [ ] 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. @@ -106,5 +114,6 @@ - [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, 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 3338ba32..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 @@ -21,6 +21,9 @@ - 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. @@ -41,6 +44,8 @@ - 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. @@ -54,6 +59,12 @@ - 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. From cfdd8ef8acf0ec197c67d1f7c5ce92bb7add5574 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 10:27:53 -0500 Subject: [PATCH 34/39] Another TUI user input performance pass, specifically around the Command Palette screen and logic. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/command_surface.go | 43 ++++- cmd/runecode-tui/palette.go | 215 +++++++++++++++++----- cmd/runecode-tui/palette_test.go | 65 ++++++- cmd/runecode-tui/shell_commands.go | 2 +- cmd/runecode-tui/shell_input.go | 5 +- cmd/runecode-tui/shell_model.go | 36 ++++ cmd/runecode-tui/shell_object_index.go | 74 +++++++- cmd/runecode-tui/shell_perf_test.go | 83 ++++++++- cmd/runecode-tui/shell_render_helpers.go | 15 +- cmd/runecode-tui/shell_shortcuts_test.go | 11 +- cmd/runecode-tui/shell_test.go | 18 +- cmd/runecode-tui/shell_update.go | 23 ++- cmd/runecode-tui/shell_watch_transport.go | 1 + cmd/runecode-tui/shell_workspace.go | 1 + cmd/runecode-tui/snapshot_scenarios.go | 6 +- 15 files changed, 517 insertions(+), 81 deletions(-) diff --git a/cmd/runecode-tui/command_surface.go b/cmd/runecode-tui/command_surface.go index 831526f5..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 } diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index fc3090ca..f8412910 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -8,13 +8,31 @@ 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 + appliedNeedle string selectedIndex int - matches []paletteEntry + matchIndexes []int entries []paletteEntry normalizedEntries []string + entriesVersion uint64 + entriesRequest uint64 + filterRequest uint64 + entriesLoading bool + filterLoading bool } func newPaletteModel(entries []paletteEntry) paletteModel { @@ -24,20 +42,29 @@ func newPaletteModel(entries []paletteEntry) paletteModel { func (m paletteModel) UpdateEntries(entries []paletteEntry) paletteModel { m.entries = append([]paletteEntry(nil), entries...) m.normalizedEntries = buildPaletteNormalizedEntries(m.entries) - m.rebuildMatches() + 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,93 @@ 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.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 + } + 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 +176,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 +198,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,52 +215,77 @@ 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] +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 i, entry := range m.entries { - if strings.Contains(m.normalizedEntries[i], needle) { - m.matches = append(m.matches, entry) - } - } + 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 { @@ -188,7 +311,7 @@ func normalizePaletteEntrySearch(entry paletteEntry) 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) @@ -198,7 +321,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 } } diff --git a/cmd/runecode-tui/palette_test.go b/cmd/runecode-tui/palette_test.go index dc6cc66e..13a5ab5b 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) } @@ -115,14 +115,63 @@ func TestPaletteFilteringMatchesAcrossCachedNormalizedFields(t *testing.T) { } m.query = "WORKSPACE-2" - m.rebuildMatches() - if len(m.matches) != 1 || m.matches[0].Index != 2 { - t.Fatalf("expected search text match for second entry, got %+v", m.matches) + 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" - m.rebuildMatches() - if len(m.matches) != 1 || m.matches[0].Index != 2 { - t.Fatalf("expected description match for second entry, got %+v", m.matches) + 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()) } } diff --git a/cmd/runecode-tui/shell_commands.go b/cmd/runecode-tui/shell_commands.go index 57bf0055..fcc63f26 100644 --- a/cmd/runecode-tui/shell_commands.go +++ b/cmd/runecode-tui/shell_commands.go @@ -126,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_input.go b/cmd/runecode-tui/shell_input.go index e34d2b0b..3fa8db2c 100644 --- a/cmd/runecode-tui/shell_input.go +++ b/cmd/runecode-tui/shell_input.go @@ -112,10 +112,11 @@ func (m shellModel) handleOpenPaletteKey(key tea.KeyMsg) (tea.Model, tea.Cmd, bo 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) { diff --git a/cmd/runecode-tui/shell_model.go b/cmd/runecode-tui/shell_model.go index bce06bbe..0f7ad503 100644 --- a/cmd/runecode-tui/shell_model.go +++ b/cmd/runecode-tui/shell_model.go @@ -87,6 +87,7 @@ type shellModel struct { workbenchScope string toasts shellToastService objectIndex shellDiscoverabilityIndex + paletteCache []paletteEntry sidebarVisible bool inspectorOn bool @@ -225,6 +226,7 @@ func newShellModelState(routes []routeDefinition, models map[routeID]routeModel, leaderBindingsSignature: shellLeaderBindingsSignature(leaderBindings), leaderKeyConfig: "space", overlayFrameCache: &shellOverlayFrameCache{}, + paletteCache: nil, } } @@ -384,6 +386,40 @@ func (m *shellModel) invalidateOverlayFrameCache() { *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_object_index.go b/cmd/runecode-tui/shell_object_index.go index 79a5d4ed..88343460 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...), @@ -98,6 +113,7 @@ func (m *shellModel) applyObjectIndexLoaded(msg shellObjectIndexLoadedMsg) { if msg.auditErr == nil { m.objectIndex.ingestAuditRecords(msg.auditRecords) } + m.invalidatePaletteCache() m.refreshObjectIndexFromShellState() } @@ -110,9 +126,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 +366,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_perf_test.go b/cmd/runecode-tui/shell_perf_test.go index 9c74e474..d31066ea 100644 --- a/cmd/runecode-tui/shell_perf_test.go +++ b/cmd/runecode-tui/shell_perf_test.go @@ -172,16 +172,45 @@ func BenchmarkShellSessionQuickSwitchTyping(b *testing.B) { } func BenchmarkPaletteFilterTyping(b *testing.B) { - entries := makePaletteBenchmarkEntries(2000) - keys := defaultShellKeyMap() - base := newPaletteModel(entries) - query := []rune("session-1999") + base := newPaletteModel(makePaletteBenchmarkEntries(2000)).Open() + query := "session-1999" b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - state := base.Open() - for _, r := range query { - state, _, _ = state.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}, keys) + 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) + } } } } @@ -215,6 +244,46 @@ func BenchmarkRenderPaletteLargeMatchWindow(b *testing.B) { } } +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 diff --git a/cmd/runecode-tui/shell_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 8e4706c5..63b45e6b 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -22,7 +22,12 @@ func (m shellModel) renderPalette() string { b := strings.Builder{} width := boundedOverlayListWidth(m.width) b.WriteString(renderOverlaySearchPrompt(m.palette.query) + "\n") - if len(m.palette.matches) == 0 { + 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() @@ -30,7 +35,7 @@ func (m shellModel) renderPalette() string { b.WriteString(tableHeader("Suggested actions")) b.WriteString("\n") b.WriteString(renderBoundedListWindowed(boundedListWindowedSpec{ - TotalRows: len(m.palette.matches), + TotalRows: m.palette.MatchCount(), SelectedRow: m.palette.selectedIndex, Width: width, Height: 10, @@ -38,7 +43,11 @@ func (m shellModel) renderPalette() string { ApplySelected: true, ActiveFill: true, RenderRow: func(index int) boundedListRow { - return boundedListRow{Text: paletteMatchLineBounded(m.palette.matches[index], width), Selectable: true} + entry, ok := m.palette.MatchEntry(index) + if !ok { + return boundedListRow{} + } + return boundedListRow{Text: paletteMatchLineBounded(entry, width), Selectable: true} }, })) b.WriteString("\n") diff --git a/cmd/runecode-tui/shell_shortcuts_test.go b/cmd/runecode-tui/shell_shortcuts_test.go index 64131ceb..27242f37 100644 --- a/cmd/runecode-tui/shell_shortcuts_test.go +++ b/cmd/runecode-tui/shell_shortcuts_test.go @@ -179,13 +179,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) { diff --git a/cmd/runecode-tui/shell_test.go b/cmd/runecode-tui/shell_test.go index d04893e2..cd5bf034 100644 --- a/cmd/runecode-tui/shell_test.go +++ b/cmd/runecode-tui/shell_test.go @@ -735,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 { @@ -749,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") } diff --git a/cmd/runecode-tui/shell_update.go b/cmd/runecode-tui/shell_update.go index 171bcd0e..06f68e5d 100644 --- a/cmd/runecode-tui/shell_update.go +++ b/cmd/runecode-tui/shell_update.go @@ -199,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: @@ -338,6 +352,13 @@ 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() } @@ -346,7 +367,7 @@ func (m shellModel) handlePaletteKey(key tea.KeyMsg) (tea.Model, tea.Cmd, bool) 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_watch_transport.go b/cmd/runecode-tui/shell_watch_transport.go index 251b5852..a638042b 100644 --- a/cmd/runecode-tui/shell_watch_transport.go +++ b/cmd/runecode-tui/shell_watch_transport.go @@ -83,6 +83,7 @@ func (m shellModel) loadWatchPollCmd() tea.Cmd { func (m *shellModel) applyWatchTransport(msg shellWatchTransportLoadedMsg) { m.watch.applyTransport(msg) m.publishWatchStateToRoutes() + m.invalidatePaletteCache() m.refreshObjectIndexFromShellState() } diff --git a/cmd/runecode-tui/shell_workspace.go b/cmd/runecode-tui/shell_workspace.go index 19be5c1a..e2732155 100644 --- a/cmd/runecode-tui/shell_workspace.go +++ b/cmd/runecode-tui/shell_workspace.go @@ -31,6 +31,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_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 9f7bb71e..10debf71 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -223,7 +223,11 @@ 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" - m.palette.rebuildMatches() + 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() }} } From ce199b8fc9324f0fe84059fb77c6361306b1b908 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 11:19:08 -0500 Subject: [PATCH 35/39] Fixes issues from PR comments. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/main.go | 28 ++++- .../route_runs_approvals_artifacts_test.go | 115 ++++++++++++++++++ cmd/runecode-tui/route_runs_evidence.go | 9 +- cmd/runecode-tui/safety_ux.go | 46 +++++-- cmd/runecode-tui/session_workspace.go | 10 +- cmd/runecode-tui/session_workspace_test.go | 69 ++++++++++- cmd/runecode-tui/shell_model.go | 86 ------------- cmd/runecode-tui/shell_model_state.go | 90 ++++++++++++++ cmd/runecode-tui/shell_render_helpers.go | 12 +- cmd/runecode-tui/snapshot_bundles.go | 14 ++- cmd/runecode-tui/snapshot_enabled.go | 21 ++-- cmd/runecode-tui/snapshot_enabled_test.go | 111 +++++++++++++++++ cmd/runecode-tui/snapshot_scenarios.go | 23 +++- cmd/runecode-tui/snapshot_scenarios_routes.go | 1 - cmd/runecode-tui/snapshot_svg.go | 15 ++- 15 files changed, 510 insertions(+), 140 deletions(-) create mode 100644 cmd/runecode-tui/shell_model_state.go diff --git a/cmd/runecode-tui/main.go b/cmd/runecode-tui/main.go index 737dea9d..3471ff15 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -2,6 +2,7 @@ package main import ( + "errors" "flag" "fmt" "io" @@ -16,6 +17,15 @@ 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 @@ -49,11 +59,7 @@ func runMain(args []string, stdin *os.File, stdout *os.File, stderr io.Writer) i } if cfg.snapshot.enabled { - if err := writeSnapshotArtifacts(cfg.snapshot); err != nil { - fmt.Fprintf(stderr, "runecode-tui snapshot failed: %v\n", err) - return 1 - } - return 0 + return runSnapshotMode(cfg.snapshot, stderr) } if !isTerminalFunc(int(stdin.Fd())) || !isTerminalFunc(int(stdout.Fd())) { @@ -79,6 +85,18 @@ func runMain(args []string, stdin *os.File, stdout *os.File, stderr io.Writer) i return 0 } +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 { switch arg { case "-h", "--help", "help": diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index 0940a52c..0273848e 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -484,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 attested posture earned from verified post-handshake evidence") { + t.Fatalf("expected sanitized valid posture to unlock supported attestation wording, 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_evidence.go b/cmd/runecode-tui/route_runs_evidence.go index f271acd9..d90dd9fe 100644 --- a/cmd/runecode-tui/route_runs_evidence.go +++ b/cmd/runecode-tui/route_runs_evidence.go @@ -9,6 +9,9 @@ import ( ) 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, " • ") } @@ -85,15 +88,15 @@ func attestationPostureFromState(state map[string]any) (string, []string) { reasonsAny, ok := state["attestation_reason_codes"].([]any) if !ok { reasons, _ := state["attestation_reason_codes"].([]string) - return posture, reasons + return sanitizeAttestationPostureAndReasons(posture, reasons) } reasons := make([]string, 0, len(reasonsAny)) for _, value := range reasonsAny { - if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { + if s, ok := value.(string); ok { reasons = append(reasons, s) } } - return posture, reasons + return sanitizeAttestationPostureAndReasons(posture, reasons) } func runInspectorContentKind(presentation contentPresentationMode) inspectorContentKind { diff --git a/cmd/runecode-tui/safety_ux.go b/cmd/runecode-tui/safety_ux.go index 4fa19d4f..22077560 100644 --- a/cmd/runecode-tui/safety_ux.go +++ b/cmd/runecode-tui/safety_ux.go @@ -46,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")) @@ -91,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 { @@ -187,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, ",") diff --git a/cmd/runecode-tui/session_workspace.go b/cmd/runecode-tui/session_workspace.go index 0f1156c4..352be78e 100644 --- a/cmd/runecode-tui/session_workspace.go +++ b/cmd/runecode-tui/session_workspace.go @@ -205,9 +205,10 @@ 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 := sanitizeUIText(summary.Identity.SessionID) + rawSessionID := strings.TrimSpace(summary.Identity.SessionID) + sid := sanitizeUIText(rawSessionID) workspaceID := sanitizeUIText(summary.Identity.WorkspaceID) - markerText := formatSessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewed[sid], active) + markerText := formatSessionDirectoryMarkers(summary, activeSessionID, pinned, recentOrder, viewed[rawSessionID], active) parts := []string{ fmt.Sprintf("%s%s", sid, markerText), fmt.Sprintf("workspace %s", valueOrNA(workspaceID)), @@ -226,11 +227,12 @@ func sessionDirectoryLine(summary brokerapi.SessionSummary, activeSessionID stri } func sessionSidebarLine(summary brokerapi.SessionSummary, activeSessionID string, pinned map[string]struct{}, recentOrder map[string]int, viewed map[string]string, active shellActivityFocus) string { - sid := strings.TrimSpace(summary.Identity.SessionID) + rawSessionID := strings.TrimSpace(summary.Identity.SessionID) + sid := sanitizeUIText(rawSessionID) if sid == "" { sid = "session" } - markers := conciseSessionMarkers(summary, activeSessionID, pinned, recentOrder, viewed[sid], active) + markers := conciseSessionMarkers(summary, activeSessionID, pinned, recentOrder, viewed[rawSessionID], active) parts := []string{sid} if cue := sessionHighLevelCue(summary); cue != "" { parts = append(parts, cue) diff --git a/cmd/runecode-tui/session_workspace_test.go b/cmd/runecode-tui/session_workspace_test.go index efd159fc..ff17a19e 100644 --- a/cmd/runecode-tui/session_workspace_test.go +++ b/cmd/runecode-tui/session_workspace_test.go @@ -44,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", "Workspace ws-1", "Recent activity run_progress", "1 run"} { + 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) { diff --git a/cmd/runecode-tui/shell_model.go b/cmd/runecode-tui/shell_model.go index 0f7ad503..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" @@ -146,90 +144,6 @@ type shellQuitConfirmState struct { reason string } -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, - } -} - func (m shellModel) Init() tea.Cmd { return tea.Batch(m.activateCurrentRouteCmd(), m.loadSessionWorkspaceCmd(), m.loadObjectIndexCmd(), m.startWatchPollCmd(), m.mouseCaptureCmd()) } 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_render_helpers.go b/cmd/runecode-tui/shell_render_helpers.go index 63b45e6b..d15743e6 100644 --- a/cmd/runecode-tui/shell_render_helpers.go +++ b/cmd/runecode-tui/shell_render_helpers.go @@ -87,8 +87,12 @@ func (m shellModel) renderSessionQuickSwitcherRow(index int, width int) string { if index == 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 { + 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) @@ -98,10 +102,10 @@ func (m shellModel) renderSessionQuickSwitcherRow(index int, width int) string { } line := clipDisplayText(strings.Join(lineParts, " "), width) detailParts := make([]string, 0, 3) - if workspace := strings.TrimSpace(s.Identity.WorkspaceID); workspace != "" { + if workspace := sanitizeUIText(s.Identity.WorkspaceID); workspace != "" { detailParts = append(detailParts, "Workspace "+workspace) } - if activityKind := strings.TrimSpace(s.LastActivityKind); activityKind != "" { + if activityKind := sanitizeUIText(s.LastActivityKind); activityKind != "" { detailParts = append(detailParts, "Recent activity "+activityKind) } if preview != "" { diff --git a/cmd/runecode-tui/snapshot_bundles.go b/cmd/runecode-tui/snapshot_bundles.go index 57aa6285..053cc30f 100644 --- a/cmd/runecode-tui/snapshot_bundles.go +++ b/cmd/runecode-tui/snapshot_bundles.go @@ -3,7 +3,6 @@ package main import ( - "fmt" "slices" "strings" ) @@ -46,23 +45,30 @@ func normalizeSnapshotBundle(name string) snapshotAuditBundle { func resolveSnapshotBundle(name string) (snapshotBundleSpec, error) { bundle, ok := snapshotBundleSpecs()[normalizeSnapshotBundle(name)] if !ok { - return snapshotBundleSpec{}, fmt.Errorf("unknown snapshot bundle %q", name) + return snapshotBundleSpec{}, usageErrorf("unknown snapshot bundle %q", name) } return bundle, nil } -func snapshotCoverageForBundle(bundle snapshotAuditBundle, scenarios []snapshotScenarioState, viewport snapshotViewportPreset) snapshotCoverage { +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: []string{string(viewport)}, Scenarios: scenarioNames} + return snapshotCoverage{Bundle: string(bundle), Routes: routes, Viewports: viewports, Scenarios: scenarioNames} } diff --git a/cmd/runecode-tui/snapshot_enabled.go b/cmd/runecode-tui/snapshot_enabled.go index a7e3284a..888f92be 100644 --- a/cmd/runecode-tui/snapshot_enabled.go +++ b/cmd/runecode-tui/snapshot_enabled.go @@ -4,7 +4,6 @@ package main import ( "flag" - "fmt" "strings" ) @@ -23,14 +22,16 @@ type snapshotViewportSpec struct { } type tuiSnapshotConfig struct { - enabled bool - scenario string - bundle string - outputDir string - viewport snapshotViewportPreset - width int - height int - theme themePreset + enabled bool + scenario string + bundle string + outputDir string + viewport snapshotViewportPreset + width int + height int + widthFromViewport bool + heightFromViewport bool + theme themePreset } type tuiSnapshotFlags struct { @@ -80,6 +81,6 @@ func resolveSnapshotViewportPreset(name snapshotViewportPreset) (snapshotViewpor case snapshotViewportMobile: return snapshotViewportSpec{Name: snapshotViewportMobile, Width: 80, Height: 32}, nil default: - return snapshotViewportSpec{}, fmt.Errorf("unknown snapshot viewport %q", name) + 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 index efdfa3eb..8f517bfd 100644 --- a/cmd/runecode-tui/snapshot_enabled_test.go +++ b/cmd/runecode-tui/snapshot_enabled_test.go @@ -3,11 +3,13 @@ package main import ( + "bytes" "encoding/json" "errors" "os" "path/filepath" "regexp" + "strings" "testing" ) @@ -199,6 +201,49 @@ func TestWriteSnapshotArtifactsIncludesPerScenarioViewportAndDimensions(t *testi } } +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 { @@ -237,3 +282,69 @@ func TestWriteSnapshotArtifactsRemovesStalePNG(t *testing.T) { 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_scenarios.go b/cmd/runecode-tui/snapshot_scenarios.go index 10debf71..95613b9d 100644 --- a/cmd/runecode-tui/snapshot_scenarios.go +++ b/cmd/runecode-tui/snapshot_scenarios.go @@ -3,7 +3,6 @@ package main import ( - "fmt" "strings" "time" @@ -42,7 +41,7 @@ func resolveSnapshotScenarios(name string) ([]snapshotScenarioState, error) { return []snapshotScenarioState{scenario}, nil } } - return nil, fmt.Errorf("unknown snapshot scenario %q", name) + return nil, usageErrorf("unknown snapshot scenario %q", name) } } @@ -101,12 +100,30 @@ func renderSnapshotScenario(state snapshotScenarioState, cfg tuiSnapshotConfig) }) } +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 } - height := cfg.height if state.Height > 0 { height = state.Height } diff --git a/cmd/runecode-tui/snapshot_scenarios_routes.go b/cmd/runecode-tui/snapshot_scenarios_routes.go index 4a4b095a..5f78e0ea 100644 --- a/cmd/runecode-tui/snapshot_scenarios_routes.go +++ b/cmd/runecode-tui/snapshot_scenarios_routes.go @@ -4,7 +4,6 @@ package main import ( "strings" - "time" "github.com/runecode-ai/runecode/internal/brokerapi" "github.com/runecode-ai/runecode/internal/trustpolicy" diff --git a/cmd/runecode-tui/snapshot_svg.go b/cmd/runecode-tui/snapshot_svg.go index fc6b8f6e..6e5d68cc 100644 --- a/cmd/runecode-tui/snapshot_svg.go +++ b/cmd/runecode-tui/snapshot_svg.go @@ -91,7 +91,7 @@ func writeSnapshotArtifacts(cfg tuiSnapshotConfig) error { 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.viewport)} + 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 @@ -105,11 +105,11 @@ func normalizeSnapshotConfig(cfg tuiSnapshotConfig) (tuiSnapshotConfig, error) { cfg.outputDir = snapshotDefaultOutputDir() } if strings.TrimSpace(cfg.bundle) != "" && strings.TrimSpace(cfg.scenario) != "" { - return tuiSnapshotConfig{}, fmt.Errorf("snapshot bundle and snapshot scenario are mutually exclusive") + return tuiSnapshotConfig{}, usageErrorf("snapshot bundle and snapshot scenario are mutually exclusive") } normalizedOutputDir, err := normalizeSnapshotOutputDir(cfg.outputDir) if err != nil { - return tuiSnapshotConfig{}, err + return tuiSnapshotConfig{}, usageErrorf("%v", err) } cfg.outputDir = normalizedOutputDir viewport, err := resolveSnapshotViewportPreset(cfg.viewport) @@ -121,12 +121,14 @@ func normalizeSnapshotConfig(cfg tuiSnapshotConfig) (tuiSnapshotConfig, error) { } 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{}, fmt.Errorf("snapshot dimensions must be positive") + return tuiSnapshotConfig{}, usageErrorf("snapshot dimensions must be positive") } return cfg, nil } @@ -154,8 +156,9 @@ func writeSnapshotScenarioArtifacts(cfg tuiSnapshotConfig, scenarios []snapshotS } func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScenarioState) (snapshotManifestEntry, error) { + viewport := snapshotScenarioViewport(scenario, cfg) width, height := snapshotScenarioDimensions(scenario, cfg) - paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name, cfg.viewport) + paths := snapshotPathsForScenario(cfg.outputDir, scenario.Name, viewport) ansiManifestPath, err := snapshotManifestArtifactPath(cfg.outputDir, paths.ANSI) if err != nil { return snapshotManifestEntry{}, err @@ -196,7 +199,7 @@ func writeSnapshotScenarioArtifact(cfg tuiSnapshotConfig, scenario snapshotScena svgArtifact.Path = svgManifestPath return snapshotManifestEntry{ Name: scenario.Name, - Viewport: string(cfg.viewport), + Viewport: string(viewport), Width: width, Height: height, Route: string(routeID), From 163dd2a309cfc068bd65d9fb3f9aa4e3b44b943b Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 16:00:33 -0500 Subject: [PATCH 36/39] Updates based on code review for the branch. Signed-off-by: Zebulon Rouse-Frantzich --- cmd/runecode-tui/help.go | 3 +- cmd/runecode-tui/help_test.go | 9 +-- cmd/runecode-tui/main.go | 5 +- cmd/runecode-tui/main_test.go | 10 ++-- cmd/runecode-tui/palette.go | 4 ++ cmd/runecode-tui/palette_test.go | 33 +++++++++++ cmd/runecode-tui/route_git_remote_mutation.go | 35 ++++++++++- .../route_git_remote_mutation_test.go | 19 ++++++ .../route_runs_approvals_artifacts_test.go | 6 +- cmd/runecode-tui/route_runs_evidence.go | 2 +- cmd/runecode-tui/route_status.go | 1 + cmd/runecode-tui/route_status_test.go | 22 ++++++- .../shell_action_metadata_test.go | 4 +- cmd/runecode-tui/shell_layout.go | 2 +- cmd/runecode-tui/shell_object_index.go | 1 + .../shell_overlay_behavior_test.go | 58 +++++++++++++++++++ cmd/runecode-tui/shell_persistence.go | 7 +++ cmd/runecode-tui/shell_shortcuts_test.go | 5 +- cmd/runecode-tui/shell_view.go | 7 ++- cmd/runecode-tui/shell_watch_transport.go | 1 + cmd/runecode-tui/shell_workspace.go | 1 + 21 files changed, 208 insertions(+), 27 deletions(-) diff --git a/cmd/runecode-tui/help.go b/cmd/runecode-tui/help.go index 0ef06a49..a298d90f 100644 --- a/cmd/runecode-tui/help.go +++ b/cmd/runecode-tui/help.go @@ -8,8 +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 { - _ = keys _ = paletteOpen _ = actions - return "" + 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 62b27c64..15c1c1ec 100644 --- a/cmd/runecode-tui/help_test.go +++ b/cmd/runecode-tui/help_test.go @@ -12,8 +12,8 @@ func TestHelpUsesBaseBindingsForLeaderOverlay(t *testing.T) { m.width = 150 m.height = 40 help := renderHelp(m.keys, false, m.actions) - if strings.TrimSpace(help) != "" { - t.Fatalf("expected footer help removed in favor of leader overlay help, got %q", help) + 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{' '}}) @@ -55,7 +55,8 @@ func TestShellViewLeaderOverlayKeepsBaseFooterHelp(t *testing.T) { func TestHelpFooterStaysCompactWhilePreservingDiscovery(t *testing.T) { m := newShellModel() help := renderHelp(m.keys, false, m.actions) - if strings.TrimSpace(help) != "" { - t.Fatalf("expected footer help removed to reclaim pane height, got %q", help) + 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/main.go b/cmd/runecode-tui/main.go index 3471ff15..79c86fca 100644 --- a/cmd/runecode-tui/main.go +++ b/cmd/runecode-tui/main.go @@ -76,7 +76,10 @@ func runMain(args []string, stdin *os.File, stdout *os.File, stderr io.Writer) i finalModel, err := runShellProgram(model) finalModel.flushWorkbenchState() if err == nil { - err = finalModel.workbenchFlushError() + 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) diff --git a/cmd/runecode-tui/main_test.go b/cmd/runecode-tui/main_test.go index 31ab8dd7..3e1d0b56 100644 --- a/cmd/runecode-tui/main_test.go +++ b/cmd/runecode-tui/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "os" "strings" "testing" @@ -120,7 +121,7 @@ func TestRunMainFlushesWorkbenchStateBeforeExit(t *testing.T) { } func TestRunMainReturnsFailureWhenWorkbenchFlushFails(t *testing.T) { - store := &flushTrackingWorkbenchStore{err: errForcedFlushFailure{}} + store := &flushTrackingWorkbenchStore{err: fmt.Errorf("open /home/user/.config/runecode/workbench.json: permission denied")} origModel := newShellModelFunc origTerm := isTerminalFunc origRunner := runShellProgram @@ -141,8 +142,7 @@ func TestRunMainReturnsFailureWhenWorkbenchFlushFails(t *testing.T) { 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()) + } } - -type errForcedFlushFailure struct{} - -func (errForcedFlushFailure) Error() string { return "forced flush failure" } diff --git a/cmd/runecode-tui/palette.go b/cmd/runecode-tui/palette.go index f8412910..916e9064 100644 --- a/cmd/runecode-tui/palette.go +++ b/cmd/runecode-tui/palette.go @@ -118,6 +118,7 @@ func (m paletteModel) ApplyEntriesRefresh(msg shellPaletteEntriesLoadedMsg) (pal 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)) @@ -132,6 +133,9 @@ func (m paletteModel) ApplyFilterRefresh(msg shellPaletteFilterLoadedMsg) (palet 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...) diff --git a/cmd/runecode-tui/palette_test.go b/cmd/runecode-tui/palette_test.go index 13a5ab5b..9a951125 100644 --- a/cmd/runecode-tui/palette_test.go +++ b/cmd/runecode-tui/palette_test.go @@ -175,3 +175,36 @@ func TestPaletteIncrementalFilteringNarrowsFromPreviousMatches(t *testing.T) { 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_git_remote_mutation.go b/cmd/runecode-tui/route_git_remote_mutation.go index 13d53e13..ec382dd2 100644 --- a/cmd/runecode-tui/route_git_remote_mutation.go +++ b/cmd/runecode-tui/route_git_remote_mutation.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" ) type gitRemoteMutationLoadedMsg struct { @@ -105,6 +106,9 @@ 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 the prepared change, confirm approval evidence, then press e for guarded broker execution." @@ -114,6 +118,7 @@ func (m gitRemoteMutationRouteModel) handleLoaded(msg gitRemoteMutationLoadedMsg 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 = "" @@ -148,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 } @@ -191,7 +197,7 @@ func gitRemoteStateCard(prepared brokerapi.GitRemoteMutationPreparedState, lease message = "Remote execution is waiting for approval." next = "Open Approvals before executing this remote change." } - if strings.TrimSpace(leaseID) != "" { + 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." @@ -265,7 +271,7 @@ func gitRemoteNextSafeAction(prepared brokerapi.GitRemoteMutationPreparedState, if state == "completed" || state == "complete" || state == "succeeded" || state == "success" { return "Review the refreshed state to confirm the remote change landed as expected." } - if strings.TrimSpace(prepared.RequiredApprovalID) == "" || prepared.RequiredApprovalRequestHash == nil || prepared.RequiredApprovalDecisionHash == nil { + if !gitRemoteApprovalBindingComplete(prepared) { return "Complete the bound approval review before executing this remote change." } if strings.TrimSpace(leaseID) != "" { @@ -274,6 +280,31 @@ func gitRemoteNextSafeAction(prepared brokerapi.GitRemoteMutationPreparedState, 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) diff --git a/cmd/runecode-tui/route_git_remote_mutation_test.go b/cmd/runecode-tui/route_git_remote_mutation_test.go index 5132aa1c..6c4c405b 100644 --- a/cmd/runecode-tui/route_git_remote_mutation_test.go +++ b/cmd/runecode-tui/route_git_remote_mutation_test.go @@ -86,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_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index 0273848e..f8ca4ea6 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -230,7 +230,7 @@ func TestRunInspectorContentIncludesPostureAndTrustCues(t *testing.T) { "Provisioning posture: Provisioning: attested", "PROVISIONING_OK", "Attestation truth: attestation posture=valid", - "post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence", + "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", ) } @@ -505,8 +505,8 @@ func TestRuntimeAttestationTruthfulnessCueSanitizesBrokerDerivedAttestationState "attestation_evidence_present": true, } got := renderRuntimeAttestationTruthfulnessCue(validState) - if !strings.Contains(got, "post-handshake verification succeeded; supported attested posture earned from verified post-handshake evidence") { - t.Fatalf("expected sanitized valid posture to unlock supported attestation wording, got %q", got) + 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{ diff --git a/cmd/runecode-tui/route_runs_evidence.go b/cmd/runecode-tui/route_runs_evidence.go index d90dd9fe..2bff4a81 100644 --- a/cmd/runecode-tui/route_runs_evidence.go +++ b/cmd/runecode-tui/route_runs_evidence.go @@ -75,7 +75,7 @@ func renderRuntimeAttestationTruthfulnessCue(state map[string]any) string { currentEvidence = "secure session bound without verified attestation" } if supportedRuntimeSatisfied && attestationPosture == "valid" { - return currentEvidence + "; supported attested posture earned from verified post-handshake evidence" + 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, ",") diff --git a/cmd/runecode-tui/route_status.go b/cmd/runecode-tui/route_status.go index 2527b3e6..57c62bac 100644 --- a/cmd/runecode-tui/route_status.go +++ b/cmd/runecode-tui/route_status.go @@ -128,6 +128,7 @@ func (m statusRouteModel) handleStatusLoaded(msg statusLoadedMsg) (routeModel, t } 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 diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index 614ac3a2..f3bb2930 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -64,9 +64,9 @@ func statusRouteActionCases() []statusRouteActionCase { return []statusRouteActionCase{ {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{"Init apply", "applied the broker-owned project setup initialization", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateInitApply"}, expectReload: true}, + {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{"Upgrade apply", "applied the broker-owned project setup upgrade", "Project setup validation refreshed."}, expectedRPCCall: []string{"ProjectSubstrateUpgradeApply"}, expectReload: true}, + {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}, } } @@ -209,6 +209,24 @@ func TestStatusRouteProjectSubstrateValidationReloadFailureStaysScoped(t *testin } } +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)) { diff --git a/cmd/runecode-tui/shell_action_metadata_test.go b/cmd/runecode-tui/shell_action_metadata_test.go index 89c50012..641f0a9a 100644 --- a/cmd/runecode-tui/shell_action_metadata_test.go +++ b/cmd/runecode-tui/shell_action_metadata_test.go @@ -19,8 +19,8 @@ func TestHelpIncludesActionMetadataFromUnifiedDefinitions(t *testing.T) { m.actions = newShellActionGraph(m.routes, m.commands) help := renderHelp(m.keys, false, m.actions) - if strings.TrimSpace(help) != "" { - t.Fatalf("expected footer help removed, got %q", help) + 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'}}) diff --git a/cmd/runecode-tui/shell_layout.go b/cmd/runecode-tui/shell_layout.go index 9f83e9d8..de2008d3 100644 --- a/cmd/runecode-tui/shell_layout.go +++ b/cmd/runecode-tui/shell_layout.go @@ -15,7 +15,7 @@ const ( shellPaneSpacerHeight = 1 shellBottomStripHeight = 2 shellStatusHeight = 1 - shellFooterHeight = 0 + shellFooterHeight = 1 ) func shellChromeReservedHeight() int { diff --git a/cmd/runecode-tui/shell_object_index.go b/cmd/runecode-tui/shell_object_index.go index 88343460..ac2b4411 100644 --- a/cmd/runecode-tui/shell_object_index.go +++ b/cmd/runecode-tui/shell_object_index.go @@ -98,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) } diff --git a/cmd/runecode-tui/shell_overlay_behavior_test.go b/cmd/runecode-tui/shell_overlay_behavior_test.go index 70098855..4f12742d 100644 --- a/cmd/runecode-tui/shell_overlay_behavior_test.go +++ b/cmd/runecode-tui/shell_overlay_behavior_test.go @@ -69,3 +69,61 @@ func TestShellSessionOverlayViewReusesCachedFrameWhileTyping(t *testing.T) { 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_persistence.go b/cmd/runecode-tui/shell_persistence.go index c18cc81e..9bd54e33 100644 --- a/cmd/runecode-tui/shell_persistence.go +++ b/cmd/runecode-tui/shell_persistence.go @@ -32,6 +32,13 @@ func (m *shellModel) workbenchFlushError() error { 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, diff --git a/cmd/runecode-tui/shell_shortcuts_test.go b/cmd/runecode-tui/shell_shortcuts_test.go index 27242f37..edcdec4f 100644 --- a/cmd/runecode-tui/shell_shortcuts_test.go +++ b/cmd/runecode-tui/shell_shortcuts_test.go @@ -12,8 +12,9 @@ func TestHelpRenderedFromRealKeyBindings(t *testing.T) { m.width = 150 m.height = 40 help := renderHelp(defaultShellKeyMap(), false, m.actions) - if strings.TrimSpace(help) != "" { - t.Fatalf("expected footer help removed, got %q", 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() diff --git a/cmd/runecode-tui/shell_view.go b/cmd/runecode-tui/shell_view.go index 02d586de..6cebacb1 100644 --- a/cmd/runecode-tui/shell_view.go +++ b/cmd/runecode-tui/shell_view.go @@ -69,8 +69,11 @@ func (m shellModel) writeShellFrame(b *strings.Builder, surface routeSurface, la } func (m shellModel) writeShellFooter(b *strings.Builder, viewportWidth int) { - _ = b - _ = viewportWidth + if shellFooterHeight <= 0 { + return + } + 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 { diff --git a/cmd/runecode-tui/shell_watch_transport.go b/cmd/runecode-tui/shell_watch_transport.go index a638042b..f16830c6 100644 --- a/cmd/runecode-tui/shell_watch_transport.go +++ b/cmd/runecode-tui/shell_watch_transport.go @@ -84,6 +84,7 @@ 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_workspace.go b/cmd/runecode-tui/shell_workspace.go index e2732155..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() From a2326b7f116eb196dc7b2411438a34fef243132a Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 16:09:43 -0500 Subject: [PATCH 37/39] Updates to fix failing checks. Signed-off-by: Zebulon Rouse-Frantzich --- nix/packages/release-artifacts.nix | 2 +- tools/tuisnapshotci/main_test.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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/tools/tuisnapshotci/main_test.go b/tools/tuisnapshotci/main_test.go index 9c3de583..d38d4aec 100644 --- a/tools/tuisnapshotci/main_test.go +++ b/tools/tuisnapshotci/main_test.go @@ -285,7 +285,10 @@ func TestPrepareWorkDirCanonicalizesSymlinkedOutputDir(t *testing.T) { } defer cleanup() - want := filepath.Join(realRoot, "nested", "artifacts") + 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) } @@ -338,7 +341,10 @@ func TestRequirePathWithinTrustedRootAcceptsCanonicalTmp(t *testing.T) { if _, err := os.Stat("/tmp"); err != nil { t.Skip("/tmp unavailable on this platform") } - candidate := filepath.Join("/tmp", ".tui-snapshots") + 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) } From a06c767eb3b2ccd613bc418727290d9c1b47c1d9 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 18:13:41 -0500 Subject: [PATCH 38/39] Updates based on failing checks. Signed-off-by: Zebulon Rouse-Frantzich --- tools/tuiperf/modes.go | 13 ++++++++----- tools/tuiperf/modes_test.go | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) 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) } From 996d39e5c643d7ea5ab47560709e8c83a5c89404 Mon Sep 17 00:00:00 2001 From: Zebulon Rouse-Frantzich Date: Sun, 10 May 2026 19:04:09 -0500 Subject: [PATCH 39/39] Updates README and adds/updates standards for TUI performance Signed-off-by: Zebulon Rouse-Frantzich --- README.md | 11 +++- runecontext/project/standards-inventory.md | 2 + .../tui-bubbletea-lipgloss-performance.md | 59 +++++++++++++++++++ .../tui-shell-input-and-command-surfaces.md | 2 + ...pshot-and-interaction-performance-gates.md | 51 ++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 runecontext/standards/product/tui-bubbletea-lipgloss-performance.md create mode 100644 runecontext/standards/testing/tui-snapshot-and-interaction-performance-gates.md diff --git a/README.md b/README.md index 6942bd4b..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: @@ -332,6 +335,8 @@ 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 @@ -339,12 +344,14 @@ 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`, and `action-center-audit` so review scope is captured in the manifest rather than inferred from filenames. +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. 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.