Files
zero/internal/tui/command_output.go
KRATOS 0e7318ff72 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315)
* fix(tui): plan panel reflects live progress + prompt cadence

The plan panel could read as stuck mid-task even while work continued. Three
fixes:
- Header showed steps[0] forever, so the title never advanced as the plan
  progressed; show the current step (in_progress, else first incomplete).
- updateFromItems matched steps by content only, so an in-place reword reset a
  step's elapsed timer to zero; add positional carry-over when the step count is
  unchanged.
- Prompt: tell the model to mark each step completed + the next in_progress
  before starting it, so the plan tracks real progress instead of jumping at the
  end.

Regression tests included.

* feat(tui): click a plan step to see what was built for it

Each plan step now records the file mutations (write_file/edit_file/apply_patch)
made while it was in_progress, captured from tool-result rows. Clicking a step
row in the context sidebar drops a transcript card listing those changes — the
implementation for that step. Step->line mapping mirrors sidebarAgentSelectables'
offset accounting (one line per step); capture is keyed to the active step and
cleared per run. Read-only and additive. Tests cover the offset math + capture.

* feat(tui): plan-step detail shows diffs + commands

Extend the clickable plan-step detail: capture bash/exec_command runs in addition
to file mutations, store each tool's full output (the diff for edit/apply_patch,
stdout/stderr for commands), and render the card grouped into Changes and
Commands with a short truncated excerpt of each item's diff/output.

* fix(tui): plan-step detail toggles instead of stacking duplicates

Re-clicking a plan step now hides its card (a stable transcript id + drop-by-id),
and clicking a different step replaces it, so at most one detail card is shown.
Previously each click appended a fresh card and they piled up.

* feat(tui): elaborate plan-step detail by status (what we did / will do)

* feat(tui): plan-step card leads with a prose explanation (agent's own narration)

* feat(tui): write plan-step explanations with the model on click

Clicking a PLAN step now shows a fresh, plain-English write-up of that
step, generated on demand by the active model:

- past tense ('what we did') for completed/failed steps, future tense
  ('what we'll do') for pending/in_progress steps
- immediate feedback: the card drops in showing 'Writing explanation…'
  and updates in place (by stable row id) when the text returns
- cached per step (content+status) so re-clicking is instant with no
  second model call; re-clicking still toggles the card closed
- the prompt is built from already-captured local data (step text,
  status, notes, plus a compact digest of file edits, commands, and the
  agent's narration) and asks for short, non-technical prose
- reuses the TUI's existing provider via a one-shot StreamCompletion;
  no new client, no hardcoded provider. Falls back to the local summary
  when no provider is configured or the request fails.

* fix(tui): minimal plan-step card styling (dim border, status-tinted title)

* feat(tui): auto-hide context sidebar when it has no agents or plan

* feat(tui): widen chat reading column to fill wide terminals with breathing room

* fix(tui): toggle context sidebar silently (no transcript notice)

* fix(tui): mark plan complete when a finished task forgot the final update_plan

* fix(tui): remove the lime streaming-text glow (render responses in static ink)

* fix(tui): remove the shaded panel band behind tool-result cards

* fix(tui): Ctrl+B hides the plan entirely (no pinned-panel fallback)

* style(tui): gofmt-align model struct fields

* feat: narrate the build as a clean story (hide plumbing cards, live plan progress, narration prompt, write_file line counts)

* feat(tui): render agent narration bright (ink) so the build reads as a story

* feat(agent): demand an elaborate, structured closing summary (not a single line)

* feat(tui): mark agent narration with a leading bullet (selection-aligned)

* feat: post-turn recap line (※ recap:) with /config recaps on|off toggle

* feat(tui): show 'still generating…' during quiet stretches + count tool-call streaming

* feat(tui): ACTIVITY panel under PLAN + push the model to step the plan per file

* fix(tui): show the quiet 'still generating' hint only when the sidebar is hidden

* feat(tui): code preview in write/edit/apply cards (card-only, no model token cost)

* fix(tui): address CodeRabbit review — stale plan timestamps, stale explanation, Ctrl+B persistence, UTF-8 truncation

* fix: address CodeRabbit re-review round 2 (7 actionable + 2 nitpicks)

* chore: drop accidentally-committed WIP internal/cli/dryrun_test.go

It is an unrelated, pre-existing dry-run test that does not compile (references a
non-existent agent.Options.DryRun field, an unused import, and out-of-scope
sandbox symbols). It was swept into the prior commit by 'git add -A' and is not
part of this PR's scope.
2026-06-25 20:23:29 +05:30

249 lines
6.3 KiB
Go

package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/redaction"
)
type commandStatus string
const (
commandStatusOK commandStatus = "ok"
commandStatusWarning commandStatus = "warning"
commandStatusBlocked commandStatus = "blocked"
commandStatusInfo commandStatus = "info"
)
type commandOutput struct {
Title string
Status commandStatus
Sections []commandSection
Hints []string
}
type commandSection struct {
Title string
Fields []commandField
Lines []string
Rows []commandRow
Hints []string
}
type commandField struct {
Key string
Value string
}
type commandRow struct {
Text string
}
type commandCard struct {
Title string
Summary []string
Sections []commandCardSection
Actions []string
}
type commandCardSection struct {
Title string
Fields []commandField
Lines []string
Rows []commandRow
}
const commandCardTranscriptPrefix = "\x00command-card\x00"
func renderCommandCardTranscript(card commandCard) string {
return commandCardTranscriptPrefix + renderCommandCard(card)
}
func commandCardTranscriptPayload(text string) (string, bool) {
if !strings.HasPrefix(text, commandCardTranscriptPrefix) {
return text, false
}
return strings.TrimPrefix(text, commandCardTranscriptPrefix), true
}
// planCardTranscriptPrefix tags the plan-step detail card so it renders through
// renderPlanCardRow — a minimal variant (dim grey border, calm status-tinted
// title, no loud lime headers) — instead of the standard command-card styling.
const planCardTranscriptPrefix = "\x00plan-card\x00"
// renderPlanCard formats a plan-step detail card with the same structured text
// as a command card, but tagged so it renders minimally.
func renderPlanCard(output commandOutput) string {
return planCardTranscriptPrefix + formatCommandOutput(output)
}
func planCardTranscriptPayload(text string) (string, bool) {
if !strings.HasPrefix(text, planCardTranscriptPrefix) {
return text, false
}
return strings.TrimPrefix(text, planCardTranscriptPrefix), true
}
func renderCommandCard(card commandCard) string {
lines := []string{}
title := compactCommandOutputText(card.Title)
if title == "" {
title = "Zero"
}
lines = append(lines, title)
if summary := compactCommandCardList(card.Summary); len(summary) > 0 {
lines = append(lines, strings.Join(summary, " | "))
}
for _, section := range card.Sections {
lines = append(lines, formatCommandCardSection(section)...)
}
if actions := compactCommandCardList(card.Actions); len(actions) > 0 {
lines = append(lines, "actions: "+strings.Join(actions, " | "))
}
return strings.Join(lines, "\n")
}
func formatCommandCardSection(section commandCardSection) []string {
lines := []string{}
if title := compactCommandOutputText(section.Title); title != "" {
lines = append(lines, title)
}
keyWidth := 0
fields := make([]commandField, 0, len(section.Fields))
for _, field := range section.Fields {
key := compactCommandOutputText(field.Key)
value := compactCommandOutputText(field.Value)
if key == "" || value == "" {
continue
}
if len(key) > keyWidth {
keyWidth = len(key)
}
fields = append(fields, commandField{Key: key, Value: value})
}
for _, field := range fields {
lines = append(lines, " "+field.Key+strings.Repeat(" ", keyWidth-len(field.Key)+2)+field.Value)
}
for _, line := range section.Lines {
if text := compactCommandOutputText(line); text != "" {
lines = append(lines, " "+text)
}
}
for _, row := range section.Rows {
if text := compactCommandOutputText(row.Text); text != "" {
lines = append(lines, " - "+text)
}
}
return lines
}
func compactCommandCardList(values []string) []string {
compacted := make([]string, 0, len(values))
for _, value := range values {
if text := compactCommandOutputText(value); text != "" {
compacted = append(compacted, text)
}
}
return compacted
}
func formatCommandOutput(output commandOutput) string {
lines := []string{}
status := normalizeCommandStatus(output.Status)
title := compactCommandOutputText(output.Title)
if title == "" {
lines = append(lines, "Zero")
} else {
lines = append(lines, title)
}
lines = append(lines, "status: "+string(status))
for _, section := range output.Sections {
lines = append(lines, formatCommandSection(section)...)
}
for _, hint := range output.Hints {
if text := compactCommandOutputText(hint); text != "" {
lines = append(lines, "hint: "+text)
}
}
return strings.Join(lines, "\n")
}
func formatCommandSection(section commandSection) []string {
lines := []string{}
if title := compactCommandOutputText(section.Title); title != "" {
lines = append(lines, title)
}
for _, field := range section.Fields {
key := compactCommandOutputText(field.Key)
value := compactCommandOutputText(field.Value)
if key == "" || value == "" {
continue
}
lines = append(lines, " "+key+": "+value)
}
for _, line := range section.Lines {
if text := compactCommandOutputText(line); text != "" {
lines = append(lines, " "+text)
}
}
for _, row := range section.Rows {
if text := compactCommandOutputText(row.Text); text != "" {
lines = append(lines, " - "+text)
}
}
for _, hint := range section.Hints {
if text := compactCommandOutputText(hint); text != "" {
lines = append(lines, " hint: "+text)
}
}
return lines
}
func normalizeCommandStatus(status commandStatus) commandStatus {
switch status {
case commandStatusOK, commandStatusWarning, commandStatusBlocked, commandStatusInfo:
return status
default:
return commandStatusInfo
}
}
func compactCommandOutputText(text string) string {
text = strings.Join(strings.Fields(text), " ")
return redaction.RedactString(text, redaction.Options{})
}
func renderCommandOutput(output commandOutput) string {
// Route every command-info screen (/tools, /permissions, /context, /config,
// /sessions, doctor, …) through the styled command-card renderer so it gets
// real visual hierarchy (accent group headers, two-tone command rows) instead
// of the flat dim-grey system note. The structured text is unchanged — the
// prefix just selects the card renderer in renderRowModeUncached.
return commandCardTranscriptPrefix + formatCommandOutput(output)
}
func commandBullet(value string) string {
return "- " + compactCommandOutputText(value)
}
func boolText(value bool) string {
if value {
return "yes"
}
return "no"
}