diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 5e203e74..c10be9dc 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -163,6 +163,29 @@ func PreviewFrame(kind string, w, h int) string { m.grantScopeIndex = 1 m.overlay = OverlayGrantScope + case "actions": + 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: "run the healthcheck and write the script"}, + {Role: "router", Content: "I'll create the script, then run it."}, + {Role: "action", Icon: "⏵", Content: "file_write"}, + {Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 −0)"}, + {Role: "action", Icon: "⌘", Content: "approved file_write"}, + {Role: "action", Icon: "⏵", Content: "shell"}, + {Role: "action", Icon: "✓", Content: "shell · exit 0"}, + {Role: "action", Icon: "⊞", Content: "granted file_write · global"}, + {Role: "router", Content: "Done — script written and the healthcheck passes."}, + } + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "execute_script" + s.LastEventAt = nowMillis() - 3000 + } + case "grants": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index f4a71ae1..3b0356a0 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -65,8 +65,9 @@ const ( // RouterEntry is one line in a session's conversation transcript. type RouterEntry struct { - Role string // user | router | tool | narration | narration_llm + Role string // user | router | tool | narration | narration_llm | action Content string + Icon string // action role only: the gutter glyph (⏵ ✓ ✎ ⌘ ✕ ⊞ ⊟) Metrics *TurnMetrics } @@ -322,6 +323,10 @@ type Model struct { sbHidden map[string]bool sbIndex int + // actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in + // the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json. + actionsHidden bool + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. grants []protocol.GrantDto grantsLoading bool @@ -385,6 +390,7 @@ func NewModel(client *ws.Client) Model { eventStripShown: true, transcriptSel: -1, sbHidden: loadStatusbarHidden(), + actionsHidden: loadPrefs().InlineActionsHidden, } } diff --git a/apps/tui-go/internal/app/prefs.go b/apps/tui-go/internal/app/prefs.go index 550616da..c6727065 100644 --- a/apps/tui-go/internal/app/prefs.go +++ b/apps/tui-go/internal/app/prefs.go @@ -9,7 +9,8 @@ import ( // prefs is the TUI's local, persisted preferences (display-only client state — kept out // of the server config, which is shared/replayed). Stored as JSON in the correx config dir. type prefs struct { - StatusbarHidden []string `json:"statusbarHidden"` + StatusbarHidden []string `json:"statusbarHidden"` + InlineActionsHidden bool `json:"inlineActionsHidden"` } // statusSegment is one toggleable status-bar segment. The always-on segments (correx label, @@ -37,44 +38,62 @@ func prefsPath() string { return filepath.Join(dir, "tui-prefs.json") } -// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or -// corrupt file yields an empty set, i.e. everything shown). -func loadStatusbarHidden() map[string]bool { - out := map[string]bool{} +// loadPrefs reads the whole prefs file (best-effort: a missing or corrupt file yields the +// zero value, i.e. everything shown / actions visible). +func loadPrefs() prefs { + var p prefs path := prefsPath() if path == "" { - return out + return p } - data, err := os.ReadFile(path) - if err != nil { - return out + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &p) } - var p prefs - if json.Unmarshal(data, &p) == nil { - for _, id := range p.StatusbarHidden { - out[id] = true - } - } - return out + return p } -// saveStatusbarHidden writes the hidden set back to disk (best-effort; ignores IO errors so -// a read-only home never crashes the UI). Order follows statusSegments for a stable file. -func saveStatusbarHidden(hidden map[string]bool) { +// savePrefs writes the whole prefs file (best-effort; ignores IO errors so a read-only home +// never crashes the UI). Callers load, mutate one field, then save so nothing is clobbered. +func savePrefs(p prefs) { path := prefsPath() if path == "" { return } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if data, err := json.MarshalIndent(p, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o644) + } +} + +// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or +// corrupt file yields an empty set, i.e. everything shown). +func loadStatusbarHidden() map[string]bool { + out := map[string]bool{} + for _, id := range loadPrefs().StatusbarHidden { + out[id] = true + } + return out +} + +// saveStatusbarHidden writes the hidden set back to disk, preserving the other prefs fields. +// Order follows statusSegments for a stable file. +func saveStatusbarHidden(hidden map[string]bool) { var ids []string for _, seg := range statusSegments { if hidden[seg.id] { ids = append(ids, seg.id) } } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return - } - if data, err := json.MarshalIndent(prefs{StatusbarHidden: ids}, "", " "); err == nil { - _ = os.WriteFile(path, data, 0o644) - } + p := loadPrefs() + p.StatusbarHidden = ids + savePrefs(p) +} + +// saveInlineActionsHidden persists the inline-action-rows toggle, preserving other prefs fields. +func saveInlineActionsHidden(hidden bool) { + p := loadPrefs() + p.InlineActionsHidden = hidden + savePrefs(p) } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index b3d7fc39..e4924787 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -198,6 +198,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.LastEventAt = nowMillis() } + m.appendAction(msg.SessionID, "⏵", msg.ToolName) case protocol.TypeToolCompleted: if s := m.session(msg.SessionID); s != nil { s.Active = false @@ -206,7 +207,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) } if msg.Diff != nil && *msg.Diff != "" { + // A diff = a write/edit: name the file + the line delta, then keep the + // existing collapsed diff row (^x opens the full diff). + path, add, del := diffSummary(*msg.Diff) + m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del)) m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff}) + } else { + m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary)) } case protocol.TypeToolAssessed: if s := m.session(msg.SessionID); s != nil { @@ -224,11 +231,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.markTool(msg.ToolName, ToolFailed) s.LastEventAt = msg.OccurredAt } + m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason)) case protocol.TypeToolRejected: if s := m.session(msg.SessionID); s != nil { s.markTool(msg.ToolName, ToolRejected) s.LastEventAt = nowMillis() } + m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason)) case protocol.TypeArtifactCreated: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) @@ -254,6 +263,15 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.onWorkflowProposed(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { + // The resolved gate names no tool on the wire; recover it from the pending + // queue before dropping the gate, for the inline row. + tool := "" + for _, a := range s.PendingQueue { + if a.RequestID == msg.RequestID { + tool = a.ToolName + break + } + } // Drop just the resolved gate; any others stay queued and the band // advances to the next rather than vanishing entirely. s.removeApproval(msg.RequestID) @@ -263,6 +281,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.addEvent(msg.OccurredAt, "ApprovalResolved", detail) s.LastEventAt = nowMillis() + m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason)) } case protocol.TypeSessionSnapshot: m.onSnapshot(msg) @@ -391,6 +410,73 @@ func sessionIDOf(msg protocol.ServerMessage) string { } } +// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) --- + +// diffSummary extracts the written path and +/- line counts from a unified diff for the +// inline write row. Falls back to "file" when no `+++` header is present. +func diffSummary(diff string) (path string, added, removed int) { + path = "file" + for _, ln := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(ln, "+++ "): + p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ ")) + p = strings.TrimPrefix(p, "b/") + if p != "" && p != "/dev/null" { + path = p + } + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + added++ + case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): + removed++ + } + } + return path, added, removed +} + +// countSuffix renders the " (+a −b)" delta, or "" when nothing changed. +func countSuffix(added, removed int) string { + if added == 0 && removed == 0 { + return "" + } + return " (+" + itoa(added) + " −" + itoa(removed) + ")" +} + +// actionToolText joins a tool label with a short, clipped result summary. +func actionToolText(label, summary string) string { + s := strings.TrimSpace(summary) + if s == "" { + return label + } + return label + " · " + clip(s, 48) +} + +func approvalIcon(outcome string) string { + if outcome == "REJECTED" { + return "✕" + } + return "⌘" +} + +// approvalActionText renders the inline approval row, noting an auto-approval that fired via a +// standing grant (reason "grant:"). +func approvalActionText(outcome, tool, reason string) string { + verb := "approved" + switch outcome { + case "REJECTED": + verb = "rejected" + case "AUTO_APPROVED": + verb = "auto-approved" + } + txt := verb + if tool != "" { + txt = verb + " " + tool + } + if strings.HasPrefix(reason, "grant:") { + txt += " · via grant" + } + return txt +} + // onSessionAnnounced fills in a session's workflow identity (the announce is the // only event carrying workflowId) and applies auto-focus. The session entry itself // was already created by the auto-vivify path in applyServer. diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 1285d8bb..b807f9f0 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -760,7 +760,9 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } 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)) + g := m.grants[m.grantIndex] + m.client.Send(protocol.RevokeGrant(g.GrantID)) + m.appendAction(m.selectedID, "⊟", "revoked "+g.ToolName+" · "+strings.ToLower(g.Scope)) // The server replies with a fresh grant.list, which refreshes m.grants. } case runeIs(k, "G"): @@ -829,6 +831,7 @@ func (m Model) applyGrantScope() (tea.Model, tea.Cmd) { 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)) + m.appendAction(p.SessionID, "⊞", "granted "+p.ToolName+" · "+strings.ToLower(scope)) if s := m.session(p.SessionID); s != nil { s.removeApproval(p.RequestID) } @@ -969,6 +972,7 @@ func paletteCommands() []paletteCmd { {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"mode", "s", "toggle mode", "switch chat / steering"}, {"cancel", "c", "cancel session", "stop the selected session"}, {"back", "l", "back to list", "leave the current session"}, @@ -1016,6 +1020,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "statusbar": m.overlay = OverlayStatusbar m.sbIndex = 0 + case "actions": + m.toggleInlineActions() case "mode": m.cycleChatMode() case "cancel": @@ -1288,6 +1294,22 @@ func (m *Model) appendRouter(sid string, e RouterEntry) { m.routerMessages[sid] = append(m.routerMessages[sid], e) } +// appendAction adds an inline "external feedback" row (a tool call, write/edit, approval, or +// grant) to a session's OUTPUT transcript, interleaved with chat turns by arrival order. The +// rows are always recorded; rendering is gated on actionsHidden so toggling reveals history. +func (m *Model) appendAction(sid, icon, text string) { + if sid == "" { + return + } + m.routerMessages[sid] = append(m.routerMessages[sid], RouterEntry{Role: "action", Icon: icon, Content: text}) +} + +// toggleInlineActions flips the inline-action-rows visibility and persists it. +func (m *Model) toggleInlineActions() { + m.actionsHidden = !m.actionsHidden + saveInlineActionsHidden(m.actionsHidden) +} + // transcriptNavUser moves the transcript selection to the previous (dir<0) or next // (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ // grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 2c81307b..954886c5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -542,6 +542,14 @@ func (m Model) routerRows(w, h int) []string { } case "narration", "stage": rows = append(rows, t.span(e.Content, t.P.Dim)) + case "action": + // Inline "external feedback" row: a side-effecting action (tool call, write/edit, + // approval, grant) surfaced in the conversation flow. Muted via actionsHidden. + if m.actionsHidden { + break + } + icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon) + rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim)) } } // With a selection, scroll so the selected message is visible (top-aligned, clamped