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) { | |||
| 4714 | 4714 | // (non-streaming providers or legacy paths). | |
| 4715 | 4715 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 4716 | 4716 | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 4717 | + | if tc.RawJSONBuf == "" { | |
| 4718 | + | tc.RawJSONBuf = string(e.Args) | |
| 4719 | + | } | |
| 4717 | 4720 | tc.Streaming = false | |
| 4718 | 4721 | } else { | |
| 4719 | 4722 | 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), | |
| 4723 | 4727 | } | |
| 4724 | 4728 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 4725 | 4729 | 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 = false | 4720 | 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 { | |||
| 788 | 788 | // - edit: shows the partial `newText` of the edit currently being | |
| 789 | 789 | // streamed, prefixed with a "editing foo.ts (edit 2)" header so | |
| 790 | 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 | 794 | // Anything else returns nil and only the tool-call header shows. | |
| 793 | 795 | func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |
| @@ -809,10 +811,43 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 809 | 811 | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 810 | 812 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 811 | 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 | 821 | 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 | 851 | // wrapLiveBody returns the streaming body content as a list of | |
| 817 | 852 | // box-side rows: each line wrapped in │ ... │ with right padding so | |
| 818 | 853 | // 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 being | 788 | // - edit: shows the partial `newText` of the edit currently being | ||
| 789 | // streamed, prefixed with a "editing foo.ts (edit 2)" header so | 789 | // 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 nil | 821 | 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 of | 851 | // wrapLiveBody returns the streaming body content as a list of | ||
| 817 | // box-side rows: each line wrapped in │ ... │ with right padding so | 852 | // 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) { | |||
| 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 | 54 | func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) { | |
| 35 | 55 | args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`) | |
| 36 | 56 | 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{ | ||