diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 0fff394..419ba32 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -50,6 +50,7 @@ func welcome(th theme, width int, cwd string) string { {"⏎ send", "^J newline · ^T toggle thinking"}, {"^L clear", "↑/↓ scroll · PgUp/PgDn page · ^C quit"}, {"approvals", "a approve · d deny · t trust"}, + {"tool steps", "^E expand the last step · --mouse to click-expand"}, } const keyW = 11 for _, t := range tips { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 3950f14..2eb5c57 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -174,8 +174,10 @@ func (m *Model) showHelp() { {"^O", "switch model"}, {"^T", "toggle extended thinking"}, {"^L", "clear the conversation"}, + {"^E", "expand the last tool step"}, {"esc", "cancel the running turn"}, {"^C", "quit"}, + {"--mouse", "click tool rows to expand"}, } { b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1])) } diff --git a/internal/tui/final_gaps_test.go b/internal/tui/final_gaps_test.go index cda16e0..8b281b0 100644 --- a/internal/tui/final_gaps_test.go +++ b/internal/tui/final_gaps_test.go @@ -37,13 +37,13 @@ func TestHeaderThinkAndSandbox(t *testing.T) { func TestRenderNoteAndStepDefaultIcon(t *testing.T) { m := wired(t) // roleNote rendering. - out := m.renderMessage(message{role: roleNote, content: "a note"}) + out, _ := m.renderMessage(message{role: roleNote, content: "a note"}, 0, 0) if !strings.Contains(plain(out), "a note") { t.Error("note message not rendered") } // A finalized assistant message with an unfinished step uses the ▸ icon. msg := message{role: roleAsst, content: "done", steps: []step{{name: "shell", done: false}}} - out = m.renderMessage(msg) + out, _ = m.renderMessage(msg, 0, 0) if !strings.Contains(plain(out), "shell") { t.Error("step not rendered") } diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index b2c229c..2a579c3 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -153,6 +153,8 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlL} case "ctrl+j": return tea.KeyMsg{Type: tea.KeyCtrlJ} + case "ctrl+e": + return tea.KeyMsg{Type: tea.KeyCtrlE} default: return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } diff --git a/internal/tui/model.go b/internal/tui/model.go index a4ac873..f79f0d3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -36,6 +36,15 @@ type step struct { isErr bool // the result reads as a failure (tints the status glyph red) subagent bool // this call delegates to a sub-agent (renders its log tree) logs []string // nested sub-agent activity, from subagent_log events + expanded bool // user has expanded this step to show full output/logs +} + +// stepRef maps a rendered transcript line to a specific step for mouse +// hit-testing. +type stepRef struct { + msgIdx int + stepIdx int + line int } // turnStats is the telemetry of one finalized assistant turn, captured from the @@ -56,6 +65,7 @@ type message struct { role role content string // raw text/markdown rendered string // cached glamour render (assistant, finalized) + thinking string // captured reasoning for this turn (finalized) steps []step streaming bool stats *turnStats // finalized-turn telemetry; nil while streaming / for history @@ -131,8 +141,10 @@ type Model struct { gradRuleW int logoCache string // cached gradient logo (width-independent) - convPrefix string // cached rendering of the finalized transcript prefix - convCount int // messages the prefix covers (-1 = invalidated) + convPrefix string // cached rendering of the finalized transcript prefix + convPrefixRefs []stepRef // step header line index for the cached prefix + convCount int // messages the prefix covers (-1 = invalidated) + stepLineIndex []stepRef // full transcript step index for mouse hit-testing } // acMode selects what the completion popup is completing. @@ -292,6 +304,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft && m.panel == panelNone && !m.ac.open { + // Viewport content begins below the header (2 rows). + top := 2 + if msg.Y >= top && msg.Y < top+m.vp.Height { + line := msg.Y - top + m.vp.YOffset + if msgIdx, stepIdx, ok := m.stepAtLine(line); ok { + m.toggleStep(msgIdx, stepIdx) + m.refresh() + return m, nil + } + } + } var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd @@ -386,6 +410,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.clearConversation() } return m, nil + case "ctrl+e": + m.toggleRecentStep() + m.refresh() + return m, nil case "up", "ctrl+p": // Scroll the transcript when the cursor is already at the top line of // the input; otherwise let the textarea move the cursor up. @@ -559,6 +587,8 @@ func (m *Model) clearConversation() { m.msgs = nil m.curIdx = -1 m.convCount = -1 // transcript replaced — drop the cached prefix + m.convPrefixRefs = nil + m.stepLineIndex = nil m.turnStats = nil m.toolTotal = 0 m.sessionStart = time.Time{} @@ -701,6 +731,7 @@ func (m *Model) finalize() { if i := m.cur(); i >= 0 { m.msgs[i].streaming = false m.msgs[i].rendered = m.render(m.msgs[i].content) + m.msgs[i].thinking = m.thinking.String() } m.curIdx = -1 m.thinking.Reset() @@ -1117,3 +1148,47 @@ func truncate(s string, n int) string { } return string(r[:n-1]) + "…" } + +// toggleStep flips the expanded state of a step and invalidates the transcript +// prefix cache so the re-render picks it up. +func (m *Model) toggleStep(msgIdx, stepIdx int) { + if msgIdx < 0 || msgIdx >= len(m.msgs) { + return + } + steps := m.msgs[msgIdx].steps + if stepIdx < 0 || stepIdx >= len(steps) { + return + } + steps[stepIdx].expanded = !steps[stepIdx].expanded + m.convCount = -1 +} + +// toggleRecentStep expands/collapses the last step of the most recent message +// that has steps. Bound to the "e" key. +func (m *Model) toggleRecentStep() { + for i := len(m.msgs) - 1; i >= 0; i-- { + if len(m.msgs[i].steps) > 0 { + m.toggleStep(i, len(m.msgs[i].steps)-1) + return + } + } +} + +// stepAtLine maps a viewport content line to the nearest step header at or +// above it. Used for mouse click-to-expand. +func (m *Model) stepAtLine(line int) (msgIdx, stepIdx int, ok bool) { + if len(m.stepLineIndex) == 0 { + return + } + var ref *stepRef + for i := range m.stepLineIndex { + if m.stepLineIndex[i].line > line { + break + } + ref = &m.stepLineIndex[i] + } + if ref == nil { + return + } + return ref.msgIdx, ref.stepIdx, true +} diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index 140a35c..7afbd3a 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -109,8 +109,8 @@ func TestSubagentLogNesting(t *testing.T) { } } -// TestRenderStepsSubagentAndError exercises the enriched step rendering: a -// sub-agent label, a nested log tree, and an error-tinted result. +// TestRenderStepsSubagentAndError exercises the one-line step summary: a +// sub-agent label, a compact result arrow, and an error-tinted result. func TestRenderStepsSubagentAndError(t *testing.T) { m := newTestModel() msg := message{ @@ -123,21 +123,155 @@ func TestRenderStepsSubagentAndError(t *testing.T) { result: "exit status 1\nFAIL"}, }, } - out := plain(m.renderSteps(msg)) - for _, want := range []string{"sub-agent", "⎿", "explorer", "✗", "exit status 1"} { - if !strings.Contains(out, want) { - t.Errorf("renderSteps missing %q in:\n%s", want, out) + out, _ := m.renderSteps(msg, 0, 0) + plainOut := plain(out) + for _, want := range []string{"sub-agent", "delegate_task", "explore", "→ done", "✗", "shell", "go test", "→ exit status 1", "▶"} { + if !strings.Contains(plainOut, want) { + t.Errorf("renderSteps missing %q in:\n%s", want, plainOut) } } + // Nested logs are still captured but no longer expanded in the default render. + if strings.Contains(plainOut, "⎿") || strings.Contains(plainOut, "explorer") { + t.Errorf("renderSteps should not expand nested logs in one-line mode:\n%s", plainOut) + } // Streaming turn: a not-done step renders the live spinner; a not-done step // in a finalized turn renders the pending glyph. Also drive the narrow-width // budget floor. m.vp.Width = 8 - if s := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}); s == "" { + if s, _ := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}, 0, 0); s == "" { t.Error("streaming step rendered empty") } - if s := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}); !strings.Contains(plain(s), "▸") { + if s, _ := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}, 0, 0); !strings.Contains(plain(s), "▸") { t.Errorf("pending step missing ▸ glyph: %q", plain(s)) } } + +func TestRenderStepsExpanded(t *testing.T) { + m := newTestModel() + msg := message{role: roleAsst, streaming: false, steps: []step{ + {name: "shell", arg: "go test", done: true, isErr: true, expanded: true, + result: "exit status 1\nFAIL"}, + }} + out, refs := m.renderSteps(msg, 5, 0) + plainOut := plain(out) + for _, want := range []string{"▼", "shell", "go test", "exit status 1", "FAIL"} { + if !strings.Contains(plainOut, want) { + t.Errorf("expanded step missing %q in:\n%s", want, plainOut) + } + } + if strings.Contains(plainOut, "▶") { + t.Errorf("expanded step should not show ▶ in:\n%s", plainOut) + } + if len(refs) != 1 || refs[0].line != 5 { + t.Errorf("unexpected step refs: %+v", refs) + } +} + +func TestResultOneLiner(t *testing.T) { + if got := resultOneLiner("first\nsecond", 10); got != "first" { + t.Errorf("resultOneLiner first line: %q", got) + } + if got := resultOneLiner("\n \nsecond", 8); got != "second" { + t.Errorf("resultOneLiner skips blanks: %q", got) + } + if got := resultOneLiner("", 8); got != "" { + t.Errorf("resultOneLiner empty: %q", got) + } + if got := resultOneLiner("a very long first meaningful line", 10); got != "a very lo…" { + t.Errorf("resultOneLiner truncation: %q", got) + } +} + +func TestToggleStep(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{ + {name: "shell", arg: "go test", done: true, result: "exit status 1\nFAIL"}, + }}) + m.toggleStep(0, 0) + if !m.msgs[0].steps[0].expanded { + t.Error("toggleStep did not expand the step") + } + if m.convCount != -1 { + t.Error("toggleStep did not invalidate the transcript cache") + } + out := plain(m.conversation()) + if !strings.Contains(out, "▼") || !strings.Contains(out, "FAIL") { + t.Errorf("expanded step not rendered:\n%s", out) + } +} + +func TestToggleRecentStep(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleAsst, steps: []step{{name: "read", done: true, result: "ok"}}}, + message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "done"}}}, + ) + m.toggleRecentStep() + if !m.msgs[1].steps[0].expanded { + t.Error("toggleRecentStep did not expand the most recent step") + } + if m.msgs[0].steps[0].expanded { + t.Error("toggleRecentStep expanded the wrong step") + } +} + +func TestKeyCtrlEExpandsStep(t *testing.T) { + m := newTestModel() + m.ta.Focus() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "ok"}}}) + + // Pressing Ctrl+E should expand the most recent step, even while typing. + m.ta.SetValue("hello") + m.Update(key("ctrl+e")) + if !m.msgs[0].steps[0].expanded { + t.Error("Ctrl+E should expand the recent step") + } + if got := m.ta.Value(); got != "hello" { + t.Errorf("Ctrl+E should not alter the input, got %q", got) + } +} + +func TestKeyETypesLetter(t *testing.T) { + m := newTestModel() + m.ta.Focus() + + // The plain 'e' key must always type the letter, even at the start. + m.Update(key("e")) + if got := m.ta.Value(); got != "e" { + t.Errorf("pressing 'e' should type the letter, got %q", got) + } + m.Update(key("e")) + if got := m.ta.Value(); got != "ee" { + t.Errorf("pressing 'e' again should append the letter, got %q", got) + } +} + +func TestStepLineIndex(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleAsst, steps: []step{{name: "read", done: true, result: "ok"}}}, + message{role: roleAsst, steps: []step{{name: "shell", done: true, result: "done"}}}, + ) + _ = m.conversation() + if len(m.stepLineIndex) != 2 { + t.Fatalf("expected 2 step refs, got %+v", m.stepLineIndex) + } + if m.stepLineIndex[0].line >= m.stepLineIndex[1].line { + t.Errorf("step refs not in ascending order: %+v", m.stepLineIndex) + } + msgIdx, stepIdx, ok := m.stepAtLine(m.stepLineIndex[0].line) + if !ok || msgIdx != 0 || stepIdx != 0 { + t.Errorf("stepAtLine first header: got %d,%d,%v", msgIdx, stepIdx, ok) + } + msgIdx, stepIdx, ok = m.stepAtLine(m.stepLineIndex[1].line) + if !ok || msgIdx != 1 || stepIdx != 0 { + t.Errorf("stepAtLine second header: got %d,%d,%v", msgIdx, stepIdx, ok) + } + // A line between the two headers still maps to the first step. + mid := (m.stepLineIndex[0].line + m.stepLineIndex[1].line) / 2 + msgIdx, stepIdx, ok = m.stepAtLine(mid) + if !ok || msgIdx != 0 || stepIdx != 0 { + t.Errorf("stepAtLine between headers: got %d,%d,%v", msgIdx, stepIdx, ok) + } +} diff --git a/internal/tui/thinking_test.go b/internal/tui/thinking_test.go index 1c1694c..0475277 100644 --- a/internal/tui/thinking_test.go +++ b/internal/tui/thinking_test.go @@ -35,3 +35,49 @@ func TestThinkingCap(t *testing.T) { t.Errorf("latest thinking not retained: %q", m.thinking.String()) } } + +// TestThinkingRendersAboveTools verifies that live reasoning appears at the top +// of the assistant body, before any tool summaries. +func TestThinkingRendersAboveTools(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + m.handleEvent(client.Event{Type: "thinking", Content: "I need to check the file."}) + m.handleEvent(client.Event{Type: "tool_call", Name: "read_file", Data: `{"path":"main.go"}`}) + m.handleEvent(client.Event{Type: "tool_result", Name: "read_file", Data: "package main\n"}) + + rendered, _ := m.renderMessage(m.msgs[0], 0, 0) + out := plain(rendered) + thinkIdx := strings.Index(out, "I need to check the file") + toolIdx := strings.Index(out, "read_file") + if thinkIdx < 0 || toolIdx < 0 || thinkIdx > toolIdx { + t.Errorf("thinking should appear before tools in:\n%s", out) + } +} + +// TestThinkingCapturedOnFinalize verifies that reasoning is stored in the +// assistant message and still renders above the final response after the turn +// ends. +func TestThinkingCapturedOnFinalize(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + m.handleEvent(client.Event{Type: "thinking", Content: "planning the response"}) + m.handleEvent(client.Event{Type: "token", Content: "hello"}) + m.handleEvent(client.Event{Type: "done", Latency: 0.5, ContextTokens: 10, OutputTokens: 1}) + + if m.msgs[0].thinking != "planning the response" { + t.Errorf("thinking not captured: %q", m.msgs[0].thinking) + } + rendered, _ := m.renderMessage(m.msgs[0], 0, 0) + out := plain(rendered) + thinkIdx := strings.Index(out, "planning the response") + respIdx := strings.Index(out, "hello") + if thinkIdx < 0 || respIdx < 0 || thinkIdx > respIdx { + t.Errorf("thinking should appear above response in finalized message:\n%s", out) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index d69c0d3..83c84cc 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -207,6 +207,7 @@ func (m *Model) refresh() { func (m *Model) conversation() string { if len(m.msgs) == 0 { + m.stepLineIndex = nil return welcome(m.th, m.vp.Width, m.opts.CWD) } // Everything before the in-flight streaming message is stable, so cache its @@ -218,56 +219,63 @@ func (m *Model) conversation() string { if i := m.cur(); i >= 0 { tail = i } + var refs []stepRef + lineOffset := 0 if m.convCount != tail { blocks := make([]string, 0, tail) for i := 0; i < tail; i++ { - blocks = append(blocks, m.renderMessage(m.msgs[i])) + s, r := m.renderMessage(m.msgs[i], i, lineOffset) + blocks = append(blocks, s) + refs = append(refs, r...) + lineOffset += lineCount(s) + 1 // blank separator between blocks } m.convPrefix = strings.Join(blocks, "\n\n") + m.convPrefixRefs = refs m.convCount = tail + } else { + refs = append(refs, m.convPrefixRefs...) + if m.convPrefix != "" { + lineOffset = lineCount(m.convPrefix) + 1 + } } blocks := make([]string, 0, len(m.msgs)-tail+2) if m.convPrefix != "" { blocks = append(blocks, m.convPrefix) } for i := tail; i < len(m.msgs); i++ { - blocks = append(blocks, m.renderMessage(m.msgs[i])) - } - // Live reasoning for the in-flight turn, shown dimly under the steps. - if m.busy && m.thinking.Len() > 0 { - think := m.th.thinkStyle.Render("… " + collapse(m.thinking.String())) - blocks = append(blocks, lipgloss.NewStyle().Width(m.vp.Width).Render(think)) + s, r := m.renderMessage(m.msgs[i], i, lineOffset) + blocks = append(blocks, s) + refs = append(refs, r...) + lineOffset += lineCount(s) + 1 } if len(m.notices) > 0 { if notes := m.renderNotices(); notes != "" { blocks = append(blocks, notes) } } + m.stepLineIndex = refs return strings.Join(blocks, "\n\n") } -func (m *Model) renderMessage(msg message) string { +func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []stepRef) { th := m.th // Pre-styled cards (e.g. /stats) render verbatim — no label, no bar, and // never re-rendered through glamour. if msg.raw { - return msg.content + return msg.content, nil } switch msg.role { case roleUser: label := th.userLabel.Render("❯ you") body := th.userBar.Width(m.vp.Width - 2).Render(msg.content) - return label + "\n" + body + return label + "\n" + body, nil case roleNote: - return th.sysBar.Width(m.vp.Width - 2).Render(msg.content) + return th.sysBar.Width(m.vp.Width - 2).Render(msg.content), nil default: // assistant label := th.asstLabel.Render("⬡ odek") - // Resolve the body first (finalized markdown, or the streaming - // "thinking…" placeholder) so the steps→body separator is based on what - // actually renders — otherwise the placeholder lands on the last step's - // line instead of its own. + // Resolve the markdown body (finalized or streaming) first. content := msg.content if !msg.streaming && msg.rendered != "" { content = msg.rendered @@ -275,14 +283,31 @@ func (m *Model) renderMessage(msg message) string { if strings.TrimSpace(content) == "" && msg.streaming { content = th.thinkStyle.Render(m.sp.View() + " thinking…") } + // Compose the turn body: thinking → tools → response. var b strings.Builder - if steps := m.renderSteps(msg); steps != "" { + thinking := msg.thinking + if msg.streaming && m.busy { + thinking = m.thinking.String() + } + thinkingLines := 0 + if t := strings.TrimSpace(thinking); t != "" { + line := th.thinkStyle.Width(max(m.vp.Width-4, 8)).Render("… " + collapse(t)) + b.WriteString(line) + thinkingLines = lineCount(line) + } + steps, refs := m.renderSteps(msg, lineOffset+1+thinkingLines, msgIdx) + if steps != "" { + if b.Len() > 0 { + b.WriteString("\n") + } b.WriteString(steps) - if strings.TrimSpace(content) != "" { + } + if strings.TrimSpace(content) != "" { + if b.Len() > 0 { b.WriteString("\n") } + b.WriteString(content) } - b.WriteString(content) body := th.asstBar.Width(m.vp.Width - 2).Render(strings.TrimRight(b.String(), "\n")) out := label + "\n" + body if msg.stats != nil { @@ -290,10 +315,21 @@ func (m *Model) renderMessage(msg message) string { out += "\n" + line } } - return out + return out, refs } } +// lineCount returns the number of newline-terminated lines in a rendered block. +func lineCount(s string) int { + if s == "" { + return 0 + } + if strings.HasSuffix(s, "\n") { + return strings.Count(s, "\n") + } + return strings.Count(s, "\n") + 1 +} + // statLine renders the compact telemetry row shown beneath a finalized // assistant turn. Glyphs carry the hue; values and separators recede in faint. // Segments self-suppress when empty and drop in priority order (tools, then @@ -383,20 +419,25 @@ func max(a, b int) int { return b } -func (m *Model) renderSteps(msg message) string { +func (m *Model) renderSteps(msg message, startLine, msgIdx int) (string, []stepRef) { if len(msg.steps) == 0 { - return "" + return "", nil } th := m.th - // Detail lines (sub-agent logs, then the result excerpt) hang under each tool - // line on a light tree connector; clamp them to the assistant bar's width - // (border + padding + the 4-column connector) so they never wrap. - budget := m.vp.Width - 8 - if budget < 16 { - budget = 16 - } - lines := make([]string, 0, len(msg.steps)*2) - for _, s := range msg.steps { + // One-line tool summaries: expand chevron, status icon, tool glyph, name, + // arg, and a short result arrow. Expanded rows show full output/logs. + budget := m.vp.Width - 10 + if budget < 14 { + budget = 14 + } + detailBudget := m.vp.Width - 8 + if detailBudget < 16 { + detailBudget = 16 + } + lines := make([]string, 0, len(msg.steps)) + var refs []stepRef + currentLine := 0 + for stepIdx, s := range msg.steps { // Status glyph: a spinner while the call runs, then ✓ / ✗ once it lands. var icon string switch { @@ -409,29 +450,62 @@ func (m *Model) renderSteps(msg message) string { default: icon = th.stepRun.Render("▸") } - head := icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + th.stepName.Render(s.name) + var chevron string + if s.done { + if s.expanded { + chevron = th.stepTree.Render("▼") + } else { + chevron = th.stepTree.Render("▶") + } + } else { + chevron = th.stepTree.Render(" ") + } + head := chevron + " " + icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + th.stepName.Render(s.name) if s.subagent { head += th.stepArg.Render(" · sub-agent") } if s.arg != "" { head += th.stepArg.Render(" " + truncate(s.arg, budget)) } - lines = append(lines, head) - - // Nested sub-agent activity, then the tool's own output. - details := append([]string{}, s.logs...) if s.done { - details = append(details, resultExcerpt(s.result)...) + resBudget := m.vp.Width - lipgloss.Width(head) - 10 + if resBudget < 12 { + resBudget = 12 + } + if res := resultOneLiner(s.result, resBudget); res != "" { + sep := th.stepTree.Render(" → ") + if s.isErr { + head += sep + th.stepErr.Render(res) + } else { + head += sep + th.stepRes.Render(res) + } + } } - for i, d := range details { - conn := " " - if i == 0 { - conn = " ⎿ " + refs = append(refs, stepRef{msgIdx: msgIdx, stepIdx: stepIdx, line: startLine + currentLine}) + lines = append(lines, head) + currentLine++ + if s.expanded { + details := append([]string{}, s.logs...) + for _, ln := range strings.Split(s.result, "\n") { + if c := collapse(ln); c != "" { + details = append(details, c) + } + } + if len(details) > 200 { + details = details[:200] + details = append(details, "… output truncated") + } + for i, d := range details { + conn := " " + if i == 0 { + conn = " ⎿ " + } + lines = append(lines, th.stepTree.Render(conn)+th.stepRes.Render(truncate(d, detailBudget))) + currentLine++ } - lines = append(lines, th.stepTree.Render(conn)+th.stepRes.Render(truncate(d, budget))) } } - return strings.Join(lines, "\n") + return strings.Join(lines, "\n"), refs } // resultExcerpt turns sanitized tool output into a compact, blank-stripped @@ -452,6 +526,17 @@ func resultExcerpt(result string) []string { return append(trimmed, fmt.Sprintf("… +%d more lines", len(out)-maxResultLines)) } +// resultOneLiner returns the first meaningful line of tool output, collapsed +// to a single line and capped to n runes for compact one-line summaries. +func resultOneLiner(result string, n int) string { + for _, ln := range strings.Split(result, "\n") { + if c := collapse(ln); c != "" { + return truncate(c, n) + } + } + return "" +} + func (m *Model) renderNotices() string { th := m.th now := time.Now()