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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// of the server config, which is shared/replayed). Stored as JSON in the correx config dir.
|
||||
type prefs struct {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user