mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* 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.
249 lines
6.3 KiB
Go
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"
|
|
}
|