From d5afcd345a661bfcbd1b5e0aff1d23cf9db5b0be Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:40:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(tui-go):=20approval=20ergonomics=20?= =?UTF-8?q?=E2=80=94=20queue=20+=20tier-gated=20confirm=20(E=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/tui-go/internal/app/approval_test.go | 257 ++++++++++++++++++++++ apps/tui-go/internal/app/demo.go | 12 +- apps/tui-go/internal/app/model.go | 110 ++++++++- apps/tui-go/internal/app/overlays.go | 22 +- apps/tui-go/internal/app/server.go | 24 +- apps/tui-go/internal/app/update.go | 111 +++++++--- 6 files changed, 483 insertions(+), 53 deletions(-) create mode 100644 apps/tui-go/internal/app/approval_test.go diff --git a/apps/tui-go/internal/app/approval_test.go b/apps/tui-go/internal/app/approval_test.go new file mode 100644 index 00000000..072f549c --- /dev/null +++ b/apps/tui-go/internal/app/approval_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 2163512c..7bdaa7c3 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -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": diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 59d79708..6ac6168f 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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,8 +183,13 @@ type Session struct { Tools []ToolRecord ToolsByStage map[string][]ManifestTool Events []EventEntry - Pending *Approval - Clar *Clarification // open questions awaiting answers (clarification view) + // 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. diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index a9bc57ad..3b52600b 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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")) } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index dd18b255..60039e5b 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -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 } diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index c8b54d40..b26687a3 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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() - } + 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 (T0–T2) 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 }