diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 07a591d1..eb5fa09c 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -75,9 +75,12 @@ func PreviewFrame(kind string, w, h int) string { s.CurrentStage = "write_script" s.Events = sampleEvents() s.Pending = &Approval{ - RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH", + RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", ToolName: "file_write", Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n", + Rationale: []string{ + "[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh", + }, } } diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index db177f59..49608692 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -91,6 +91,9 @@ type Approval struct { Risk string ToolName string Preview string + // Rationale holds the plane-2 verified preconditions ("[PATH_OUTSIDE_WORKSPACE] …") + // that justify the gate — shown in the approval band instead of an opaque tier. + Rationale []string } // Session is the UI's view of one server session. @@ -129,13 +132,13 @@ type Model struct { reconnecting bool // sessions - sessions []Session - selectedID string - filter string - workflows []Workflow - wfIndex int // -1 = not in workflow picker - wfVisible bool - bgUpdates int + sessions []Session + selectedID string + filter string + workflows []Workflow + wfIndex int // -1 = not in workflow picker + wfVisible bool + bgUpdates int // input editMode EditMode @@ -147,9 +150,9 @@ type Model struct { savedBuffer string // flow flags - sessionEntered bool - approvalDismissed bool - pendingWorkflowFocus bool + sessionEntered bool + approvalDismissed bool + pendingWorkflowFocus bool // router transcript routerMessages map[string][]RouterEntry diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index e3af420b..a13e7405 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -56,11 +56,14 @@ func mbg(t Theme, s string, fg lipgloss.Color) string { // never crowds out the session above (longer diffs spill to the ^x fullscreen // view). Layout: 2 borders + header + blank + diff rows + blank + action row. func (m Model) approvalBandHeight() int { - rows := 0 + rows, rat := 0, 0 if s := m.session(m.selectedID); s != nil && s.Pending != nil { rows = len(parseUnifiedDiff(s.Pending.Preview)) + if n := len(s.Pending.Rationale); n > 0 { + rat = n + 1 // rationale lines + trailing blank + } } - h := 2 + 1 + 1 + rows + 1 + 1 + h := 2 + 1 + 1 + rat + rows + 1 + 1 if maxH := m.height * 55 / 100; h > maxH { h = maxH } @@ -103,7 +106,11 @@ func (m Model) renderApprovalBand(h int) string { lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk)) innerH := h - 2 - diffH := innerH - 4 // header, blank, blank, action row + ratBlock := 0 + if len(a.Rationale) > 0 { + ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank + } + diffH := innerH - 4 - ratBlock // header, blank, blank, action row, + rationale block if diffH < 1 { diffH = 1 } @@ -111,8 +118,16 @@ func (m Model) renderApprovalBand(h int) string { body := make([]string, 0, innerH) body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "") + for _, r := range a.Rationale { + marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ") + body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim)) + } + if len(a.Rationale) > 0 { + body = append(body, "") + } + diffStart := len(body) body = append(body, m.renderSplitDiff(rows, textW, diffH, 0)...) - for len(body) < 2+diffH { + for len(body) < diffStart+diffH { body = append(body, "") } body = append(body, "", m.approvalActions()) diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 31d3790d..df8b9854 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -261,8 +261,10 @@ func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) { func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { risk := "unknown" + var rationale []string if msg.RiskSummary != nil { risk = msg.RiskSummary.Level + rationale = msg.RiskSummary.Rationale } info := &Approval{ RequestID: msg.RequestID, @@ -271,6 +273,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { Risk: risk, ToolName: deref(&msg.ToolName), Preview: derefp(msg.Preview), + Rationale: rationale, } if s := m.session(msg.SessionID); s != nil { s.Pending = info diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index f9d9b198..b427f508 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -58,6 +58,22 @@ func TestDecodeGoldenServerFrames(t *testing.T) { } }, }, + { + // Plane-2 rationale (verified preconditions) must survive decode — it is the + // justification shown in the approval band, not the opaque tier. + name: "approval.required carries plane-2 rationale", + json: `{"type":"approval.required","sessionId":"s1","requestId":"req-1","tier":"T3","riskSummary":{"level":"MEDIUM","factors":[],"recommendedAction":"PROMPT_USER","rationale":["[PATH_OUTSIDE_WORKSPACE] outside: /tmp/x"]},"toolName":"file_write","preview":"--- a/x\n+++ b/x","sequence":12,"sessionSequence":3}`, + wantType: TypeApprovalRequired, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.RiskSummary == nil { + t.Fatalf("approval.required: riskSummary decoded nil") + } + if len(m.RiskSummary.Rationale) != 1 || m.RiskSummary.Rationale[0] != "[PATH_OUTSIDE_WORKSPACE] outside: /tmp/x" { + t.Fatalf("rationale not decoded: %+v", m.RiskSummary) + } + }, + }, { name: "snapshot_complete (non-event control frame)", json: `{"type":"snapshot_complete"}`, diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 2fea4a86..1887d220 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -110,6 +110,7 @@ type RiskSummaryDto struct { Level string `json:"level"` Factors []string `json:"factors"` RecommendedAction string `json:"recommendedAction"` + Rationale []string `json:"rationale"` } type AssessedIssueDto struct {