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
77 changes: 53 additions & 24 deletions internal/tui/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"unicode"

"charm.land/lipgloss/v2"
"charm.land/glamour/v2"
)

func formatToolArgs(argsJSON string) string {
Expand Down Expand Up @@ -66,12 +67,12 @@ func truncate(s string, maxLen int) string {
}

// formatToolResult returns styled output lines depending on the tool name.
func formatToolResult(toolName, output string, termWidth int) []string {
return formatToolResultBody(toolName, output, nil, termWidth)
func formatToolResult(toolName, output string, termWidth int, expanded bool, mdRenderer *glamour.TermRenderer) []string {
return formatToolResultBody(toolName, output, nil, termWidth, expanded, mdRenderer)
}

// formatToolResultBody returns styled output lines for a tool result with optional error.
func formatToolResultBody(toolName, output string, err error, termWidth int) []string {
func formatToolResultBody(toolName, output string, err error, termWidth int, expanded bool, mdRenderer *glamour.TermRenderer) []string {
if err != nil {
errText := truncate(sanitize(err.Error()), maxToolOutputLen)
return []string{
Expand All @@ -87,7 +88,7 @@ func formatToolResultBody(toolName, output string, err error, termWidth int) []s
case "edit":
return formatEditOutput(output, termWidth)
case "subagent":
return formatSubagentOutput(output, termWidth)
return formatSubagentOutput(output, termWidth, expanded, mdRenderer)
case "todowrite":
return formatTodoWriteOutput(output)
default:
Expand Down Expand Up @@ -215,37 +216,65 @@ func formatEditOutput(output string, termWidth int) []string {
return result
}

// formatSubagentOutput shows the first few lines of subagent output with left border.
func formatSubagentOutput(output string, termWidth int) []string {
const tailLines = 8
rawLines := strings.Split(strings.TrimRight(output, "\n"), "\n")
// formatSubagentOutput renders subagent output with markdown support.
// When collapsed, it shows a limited number of lines with a hint to expand.
// When expanded, it shows the full rendered markdown output.
func formatSubagentOutput(output string, termWidth int, expanded bool, mdRenderer *glamour.TermRenderer) []string {
output = strings.TrimRight(output, "\n")
if output == "" {
return nil
}

shown := rawLines
hidden := 0
if len(rawLines) > tailLines {
shown = rawLines[:tailLines]
hidden = len(rawLines) - tailLines
// Render markdown via glamour if available.
rendered := output
if mdRenderer != nil {
if md, err := mdRenderer.Render(output); err == nil {
rendered = strings.TrimRight(md, "\n")
}
}

const collapsedLines = 12
rawLines := strings.Split(rendered, "\n")

boxWidth := termWidth - 8
if boxWidth < 30 {
boxWidth = 30
}

if expanded || len(rawLines) <= collapsedLines {
// Show all content.
var boxContent strings.Builder
for i, line := range rawLines {
boxContent.WriteString(line)
if i < len(rawLines)-1 {
boxContent.WriteString("\n")
}
}
if expanded && len(rawLines) > collapsedLines {
boxContent.WriteString("\n")
boxContent.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Italic(true).
Render("▲ ctrl+e collapse"))
}
box := subagentBodyStyle.Width(boxWidth).Render(boxContent.String())
return []string{box}
}
Comment on lines +222 to 260
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: short rendered content + expanded=true shows no collapse hint.

When expanded is true but len(rawLines) <= collapsedLines, the user sees the same output as the collapsed/short state with no indication that they're already in "expanded" mode or that Ctrl+E will toggle. This is a minor UX wart since pressing Ctrl+E will silently flip a hidden state, then a second press still does nothing visible. Not a blocker — the content is already fully visible — but worth confirming intent. If desired, you could also skip the toggle entirely for short content in toggleSubagentExpand.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/tui/format.go` around lines 222 - 260, The function
formatSubagentOutput currently shows the "▲ ctrl+e 收起" hint only when expanded
AND len(rawLines) > collapsedLines, but it doesn't indicate when expanded==true
and len(rawLines) <= collapsedLines; update formatSubagentOutput to either (A)
suppress the expand/collapse hint entirely for short content by ensuring the
hint is only added when len(rawLines) > collapsedLines, or (B) explicitly render
a no-op/disabled hint when expanded is true but content is short so the UI
reflects the state; alternatively, also update toggleSubagentExpand to be a
no-op when len(rawLines) <= collapsedLines to prevent state flips—make the
change around the expanded check and the place that appends
lipgloss.NewStyle().Render("▲ ctrl+e 收起") so the behavior is consistent.


// Collapsed: show limited lines.
shown := rawLines[:collapsedLines]
hidden := len(rawLines) - collapsedLines

var boxContent strings.Builder
for i, line := range shown {
boxContent.WriteString(line)
if i < len(shown)-1 {
boxContent.WriteString("\n")
}
}
if hidden > 0 {
boxContent.WriteString("\n")
boxContent.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Italic(true).
Render(fmt.Sprintf("… %d more lines", hidden)))
}

boxWidth := termWidth - 8
if boxWidth < 30 {
boxWidth = 30
}
boxContent.WriteString("\n")
boxContent.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Italic(true).
Render(fmt.Sprintf("… %d more lines (ctrl+e expand)", hidden)))

box := toolBodyStyle.Width(boxWidth).Render(boxContent.String())
box := subagentBodyStyle.Width(boxWidth).Render(boxContent.String())
return []string{box}
}

Expand Down
1 change: 1 addition & 0 deletions internal/tui/pickers.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ func (m Model) helpPanelView() string {
left = append(left, renderEntry("Ctrl+P", "Agent ↔ Plan mode"))
left = append(left, renderEntry("Ctrl+A", "Ask ↔ Auto approve"))
left = append(left, renderEntry("Ctrl+Y", "Copy last response"))
left = append(left, renderEntry("Ctrl+E", "Expand subagent output"))
left = append(left, renderEntry("Escape", "Cancel / Back"))
left = append(left, "")
left = append(left, sectionStyle.Render("Navigation"))
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ var (
PaddingLeft(1).
MarginLeft(3)

// --- Subagent output body style (indented with left border, purple accent) ---
subagentBodyStyle = lipgloss.NewStyle().
Border(lipgloss.Border{Left: "│"}).
BorderForeground(lipgloss.Color("99")).
PaddingLeft(1).
MarginLeft(3)
// --- Button styles for dialogs ---
buttonFocusStyle = lipgloss.NewStyle().
Bold(true).
Expand Down
158 changes: 110 additions & 48 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ type Model struct {
agentMode AgentMode
bgRunning int // count of running background tasks

// lastAssistantRawText stores the raw (unrendered) text of the last
// assistant response, used by Ctrl+Y to copy to clipboard without
// picking up structural elements like dividers.
lastAssistantRawText string

// Plan review state
planReviewActive bool
planReviewTitle string
Expand Down Expand Up @@ -193,9 +198,10 @@ type contentLine struct {
// toolResultData stores the raw data for a tool result, allowing
// re-rendering with the current terminal width on resize.
type toolResultData struct {
name string
output string
err error // non-nil for error results
name string
output string
err error // non-nil for error results
expanded bool // true when subagent output is expanded (full markdown)
}

// textLine creates a plain text content line.
Expand All @@ -210,9 +216,9 @@ func toolResultContentLine(name, output string, err error) contentLine {

// render returns the rendered string for this content line, using the
// given width for tool result boxes. Plain text lines are returned as-is.
func (cl contentLine) render(width int) string {
func (cl contentLine) render(width int, mdRenderer *glamour.TermRenderer) string {
if cl.tool != nil {
lines := formatToolResultBody(cl.tool.name, cl.tool.output, cl.tool.err, width)
lines := formatToolResultBody(cl.tool.name, cl.tool.output, cl.tool.err, width, cl.tool.expanded, mdRenderer)
return strings.Join(lines, "\n")
}
return cl.text
Expand Down Expand Up @@ -1460,9 +1466,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen
m.refreshViewport()
return m, tea.Batch(cmds...)
}
case "ctrl+e":
// Toggle expand/collapse of subagent output near viewport top
m.toggleSubagentExpand()
return m, tea.Batch(cmds...)
case "ctrl+y":
// Copy last assistant message to clipboard
text := m.getLastAssistantText()
text := m.currentText.String()
if text == "" {
text = m.lastAssistantRawText
}
if text != "" {
cmds = append(cmds, tea.SetClipboard(text))
}
Expand Down Expand Up @@ -1761,6 +1774,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen
m.lines = append(m.lines, textLine(userPromptStyle.Render("> "+displayContent)))
case string(session.EntryAssistant):
if e.Content != "" {
m.lastAssistantRawText = e.Content
rendered := e.Content
if m.mdRenderer != nil {
if md, err := m.mdRenderer.Render(e.Content); err == nil {
Expand Down Expand Up @@ -1799,9 +1813,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen
toolErrorStyle.Render("✗ Subagent Error:"),
toolResultStyle.Render(truncate(sanitize(e.Error), maxToolOutputLen)))))
} else {
m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s",
toolSuccessStyle.Render("✓ Subagent Done:"),
toolResultStyle.Render(truncate(sanitize(e.Output), maxToolOutputLen)))))
m.lines = append(m.lines, textLine(fmt.Sprintf(" %s",
toolSuccessStyle.Render("✓ Subagent Done"))))
m.lines = append(m.lines, toolResultContentLine("subagent", sanitize(e.Output), nil))
}
case string(session.EntryPlanUpdate):
statusIcon := "📝"
Expand Down Expand Up @@ -2105,9 +2119,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen
toolErrorStyle.Render("✗ Subagent Error:"),
toolResultStyle.Render(truncate(sanitize(msg.Err.Error()), maxToolOutputLen)))))
} else {
m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s",
toolSuccessStyle.Render("✓ Subagent Done:"),
toolResultStyle.Render(truncate(sanitize(msg.Result), maxToolOutputLen)))))
m.lines = append(m.lines, textLine(fmt.Sprintf(" %s",
toolSuccessStyle.Render("✓ Subagent Done"))))
}
m.refreshViewport()
cmds = append(cmds, m.spinner.Tick)
Expand Down Expand Up @@ -2230,7 +2243,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen
toolResultStyle.Render(truncate(sanitize(msg.ToolErr), maxToolOutputLen))))
} else {
m.teamState.AppendTeammateLine(msg.AgentID,
formatToolResult(msg.ToolName, sanitize(msg.Content), m.contentWidth())...)
formatToolResult(msg.ToolName, sanitize(msg.Content), m.contentWidth(), false, nil)...)
}
case "assistant":
m.teamState.FlushTeammateText(msg.AgentID)
Expand Down Expand Up @@ -2602,6 +2615,88 @@ func (m *Model) refreshViewport() {

// --- Helpers ---

// maxRenderedLines estimates the number of rendered lines for a tool result.
// This is used to map viewport scroll position back to content line indices.
func maxRenderedLines(tool *toolResultData, termWidth int) int {
if tool == nil {
return 1
}
output := tool.output
if tool.err != nil {
output = tool.err.Error()
}
output = strings.TrimRight(output, "\n")
if output == "" {
return 1
}
lines := strings.Count(output, "\n") + 1

// Account for word-wrapping: estimate based on width.
boxWidth := termWidth - 12 // account for border + margin + padding
if boxWidth < 20 {
boxWidth = 20
}
wrapped := 0
for _, line := range strings.Split(output, "\n") {
if len(line) > boxWidth {
wrapped += (len(line) / boxWidth)
}
}
return lines + wrapped
}

// toggleSubagentExpand finds the nearest subagent tool result to the viewport
// top and toggles its expanded state. It searches from the viewport top line
// downward to find the first subagent tool result.
func (m *Model) toggleSubagentExpand() {
if !m.ready || len(m.lines) == 0 {
return
}

// Estimate which content line index corresponds to the viewport top.
// Each content line produces one or more rendered lines. For tool results,
// the rendered output can be multi-line. We estimate by counting newlines
// in the text content and using a simple heuristic for tool results.
topRenderedLine := m.viewport.YOffset()
lineCount := 0
startIdx := 0
for i, cl := range m.lines {
var n int
if cl.tool != nil {
// Tool results are multi-line boxes; estimate conservatively.
n = maxRenderedLines(cl.tool, m.contentWidth())
} else {
n = strings.Count(cl.text, "\n") + 1
}
if lineCount+n > topRenderedLine {
startIdx = i
break
}
lineCount += n
if i == len(m.lines)-1 {
startIdx = i
}
}

// Search from viewport top downward.
for i := startIdx; i < len(m.lines); i++ {
if m.lines[i].tool != nil && m.lines[i].tool.name == "subagent" {
m.lines[i].tool.expanded = !m.lines[i].tool.expanded
m.refreshViewport()
return
}
}

// If not found forward, search from beginning to viewport top.
for i := 0; i < startIdx; i++ {
if m.lines[i].tool != nil && m.lines[i].tool.name == "subagent" {
m.lines[i].tool.expanded = !m.lines[i].tool.expanded
m.refreshViewport()
return
}
}
}

// replaceLastToolIcon replaces the status icon on the last tool call line.
func (m *Model) replaceLastToolIcon(newIcon string) {
for i := len(m.lines) - 1; i >= 0; i-- {
Expand All @@ -2617,46 +2712,13 @@ func (m *Model) replaceLastToolIcon(newIcon string) {
}
}

// getLastAssistantText extracts the last assistant response text from lines.
func (m *Model) getLastAssistantText() string {
// If we have streaming text, that's the latest
if m.currentText.Len() > 0 {
return m.currentText.String()
}
// Scan backwards from the end, collecting text until we hit a boundary
// (user prompt, tool call, or other structural marker)
var textLines []string
for i := len(m.lines) - 1; i >= 0; i-- {
line := m.lines[i]
// Stop at user prompt (contains orange background ANSI), tool icons, or other boundaries
if strings.Contains(line.text, toolIconRunning) ||
strings.Contains(line.text, toolIconSuccess) ||
strings.Contains(line.text, toolIconError) ||
strings.Contains(line.text, "Session resumed:") ||
strings.Contains(line.text, "Subagent:") {
break
}
// Detect user prompt line (rendered with background color via userPromptStyle)
if strings.Contains(line.text, "\x1b[") && strings.Contains(line.text, "> ") && strings.Contains(line.text, "48;2;") {
break
}
if line.text != "" {
textLines = append(textLines, line.text)
}
}
// Reverse since we scanned backwards
for i, j := 0, len(textLines)-1; i < j; i, j = i+1, j-1 {
textLines[i], textLines[j] = textLines[j], textLines[i]
}
return ansi.Strip(strings.Join(textLines, "\n"))
}

func (m *Model) flushText() {
text := m.currentText.String()
if text == "" {
return
}
m.currentText.Reset()
m.lastAssistantRawText = text
rendered := text
if m.mdRenderer != nil {
if md, err := m.mdRenderer.Render(text); err == nil {
Expand Down Expand Up @@ -2695,7 +2757,7 @@ func (m *Model) renderContent() string {
width := m.contentWidth()
var sb strings.Builder
for _, line := range m.lines {
sb.WriteString(line.render(width))
sb.WriteString(line.render(width, m.mdRenderer))
sb.WriteString("\n")
}
if m.currentText.Len() > 0 {
Expand Down