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
+78 -33
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()
}
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
}