feat(tui): surface plane-2 rationale in the approval band
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.
This commit is contained in:
@@ -75,9 +75,12 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
s.Events = sampleEvents()
|
s.Events = sampleEvents()
|
||||||
s.Pending = &Approval{
|
s.Pending = &Approval{
|
||||||
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
||||||
ToolName: "file_write",
|
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",
|
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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ type Approval struct {
|
|||||||
Risk string
|
Risk string
|
||||||
ToolName string
|
ToolName string
|
||||||
Preview 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.
|
// Session is the UI's view of one server session.
|
||||||
@@ -129,13 +132,13 @@ type Model struct {
|
|||||||
reconnecting bool
|
reconnecting bool
|
||||||
|
|
||||||
// sessions
|
// sessions
|
||||||
sessions []Session
|
sessions []Session
|
||||||
selectedID string
|
selectedID string
|
||||||
filter string
|
filter string
|
||||||
workflows []Workflow
|
workflows []Workflow
|
||||||
wfIndex int // -1 = not in workflow picker
|
wfIndex int // -1 = not in workflow picker
|
||||||
wfVisible bool
|
wfVisible bool
|
||||||
bgUpdates int
|
bgUpdates int
|
||||||
|
|
||||||
// input
|
// input
|
||||||
editMode EditMode
|
editMode EditMode
|
||||||
@@ -147,9 +150,9 @@ type Model struct {
|
|||||||
savedBuffer string
|
savedBuffer string
|
||||||
|
|
||||||
// flow flags
|
// flow flags
|
||||||
sessionEntered bool
|
sessionEntered bool
|
||||||
approvalDismissed bool
|
approvalDismissed bool
|
||||||
pendingWorkflowFocus bool
|
pendingWorkflowFocus bool
|
||||||
|
|
||||||
// router transcript
|
// router transcript
|
||||||
routerMessages map[string][]RouterEntry
|
routerMessages map[string][]RouterEntry
|
||||||
|
|||||||
@@ -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
|
// never crowds out the session above (longer diffs spill to the ^x fullscreen
|
||||||
// view). Layout: 2 borders + header + blank + diff rows + blank + action row.
|
// view). Layout: 2 borders + header + blank + diff rows + blank + action row.
|
||||||
func (m Model) approvalBandHeight() int {
|
func (m Model) approvalBandHeight() int {
|
||||||
rows := 0
|
rows, rat := 0, 0
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
rows = len(parseUnifiedDiff(s.Pending.Preview))
|
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 {
|
if maxH := m.height * 55 / 100; h > maxH {
|
||||||
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))
|
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
|
||||||
|
|
||||||
innerH := h - 2
|
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 {
|
if diffH < 1 {
|
||||||
diffH = 1
|
diffH = 1
|
||||||
}
|
}
|
||||||
@@ -111,8 +118,16 @@ func (m Model) renderApprovalBand(h int) string {
|
|||||||
|
|
||||||
body := make([]string, 0, innerH)
|
body := make([]string, 0, innerH)
|
||||||
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
|
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)...)
|
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, "")
|
||||||
}
|
}
|
||||||
body = append(body, "", m.approvalActions())
|
body = append(body, "", m.approvalActions())
|
||||||
|
|||||||
@@ -261,8 +261,10 @@ func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
|
|||||||
|
|
||||||
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||||||
risk := "unknown"
|
risk := "unknown"
|
||||||
|
var rationale []string
|
||||||
if msg.RiskSummary != nil {
|
if msg.RiskSummary != nil {
|
||||||
risk = msg.RiskSummary.Level
|
risk = msg.RiskSummary.Level
|
||||||
|
rationale = msg.RiskSummary.Rationale
|
||||||
}
|
}
|
||||||
info := &Approval{
|
info := &Approval{
|
||||||
RequestID: msg.RequestID,
|
RequestID: msg.RequestID,
|
||||||
@@ -271,6 +273,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
|||||||
Risk: risk,
|
Risk: risk,
|
||||||
ToolName: deref(&msg.ToolName),
|
ToolName: deref(&msg.ToolName),
|
||||||
Preview: derefp(msg.Preview),
|
Preview: derefp(msg.Preview),
|
||||||
|
Rationale: rationale,
|
||||||
}
|
}
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Pending = info
|
s.Pending = info
|
||||||
|
|||||||
@@ -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)",
|
name: "snapshot_complete (non-event control frame)",
|
||||||
json: `{"type":"snapshot_complete"}`,
|
json: `{"type":"snapshot_complete"}`,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type RiskSummaryDto struct {
|
|||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Factors []string `json:"factors"`
|
Factors []string `json:"factors"`
|
||||||
RecommendedAction string `json:"recommendedAction"`
|
RecommendedAction string `json:"recommendedAction"`
|
||||||
|
Rationale []string `json:"rationale"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssessedIssueDto struct {
|
type AssessedIssueDto struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user