Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/tui/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
}
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/final_gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
}
Expand Down
79 changes: 77 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand 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{}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
150 changes: 142 additions & 8 deletions internal/tui/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)
}
}
46 changes: 46 additions & 0 deletions internal/tui/thinking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading