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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user