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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,14 +78,14 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
||||||
s.Events = sampleEvents()
|
s.Events = sampleEvents()
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
||||||
ToolName: "file_write",
|
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",
|
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{
|
Rationale: []string{
|
||||||
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
|
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case "approval-steer":
|
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"
|
m.steerBuffer = "also print the current distro and kernel version"
|
||||||
if s := m.session("04a546aa"); s != nil {
|
if s := m.session("04a546aa"); s != nil {
|
||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
||||||
ToolName: "file_write",
|
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",
|
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":
|
case "approval-shell":
|
||||||
@@ -114,14 +114,14 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.CurrentStage = "execute_script"
|
s.CurrentStage = "execute_script"
|
||||||
s.Events = sampleEvents()
|
s.Events = sampleEvents()
|
||||||
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
||||||
ToolName: "shell",
|
ToolName: "shell",
|
||||||
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
|
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
|
||||||
Rationale: []string{
|
Rationale: []string{
|
||||||
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
|
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case "diff":
|
case "diff":
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -111,6 +114,27 @@ type Approval struct {
|
|||||||
Rationale []string
|
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).
|
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
|
||||||
type ClarQuestion struct {
|
type ClarQuestion struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -159,8 +183,13 @@ type Session struct {
|
|||||||
Tools []ToolRecord
|
Tools []ToolRecord
|
||||||
ToolsByStage map[string][]ManifestTool
|
ToolsByStage map[string][]ManifestTool
|
||||||
Events []EventEntry
|
Events []EventEntry
|
||||||
Pending *Approval
|
// Pending is the *current* approval gate shown in the band. It always mirrors
|
||||||
Clar *Clarification // open questions awaiting answers (clarification view)
|
// 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)
|
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
|
||||||
Active bool // an inference/tool is in flight (drives the spinner)
|
Active bool // an inference/tool is in flight (drives the spinner)
|
||||||
}
|
}
|
||||||
@@ -282,6 +311,10 @@ type Model struct {
|
|||||||
// approval steering input buffer
|
// approval steering input buffer
|
||||||
steerBuffer string
|
steerBuffer string
|
||||||
steering bool
|
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)
|
// clarification view (the interactive question form)
|
||||||
clarFocus int // focused question index
|
clarFocus int // focused question index
|
||||||
@@ -352,6 +385,79 @@ func (m Model) session(id string) *Session {
|
|||||||
return nil
|
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.
|
// 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 existence is derived from the event stream (any event for an unknown
|
||||||
// session vivifies it) rather than a dedicated control frame.
|
// session vivifies it) rather than a dedicated control frame.
|
||||||
|
|||||||
@@ -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) +
|
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
|
||||||
t.span(" · risk ", t.P.Faint) +
|
t.span(" · risk ", t.P.Faint) +
|
||||||
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
|
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
|
innerH := h - 2
|
||||||
ratBlock := 0
|
ratBlock := 0
|
||||||
@@ -218,8 +225,19 @@ func (m Model) approvalActions() string {
|
|||||||
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
||||||
}, gap)
|
}, gap)
|
||||||
}
|
}
|
||||||
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
|
s := m.session(m.selectedID)
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
|
// 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)) {
|
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
|
||||||
parts = append(parts, hint("^x", "fullscreen"))
|
parts = append(parts, hint("^x", "fullscreen"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
case protocol.TypeSessionResumed:
|
case protocol.TypeSessionResumed:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Status = "ACTIVE"
|
s.Status = "ACTIVE"
|
||||||
s.Pending = nil
|
s.clearApprovals()
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionCompleted:
|
case protocol.TypeSessionCompleted:
|
||||||
@@ -134,6 +134,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
s.Propose = nil
|
s.Propose = nil
|
||||||
|
s.clearApprovals()
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionFailed:
|
case protocol.TypeSessionFailed:
|
||||||
m.touch(msg.SessionID, "FAILED")
|
m.touch(msg.SessionID, "FAILED")
|
||||||
@@ -141,6 +142,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
s.Propose = nil
|
s.Propose = nil
|
||||||
|
s.clearApprovals()
|
||||||
}
|
}
|
||||||
case protocol.TypeStageStarted:
|
case protocol.TypeStageStarted:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
@@ -252,7 +254,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
m.onWorkflowProposed(msg)
|
m.onWorkflowProposed(msg)
|
||||||
case protocol.TypeApprovalResolved:
|
case protocol.TypeApprovalResolved:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
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
|
detail := msg.Outcome
|
||||||
if msg.Reason != "" {
|
if msg.Reason != "" {
|
||||||
detail += " — " + msg.Reason
|
detail += " — " + msg.Reason
|
||||||
@@ -411,7 +415,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
|||||||
Rationale: rationale,
|
Rationale: rationale,
|
||||||
}
|
}
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
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) {
|
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||||
var pending *Approval
|
var queue []*Approval
|
||||||
if len(msg.PendingAppr) > 0 {
|
for _, a := range msg.PendingAppr {
|
||||||
a := msg.PendingAppr[0]
|
queue = append(queue, &Approval{
|
||||||
pending = &Approval{
|
|
||||||
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
||||||
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
status := "running"
|
status := "running"
|
||||||
if msg.State != nil {
|
if msg.State != nil {
|
||||||
status = msg.State.Status
|
status = msg.State.Status
|
||||||
}
|
}
|
||||||
if pending != nil {
|
if len(queue) > 0 {
|
||||||
status = "PAUSED awaiting approval"
|
status = "PAUSED awaiting approval"
|
||||||
}
|
}
|
||||||
sess := Session{
|
sess := Session{
|
||||||
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
|
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 {
|
if msg.State != nil && msg.State.CurrentStageID != nil {
|
||||||
sess.CurrentStage = *msg.State.CurrentStageID
|
sess.CurrentStage = *msg.State.CurrentStageID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// handleNormalKey processes vim-style bare-key commands.
|
// handleNormalKey processes vim-style bare-key commands.
|
||||||
func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
ds := m.displayState()
|
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 {
|
switch k.Type {
|
||||||
case tea.KeyUp:
|
case tea.KeyUp:
|
||||||
m.navUp()
|
m.navUp()
|
||||||
@@ -155,22 +161,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
return m.normalEnter()
|
return m.normalEnter()
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
if ds == StateApproval {
|
|
||||||
m.approvalDismissed = true
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.filter = ""
|
m.filter = ""
|
||||||
return m, nil
|
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:
|
case tea.KeyCtrlX:
|
||||||
if m.currentDiff() != "" {
|
if m.currentDiff() != "" {
|
||||||
m.overlay = OverlayDiff
|
m.overlay = OverlayDiff
|
||||||
@@ -228,33 +220,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
}
|
}
|
||||||
case "s":
|
case "s":
|
||||||
if ds == StateApproval {
|
m.cycleChatMode()
|
||||||
m.steering = true
|
|
||||||
m.editMode = ModeInsert
|
|
||||||
} else {
|
|
||||||
m.cycleChatMode()
|
|
||||||
}
|
|
||||||
case "c":
|
case "c":
|
||||||
if m.selectedID != "" {
|
if m.selectedID != "" {
|
||||||
m.client.Send(protocol.CancelSession(m.selectedID))
|
m.client.Send(protocol.CancelSession(m.selectedID))
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if ds == StateApproval {
|
// In-session (gate peeked away with esc): `a` brings the band back.
|
||||||
return m.decide("APPROVE")
|
|
||||||
}
|
|
||||||
if ds == StateInSession {
|
if ds == StateInSession {
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "r":
|
|
||||||
if ds == StateApproval {
|
|
||||||
return m.decide("REJECT")
|
|
||||||
}
|
|
||||||
case "A":
|
|
||||||
if ds == StateApproval {
|
|
||||||
return m.autoApprove()
|
|
||||||
}
|
|
||||||
case "q":
|
case "q":
|
||||||
m.quitting = true
|
m.quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -262,6 +239,72 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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).
|
// handleInsertKey processes typing (chat/filter/steer note).
|
||||||
func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.steering {
|
if m.steering {
|
||||||
@@ -376,9 +419,10 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
|
|||||||
p := s.Pending
|
p := s.Pending
|
||||||
m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName))
|
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))
|
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
|
||||||
s.Pending = nil
|
s.removeApproval(p.RequestID)
|
||||||
m.steerBuffer = ""
|
m.steerBuffer = ""
|
||||||
m.steering = false
|
m.steering = false
|
||||||
|
m.approvalArmed = false
|
||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -778,10 +822,11 @@ func (m Model) decide(decision string) (tea.Model, tea.Cmd) {
|
|||||||
note = &n
|
note = &n
|
||||||
}
|
}
|
||||||
m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note))
|
m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note))
|
||||||
s.Pending = nil
|
s.removeApproval(s.Pending.RequestID)
|
||||||
m.steerBuffer = ""
|
m.steerBuffer = ""
|
||||||
m.steering = false
|
m.steering = false
|
||||||
m.editMode = ModeNormal
|
m.editMode = ModeNormal
|
||||||
|
m.approvalArmed = false
|
||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user