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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user