feat(tui,bash): shell-log style bash results
Bash tool results now render in the TUI like a terminal log:
──────────────────────────────────────────────
$ npm run build
(accent color)
> example@1.0.0 build
> webpack --mode production
built in 2340ms
[exit 0] Took 2.4s
(muted color)
──────────────────────────────────────────────
bash.go: prefixes every result with `$ <command>\n`, adds a
trailing `Took X.Ys` after the `[exit N]` marker, stores the
elapsed duration in Details.duration_ms for programmatic use.
New humanDuration helper formats the duration as "0.1s" for
sub-minute runs, "2m3s" / "1h5m" above that.
view.go: renderBashResult styles three zones:
- first line (starts with "$ ") in accent
- the "[exit N] Took X.Ys" footer line in muted
- everything in between on the default tool-output color
Detected automatically by looking for "$ " at the top of a
tool_result block, so no plumbing changes needed.
Result text stays plain-text so the model sees the same shell-log
format when it reasons about the command's outcome. That matches
how a human would see it in their own terminal and doesn't need
any special escape-code stripping on the model's side.- commit
- 71933343de65ef932b985f08ca6c7ae568d8d29c
- author
- patriceckhart · · 2026-04-20 07:03 UTC
- parent
- b6fc3fd
- signature
- unsigned
- refs
- tag: v0.0.53
2 files · +89 -1
M internal/agent/tools/bash.go +40 -1
| @@ -65,6 +65,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 65 | 65 | defer cancel() | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | start := time.Now() | |
| 68 | 69 | cmd := newShellCmd(runCtx, a.Command) | |
| 69 | 70 | cmd.Dir = cwd | |
| 70 | 71 | cmd.Env = os.Environ() | |
| @@ -134,8 +135,17 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 134 | 135 | } | |
| 135 | 136 | } | |
| 136 | 137 | ||
| 138 | + | elapsed := time.Since(start) | |
| 139 | + | ||
| 140 | + | // Terminal-log style: echo the command on the first line with | |
| 141 | + | // a shell-prompt prefix, a blank line, the captured output, and | |
| 142 | + | // a footer showing exit code + elapsed time. Matches the look | |
| 143 | + | // a human would see if they ran the command themselves, which | |
| 144 | + | // makes the model's reasoning about it more natural too. | |
| 137 | 145 | var sb strings.Builder | |
| 146 | + | fmt.Fprintf(&sb, "$ %s\n", a.Command) | |
| 138 | 147 | if trimmed != "" { | |
| 148 | + | sb.WriteString("\n") | |
| 139 | 149 | sb.WriteString(trimmed) | |
| 140 | 150 | if !strings.HasSuffix(trimmed, "\n") { | |
| 141 | 151 | sb.WriteString("\n") | |
| @@ -147,7 +157,12 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 147 | 157 | if truncBytes { | |
| 148 | 158 | fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes) | |
| 149 | 159 | } | |
| 150 | - | sb.WriteString(fmt.Sprintf("[exit %d]", exitCode)) | |
| 160 | + | sb.WriteString("\n") | |
| 161 | + | if exitCode == 0 { | |
| 162 | + | fmt.Fprintf(&sb, "[exit 0]") | |
| 163 | + | } else { | |
| 164 | + | fmt.Fprintf(&sb, "[exit %d]", exitCode) | |
| 165 | + | } | |
| 151 | 166 | ||
| 152 | 167 | var fullPath string | |
| 153 | 168 | if truncBytes || truncLines { | |
| @@ -156,6 +171,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 156 | 171 | fmt.Fprintf(&sb, " (full output: %s)", fullPath) | |
| 157 | 172 | } | |
| 158 | 173 | } | |
| 174 | + | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | |
| 159 | 175 | ||
| 160 | 176 | isErr := exitCode != 0 || ctx.Err() != nil | |
| 161 | 177 | return core.ToolResult{ | |
| @@ -166,10 +182,33 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 166 | 182 | "full_output_path": fullPath, | |
| 167 | 183 | "lines_truncated": truncLines, | |
| 168 | 184 | "bytes_truncated": truncBytes, | |
| 185 | + | "duration_ms": elapsed.Milliseconds(), | |
| 169 | 186 | }, | |
| 170 | 187 | }, nil | |
| 171 | 188 | } | |
| 172 | 189 | ||
| 190 | + | // humanDuration renders a duration in the "Took X.Ys" style used by | |
| 191 | + | // the shell-log output: tenths of a second for sub-minute runs, | |
| 192 | + | // whole seconds once we pass a minute. Trailing zeros dropped so | |
| 193 | + | // "0.1s" instead of "0.10s". | |
| 194 | + | func humanDuration(d time.Duration) string { | |
| 195 | + | switch { | |
| 196 | + | case d < time.Millisecond: | |
| 197 | + | return "0.0s" | |
| 198 | + | case d < time.Minute: | |
| 199 | + | s := d.Seconds() | |
| 200 | + | return fmt.Sprintf("%.1fs", s) | |
| 201 | + | case d < time.Hour: | |
| 202 | + | m := int(d.Minutes()) | |
| 203 | + | s := int(d.Seconds()) - m*60 | |
| 204 | + | return fmt.Sprintf("%dm%ds", m, s) | |
| 205 | + | default: | |
| 206 | + | h := int(d.Hours()) | |
| 207 | + | m := int(d.Minutes()) - h*60 | |
| 208 | + | return fmt.Sprintf("%dh%dm", h, m) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 173 | 212 | func writeFullOutput(s string) string { | |
| 174 | 213 | b := make([]byte, 6) | |
| 175 | 214 | _, _ = rand.Read(b) | |
| @@ -65,6 +65,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 65 | defer cancel() | 65 | defer cancel() | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | start := time.Now() | |||
| 68 | cmd := newShellCmd(runCtx, a.Command) | 69 | cmd := newShellCmd(runCtx, a.Command) | ||
| 69 | cmd.Dir = cwd | 70 | cmd.Dir = cwd | ||
| 70 | cmd.Env = os.Environ() | 71 | cmd.Env = os.Environ() | ||
| @@ -134,8 +135,17 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 134 | } | 135 | } | ||
| 135 | } | 136 | } | ||
| 136 | 137 | ||||
| 138 | + | elapsed := time.Since(start) | |||
| 139 | + | ||||
| 140 | + | // Terminal-log style: echo the command on the first line with | |||
| 141 | + | // a shell-prompt prefix, a blank line, the captured output, and | |||
| 142 | + | // a footer showing exit code + elapsed time. Matches the look | |||
| 143 | + | // a human would see if they ran the command themselves, which | |||
| 144 | + | // makes the model's reasoning about it more natural too. | |||
| 137 | var sb strings.Builder | 145 | var sb strings.Builder | ||
| 146 | + | fmt.Fprintf(&sb, "$ %s\n", a.Command) | |||
| 138 | if trimmed != "" { | 147 | if trimmed != "" { | ||
| 148 | + | sb.WriteString("\n") | |||
| 139 | sb.WriteString(trimmed) | 149 | sb.WriteString(trimmed) | ||
| 140 | if !strings.HasSuffix(trimmed, "\n") { | 150 | if !strings.HasSuffix(trimmed, "\n") { | ||
| 141 | sb.WriteString("\n") | 151 | sb.WriteString("\n") | ||
| @@ -147,7 +157,12 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 147 | if truncBytes { | 157 | if truncBytes { | ||
| 148 | fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes) | 158 | fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes) | ||
| 149 | } | 159 | } | ||
| 150 | - | sb.WriteString(fmt.Sprintf("[exit %d]", exitCode)) | 160 | + | sb.WriteString("\n") |
| 161 | + | if exitCode == 0 { | |||
| 162 | + | fmt.Fprintf(&sb, "[exit 0]") | |||
| 163 | + | } else { | |||
| 164 | + | fmt.Fprintf(&sb, "[exit %d]", exitCode) | |||
| 165 | + | } | |||
| 151 | 166 | ||||
| 152 | var fullPath string | 167 | var fullPath string | ||
| 153 | if truncBytes || truncLines { | 168 | if truncBytes || truncLines { | ||
| @@ -156,6 +171,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 156 | fmt.Fprintf(&sb, " (full output: %s)", fullPath) | 171 | fmt.Fprintf(&sb, " (full output: %s)", fullPath) | ||
| 157 | } | 172 | } | ||
| 158 | } | 173 | } | ||
| 174 | + | fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed)) | |||
| 159 | 175 | ||||
| 160 | isErr := exitCode != 0 || ctx.Err() != nil | 176 | isErr := exitCode != 0 || ctx.Err() != nil | ||
| 161 | return core.ToolResult{ | 177 | return core.ToolResult{ | ||
| @@ -166,10 +182,33 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 166 | "full_output_path": fullPath, | 182 | "full_output_path": fullPath, | ||
| 167 | "lines_truncated": truncLines, | 183 | "lines_truncated": truncLines, | ||
| 168 | "bytes_truncated": truncBytes, | 184 | "bytes_truncated": truncBytes, | ||
| 185 | + | "duration_ms": elapsed.Milliseconds(), | |||
| 169 | }, | 186 | }, | ||
| 170 | }, nil | 187 | }, nil | ||
| 171 | } | 188 | } | ||
| 172 | 189 | ||||
| 190 | + | // humanDuration renders a duration in the "Took X.Ys" style used by | |||
| 191 | + | // the shell-log output: tenths of a second for sub-minute runs, | |||
| 192 | + | // whole seconds once we pass a minute. Trailing zeros dropped so | |||
| 193 | + | // "0.1s" instead of "0.10s". | |||
| 194 | + | func humanDuration(d time.Duration) string { | |||
| 195 | + | switch { | |||
| 196 | + | case d < time.Millisecond: | |||
| 197 | + | return "0.0s" | |||
| 198 | + | case d < time.Minute: | |||
| 199 | + | s := d.Seconds() | |||
| 200 | + | return fmt.Sprintf("%.1fs", s) | |||
| 201 | + | case d < time.Hour: | |||
| 202 | + | m := int(d.Minutes()) | |||
| 203 | + | s := int(d.Seconds()) - m*60 | |||
| 204 | + | return fmt.Sprintf("%dm%ds", m, s) | |||
| 205 | + | default: | |||
| 206 | + | h := int(d.Hours()) | |||
| 207 | + | m := int(d.Minutes()) - h*60 | |||
| 208 | + | return fmt.Sprintf("%dh%dm", h, m) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 173 | func writeFullOutput(s string) string { | 212 | func writeFullOutput(s string) string { | ||
| 174 | b := make([]byte, 6) | 213 | b := make([]byte, 6) | ||
| 175 | _, _ = rand.Read(b) | 214 | _, _ = rand.Read(b) | ||
M internal/tui/view.go +49 -0
| @@ -586,6 +586,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 586 | 586 | // becomes part of the scrollback you can page back through. | |
| 587 | 587 | lines := strings.Split(text, "\n") | |
| 588 | 588 | ||
| 589 | + | // Bash-result styling: when the first row looks like a shell | |
| 590 | + | // prompt line ("$ ...") emitted by the bash tool, style the | |
| 591 | + | // prompt line in accent and the trailing "[exit N] Took X.Ys" | |
| 592 | + | // line in muted type so the command + timing read at a glance. | |
| 593 | + | // Everything between is left on the default tool-output color. | |
| 594 | + | if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") { | |
| 595 | + | return v.renderBashResult(lines, width, defaultColor) | |
| 596 | + | } | |
| 597 | + | ||
| 589 | 598 | inDiff := false | |
| 590 | 599 | oldLine, newLine := 1, 1 | |
| 591 | 600 | var out []string | |
| @@ -849,6 +858,46 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 849 | 858 | // or "error:"-style prefixes tools emit. False positives are OK, | |
| 850 | 859 | // the worst case is a line-number gutter on something that isn't | |
| 851 | 860 | // really code. | |
| 861 | + | // renderBashResult styles a bash tool result: the "$ command" first | |
| 862 | + | // line in the accent color, the trailing "[exit N] Took X.Ys" line | |
| 863 | + | // in muted type, everything else on the default tool-output color. | |
| 864 | + | // Called from renderToolText when the first line starts with "$ ". | |
| 865 | + | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | |
| 866 | + | // Identify the footer line (exit + timing). The bash tool writes | |
| 867 | + | // it as the last non-empty line of the result. | |
| 868 | + | footerIdx := -1 | |
| 869 | + | for i := len(lines) - 1; i >= 0; i-- { | |
| 870 | + | trimmed := strings.TrimSpace(lines[i]) | |
| 871 | + | if trimmed == "" { | |
| 872 | + | continue | |
| 873 | + | } | |
| 874 | + | if strings.HasPrefix(trimmed, "[exit ") { | |
| 875 | + | footerIdx = i | |
| 876 | + | } | |
| 877 | + | break | |
| 878 | + | } | |
| 879 | + | ||
| 880 | + | var out []string | |
| 881 | + | for i, l := range lines { | |
| 882 | + | switch { | |
| 883 | + | case i == 0 && strings.HasPrefix(l, "$ "): | |
| 884 | + | // Style the $ and the command text in accent. No | |
| 885 | + | // further processing / wrapping so the shell-style | |
| 886 | + | // prompt reads at a glance. | |
| 887 | + | for _, w := range wrapLine(l, width-4, " ") { | |
| 888 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w)) | |
| 889 | + | } | |
| 890 | + | case i == footerIdx: | |
| 891 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |
| 892 | + | default: | |
| 893 | + | for _, w := range wrapLine(l, width-4, " ") { | |
| 894 | + | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | |
| 895 | + | } | |
| 896 | + | } | |
| 897 | + | } | |
| 898 | + | return out | |
| 899 | + | } | |
| 900 | + | ||
| 852 | 901 | func looksLikeFileContent(text string) bool { | |
| 853 | 902 | if strings.TrimSpace(text) == "" { | |
| 854 | 903 | return false | |
| @@ -586,6 +586,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||||
| 586 | // becomes part of the scrollback you can page back through. | 586 | // becomes part of the scrollback you can page back through. | ||
| 587 | lines := strings.Split(text, "\n") | 587 | lines := strings.Split(text, "\n") | ||
| 588 | 588 | ||||
| 589 | + | // Bash-result styling: when the first row looks like a shell | |||
| 590 | + | // prompt line ("$ ...") emitted by the bash tool, style the | |||
| 591 | + | // prompt line in accent and the trailing "[exit N] Took X.Ys" | |||
| 592 | + | // line in muted type so the command + timing read at a glance. | |||
| 593 | + | // Everything between is left on the default tool-output color. | |||
| 594 | + | if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") { | |||
| 595 | + | return v.renderBashResult(lines, width, defaultColor) | |||
| 596 | + | } | |||
| 597 | + | ||||
| 589 | inDiff := false | 598 | inDiff := false | ||
| 590 | oldLine, newLine := 1, 1 | 599 | oldLine, newLine := 1, 1 | ||
| 591 | var out []string | 600 | var out []string | ||
| @@ -849,6 +858,46 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 849 | // or "error:"-style prefixes tools emit. False positives are OK, | 858 | // or "error:"-style prefixes tools emit. False positives are OK, | ||
| 850 | // the worst case is a line-number gutter on something that isn't | 859 | // the worst case is a line-number gutter on something that isn't | ||
| 851 | // really code. | 860 | // really code. | ||
| 861 | + | // renderBashResult styles a bash tool result: the "$ command" first | |||
| 862 | + | // line in the accent color, the trailing "[exit N] Took X.Ys" line | |||
| 863 | + | // in muted type, everything else on the default tool-output color. | |||
| 864 | + | // Called from renderToolText when the first line starts with "$ ". | |||
| 865 | + | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | |||
| 866 | + | // Identify the footer line (exit + timing). The bash tool writes | |||
| 867 | + | // it as the last non-empty line of the result. | |||
| 868 | + | footerIdx := -1 | |||
| 869 | + | for i := len(lines) - 1; i >= 0; i-- { | |||
| 870 | + | trimmed := strings.TrimSpace(lines[i]) | |||
| 871 | + | if trimmed == "" { | |||
| 872 | + | continue | |||
| 873 | + | } | |||
| 874 | + | if strings.HasPrefix(trimmed, "[exit ") { | |||
| 875 | + | footerIdx = i | |||
| 876 | + | } | |||
| 877 | + | break | |||
| 878 | + | } | |||
| 879 | + | ||||
| 880 | + | var out []string | |||
| 881 | + | for i, l := range lines { | |||
| 882 | + | switch { | |||
| 883 | + | case i == 0 && strings.HasPrefix(l, "$ "): | |||
| 884 | + | // Style the $ and the command text in accent. No | |||
| 885 | + | // further processing / wrapping so the shell-style | |||
| 886 | + | // prompt reads at a glance. | |||
| 887 | + | for _, w := range wrapLine(l, width-4, " ") { | |||
| 888 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w)) | |||
| 889 | + | } | |||
| 890 | + | case i == footerIdx: | |||
| 891 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |||
| 892 | + | default: | |||
| 893 | + | for _, w := range wrapLine(l, width-4, " ") { | |||
| 894 | + | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | |||
| 895 | + | } | |||
| 896 | + | } | |||
| 897 | + | } | |||
| 898 | + | return out | |||
| 899 | + | } | |||
| 900 | + | ||||
| 852 | func looksLikeFileContent(text string) bool { | 901 | func looksLikeFileContent(text string) bool { | ||
| 853 | if strings.TrimSpace(text) == "" { | 902 | if strings.TrimSpace(text) == "" { | ||
| 854 | return false | 903 | return false | ||