diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 7bdaa7c3..5e203e74 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -152,6 +152,30 @@ func PreviewFrame(kind string, w, h int) string { m.overlay = OverlayStats m.statsFor = "04a546aa" m.stats = sampleStats() + + case "grant-scope": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"} + m.grantScopeIndex = 1 + m.overlay = OverlayGrantScope + + case "grants": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grants = []protocol.GrantDto{ + {GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}}, + {GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"}, + {GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}}, + } + m.grantIndex = 1 + m.overlay = OverlayGrants } return m.View() } diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index f3a733a4..f4a71ae1 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -59,6 +59,8 @@ const ( OverlaySessions OverlayFiles OverlayStatusbar + OverlayGrants + OverlayGrantScope ) // RouterEntry is one line in a session's conversation transcript. @@ -320,6 +322,15 @@ type Model struct { sbHidden map[string]bool sbIndex int + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. + grants []protocol.GrantDto + grantsLoading bool + grantIndex int + // grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval. + // grantScopeIndex selects session/project/global; grantFor holds the approval being widened. + grantScopeIndex int + grantFor *Approval + // animation frame int // tick counter; drives spinner + caret blink ticking bool // true while a tick loop is scheduled. The loop is gated on animating() diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 832938b6..38fc79ad 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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() diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 55af662a..5b17ed7d 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -598,6 +598,7 @@ var nonRenderingEventTypes = map[string]string{ protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript", + protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript", } // TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 2ff8cb4a..b3d7fc39 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -333,6 +333,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.fileIndex >= len(m.filteredFiles()) { m.fileIndex = 0 } + case protocol.TypeGrantList: + m.grants = msg.Grants + m.grantsLoading = false + if m.grantIndex >= len(m.grants) { + m.grantIndex = 0 + } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -378,7 +384,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, - protocol.TypeIdeaList, protocol.TypeFileList: + protocol.TypeIdeaList, protocol.TypeFileList, protocol.TypeGrantList: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index c54de861..1285d8bb 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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 diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index dc6b363e..276b7cca 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -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 {