feat(tui-go): approval ergonomics — queue + tier-gated confirm (E §3)

- pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count
  shown), no modal stacking; resolve removes the specific gate by request id
- y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm
  ("press y again"), any other key disarms; reject/steer/auto never gated
- snapshot restore now rehydrates the full pending list, not just the first

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 16:40:49 +00:00
parent 35596dc723
commit d5afcd345a
6 changed files with 483 additions and 53 deletions
+257
View File
@@ -0,0 +1,257 @@
package app
import (
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key
// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable
// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws
// client buffers Sent frames, Drain reads them back.
// approvalModel builds an entered session and queues each given approval through the
// production applyServer path, so the queue/Pending state matches real event flow.
func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) {
client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back
m := NewModel(client)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
for _, a := range approvals {
m.applyServer(a)
}
return m, client
}
// apprReq builds an approval.required frame for the given request id and tier.
func apprReq(reqID, tier string) protocol.ServerMessage {
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
m.RequestID, m.Tier = reqID, tier
m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"}
})
}
// applyKey drives a key through the production handleKey entry and returns the model.
func applyKey(m Model, k tea.KeyMsg) Model {
updated, _ := m.handleKey(k)
return updated.(Model)
}
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
// its request id + decision. Fails if there isn't exactly one decision frame.
func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) {
t.Helper()
var resp map[string]any
n := 0
for _, f := range client.Drain() {
var raw map[string]any
if err := json.Unmarshal(f, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
resp = raw
n++
}
}
if n != 1 {
t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n)
}
rid, _ := resp["requestId"].(string)
dec, _ := resp["decision"].(string)
return rid, dec
}
// noApprovalSent asserts the client buffered no ApprovalResponse frame.
func noApprovalSent(t *testing.T, client *ws.Client) {
t.Helper()
for _, f := range client.Drain() {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
t.Fatalf("did not expect an ApprovalResponse frame yet")
}
}
}
func TestApprovalLowTierApprovesImmediately(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"))
if m.displayState() != StateApproval {
t.Fatalf("want StateApproval, got %v", m.displayState())
}
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
if s := m.session("s1"); s == nil || s.Pending != nil {
t.Fatalf("pending gate should be cleared after a low-tier approve")
}
if m.approvalArmed {
t.Fatalf("low-tier approve must not leave an armed confirm")
}
}
func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
// First `y` arms; nothing is sent yet.
m = applyKey(m, runeKey('y'))
if !m.approvalArmed {
t.Fatalf("T3 approve should arm a confirm, not send")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.Pending == nil {
t.Fatalf("gate must remain pending while armed")
}
// Second `y` confirms and sends.
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec)
}
if m.approvalArmed {
t.Fatalf("arm should clear after the confirming keypress")
}
}
func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
m = applyKey(m, runeKey('y')) // arm
if !m.approvalArmed {
t.Fatalf("expected armed after first y")
}
// A non-confirm key (here `n` reject) cancels the arm. The arm itself must be
// gone, and no spurious APPROVE may slip through.
m = applyKey(m, runeKey('n'))
if m.approvalArmed {
t.Fatalf("arm should be cancelled by a non-confirm key")
}
rid, dec := decodeApproval(t, client)
if dec != "REJECT" || rid != "req-1" {
t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec)
}
}
func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
// Two T3 gates: arming then pressing ↓ must disarm and only move the cursor —
// no decision may be emitted.
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
m = applyKey(m, runeKey('y')) // arm req-1
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
if m.approvalArmed {
t.Fatalf("nav key should disarm")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.PendingIdx != 1 {
t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx)
}
}
func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1"))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 3 {
t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue))
}
// The band shows a "1/3" count and the current request, never stacked modals.
out := stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "1/3") {
t.Fatalf("band should show queue count 1/3:\n%s", out)
}
// ↓ advances the selection; Pending follows the cursor.
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
s = m.session("s1")
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
}
out = stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "2/3") {
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
}
// ↑ wraps back.
m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp})
if m.session("s1").PendingIdx != 0 {
t.Fatalf("↑ should return to index 0")
}
}
func TestApprovalResolveFrontAdvancesQueue(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
// Approve the front gate (low tier → immediate).
m = applyKey(m, runeKey('y'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue)
}
// A server-side resolve of the remaining gate empties the queue and exits approval.
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 {
t.Fatalf("server resolve should clear the last gate")
}
if m.displayState() != StateInSession {
t.Fatalf("empty queue should leave approval state, got %v", m.displayState())
}
}
func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) {
// A resolve for the *back* gate must drop only that one and keep the front shown.
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" {
t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue)
}
}
func TestApprovalRejectAndSteerRoute(t *testing.T) {
// Reject is single-key for a low tier.
m, client := approvalModel(apprReq("req-1", "T1"))
m = applyKey(m, runeKey('n'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" {
t.Fatalf("want reject req-1, got %s/%s", rid, dec)
}
// Steer enters the existing note-input path (no decision sent yet).
m2, client2 := approvalModel(apprReq("req-2", "T1"))
m2 = applyKey(m2, runeKey('e'))
if !m2.steering || m2.editMode != ModeInsert {
t.Fatalf("steer key should enter the steering-note input path")
}
noApprovalSent(t, client2)
// Typing a note then Enter approves with the note riding along.
for _, r := range "use sudo" {
m2 = applyKey(m2, runeKey(r))
}
m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter})
frames := client2.Drain()
var note string
for _, f := range frames {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
note, _ = raw["steeringNote"].(string)
if raw["decision"] != "APPROVE" {
t.Fatalf("steer+enter should approve, got %v", raw["decision"])
}
}
}
if note != "use sudo" {
t.Fatalf("steering note should ride along with the decision, got %q", note)
}
}
+6 -6
View File
@@ -78,14 +78,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "write_script"
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Events = sampleEvents()
s.Pending = &Approval{
s.enqueueApproval(&Approval{
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",
},
}
})
}
case "approval-steer":
@@ -97,11 +97,11 @@ func PreviewFrame(kind string, w, h int) string {
m.steerBuffer = "also print the current distro and kernel version"
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
ToolName: "file_write",
Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo ok\n",
}
})
}
case "approval-shell":
@@ -114,14 +114,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "execute_script"
s.Events = sampleEvents()
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
ToolName: "shell",
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
Rationale: []string{
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
},
}
})
}
case "diff":
+106
View File
@@ -1,6 +1,9 @@
package app
import (
"strconv"
"strings"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -111,6 +114,27 @@ type Approval struct {
Rationale []string
}
// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or
// empty tiers return -1, which is treated as low-risk (never requires confirm).
func tierNum(tier string) int {
t := strings.TrimSpace(strings.ToUpper(tier))
t = strings.TrimPrefix(t, "T")
if t == "" {
return -1
}
n, err := strconv.Atoi(t)
if err != nil {
return -1
}
return n
}
// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve
// must be confirmed with a second keypress rather than acting on one keystroke.
func (a *Approval) HighTier() bool {
return tierNum(a.Tier) >= 3
}
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
type ClarQuestion struct {
ID string
@@ -159,7 +183,12 @@ type Session struct {
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
Events []EventEntry
// Pending is the *current* approval gate shown in the band. It always mirrors
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it.
Pending *Approval
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
PendingIdx int // selected index into PendingQueue
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
@@ -282,6 +311,10 @@ type Model struct {
// approval steering input buffer
steerBuffer string
steering bool
// approvalArmed is set while a high-tier (T3+) approve is awaiting its second
// confirming keypress — the destructive-action safety. Any non-confirm key
// disarms it; a confirming `y`/`a`/enter sends the decision.
approvalArmed bool
// clarification view (the interactive question form)
clarFocus int // focused question index
@@ -352,6 +385,79 @@ func (m Model) session(id string) *Session {
return nil
}
// syncPending re-points Pending at the selected queue entry, clamping the index
// so it stays in range as the queue grows and shrinks. Called after any queue
// mutation; Pending is nil exactly when the queue is empty.
func (s *Session) syncPending() {
if len(s.PendingQueue) == 0 {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
return
}
if s.PendingIdx < 0 {
s.PendingIdx = 0
}
if s.PendingIdx >= len(s.PendingQueue) {
s.PendingIdx = len(s.PendingQueue) - 1
}
s.Pending = s.PendingQueue[s.PendingIdx]
}
// enqueueApproval adds a gate to the queue (replacing one with the same request
// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where
// it is in the order; the current selection is preserved.
func (s *Session) enqueueApproval(a *Approval) {
for i, p := range s.PendingQueue {
if p.RequestID == a.RequestID {
s.PendingQueue[i] = a
s.syncPending()
return
}
}
s.PendingQueue = append(s.PendingQueue, a)
s.syncPending()
}
// removeApproval drops the gate with requestID and advances the selection. If the
// removed entry was before the cursor the index shifts down to stay on the same
// gate; if it was the selected one the cursor stays put (now showing the next gate).
func (s *Session) removeApproval(requestID string) {
idx := -1
for i, p := range s.PendingQueue {
if p.RequestID == requestID {
idx = i
break
}
}
if idx < 0 {
return
}
s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...)
if idx < s.PendingIdx {
s.PendingIdx--
}
s.syncPending()
}
// clearApprovals empties the queue (used on resume/completion, where the server
// invalidates every gate at once).
func (s *Session) clearApprovals() {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
}
// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending.
func (s *Session) navApproval(dir int) {
n := len(s.PendingQueue)
if n <= 1 {
return
}
s.PendingIdx = (s.PendingIdx + dir + n) % n
s.syncPending()
}
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
// Session existence is derived from the event stream (any event for an unknown
// session vivifies it) rather than a dedicated control frame.
+20 -2
View File
@@ -132,6 +132,13 @@ func (m Model) renderApprovalBand(h int) string {
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
t.span(" · risk ", t.P.Faint) +
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
// When more than one gate is queued, show "approval i/n" so the operator knows
// there are others behind this one (↑/↓ to walk them) — never stack modals.
if n := len(s.PendingQueue); n > 1 {
rightHdr = t.span("approval ", t.P.Faint) +
t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) +
t.span(" · ", t.P.Faint) + rightHdr
}
innerH := h - 2
ratBlock := 0
@@ -218,8 +225,19 @@ func (m Model) approvalActions() string {
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
}, gap)
}
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
s := m.session(m.selectedID)
// Armed T3+ approve: a single decisive line so an accidental keystroke can't slip
// a destructive action through — the confirm key must be pressed again.
if m.approvalArmed && s != nil && s.Pending != nil {
warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).
Render("press y again to confirm " + s.Pending.Tier + " approval")
return warn + gap + hint("any other key", "cancel")
}
parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")}
if s != nil && len(s.PendingQueue) > 1 {
parts = append(parts, hint("↑↓", "queue"))
}
if s != nil && s.Pending != nil &&
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
parts = append(parts, hint("^x", "fullscreen"))
}
+14 -10
View File
@@ -125,7 +125,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeSessionResumed:
if s := m.session(msg.SessionID); s != nil {
s.Status = "ACTIVE"
s.Pending = nil
s.clearApprovals()
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionCompleted:
@@ -134,6 +134,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
@@ -141,6 +142,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
@@ -252,7 +254,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.onWorkflowProposed(msg)
case protocol.TypeApprovalResolved:
if s := m.session(msg.SessionID); s != nil {
s.Pending = nil
// Drop just the resolved gate; any others stay queued and the band
// advances to the next rather than vanishing entirely.
s.removeApproval(msg.RequestID)
detail := msg.Outcome
if msg.Reason != "" {
detail += " — " + msg.Reason
@@ -411,7 +415,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
Rationale: rationale,
}
if s := m.session(msg.SessionID); s != nil {
s.Pending = info
s.enqueueApproval(info)
}
}
@@ -453,25 +457,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
a := msg.PendingAppr[0]
pending = &Approval{
var queue []*Approval
for _, a := range msg.PendingAppr {
queue = append(queue, &Approval{
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
}
})
}
status := "running"
if msg.State != nil {
status = msg.State.Status
}
if pending != nil {
if len(queue) > 0 {
status = "PAUSED awaiting approval"
}
sess := Session{
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
}
sess.syncPending()
if msg.State != nil && msg.State.CurrentStageID != nil {
sess.CurrentStage = *msg.State.CurrentStageID
}
+77 -32
View File
@@ -145,6 +145,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// handleNormalKey processes vim-style bare-key commands.
func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
ds := m.displayState()
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
// general bindings (e=events, t=tools, …) from shadowing the decision keys.
if ds == StateApproval {
return m.handleApprovalKey(k)
}
switch k.Type {
case tea.KeyUp:
m.navUp()
@@ -155,22 +161,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyEnter:
return m.normalEnter()
case tea.KeyEsc:
if ds == StateApproval {
m.approvalDismissed = true
return m, nil
}
m.filter = ""
return m, nil
case tea.KeyCtrlA:
if ds == StateApproval {
return m.decide("APPROVE")
}
return m, nil
case tea.KeyCtrlR:
if ds == StateApproval {
return m.decide("REJECT")
}
return m, nil
case tea.KeyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
@@ -228,33 +220,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.approvalDismissed = false
}
case "s":
if ds == StateApproval {
m.steering = true
m.editMode = ModeInsert
} else {
m.cycleChatMode()
}
case "c":
if m.selectedID != "" {
m.client.Send(protocol.CancelSession(m.selectedID))
}
case "a":
if ds == StateApproval {
return m.decide("APPROVE")
}
// In-session (gate peeked away with esc): `a` brings the band back.
if ds == StateInSession {
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
m.approvalDismissed = false
}
}
case "r":
if ds == StateApproval {
return m.decide("REJECT")
}
case "A":
if ds == StateApproval {
return m.autoApprove()
}
case "q":
m.quitting = true
return m, tea.Quit
@@ -262,6 +239,72 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// handleApprovalKey owns the approval band's bare keys. Low tiers (T0T2) act on a
// single keypress; a T3+ approve arms first and only a second confirming key sends
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
// Queue navigation never resolves a gate; it just changes which one is shown.
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
s.navApproval(-1)
m.approvalArmed = false
return m, nil
case k.Type == tea.KeyDown || runeIs(k, "j"):
s.navApproval(1)
m.approvalArmed = false
return m, nil
}
// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
// sending; the same key pressed again (while armed) confirms.
approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA ||
runeIs(k, "y") || runeIs(k, "a")
if approveKey {
if s.Pending.HighTier() && !m.approvalArmed {
m.approvalArmed = true
return m, nil
}
return m.decide("APPROVE")
}
// Any other key cancels a pending T3 arm rather than acting on it.
m.approvalArmed = false
switch {
case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
return m.decide("REJECT")
case runeIs(k, "e") || runeIs(k, "s"):
// Steer/edit flows into the existing steering-note input path.
m.steering = true
m.editMode = ModeInsert
return m, nil
case runeIs(k, "A"):
return m.autoApprove()
case runeIs(k, "l"):
m.sessionEntered = false
m.approvalDismissed = false
return m, nil
case k.Type == tea.KeyEsc:
m.approvalDismissed = true
return m, nil
case k.Type == tea.KeyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
return m, nil
case runeIs(k, "q"):
m.quitting = true
return m, tea.Quit
}
// A bare disarm (e.g. an unrelated key while armed) just falls through.
return m, nil
}
// handleInsertKey processes typing (chat/filter/steer note).
func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.steering {
@@ -376,9 +419,10 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
p := s.Pending
m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName))
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
s.Pending = nil
s.removeApproval(p.RequestID)
m.steerBuffer = ""
m.steering = false
m.approvalArmed = false
m.approvalDismissed = false
return m, nil
}
@@ -778,10 +822,11 @@ func (m Model) decide(decision string) (tea.Model, tea.Cmd) {
note = &n
}
m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note))
s.Pending = nil
s.removeApproval(s.Pending.RequestID)
m.steerBuffer = ""
m.steering = false
m.editMode = ModeNormal
m.approvalArmed = false
m.approvalDismissed = false
return m, nil
}