feat(tui-go): inline action rows in the OUTPUT transcript

Surface side-effecting ("external feedback") events in the conversation
flow, opencode-style, instead of only in the EVENTS side log.

- New RouterEntry role "action" (glyph + text) appended at arrival so it
  interleaves with chat turns by order. Rendered dim with an accent gutter
  glyph; the EVENTS panel still carries the full stream.
- Surfaced: tool start (⏵), tool done (✓ / ✎ wrote <path> (+a −b) for a
  diff), tool failed (✗), rejected (✕ blocked), approval resolved
  (⌘ approved / ✕ rejected, noting "· via grant" on a grant auto-approve),
  and grant create/revoke (⊞ / ⊟ · scope). ApprovalResolved names no tool
  on the wire, so the tool is recovered from the pending queue before the
  gate is dropped.
- Toggle: "inline actions" palette command flips actionsHidden (rows are
  always recorded, gated at render, so toggling reveals history); persisted
  in tui-prefs.json. prefs.go refactored to load/mutate/save so the new
  field and statusbarHidden no longer clobber each other.
- demo.go: "actions" preview kind.

go build / vet / test green; rendered the flow via cmd/preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:00:18 +00:00
parent 3f4c45a7b0
commit 6a06f2ead5
6 changed files with 191 additions and 27 deletions
+23
View File
@@ -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"
+7 -1
View File
@@ -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,
}
}
+44 -25
View File
@@ -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)
}
+86
View File
@@ -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:<id>").
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.
+23 -1
View File
@@ -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.
+8
View File
@@ -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