From 3600ec689770ff03bb33c42e9efaf75744c2c0a4 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 00:30:26 +0400 Subject: [PATCH] feat(tui): surface plane-2 rationale in the approval band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plane-2 tool-call assessor records verified preconditions ("[PATH_OUTSIDE_WORKSPACE] …") and the server already ships them in RiskSummaryDto.rationale, but the Go TUI's RiskSummaryDto had no Rationale field — so the justification was silently dropped at decode and the gate showed only an opaque tier. Decode the rationale, carry it on Approval, and render it under the header in the approval band (warn-marked, above the diff). This closes the last deferred item of the plane-2 slice-1 plan: the assessment surfaced to the approval UX. Golden test pins the rationale decode. --- apps/tui-go/internal/app/demo.go | 5 ++++- apps/tui-go/internal/app/model.go | 23 +++++++++++--------- apps/tui-go/internal/app/overlays.go | 23 ++++++++++++++++---- apps/tui-go/internal/app/server.go | 3 +++ apps/tui-go/internal/protocol/golden_test.go | 16 ++++++++++++++ apps/tui-go/internal/protocol/protocol.go | 1 + 6 files changed, 56 insertions(+), 15 deletions(-) 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 {