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
+27 -1
View File
@@ -56,6 +56,7 @@ const (
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeFileList = "file.list"
TypeGrantList = "grant.list"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -154,6 +155,20 @@ type ServerMessage struct {
// file.list — the session workspace's file paths (for the @ file-ref picker)
Paths []string `json:"paths"`
// grant.list — the active cross-session (PROJECT/GLOBAL) standing grants
Grants []GrantDto `json:"grants"`
}
// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto.
type GrantDto struct {
GrantID string `json:"grantId"`
Scope string `json:"scope"` // "PROJECT" | "GLOBAL"
ToolName string `json:"toolName"`
ProjectID string `json:"projectId"`
Tiers []string `json:"tiers"`
Reason string `json:"reason"`
ExpiresAtMs *int64 `json:"expiresAtMs"`
}
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
@@ -457,7 +472,8 @@ func ClearModelPin() []byte {
}
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3".
// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3".
// For PROJECT/GLOBAL the server derives the projectId from the session's workspace.
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
return encode("CreateGrant", map[string]any{
"sessionId": sessionID,
@@ -470,6 +486,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
})
}
// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list.
func RevokeGrant(grantID string) []byte {
return encode("RevokeGrant", map[string]any{"grantId": grantID})
}
// ListGrants requests the active cross-session grants (replied to with a grant.list).
func ListGrants() []byte {
return encode("ListGrants", map[string]any{})
}
// Hello is the first frame sent on every (re)connect. It carries the client's
// working directory so the server can bind a workspace before any session starts.
func Hello(workingDir string) []byte {