8df0ec750c
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>
259 lines
9.1 KiB
Go
259 lines
9.1 KiB
Go
package app
|
|
|
|
import "github.com/correx/tui-go/internal/protocol"
|
|
|
|
// PreviewFrame renders a single static frame for a named UI state at the given
|
|
// terminal size. Used by cmd/preview to screenshot the look without a live
|
|
// server. Not part of the runtime path.
|
|
func PreviewFrame(kind string, w, h int) string {
|
|
m := NewModel(nil)
|
|
m.width, m.height = w, h
|
|
m.theme = NewTheme(SoftBlue)
|
|
|
|
switch kind {
|
|
case "idle-empty":
|
|
m.connected = false
|
|
|
|
case "idle":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.workflows = sampleWorkflows()
|
|
m.bgUpdates = 3
|
|
m.selectedID = "04a546aa"
|
|
|
|
case "workflows":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.workflows = sampleWorkflows()
|
|
m.selectedID = "04a546aa"
|
|
m.wfVisible = true
|
|
m.wfIndex = 1
|
|
|
|
case "session", "insert":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
m.routerConnected = true
|
|
m.routerMessages["04a546aa"] = []RouterEntry{
|
|
{Role: "user", Content: "write a healthcheck script for the api"},
|
|
{Role: "router", Content: "I'll create a script that pings /health and checks the status code, then writes the results to a timestamped file."},
|
|
{Role: "tool", Content: "--- 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 $(date)\"\n"},
|
|
}
|
|
if s := m.session("04a546aa"); s != nil {
|
|
s.CurrentStage = "write_script"
|
|
s.Events = sampleEvents()
|
|
s.Active = true
|
|
s.ToolsByStage = sampleManifest()
|
|
s.LastEventAt = nowMillis() - 3000 // 3s ago — exercises the last-event clock
|
|
}
|
|
if kind == "insert" {
|
|
m.editMode = ModeInsert
|
|
m.inputBuffer = "now add a retry with backoff"
|
|
m.inputCursor = len(m.inputBuffer)
|
|
}
|
|
|
|
case "tools":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
if s := m.session("04a546aa"); s != nil {
|
|
s.CurrentStage = "write_script"
|
|
s.ToolsByStage = sampleManifest()
|
|
}
|
|
m.overlay = OverlayToolPalette
|
|
|
|
case "approval":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
if s := m.session("04a546aa"); s != nil {
|
|
s.CurrentStage = "write_script"
|
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
|
s.Events = sampleEvents()
|
|
s.enqueueApproval(&Approval{
|
|
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
|
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",
|
|
Rationale: []string{
|
|
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
|
|
},
|
|
})
|
|
}
|
|
|
|
case "approval-steer":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
m.steerBuffer = "also print the current distro and kernel version"
|
|
if s := m.session("04a546aa"); s != nil {
|
|
s.CurrentStage = "write_script"
|
|
s.enqueueApproval(&Approval{
|
|
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
|
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",
|
|
})
|
|
}
|
|
|
|
case "approval-shell":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
if s := m.session("04a546aa"); s != nil {
|
|
s.CurrentStage = "execute_script"
|
|
s.Events = sampleEvents()
|
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
|
s.enqueueApproval(&Approval{
|
|
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
|
ToolName: "shell",
|
|
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
|
|
Rationale: []string{
|
|
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
|
|
},
|
|
})
|
|
}
|
|
|
|
case "diff":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
m.routerMessages["04a546aa"] = []RouterEntry{
|
|
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -1,2 +1,6 @@\n #!/usr/bin/env bash\n-echo hi\n+curl -sf http://localhost:8080/health\n+if [ $? -ne 0 ]; then\n+ echo \"unhealthy\" >&2\n+ exit 1\n+fi\n+echo \"ok $(date)\"\n"},
|
|
}
|
|
m.overlay = OverlayDiff
|
|
|
|
case "palette":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.overlay = OverlayPalette
|
|
m.paletteFilter = "to"
|
|
|
|
case "stats":
|
|
m.connected = true
|
|
m.currentModel = "llama-cpp:default"
|
|
m.sessions = sampleSessions()
|
|
m.selectedID = "04a546aa"
|
|
m.sessionEntered = true
|
|
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()
|
|
}
|
|
|
|
func sampleStats() *protocol.StatsDto {
|
|
return &protocol.StatsDto{
|
|
SessionID: "04a546aa",
|
|
EventCount: 28,
|
|
SessionDurationMs: 204_000,
|
|
InferenceCount: 5,
|
|
InferenceMs: 38_400,
|
|
PromptTokens: 4120,
|
|
CompletionTokens: 1860,
|
|
TokensPerSecond: 48.4,
|
|
PerProvider: []protocol.ProviderStatsDto{
|
|
{Provider: "llama-cpp:qwen2.5-coder-14b", CompletedCount: 4, TotalLatencyMs: 33_200, PromptTokens: 3800, CompletionTokens: 1700, TokensPerSecond: 51.2},
|
|
{Provider: "llama-cpp:default", CompletedCount: 1, TotalLatencyMs: 5200, PromptTokens: 320, CompletionTokens: 160, TokensPerSecond: 30.8},
|
|
},
|
|
ToolCount: 6,
|
|
ToolMs: 4300,
|
|
PerTool: []protocol.ToolStatsDto{
|
|
{ToolName: "file_write", CompletedCount: 2, TotalDurationMs: 2600},
|
|
{ToolName: "read_file", CompletedCount: 3, TotalDurationMs: 1200},
|
|
{ToolName: "shell_exec", CompletedCount: 1, TotalDurationMs: 500},
|
|
},
|
|
ApprovalsRequested: 2,
|
|
ApprovalsResolved: 2,
|
|
ApprovalsPending: 0,
|
|
ApprovalWaitMs: 45_000,
|
|
AvgApprovalWaitMs: 22_500,
|
|
PerTier: []protocol.TierApprovalStatsDto{
|
|
{Tier: "T2", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 15_000, AvgWaitMs: 15_000},
|
|
{Tier: "T3", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 30_000, AvgWaitMs: 30_000},
|
|
},
|
|
Failures: protocol.FailureMetricsDto{ToolFailures: 1},
|
|
InferencePct: 18.8,
|
|
ToolPct: 2.1,
|
|
ApprovalWaitPct: 22.1,
|
|
}
|
|
}
|
|
|
|
func sampleWorkflows() []Workflow {
|
|
return []Workflow{
|
|
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
|
{ID: "refactor", Description: "multi-stage code refactor with review"},
|
|
{ID: "triage", Description: "classify and route incoming issues"},
|
|
}
|
|
}
|
|
|
|
func sampleManifest() map[string][]ManifestTool {
|
|
return map[string][]ManifestTool{
|
|
"plan": {{"read_file", 0}, {"list_dir", 0}},
|
|
"write_script": {{"file_write", 3}, {"shell_exec", 4}, {"read_file", 0}},
|
|
"verify": {{"shell_exec", 4}, {"http_get", 1}},
|
|
}
|
|
}
|
|
|
|
func sampleSessions() []Session {
|
|
return []Session{
|
|
{ID: "04a546aa", Status: "FAILED", WorkflowID: "healthcheck", Name: "healthcheck", CurrentStage: "write_script", LastEventAt: 1748000000000},
|
|
{ID: "0d7097bb", Status: "COMPLETED", WorkflowID: "healthcheck", Name: "healthcheck", LastEventAt: 1748000000000},
|
|
{ID: "1dae17cc", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000},
|
|
{ID: "338f09dd", Status: "FAILED", WorkflowID: "healthcheck", Name: "healthcheck", CurrentStage: "write_script", LastEventAt: 1748000000000},
|
|
{ID: "3f362dee", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000},
|
|
{ID: "92f6c3ff", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000},
|
|
{ID: "9a7682gg", Status: "COMPLETED", WorkflowID: "healthcheck", Name: "healthcheck", LastEventAt: 1748000000000},
|
|
}
|
|
}
|
|
|
|
func sampleEvents() []EventEntry {
|
|
return []EventEntry{
|
|
{"13:42:36", "InferenceStarted", "write_script"},
|
|
{"13:43:00", "InferenceCompleted", "write_script"},
|
|
{"13:43:00", "ToolStarted", "file_write"},
|
|
{"13:43:00", "SessionPaused", "APPROVAL_PENDING"},
|
|
{"13:44:10", "ToolCompleted", "file_write"},
|
|
{"13:44:10", "InferenceStarted", "write_script"},
|
|
{"13:44:10", "SessionFailed", "CANCELLED"},
|
|
}
|
|
}
|