feat(tui-go): grant scope picker (A) + standing-grants viewer

Surface the new cross-session grant scopes in the TUI.

- Approve-always (A) now opens a scope picker instead of immediately
  creating a SESSION grant: choose this session / this project / everywhere.
  SESSION stays the default (A then enter/s = old behaviour); p and g
  create PROJECT / GLOBAL grants. The grant + approval are sent on confirm
  (esc cancels, leaving the call pending).
- New "grants" palette command + G shortcut open a standing-grants viewer
  (OverlayGrants) listing the active PROJECT/GLOBAL grants — scope, tool,
  permitted tiers, project path — with x/enter to revoke. The server's
  RevokeGrant reply (a fresh grant.list) refreshes it in place.
- protocol.go: TypeGrantList + GrantDto; RevokeGrant/ListGrants encoders;
  CreateGrant now documents the wider scopes. server.go decodes grant.list
  into m.grants and treats it as a non-session global reply.
- render-matrix coverage: grant.list classified as a non-rendering query
  reply (populates the overlay, not the transcript).
- demo.go: grants + grant-scope preview kinds.

go build / vet / test green; rendered both overlays via cmd/preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:35:01 +00:00
parent c36d41b9d5
commit 8df0ec750c
7 changed files with 252 additions and 10 deletions
+109 -8
View File
@@ -275,6 +275,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.openSessions()
case "S":
m.openStats()
case "G":
m.openGrants()
case "g":
m.openConfig()
case "m":
@@ -573,19 +575,17 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) {
return m, nil
}
// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being
// asked: this session (the old behaviour), this project, or globally. The actual grant +
// approval are sent by applyGrantScope once a scope is picked.
func (m Model) autoApprove() (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
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.removeApproval(p.RequestID)
m.steerBuffer = ""
m.steering = false
m.approvalArmed = false
m.approvalDismissed = false
m.grantFor = s.Pending
m.grantScopeIndex = 0
m.overlay = OverlayGrantScope
return m, nil
}
@@ -748,10 +748,108 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "g"):
m.overlay = OverlayNone
}
case OverlayGrants:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
if m.grantIndex > 0 {
m.grantIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
if m.grantIndex < len(m.grants)-1 {
m.grantIndex++
}
case k.Type == tea.KeyEnter || runeIs(k, "x"):
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
m.client.Send(protocol.RevokeGrant(m.grants[m.grantIndex].GrantID))
// The server replies with a fresh grant.list, which refreshes m.grants.
}
case runeIs(k, "G"):
m.overlay = OverlayNone
}
case OverlayGrantScope:
return m.handleGrantScopeKey(k)
}
return m, nil
}
// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the
// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on
// the same repo, GLOBAL every session on this machine.
type grantScopeOption struct{ key, wire, title, hint string }
func grantScopeOptions() []grantScopeOption {
return []grantScopeOption{
{"s", "SESSION", "this session", "until this session ends"},
{"p", "PROJECT", "this project", "any session on this repo"},
{"g", "GLOBAL", "everywhere", "any session on this machine"},
}
}
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
opts := grantScopeOptions()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
if m.grantScopeIndex > 0 {
m.grantScopeIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
if m.grantScopeIndex < len(opts)-1 {
m.grantScopeIndex++
}
case runeIs(k, "s"):
m.grantScopeIndex = 0
return m.applyGrantScope()
case runeIs(k, "p"):
m.grantScopeIndex = 1
return m.applyGrantScope()
case runeIs(k, "g"):
m.grantScopeIndex = 2
return m.applyGrantScope()
case k.Type == tea.KeyEnter:
return m.applyGrantScope()
}
return m, nil
}
// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current
// call, and closes the picker. The SESSION choice is identical to the old A behaviour.
func (m Model) applyGrantScope() (tea.Model, tea.Cmd) {
p := m.grantFor
if p == nil {
m.overlay = OverlayNone
return m, nil
}
opts := grantScopeOptions()
idx := m.grantScopeIndex
if idx < 0 || idx >= len(opts) {
idx = 0
}
scope := opts[idx].wire
m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName))
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
if s := m.session(p.SessionID); s != nil {
s.removeApproval(p.RequestID)
}
m.grantFor = nil
m.overlay = OverlayNone
m.steerBuffer = ""
m.steering = false
m.approvalArmed = false
m.approvalDismissed = false
return m, nil
}
// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants.
// The board is global (not session-scoped), so it opens from anywhere.
func (m *Model) openGrants() {
m.overlay = OverlayGrants
m.grantIndex = 0
m.grantsLoading = true
m.client.Send(protocol.ListGrants())
}
// toggleStatusSegment flips a status-bar segment's visibility and persists the change.
func (m *Model) toggleStatusSegment(id string) {
if m.sbHidden == nil {
@@ -869,6 +967,7 @@ func paletteCommands() []paletteCmd {
{"stats", "S", "session stats", "metrics for the selected session"},
{"sessions", "R", "resume session", "browse / resume recent sessions"},
{"config", "g", "edit config", "view / change correx settings"},
{"grants", "G", "grants", "view / revoke standing grants"},
{"statusbar", "", "status bar", "show / hide status-bar segments"},
{"mode", "s", "toggle mode", "switch chat / steering"},
{"cancel", "c", "cancel session", "stop the selected session"},
@@ -912,6 +1011,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
return m, m.openSessions()
case "config":
m.openConfig()
case "grants":
m.openGrants()
case "statusbar":
m.overlay = OverlayStatusbar
m.sbIndex = 0