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