detached 7193334

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
6565 defer cancel()
6666 }
6767
68+ start := time.Now()
6869 cmd := newShellCmd(runCtx, a.Command)
6970 cmd.Dir = cwd
7071 cmd.Env = os.Environ()
@@ -134,8 +135,17 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
134135 }
135136 }
136137
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.
137145 var sb strings.Builder
146+ fmt.Fprintf(&sb, "$ %s\n", a.Command)
138147 if trimmed != "" {
148+ sb.WriteString("\n")
139149 sb.WriteString(trimmed)
140150 if !strings.HasSuffix(trimmed, "\n") {
141151 sb.WriteString("\n")
@@ -147,7 +157,12 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
147157 if truncBytes {
148158 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes)
149159 }
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+ }
151166
152167 var fullPath string
153168 if truncBytes || truncLines {
@@ -156,6 +171,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
156171 fmt.Fprintf(&sb, " (full output: %s)", fullPath)
157172 }
158173 }
174+ fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
159175
160176 isErr := exitCode != 0 || ctx.Err() != nil
161177 return core.ToolResult{
@@ -166,10 +182,33 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
166182 "full_output_path": fullPath,
167183 "lines_truncated": truncLines,
168184 "bytes_truncated": truncBytes,
185+ "duration_ms": elapsed.Milliseconds(),
169186 },
170187 }, nil
171188 }
172189
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+
173212 func writeFullOutput(s string) string {
174213 b := make([]byte, 6)
175214 _, _ = 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 = cwd70 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.Builder145 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 string167 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() != nil176 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 }, nil187 }, 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
586586 // becomes part of the scrollback you can page back through.
587587 lines := strings.Split(text, "\n")
588588
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+
589598 inDiff := false
590599 oldLine, newLine := 1, 1
591600 var out []string
@@ -849,6 +858,46 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
849858 // or "error:"-style prefixes tools emit. False positives are OK,
850859 // the worst case is a line-number gutter on something that isn't
851860 // 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+
852901 func looksLikeFileContent(text string) bool {
853902 if strings.TrimSpace(text) == "" {
854903 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 := false598 inDiff := false
590 oldLine, newLine := 1, 1599 oldLine, newLine := 1, 1
591 var out []string600 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't859 // 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 false903 return false

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close