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