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
+73
View File
@@ -128,6 +128,10 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.filesModal())
case OverlayStatusbar:
return m.center(m.statusbarModal())
case OverlayGrants:
return m.center(m.grantsModal())
case OverlayGrantScope:
return m.center(m.grantScopeModal())
}
return base
}
@@ -615,6 +619,75 @@ func (m Model) statusbarModal() string {
return m.center(modal)
}
// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
// These are cross-session auto-approvals, so the board opens from anywhere.
func (m Model) grantsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("standing grants") + "\n\n")
b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")
switch {
case m.grantsLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.grants) == 0:
b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n")
default:
for i, g := range m.grants {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.grantIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
b.WriteString(marker + scope + tool + tiers)
if g.Scope == "PROJECT" && g.ProjectID != "" {
b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint))
}
b.WriteString("\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
// for the pending tool — this session, this project, or everywhere.
func (m Model) grantScopeModal() string {
t := m.theme
w := m.modalWidth()
tool := ""
if m.grantFor != nil {
tool = m.grantFor.ToolName
}
var b strings.Builder
b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
b.WriteString(mbg(t, " stop asking for ", t.P.Faint) +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")
for i, opt := range grantScopeOptions() {
marker := mbg(t, " ", t.P.BgPanel)
labelFg := t.P.Fg
if i == m.grantScopeIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
labelFg = t.P.FgStrong
}
key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
func (m Model) toolPaletteModal() string {
t := m.theme
w := m.modalWidth()