Skip to content

Commit 4d8eff6

Browse files
authored
Merge pull request #12 from BackendStack21/feat/tui-expand-tool-steps
feat(tui): expand tool steps via keyboard (^E) and mouse
2 parents 40e7a65 + 1fb8bfb commit 4d8eff6

8 files changed

Lines changed: 399 additions & 54 deletions

File tree

internal/tui/banner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func welcome(th theme, width int, cwd string) string {
5050
{"⏎ send", "^J newline · ^T toggle thinking"},
5151
{"^L clear", "↑/↓ scroll · PgUp/PgDn page · ^C quit"},
5252
{"approvals", "a approve · d deny · t trust"},
53+
{"tool steps", "^E expand the last step · --mouse to click-expand"},
5354
}
5455
const keyW = 11
5556
for _, t := range tips {

internal/tui/commands.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ func (m *Model) showHelp() {
174174
{"^O", "switch model"},
175175
{"^T", "toggle extended thinking"},
176176
{"^L", "clear the conversation"},
177+
{"^E", "expand the last tool step"},
177178
{"esc", "cancel the running turn"},
178179
{"^C", "quit"},
180+
{"--mouse", "click tool rows to expand"},
179181
} {
180182
b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1]))
181183
}

internal/tui/final_gaps_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ func TestHeaderThinkAndSandbox(t *testing.T) {
3737
func TestRenderNoteAndStepDefaultIcon(t *testing.T) {
3838
m := wired(t)
3939
// roleNote rendering.
40-
out := m.renderMessage(message{role: roleNote, content: "a note"})
40+
out, _ := m.renderMessage(message{role: roleNote, content: "a note"}, 0, 0)
4141
if !strings.Contains(plain(out), "a note") {
4242
t.Error("note message not rendered")
4343
}
4444
// A finalized assistant message with an unfinished step uses the ▸ icon.
4545
msg := message{role: roleAsst, content: "done", steps: []step{{name: "shell", done: false}}}
46-
out = m.renderMessage(msg)
46+
out, _ = m.renderMessage(msg, 0, 0)
4747
if !strings.Contains(plain(out), "shell") {
4848
t.Error("step not rendered")
4949
}

internal/tui/integration_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ func key(s string) tea.KeyMsg {
153153
return tea.KeyMsg{Type: tea.KeyCtrlL}
154154
case "ctrl+j":
155155
return tea.KeyMsg{Type: tea.KeyCtrlJ}
156+
case "ctrl+e":
157+
return tea.KeyMsg{Type: tea.KeyCtrlE}
156158
default:
157159
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
158160
}

internal/tui/model.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ type step struct {
3636
isErr bool // the result reads as a failure (tints the status glyph red)
3737
subagent bool // this call delegates to a sub-agent (renders its log tree)
3838
logs []string // nested sub-agent activity, from subagent_log events
39+
expanded bool // user has expanded this step to show full output/logs
40+
}
41+
42+
// stepRef maps a rendered transcript line to a specific step for mouse
43+
// hit-testing.
44+
type stepRef struct {
45+
msgIdx int
46+
stepIdx int
47+
line int
3948
}
4049

4150
// turnStats is the telemetry of one finalized assistant turn, captured from the
@@ -56,6 +65,7 @@ type message struct {
5665
role role
5766
content string // raw text/markdown
5867
rendered string // cached glamour render (assistant, finalized)
68+
thinking string // captured reasoning for this turn (finalized)
5969
steps []step
6070
streaming bool
6171
stats *turnStats // finalized-turn telemetry; nil while streaming / for history
@@ -131,8 +141,10 @@ type Model struct {
131141
gradRuleW int
132142
logoCache string // cached gradient logo (width-independent)
133143

134-
convPrefix string // cached rendering of the finalized transcript prefix
135-
convCount int // messages the prefix covers (-1 = invalidated)
144+
convPrefix string // cached rendering of the finalized transcript prefix
145+
convPrefixRefs []stepRef // step header line index for the cached prefix
146+
convCount int // messages the prefix covers (-1 = invalidated)
147+
stepLineIndex []stepRef // full transcript step index for mouse hit-testing
136148
}
137149

138150
// acMode selects what the completion popup is completing.
@@ -292,6 +304,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
292304
return m, nil
293305

294306
case tea.MouseMsg:
307+
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft && m.panel == panelNone && !m.ac.open {
308+
// Viewport content begins below the header (2 rows).
309+
top := 2
310+
if msg.Y >= top && msg.Y < top+m.vp.Height {
311+
line := msg.Y - top + m.vp.YOffset
312+
if msgIdx, stepIdx, ok := m.stepAtLine(line); ok {
313+
m.toggleStep(msgIdx, stepIdx)
314+
m.refresh()
315+
return m, nil
316+
}
317+
}
318+
}
295319
var cmd tea.Cmd
296320
m.vp, cmd = m.vp.Update(msg)
297321
return m, cmd
@@ -386,6 +410,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
386410
m.clearConversation()
387411
}
388412
return m, nil
413+
case "ctrl+e":
414+
m.toggleRecentStep()
415+
m.refresh()
416+
return m, nil
389417
case "up", "ctrl+p":
390418
// Scroll the transcript when the cursor is already at the top line of
391419
// the input; otherwise let the textarea move the cursor up.
@@ -559,6 +587,8 @@ func (m *Model) clearConversation() {
559587
m.msgs = nil
560588
m.curIdx = -1
561589
m.convCount = -1 // transcript replaced — drop the cached prefix
590+
m.convPrefixRefs = nil
591+
m.stepLineIndex = nil
562592
m.turnStats = nil
563593
m.toolTotal = 0
564594
m.sessionStart = time.Time{}
@@ -701,6 +731,7 @@ func (m *Model) finalize() {
701731
if i := m.cur(); i >= 0 {
702732
m.msgs[i].streaming = false
703733
m.msgs[i].rendered = m.render(m.msgs[i].content)
734+
m.msgs[i].thinking = m.thinking.String()
704735
}
705736
m.curIdx = -1
706737
m.thinking.Reset()
@@ -1117,3 +1148,47 @@ func truncate(s string, n int) string {
11171148
}
11181149
return string(r[:n-1]) + "…"
11191150
}
1151+
1152+
// toggleStep flips the expanded state of a step and invalidates the transcript
1153+
// prefix cache so the re-render picks it up.
1154+
func (m *Model) toggleStep(msgIdx, stepIdx int) {
1155+
if msgIdx < 0 || msgIdx >= len(m.msgs) {
1156+
return
1157+
}
1158+
steps := m.msgs[msgIdx].steps
1159+
if stepIdx < 0 || stepIdx >= len(steps) {
1160+
return
1161+
}
1162+
steps[stepIdx].expanded = !steps[stepIdx].expanded
1163+
m.convCount = -1
1164+
}
1165+
1166+
// toggleRecentStep expands/collapses the last step of the most recent message
1167+
// that has steps. Bound to the "e" key.
1168+
func (m *Model) toggleRecentStep() {
1169+
for i := len(m.msgs) - 1; i >= 0; i-- {
1170+
if len(m.msgs[i].steps) > 0 {
1171+
m.toggleStep(i, len(m.msgs[i].steps)-1)
1172+
return
1173+
}
1174+
}
1175+
}
1176+
1177+
// stepAtLine maps a viewport content line to the nearest step header at or
1178+
// above it. Used for mouse click-to-expand.
1179+
func (m *Model) stepAtLine(line int) (msgIdx, stepIdx int, ok bool) {
1180+
if len(m.stepLineIndex) == 0 {
1181+
return
1182+
}
1183+
var ref *stepRef
1184+
for i := range m.stepLineIndex {
1185+
if m.stepLineIndex[i].line > line {
1186+
break
1187+
}
1188+
ref = &m.stepLineIndex[i]
1189+
}
1190+
if ref == nil {
1191+
return
1192+
}
1193+
return ref.msgIdx, ref.stepIdx, true
1194+
}

internal/tui/steps_test.go

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ func TestSubagentLogNesting(t *testing.T) {
109109
}
110110
}
111111

112-
// TestRenderStepsSubagentAndError exercises the enriched step rendering: a
113-
// sub-agent label, a nested log tree, and an error-tinted result.
112+
// TestRenderStepsSubagentAndError exercises the one-line step summary: a
113+
// sub-agent label, a compact result arrow, and an error-tinted result.
114114
func TestRenderStepsSubagentAndError(t *testing.T) {
115115
m := newTestModel()
116116
msg := message{
@@ -123,21 +123,155 @@ func TestRenderStepsSubagentAndError(t *testing.T) {
123123
result: "exit status 1\nFAIL"},
124124
},
125125
}
126-
out := plain(m.renderSteps(msg))
127-
for _, want := range []string{"sub-agent", "⎿", "explorer", "✗", "exit status 1"} {
128-
if !strings.Contains(out, want) {
129-
t.Errorf("renderSteps missing %q in:\n%s", want, out)
126+
out, _ := m.renderSteps(msg, 0, 0)
127+
plainOut := plain(out)
128+
for _, want := range []string{"sub-agent", "delegate_task", "explore", "→ done", "✗", "shell", "go test", "→ exit status 1", "▶"} {
129+
if !strings.Contains(plainOut, want) {
130+
t.Errorf("renderSteps missing %q in:\n%s", want, plainOut)
130131
}
131132
}
133+
// Nested logs are still captured but no longer expanded in the default render.
134+
if strings.Contains(plainOut, "⎿") || strings.Contains(plainOut, "explorer") {
135+
t.Errorf("renderSteps should not expand nested logs in one-line mode:\n%s", plainOut)
136+
}
132137

133138
// Streaming turn: a not-done step renders the live spinner; a not-done step
134139
// in a finalized turn renders the pending glyph. Also drive the narrow-width
135140
// budget floor.
136141
m.vp.Width = 8
137-
if s := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}); s == "" {
142+
if s, _ := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}, 0, 0); s == "" {
138143
t.Error("streaming step rendered empty")
139144
}
140-
if s := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}); !strings.Contains(plain(s), "▸") {
145+
if s, _ := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}, 0, 0); !strings.Contains(plain(s), "▸") {
141146
t.Errorf("pending step missing ▸ glyph: %q", plain(s))
142147
}
143148
}
149+
150+
func TestRenderStepsExpanded(t *testing.T) {
151+
m := newTestModel()
152+
msg := message{role: roleAsst, streaming: false, steps: []step{
153+
{name: "shell", arg: "go test", done: true, isErr: true, expanded: true,
154+
result: "exit status 1\nFAIL"},
155+
}}
156+
out, refs := m.renderSteps(msg, 5, 0)
157+
plainOut := plain(out)
158+
for _, want := range []string{"▼", "shell", "go test", "exit status 1", "FAIL"} {
159+
if !strings.Contains(plainOut, want) {
160+
t.Errorf("expanded step missing %q in:\n%s", want, plainOut)
161+
}
162+
}
163+
if strings.Contains(plainOut, "▶") {
164+
t.Errorf("expanded step should not show ▶ in:\n%s", plainOut)
165+
}
166+
if len(refs) != 1 || refs[0].line != 5 {
167+
t.Errorf("unexpected step refs: %+v", refs)
168+
}
169+
}
170+
171+
func TestResultOneLiner(t *testing.T) {
172+
if got := resultOneLiner("first\nsecond", 10); got != "first" {
173+
t.Errorf("resultOneLiner first line: %q", got)
174+
}
175+
if got := resultOneLiner("\n \nsecond", 8); got != "second" {
176+
t.Errorf("resultOneLiner skips blanks: %q", got)
177+
}
178+
if got := resultOneLiner("", 8); got != "" {
179+
t.Errorf("resultOneLiner empty: %q", got)
180+
}
181+
if got := resultOneLiner("a very long first meaningful line", 10); got != "a very lo…" {
182+
t.Errorf("resultOneLiner truncation: %q", got)
183+
}
184+
}
185+
186+
func TestToggleStep(t *testing.T) {
187+
m := newTestModel()
188+
m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{
189+
{name: "shell", arg: "go test", done: true, result: "exit status 1\nFAIL"},
190+
}})
191+
m.toggleStep(0, 0)
192+
if !m.msgs[0].steps[0].expanded {
193+
t.Error("toggleStep did not expand the step")
194+
}
195+
if m.convCount != -1 {
196+
t.Error("toggleStep did not invalidate the transcript cache")
197+
}
198+
out := plain(m.conversation())
199+
if !strings.Contains(out, "▼") || !strings.Contains(out, "FAIL") {
200+
t.Errorf("expanded step not rendered:\n%s", out)
201+
}
202+
}
203+
204+
func TestToggleRecentStep(t *testing.T) {
205+
m := newTestModel()
206+
m.msgs = append(m.msgs,
207+
message{role: roleAsst, steps: []step{{name: "read", done: true, result: "ok"}}},
208+
message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "done"}}},
209+
)
210+
m.toggleRecentStep()
211+
if !m.msgs[1].steps[0].expanded {
212+
t.Error("toggleRecentStep did not expand the most recent step")
213+
}
214+
if m.msgs[0].steps[0].expanded {
215+
t.Error("toggleRecentStep expanded the wrong step")
216+
}
217+
}
218+
219+
func TestKeyCtrlEExpandsStep(t *testing.T) {
220+
m := newTestModel()
221+
m.ta.Focus()
222+
m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "ok"}}})
223+
224+
// Pressing Ctrl+E should expand the most recent step, even while typing.
225+
m.ta.SetValue("hello")
226+
m.Update(key("ctrl+e"))
227+
if !m.msgs[0].steps[0].expanded {
228+
t.Error("Ctrl+E should expand the recent step")
229+
}
230+
if got := m.ta.Value(); got != "hello" {
231+
t.Errorf("Ctrl+E should not alter the input, got %q", got)
232+
}
233+
}
234+
235+
func TestKeyETypesLetter(t *testing.T) {
236+
m := newTestModel()
237+
m.ta.Focus()
238+
239+
// The plain 'e' key must always type the letter, even at the start.
240+
m.Update(key("e"))
241+
if got := m.ta.Value(); got != "e" {
242+
t.Errorf("pressing 'e' should type the letter, got %q", got)
243+
}
244+
m.Update(key("e"))
245+
if got := m.ta.Value(); got != "ee" {
246+
t.Errorf("pressing 'e' again should append the letter, got %q", got)
247+
}
248+
}
249+
250+
func TestStepLineIndex(t *testing.T) {
251+
m := newTestModel()
252+
m.msgs = append(m.msgs,
253+
message{role: roleAsst, steps: []step{{name: "read", done: true, result: "ok"}}},
254+
message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "done"}}},
255+
)
256+
_ = m.conversation()
257+
if len(m.stepLineIndex) != 2 {
258+
t.Fatalf("expected 2 step refs, got %+v", m.stepLineIndex)
259+
}
260+
if m.stepLineIndex[0].line >= m.stepLineIndex[1].line {
261+
t.Errorf("step refs not in ascending order: %+v", m.stepLineIndex)
262+
}
263+
msgIdx, stepIdx, ok := m.stepAtLine(m.stepLineIndex[0].line)
264+
if !ok || msgIdx != 0 || stepIdx != 0 {
265+
t.Errorf("stepAtLine first header: got %d,%d,%v", msgIdx, stepIdx, ok)
266+
}
267+
msgIdx, stepIdx, ok = m.stepAtLine(m.stepLineIndex[1].line)
268+
if !ok || msgIdx != 1 || stepIdx != 0 {
269+
t.Errorf("stepAtLine second header: got %d,%d,%v", msgIdx, stepIdx, ok)
270+
}
271+
// A line between the two headers still maps to the first step.
272+
mid := (m.stepLineIndex[0].line + m.stepLineIndex[1].line) / 2
273+
msgIdx, stepIdx, ok = m.stepAtLine(mid)
274+
if !ok || msgIdx != 0 || stepIdx != 0 {
275+
t.Errorf("stepAtLine between headers: got %d,%d,%v", msgIdx, stepIdx, ok)
276+
}
277+
}

internal/tui/thinking_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,49 @@ func TestThinkingCap(t *testing.T) {
3535
t.Errorf("latest thinking not retained: %q", m.thinking.String())
3636
}
3737
}
38+
39+
// TestThinkingRendersAboveTools verifies that live reasoning appears at the top
40+
// of the assistant body, before any tool summaries.
41+
func TestThinkingRendersAboveTools(t *testing.T) {
42+
m := newTestModel()
43+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
44+
m.curIdx = 0
45+
m.busy = true
46+
47+
m.handleEvent(client.Event{Type: "thinking", Content: "I need to check the file."})
48+
m.handleEvent(client.Event{Type: "tool_call", Name: "read_file", Data: `{"path":"main.go"}`})
49+
m.handleEvent(client.Event{Type: "tool_result", Name: "read_file", Data: "package main\n"})
50+
51+
rendered, _ := m.renderMessage(m.msgs[0], 0, 0)
52+
out := plain(rendered)
53+
thinkIdx := strings.Index(out, "I need to check the file")
54+
toolIdx := strings.Index(out, "read_file")
55+
if thinkIdx < 0 || toolIdx < 0 || thinkIdx > toolIdx {
56+
t.Errorf("thinking should appear before tools in:\n%s", out)
57+
}
58+
}
59+
60+
// TestThinkingCapturedOnFinalize verifies that reasoning is stored in the
61+
// assistant message and still renders above the final response after the turn
62+
// ends.
63+
func TestThinkingCapturedOnFinalize(t *testing.T) {
64+
m := newTestModel()
65+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
66+
m.curIdx = 0
67+
m.busy = true
68+
69+
m.handleEvent(client.Event{Type: "thinking", Content: "planning the response"})
70+
m.handleEvent(client.Event{Type: "token", Content: "hello"})
71+
m.handleEvent(client.Event{Type: "done", Latency: 0.5, ContextTokens: 10, OutputTokens: 1})
72+
73+
if m.msgs[0].thinking != "planning the response" {
74+
t.Errorf("thinking not captured: %q", m.msgs[0].thinking)
75+
}
76+
rendered, _ := m.renderMessage(m.msgs[0], 0, 0)
77+
out := plain(rendered)
78+
thinkIdx := strings.Index(out, "planning the response")
79+
respIdx := strings.Index(out, "hello")
80+
if thinkIdx < 0 || respIdx < 0 || thinkIdx > respIdx {
81+
t.Errorf("thinking should appear above response in finalized message:\n%s", out)
82+
}
83+
}

0 commit comments

Comments
 (0)