detached 8cd6818

feat(tui): show live bash command body

commit
8cd6818405829aa5458c7eb52006391268b0596c
author
patriceckhart · · 2026-06-18 05:34 UTC
parent
6d28881
signature
unsigned
refs
tag: v0.2.39
3 files · +62 -3
M packages/agent/modes/interactive.go +7 -3
@@ -4714,12 +4714,16 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
47144714 // (non-streaming providers or legacy paths).
47154715 if tc, ok := i.toolCalls[e.ID]; ok {
47164716 tc.Args = tui.ShortArgs(e.Name, e.Args)
4717+ if tc.RawJSONBuf == "" {
4718+ tc.RawJSONBuf = string(e.Args)
4719+ }
47174720 tc.Streaming = false
47184721 } else {
47194722 i.toolCalls[e.ID] = &tui.ToolCallView{
4720- ID: e.ID,
4721- Name: e.Name,
4722- Args: tui.ShortArgs(e.Name, e.Args),
4723+ ID: e.ID,
4724+ Name: e.Name,
4725+ Args: tui.ShortArgs(e.Name, e.Args),
4726+ RawJSONBuf: string(e.Args),
47234727 }
47244728 i.toolOrder = append(i.toolOrder, e.ID)
47254729 i.gateToolLocked(e.ID)
@@ -4714,12 +4714,16 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4714 // (non-streaming providers or legacy paths).4714 // (non-streaming providers or legacy paths).
4715 if tc, ok := i.toolCalls[e.ID]; ok {4715 if tc, ok := i.toolCalls[e.ID]; ok {
4716 tc.Args = tui.ShortArgs(e.Name, e.Args)4716 tc.Args = tui.ShortArgs(e.Name, e.Args)
4717+ if tc.RawJSONBuf == "" {
4718+ tc.RawJSONBuf = string(e.Args)
4719+ }
4717 tc.Streaming = false4720 tc.Streaming = false
4718 } else {4721 } else {
4719 i.toolCalls[e.ID] = &tui.ToolCallView{4722 i.toolCalls[e.ID] = &tui.ToolCallView{
4720- ID: e.ID,4723+ ID: e.ID,
4721- Name: e.Name,4724+ Name: e.Name,
4722- Args: tui.ShortArgs(e.Name, e.Args),4725+ Args: tui.ShortArgs(e.Name, e.Args),
4726+ RawJSONBuf: string(e.Args),
4723 }4727 }
4724 i.toolOrder = append(i.toolOrder, e.ID)4728 i.toolOrder = append(i.toolOrder, e.ID)
4725 i.gateToolLocked(e.ID)4729 i.gateToolLocked(e.ID)
M packages/tui/view.go +35 -0
@@ -788,6 +788,8 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
788788 // - edit: shows the partial `newText` of the edit currently being
789789 // streamed, prefixed with a "editing foo.ts (edit 2)" header so
790790 // the user can see which of a multi-edit batch is in progress.
791+// - bash: shows the full command as soon as the JSON args are known,
792+// before stdout/stderr has arrived, so long-running commands are legible.
791793 //
792794 // Anything else returns nil and only the tool-call header shows.
793795 func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
@@ -809,10 +811,43 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
809811 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
810812 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
811813 return v.wrapLiveBody(body, width)
814+ case "bash", "Bash":
815+ command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
816+ if !ok || strings.TrimSpace(command) == "" {
817+ return nil
818+ }
819+ return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
812820 }
813821 return nil
814822 }
815823
824+func (v *View) renderLiveBashCommand(command string, width int) []string {
825+ inner := toolBoxBodyRenderWidth(width)
826+ prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
827+ var out []string
828+ for i, line := range strings.Split(command, "\n") {
829+ firstPrefix := " "
830+ if i == 0 {
831+ firstPrefix += prompt
832+ } else {
833+ firstPrefix += " "
834+ }
835+ contPrefix := " "
836+ wrapWidth := inner - visibleWidth(firstPrefix)
837+ if wrapWidth < 10 {
838+ wrapWidth = 10
839+ }
840+ for j, wrapped := range wrapANSILine(v.Theme.FG256(v.Theme.ToolOut, line), wrapWidth) {
841+ prefix := firstPrefix
842+ if j > 0 {
843+ prefix = contPrefix
844+ }
845+ out = append(out, prefix+wrapped)
846+ }
847+ }
848+ return out
849+}
850+
816851 // wrapLiveBody returns the streaming body content as a list of
817852 // box-side rows: each line wrapped in │ ... │ with right padding so
818853 // the closing edge sits at column width-1. The caller (renderToolCall)
@@ -788,6 +788,8 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
788 // - edit: shows the partial `newText` of the edit currently being788 // - edit: shows the partial `newText` of the edit currently being
789 // streamed, prefixed with a "editing foo.ts (edit 2)" header so789 // streamed, prefixed with a "editing foo.ts (edit 2)" header so
790 // the user can see which of a multi-edit batch is in progress.790 // the user can see which of a multi-edit batch is in progress.
791+// - bash: shows the full command as soon as the JSON args are known,
792+// before stdout/stderr has arrived, so long-running commands are legible.
791 //793 //
792 // Anything else returns nil and only the tool-call header shows.794 // Anything else returns nil and only the tool-call header shows.
793 func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {795 func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
@@ -809,10 +811,43 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
809 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}811 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
810 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)812 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
811 return v.wrapLiveBody(body, width)813 return v.wrapLiveBody(body, width)
814+ case "bash", "Bash":
815+ command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
816+ if !ok || strings.TrimSpace(command) == "" {
817+ return nil
818+ }
819+ return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
812 }820 }
813 return nil821 return nil
814 }822 }
815 823
824+func (v *View) renderLiveBashCommand(command string, width int) []string {
825+ inner := toolBoxBodyRenderWidth(width)
826+ prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
827+ var out []string
828+ for i, line := range strings.Split(command, "\n") {
829+ firstPrefix := " "
830+ if i == 0 {
831+ firstPrefix += prompt
832+ } else {
833+ firstPrefix += " "
834+ }
835+ contPrefix := " "
836+ wrapWidth := inner - visibleWidth(firstPrefix)
837+ if wrapWidth < 10 {
838+ wrapWidth = 10
839+ }
840+ for j, wrapped := range wrapANSILine(v.Theme.FG256(v.Theme.ToolOut, line), wrapWidth) {
841+ prefix := firstPrefix
842+ if j > 0 {
843+ prefix = contPrefix
844+ }
845+ out = append(out, prefix+wrapped)
846+ }
847+ }
848+ return out
849+}
850+
816 // wrapLiveBody returns the streaming body content as a list of851 // wrapLiveBody returns the streaming body content as a list of
817 // box-side rows: each line wrapped in │ ... │ with right padding so852 // box-side rows: each line wrapped in │ ... │ with right padding so
818 // the closing edge sits at column width-1. The caller (renderToolCall)853 // the closing edge sits at column width-1. The caller (renderToolCall)
M packages/tui/view_tool_overlay_test.go +20 -0
@@ -31,6 +31,26 @@ func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
3131 }
3232 }
3333
34+func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
35+ args := json.RawMessage(`{"command":"printf 'start' && sleep 60 && printf 'done with a command long enough to exceed the header truncation limit'"}`)
36+ v := View{
37+ Theme: Dark,
38+ ToolCalls: []ToolCallView{
39+ {
40+ ID: "toolu_1",
41+ Name: "bash",
42+ Args: ShortArgs("bash", args),
43+ RawJSONBuf: string(args),
44+ },
45+ },
46+ }
47+
48+ plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
49+ if !strings.Contains(plain, "$ printf 'start' && sleep 60") || !strings.Contains(plain, "header truncation limit") {
50+ t.Fatalf("bash command was not visible before result arrived:\n%s", plain)
51+ }
52+}
53+
3454 func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
3555 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
3656 v := View{
@@ -31,6 +31,26 @@ func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
31 }31 }
32 }32 }
33 33
34+func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
35+ args := json.RawMessage(`{"command":"printf 'start' && sleep 60 && printf 'done with a command long enough to exceed the header truncation limit'"}`)
36+ v := View{
37+ Theme: Dark,
38+ ToolCalls: []ToolCallView{
39+ {
40+ ID: "toolu_1",
41+ Name: "bash",
42+ Args: ShortArgs("bash", args),
43+ RawJSONBuf: string(args),
44+ },
45+ },
46+ }
47+
48+ plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
49+ if !strings.Contains(plain, "$ printf 'start' && sleep 60") || !strings.Contains(plain, "header truncation limit") {
50+ t.Fatalf("bash command was not visible before result arrived:\n%s", plain)
51+ }
52+}
53+
34 func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {54 func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
35 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)55 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
36 v := View{56 v := View{

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