Skip to content

Commit d40879e

Browse files
mzhaomclaude
andauthored
bramble: fix excessive line breaks in Codex thinking output (#41)
* bramble: fix excessive line breaks in Codex thinking output Each streaming thinking delta from Codex was creating a separate OutputLine, causing each word to appear on its own line with a 💭 prefix. Accumulate thinking deltas into a single line, matching the existing text accumulation pattern. Also consolidate AppendStreamingDelta into the session package so both the live Manager and codexlogview replay parser share one implementation with overlap-aware deduplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review: fix truncation guards and show 💭 only once - Exclude OutputTypeThinking from the post-switch single-line truncation guard in both output.go and view.go, since thinking content is now multi-line after accumulation. - Update view.go's formatOutputLine to use the shared formatThinkingContent helper instead of single-line truncate. - Show 💭 prefix only on the first line of thinking content; continuation lines are indented with spaces. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use plain concatenation for live streaming deltas in Manager AppendStreamingDelta's overlap detection can produce false positives for live streaming where deltas are non-overlapping token chunks (e.g. "..." followed by "..." would be deduped). Revert the Manager to plain += concatenation; keep AppendStreamingDelta only for the codexlogview replay parser where protocol log deltas may overlap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 679c041 commit d40879e

File tree

6 files changed

+85
-45
lines changed

6 files changed

+85
-45
lines changed

bramble/app/output.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func formatOutputLineWithStyles(line session.OutputLine, width int, s *Styles) s
156156
formatted = s.Error.Render("✗ " + line.Content)
157157

158158
case session.OutputTypeThinking:
159-
formatted = s.Dim.Render("💭 " + truncate(line.Content, width-4))
159+
formatted = formatThinkingContent(line.Content, width, "", s)
160160

161161
case session.OutputTypeTool:
162162
// Legacy tool type - kept for backward compat
@@ -197,10 +197,37 @@ func formatOutputLineWithStyles(line session.OutputLine, width int, s *Styles) s
197197
formatted = line.Content
198198
}
199199

200-
// Truncate if needed (skip for markdown content which may have multi-line)
201-
if line.Type != session.OutputTypeText && runewidth.StringWidth(stripAnsi(formatted)) > width-2 {
200+
// Truncate if needed (skip for multi-line content: markdown text and thinking)
201+
if line.Type != session.OutputTypeText && line.Type != session.OutputTypeThinking && runewidth.StringWidth(stripAnsi(formatted)) > width-2 {
202202
formatted = truncateVisual(formatted, width-2)
203203
}
204204

205205
return formatted
206206
}
207+
208+
// formatThinkingContent formats accumulated thinking text for display.
209+
// The 💭 prefix is shown only on the first line; continuation lines are
210+
// indented with spaces. prefix is prepended to every line (e.g. " " for
211+
// the session-detail view).
212+
func formatThinkingContent(content string, width int, prefix string, s *Styles) string {
213+
thinkingLines := strings.Split(content, "\n")
214+
prefixLen := len(prefix)
215+
// "💭 " occupies 3 rune-widths (emoji + space); continuation uses same indent.
216+
iconPrefix := "💭 "
217+
contPrefix := " "
218+
maxContent := width - prefixLen - 4 // room for prefix + icon/indent
219+
220+
var parts []string
221+
for _, tl := range thinkingLines {
222+
tl = strings.TrimRight(tl, " \t\r")
223+
if tl == "" {
224+
continue
225+
}
226+
lp := iconPrefix
227+
if len(parts) > 0 {
228+
lp = contPrefix
229+
}
230+
parts = append(parts, s.Dim.Render(prefix+lp+truncate(tl, maxContent)))
231+
}
232+
return strings.Join(parts, "\n")
233+
}

bramble/app/view.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ func (m Model) formatOutputLine(line session.OutputLine, width int) string {
459459
formatted = s.Error.Render(" ✗ " + line.Content)
460460

461461
case session.OutputTypeThinking:
462-
formatted = s.Dim.Render(" 💭 " + truncate(line.Content, width-8))
462+
formatted = formatThinkingContent(line.Content, width, " ", s)
463463

464464
case session.OutputTypeTool:
465465
formatted = " 🔧 " + line.Content
@@ -509,8 +509,8 @@ func (m Model) formatOutputLine(line session.OutputLine, width int) string {
509509
formatted = " " + line.Content
510510
}
511511

512-
// Truncate width if needed (skip for markdown-rendered content which may have ANSI)
513-
if line.Type != session.OutputTypeText && line.Type != session.OutputTypePlanReady && runewidth.StringWidth(stripAnsi(formatted)) > width-2 {
512+
// Truncate width if needed (skip for multi-line content: text, plan, thinking)
513+
if line.Type != session.OutputTypeText && line.Type != session.OutputTypePlanReady && line.Type != session.OutputTypeThinking && runewidth.StringWidth(stripAnsi(formatted)) > width-2 {
514514
formatted = truncateVisual(formatted, width-2)
515515
}
516516

bramble/cmd/codexlogview/parser.go

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ func (p *codexReplayParser) appendTextDelta(ts time.Time, threadID, itemID, delt
441441
}
442442

443443
if idx, ok := p.itemTextLine[itemID]; ok && idx >= 0 && idx < len(p.lines) {
444-
p.lines[idx].Content = appendStreamingDelta(p.lines[idx].Content, delta)
444+
p.lines[idx].Content = session.AppendStreamingDelta(p.lines[idx].Content, delta)
445445
return
446446
}
447447

@@ -500,7 +500,7 @@ func (p *codexReplayParser) appendOrAddText(ts time.Time, text string) {
500500
return
501501
}
502502
if len(p.lines) > 0 && p.lines[len(p.lines)-1].Type == session.OutputTypeText {
503-
p.lines[len(p.lines)-1].Content = appendStreamingDelta(p.lines[len(p.lines)-1].Content, text)
503+
p.lines[len(p.lines)-1].Content = session.AppendStreamingDelta(p.lines[len(p.lines)-1].Content, text)
504504
return
505505
}
506506
p.lines = append(p.lines, session.OutputLine{
@@ -515,7 +515,7 @@ func (p *codexReplayParser) appendOrAddThinking(ts time.Time, text string) {
515515
return
516516
}
517517
if len(p.lines) > 0 && p.lines[len(p.lines)-1].Type == session.OutputTypeThinking {
518-
p.lines[len(p.lines)-1].Content = appendStreamingDelta(p.lines[len(p.lines)-1].Content, text)
518+
p.lines[len(p.lines)-1].Content = session.AppendStreamingDelta(p.lines[len(p.lines)-1].Content, text)
519519
return
520520
}
521521
p.lines = append(p.lines, session.OutputLine{
@@ -569,27 +569,6 @@ func parseTimestamp(ts string) time.Time {
569569
return time.Now()
570570
}
571571

572-
// appendStreamingDelta appends a new streaming delta while removing duplicated
573-
// overlap between the end of the existing text and the start of the delta.
574-
func appendStreamingDelta(existing, delta string) string {
575-
if existing == "" || delta == "" {
576-
return existing + delta
577-
}
578-
579-
maxOverlap := len(existing)
580-
if len(delta) < maxOverlap {
581-
maxOverlap = len(delta)
582-
}
583-
584-
for overlap := maxOverlap; overlap > 0; overlap-- {
585-
if existing[len(existing)-overlap:] == delta[:overlap] {
586-
return existing + delta[overlap:]
587-
}
588-
}
589-
590-
return existing + delta
591-
}
592-
593572
func tokenSummaryContent(usage codex.TokenUsage) string {
594573
return fmt.Sprintf("Tokens: %d input / %d output", usage.InputTokens, usage.OutputTokens)
595574
}

bramble/session/event_handler.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,7 @@ func (h *sessionEventHandler) OnText(text string) {
3333
}
3434

3535
func (h *sessionEventHandler) OnThinking(thinking string) {
36-
h.manager.addOutput(h.sessionID, OutputLine{
37-
Timestamp: time.Now(),
38-
Type: OutputTypeThinking,
39-
Content: thinking,
40-
})
36+
h.manager.appendOrAddThinking(h.sessionID, thinking)
4137
}
4238

4339
func (h *sessionEventHandler) OnToolStart(name, id string, input map[string]interface{}) {

bramble/session/manager.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -928,22 +928,24 @@ func (m *Manager) addOutput(sessionID SessionID, line OutputLine) {
928928
}
929929
}
930930

931-
// appendOrAddText appends text to the last output line if it's a text line,
932-
// otherwise adds a new text line. This allows streaming text to accumulate
933-
// into a single OutputLine for proper markdown rendering.
934-
func (m *Manager) appendOrAddText(sessionID SessionID, text string) {
931+
// appendOrAddOutput appends a streaming delta to the last output line if its
932+
// type matches, otherwise adds a new line. This allows streaming text and
933+
// thinking deltas to accumulate into a single OutputLine instead of creating
934+
// one line per delta. Plain concatenation is used because live streaming
935+
// deltas are non-overlapping token chunks. (For replay of protocol logs where
936+
// deltas may overlap, use AppendStreamingDelta instead.)
937+
func (m *Manager) appendOrAddOutput(sessionID SessionID, lineType OutputLineType, delta string) {
935938
m.outputsMu.Lock()
936939
lines, ok := m.outputs[sessionID]
937-
if ok && len(lines) > 0 && lines[len(lines)-1].Type == OutputTypeText {
938-
// Append to existing text line
939-
lines[len(lines)-1].Content += text
940+
if ok && len(lines) > 0 && lines[len(lines)-1].Type == lineType {
941+
lines[len(lines)-1].Content += delta
940942
m.outputsMu.Unlock()
941943
} else {
942944
m.outputsMu.Unlock()
943945
m.addOutput(sessionID, OutputLine{
944946
Timestamp: time.Now(),
945-
Type: OutputTypeText,
946-
Content: text,
947+
Type: lineType,
948+
Content: delta,
947949
})
948950
return
949951
}
@@ -952,8 +954,21 @@ func (m *Manager) appendOrAddText(sessionID SessionID, text string) {
952954
select {
953955
case m.events <- SessionOutputEvent{SessionID: sessionID}:
954956
default:
955-
log.Printf("WARNING: events channel full, dropping text append event for session %s", sessionID)
957+
log.Printf("WARNING: events channel full, dropping %s append event for session %s", lineType, sessionID)
958+
}
959+
}
960+
961+
// appendOrAddText appends text to the last text output line, or adds a new one.
962+
func (m *Manager) appendOrAddText(sessionID SessionID, text string) {
963+
m.appendOrAddOutput(sessionID, OutputTypeText, text)
964+
}
965+
966+
// appendOrAddThinking appends thinking to the last thinking output line, or adds a new one.
967+
func (m *Manager) appendOrAddThinking(sessionID SessionID, thinking string) {
968+
if strings.TrimSpace(thinking) == "" {
969+
return
956970
}
971+
m.appendOrAddOutput(sessionID, OutputTypeThinking, thinking)
957972
}
958973

959974
// updateToolOutput updates an existing tool output line by ToolID.

bramble/session/types.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,29 @@ func deepCopyOutputLine(line OutputLine) OutputLine {
286286
return line
287287
}
288288

289+
// AppendStreamingDelta appends a new streaming delta while removing duplicated
290+
// overlap between the end of the existing text and the start of the delta.
291+
// This is used to accumulate streaming text/thinking deltas into a single
292+
// OutputLine.Content without producing duplicate text at chunk boundaries.
293+
func AppendStreamingDelta(existing, delta string) string {
294+
if existing == "" || delta == "" {
295+
return existing + delta
296+
}
297+
298+
maxOverlap := len(existing)
299+
if len(delta) < maxOverlap {
300+
maxOverlap = len(delta)
301+
}
302+
303+
for overlap := maxOverlap; overlap > 0; overlap-- {
304+
if existing[len(existing)-overlap:] == delta[:overlap] {
305+
return existing + delta[overlap:]
306+
}
307+
}
308+
309+
return existing + delta
310+
}
311+
289312
// SessionOutputEvent is sent when session produces output.
290313
type SessionOutputEvent struct {
291314
SessionID SessionID

0 commit comments

Comments
 (0)