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 -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.