From 66fca3a7586885dd0472c6f7243ecd575e981ff6 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 12:14:16 +0900 Subject: [PATCH 1/3] feat: Texture the claude scenarios with diffs, output, and spacing --- internal/themes/claude_theme.go | 100 ++++++++++++++------- internal/themes/claude_theme_test.go | 126 +++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 30 deletions(-) diff --git a/internal/themes/claude_theme.go b/internal/themes/claude_theme.go index 3e6f080..9cf09b2 100644 --- a/internal/themes/claude_theme.go +++ b/internal/themes/claude_theme.go @@ -45,6 +45,10 @@ const ( cAsst cToolCall // ⏺ Name(args) — a tool invocation cToolRes // ⎿ result line under a call + cBlank // blank line — draws nothing, spacing between blocks + cDiffDel // diff removal line: "12 - old code" — line number dim, rest red + cDiffAdd // diff addition line: "12 + new code" — line number dim, rest green + cOut // Bash output dump line — faint, indented under a Bash(...) call ) type cLine struct { @@ -62,54 +66,72 @@ type claudeScenario struct { var claudeScenarios = []claudeScenario{ { history: []cLine{ - {cHuman, "split the monolith handler into smaller functions"}, - {cAsst, "I'll extract request parsing and validation first."}, - {cToolCall, "Read(handler.go)"}, - {cToolRes, "Read 210 lines (ctrl+o to expand)"}, - {cToolCall, "Update(handler.go)"}, - {cToolRes, "Updated handler.go with 96 additions and 120 removals"}, - {cAsst, "Done - split into parseRequest, validate, and dispatch."}, + {cHuman, "why is the cache test flaky"}, + {cBlank, ""}, + {cAsst, "The TTL assertion races the clock; I'll inject a fake timer."}, + {cBlank, ""}, + {cToolCall, "Search(pattern: \"time.Sleep\", path: internal/cache)"}, + {cToolRes, "Found 3 matches in 2 files"}, }, active: []cLine{ - {cAsst, "I'll add graceful shutdown so in-flight work can finish."}, - {cToolCall, "Update(server.go)"}, - {cToolRes, "Updated server.go with 38 additions and 6 removals"}, - {cToolCall, "Bash(go test ./...)"}, - {cToolRes, "ok - 42 tests passed"}, - {cAsst, "Done - the server now drains connections on SIGTERM."}, + {cToolCall, "Update(cache_test.go)"}, + {cToolRes, "Updated cache_test.go with 2 additions and 1 removal"}, + {cDiffDel, "12 - time.Sleep(ttl)"}, + {cDiffAdd, "12 + clock.Advance(ttl)"}, + {cDiffAdd, "13 + c.Sweep()"}, + {cBlank, ""}, + {cToolCall, "Bash(go test ./internal/cache/ -count=20)"}, + {cOut, "ok internal/cache 2.41s"}, + {cOut, "PASS (20 runs, no failures)"}, + {cBlank, ""}, + {cAsst, "All green - the test drives a fake clock now."}, }, }, { history: []cLine{ {cHuman, "add retry with backoff to the fetcher"}, - {cAsst, "I'll wrap the client call in a retry loop."}, + {cBlank, ""}, {cToolCall, "Update Todos"}, {cToolRes, "TODO_DONE Add retry helper with jitter"}, {cToolRes, "TODO_OPEN Make the timeout configurable"}, - {cAsst, "Done - three attempts with jittered backoff."}, + {cBlank, ""}, + {cAsst, "I'll wrap the client call in a retry loop."}, }, active: []cLine{ - {cAsst, "I'll make the timeout configurable next."}, - {cToolCall, "Read(config.go)"}, - {cToolRes, "Read 54 lines (ctrl+o to expand)"}, - {cToolCall, "Update(config.go)"}, - {cToolRes, "Updated config.go with 12 additions and 2 removals"}, - {cAsst, "Done - FETCH_TIMEOUT now overrides the default."}, + {cToolCall, "Read(fetch.go)"}, + {cToolRes, "Read 88 lines (ctrl+o to expand)"}, + {cBlank, ""}, + {cToolCall, "Update(fetch.go)"}, + {cToolRes, "Updated fetch.go with 3 additions and 1 removal"}, + {cDiffDel, "41 - resp, err := client.Do(req)"}, + {cDiffAdd, "41 + resp, err := retry(3, backoff, func() (*http.Response, error) {"}, + {cDiffAdd, "42 + return client.Do(req)"}, + {cDiffAdd, "43 + })"}, + {cBlank, ""}, + {cAsst, "Done - three attempts with jittered backoff."}, }, }, { history: []cLine{ - {cHuman, "why is the cache test flaky"}, - {cAsst, "The TTL assertion races the clock; I'll inject a fake timer."}, - {cToolCall, "Update(cache_test.go)"}, - {cToolRes, "Updated cache_test.go with 18 additions and 9 removals"}, - {cAsst, "Done - the test drives a fake clock now."}, + {cHuman, "the worker crashed overnight - find out why"}, + {cBlank, ""}, + {cToolCall, "Bash(grep -c ERROR /var/log/worker.log)"}, + {cOut, "217"}, }, active: []cLine{ - {cAsst, "I'll run the suite to confirm the flake is gone."}, - {cToolCall, "Bash(go test ./internal/cache/ -count=20)"}, - {cToolRes, "ok - 20 runs, no failures"}, - {cAsst, "All green - want me to check the other suites?"}, + {cToolCall, "Bash(tail -n 3 /var/log/worker.log)"}, + {cOut, "02:14:07 ERROR queue: connection reset by peer"}, + {cOut, "02:14:08 ERROR queue: reconnect failed (attempt 5)"}, + {cOut, "02:14:08 FATAL worker: giving up after 5 attempts"}, + {cBlank, ""}, + {cAsst, "The queue connection dies at 02:14 and retries are capped at 5."}, + {cBlank, ""}, + {cToolCall, "Update(worker.go)"}, + {cToolRes, "Updated worker.go with 1 addition and 1 removal"}, + {cDiffDel, "58 - MaxRetries: 5,"}, + {cDiffAdd, "58 + MaxRetries: 0, // retry forever with backoff"}, + {cBlank, ""}, + {cAsst, "Done - the worker now rides out queue outages."}, }, }, } @@ -232,7 +254,25 @@ func isTodoLine(text string) bool { func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine, white, orange, faint tcell.Style, skipBranch bool) { gl := ui.Glyphs() green := tcell.StyleDefault.Foreground(tcell.ColorGreen) + red := tcell.StyleDefault.Foreground(tcell.ColorRed) switch ln.kind { + case cBlank: + // draws nothing — spacing between blocks + case cDiffDel, cDiffAdd: + color := red + if ln.kind == cDiffAdd { + color = green + } + numStr, rest, ok := strings.Cut(ln.text, " ") + if !ok { + renderer.DrawText(7, y, color, ui.Truncate(ln.text, w-7)) + return + } + renderer.DrawText(7, y, faint, ui.Truncate(numStr, w-7)) + restX := 7 + runewidth.StringWidth(numStr) + 1 + renderer.DrawText(restX, y, color, ui.Truncate(rest, w-restX)) + case cOut: + renderer.DrawText(5, y, faint, ui.Truncate(ln.text, w-5)) case cHuman: renderer.DrawText(0, y, faint, "> ") renderer.DrawText(2, y, white, ui.Truncate(ln.text, w-2)) diff --git a/internal/themes/claude_theme_test.go b/internal/themes/claude_theme_test.go index 12c7774..62cf235 100644 --- a/internal/themes/claude_theme_test.go +++ b/internal/themes/claude_theme_test.go @@ -3,6 +3,10 @@ package themes import ( "strings" "testing" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" + "github.com/namest504/termtype/internal/domain" ) // 시나리오 데이터가 최신 CLI 형식을 따르는지 — 렌더 없이 데이터로 검증 @@ -40,3 +44,125 @@ func TestClaudeScenarios_HasTodoScene(t *testing.T) { } t.Fatal("no scenario contains an Update Todos scene") } + +// The scenario set as a whole should be textured with breathing room, diff +// hunks, and output dump lines — not just an unbroken stream of tool calls. +func TestClaudeScenarios_HasTexture(t *testing.T) { + var blanks, dels, adds, outs int + for _, sc := range claudeScenarios { + lines := append(append([]cLine{}, sc.history...), sc.active...) + for _, ln := range lines { + switch ln.kind { + case cBlank: + blanks++ + case cDiffDel: + dels++ + case cDiffAdd: + adds++ + case cOut: + outs++ + } + } + } + if blanks == 0 { + t.Error("scenarios: need at least one cBlank") + } + if dels == 0 || adds == 0 { + t.Error("scenarios: need at least one cDiffDel+cDiffAdd pair") + } + if outs == 0 { + t.Error("scenarios: need at least one cOut line") + } +} + +// recordingRenderer captures drawn text so render output can be inspected. +type recordingRenderer struct { + w, h int + rows []string +} + +func newRecordingRenderer(w, h int) *recordingRenderer { + rows := make([]string, h) + for i := range rows { + rows[i] = strings.Repeat(" ", w) + } + return &recordingRenderer{w: w, h: h, rows: rows} +} + +func (r *recordingRenderer) DrawText(x, y int, style tcell.Style, text string) { + if y < 0 || y >= r.h { + return + } + rw := []rune(r.rows[y]) + for _, ch := range text { + if x < 0 || x >= len(rw) { + x += runewidth.RuneWidth(ch) + continue + } + rw[x] = ch + x += runewidth.RuneWidth(ch) + } + r.rows[y] = string(rw) +} +func (r *recordingRenderer) DrawRune(x, y int, ru rune, style tcell.Style) int { + r.DrawText(x, y, style, string(ru)) + return runewidth.RuneWidth(ru) +} +func (r *recordingRenderer) Clear() {} +func (r *recordingRenderer) Show() {} +func (r *recordingRenderer) Size() (int, int) { return r.w, r.h } +func (r *recordingRenderer) SetContent(x, y int, ru rune, style tcell.Style) { + r.DrawRune(x, y, ru, style) +} +func (r *recordingRenderer) HideCursor() {} +func (r *recordingRenderer) ShowCursor(x, y int) {} + +func (r *recordingRenderer) dump() string { + return strings.Join(r.rows, "\n") +} + +// Render smoke test: forcing the scenario to fully reveal should surface the +// diff "- " marker text and a tail-style cOut line in the drawn output. +func TestClaudeTheme_RenderShowsDiffAndOutput(t *testing.T) { + theme := &ClaudeTheme{} + for i := range claudeScenarios { + gs := &domain.GameState{Sentences: domain.Sentences} + theme.ResetState(gs) + st := gs.CustomState.(*ClaudeThemeState) + st.scen = i + // Tick enough to reveal every line of the active turn. + sc := claudeScenarios[i] + for st.tick < (len(sc.active)+1)*claudeRevealEvery { + theme.OnTick(gs) + } + r := newRecordingRenderer(120, 40) + theme.UpdateScreen(r, gs) + out := r.dump() + + wantDiff, wantOut := false, false + for _, ln := range append(append([]cLine{}, sc.history...), sc.active...) { + if ln.kind == cDiffDel || ln.kind == cDiffAdd { + wantDiff = true + } + if ln.kind == cOut { + wantOut = true + } + } + if wantDiff && !strings.Contains(out, "- ") && !strings.Contains(out, "+ ") { + t.Errorf("scenario %d: expected diff marker in render output", i) + } + if wantOut { + // At least one cOut line's text should appear verbatim somewhere. + found := false + for _, ln := range append(append([]cLine{}, sc.history...), sc.active...) { + if ln.kind == cOut && strings.Contains(out, ln.text) { + found = true + break + } + } + if !found { + t.Errorf("scenario %d: expected an output-dump line in render output", i) + } + } + } +} From 5d9dd64ac8f893ef55e2d5afb227ac7640df4128 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 12:19:13 +0900 Subject: [PATCH 2/3] fix: Respect the terminal palette for body text --- internal/themes/claude_theme.go | 2 +- internal/themes/hex_editor_theme.go | 4 +- internal/themes/log_theme.go | 2 +- internal/themes/simple_theme.go | 2 +- internal/themes/tutor_theme.go | 2 +- internal/ui/typing_renderer.go | 14 ++----- internal/ui/typing_renderer_test.go | 62 +++++++++++++++++++++++++++++ 7 files changed, 72 insertions(+), 16 deletions(-) diff --git a/internal/themes/claude_theme.go b/internal/themes/claude_theme.go index 9cf09b2..b962565 100644 --- a/internal/themes/claude_theme.go +++ b/internal/themes/claude_theme.go @@ -180,7 +180,7 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat dim := tcell.StyleDefault.Foreground(tcell.ColorGray) faint := tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - white := tcell.StyleDefault.Foreground(tcell.ColorWhite) + white := tcell.StyleDefault orange := tcell.StyleDefault.Foreground(tcell.ColorOrange) wrapWidth := (w - 1) - claudePromptWidth - 3 diff --git a/internal/themes/hex_editor_theme.go b/internal/themes/hex_editor_theme.go index 503316b..a0af6c3 100644 --- a/internal/themes/hex_editor_theme.go +++ b/internal/themes/hex_editor_theme.go @@ -71,7 +71,7 @@ func (t *HexTheme) ensureDump(state *HexThemeState, h int) { func (t *HexTheme) drawHexDump(renderer domain.Renderer, state *HexThemeState, h int) { addrStyle := tcell.StyleDefault.Foreground(tcell.ColorBlue) - hexStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) + hexStyle := tcell.StyleDefault asciiStyle := tcell.StyleDefault.Foreground(tcell.ColorGray) for y := 0; y < h && y < len(state.rows); y++ { @@ -116,7 +116,7 @@ func hexWindow(targetLen, inputLen, availRows int) (winStart, visible int) { // coloring the ascii pane with the typed input. It returns the window start // row so the cursor can be placed. func (t *HexTheme) drawTarget(renderer domain.Renderer, gs *domain.GameState, state *HexThemeState, h int) int { - hexStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) + hexStyle := tcell.StyleDefault asciiStyle := tcell.StyleDefault.Foreground(tcell.ColorGray) correctStyle := tcell.StyleDefault.Foreground(tcell.ColorGreen) incorrectStyle := tcell.StyleDefault.Foreground(tcell.ColorRed) diff --git a/internal/themes/log_theme.go b/internal/themes/log_theme.go index 8168403..27d30dd 100644 --- a/internal/themes/log_theme.go +++ b/internal/themes/log_theme.go @@ -191,6 +191,6 @@ func getStyleForLogLevel(level string) tcell.Style { case "DEBUG": return tcell.StyleDefault.Foreground(tcell.ColorBlue) default: - return tcell.StyleDefault.Foreground(tcell.ColorWhite) + return tcell.StyleDefault } } diff --git a/internal/themes/simple_theme.go b/internal/themes/simple_theme.go index 3b95938..2a574df 100644 --- a/internal/themes/simple_theme.go +++ b/internal/themes/simple_theme.go @@ -63,7 +63,7 @@ func (t *SimpleTheme) drawResultScreen(renderer domain.Renderer, gs *domain.Game } renderer.DrawText(x, y, style, ui.Truncate(s, w)) } - center(h/2-1, tcell.StyleDefault.Foreground(tcell.ColorWhite), ui.ResultText(gs)) + center(h/2-1, tcell.StyleDefault, ui.ResultText(gs)) center(h/2+1, tcell.StyleDefault.Foreground(tcell.ColorGray), gl.Send+" next "+gl.Sep+" esc menu") } diff --git a/internal/themes/tutor_theme.go b/internal/themes/tutor_theme.go index 431f21b..9cd0a3a 100644 --- a/internal/themes/tutor_theme.go +++ b/internal/themes/tutor_theme.go @@ -180,7 +180,7 @@ func (t *TutorTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState if rx < colX { rx = colX } - renderer.DrawText(rx, top, tcell.StyleDefault.Foreground(tcell.ColorWhite), result) + renderer.DrawText(rx, top, tcell.StyleDefault, result) if showKB { t.drawKeyboard(renderer, (w-kbWidth)/2, kbTop, 0, false, false) } diff --git a/internal/ui/typing_renderer.go b/internal/ui/typing_renderer.go index 013f8df..6c05660 100644 --- a/internal/ui/typing_renderer.go +++ b/internal/ui/typing_renderer.go @@ -86,26 +86,20 @@ func (tr *TypingRenderer) Draw(renderer domain.Renderer, gs *domain.GameState, o currentInputIdx := inputOffset + charIdx // Determine the style. - style := tcell.StyleDefault.Foreground(tcell.ColorWhite) - if opts.CenterText { - style = style.Background(tcell.ColorBlack) - } else { + style := tcell.StyleDefault + if !opts.CenterText { style = tcell.StyleDefault.Foreground(tcell.ColorGray) } if currentInputIdx < len(inputRunes) { if inputRunes[currentInputIdx] == r { if opts.CenterText { - style = tcell.StyleDefault.Foreground(tcell.ColorLawnGreen).Background(tcell.ColorBlack) + style = tcell.StyleDefault.Foreground(tcell.ColorLawnGreen) } else { style = tcell.StyleDefault.Foreground(tcell.ColorGreen) } } else { - if opts.CenterText { - style = tcell.StyleDefault.Foreground(tcell.ColorRed).Background(tcell.ColorBlack) - } else { - style = tcell.StyleDefault.Foreground(tcell.ColorRed) - } + style = tcell.StyleDefault.Foreground(tcell.ColorRed) } } diff --git a/internal/ui/typing_renderer_test.go b/internal/ui/typing_renderer_test.go index 10022b2..99eccdf 100644 --- a/internal/ui/typing_renderer_test.go +++ b/internal/ui/typing_renderer_test.go @@ -99,3 +99,65 @@ func TestTypingRenderer_Draw_Padding(t *testing.T) { } } } + +// TestTypingRenderer_Draw_RespectsTerminalPalette pins the contract that the +// typing area never forces a fg/bg pair that fights the terminal's own +// palette. This exercises the CenterText path (used by simple/matrix +// themes), which used to force a white-on-black untyped style and a +// colored-on-black correct/incorrect style: untyped runes must now carry +// plain tcell.StyleDefault (no forced fg/bg so light and dark terminals both +// stay legible), correct runes must be green-on-default, and incorrect runes +// must be red-on-default. +func TestTypingRenderer_Draw_RespectsTerminalPalette(t *testing.T) { + width := 40 + height := 5 + mockScreen := NewMockScreen(width, height) + renderer := NewRenderer(mockScreen) + + tr := &TypingRenderer{} + gs := &domain.GameState{ + TargetSentence: "abcdef", + UserInput: "abX", // 'a','b' correct, 'X' wrong vs 'c', 'd','e','f' untyped + } + + opts := TypingRendererOptions{ + StartY: 0, + Width: width, + PrefixWidth: 0, + CenterText: true, + } + + tr.Draw(renderer, gs, opts) + + line := gs.TargetSentence + startX := (width - len(line)) / 2 + + // 'a' -> correct: green fg, no forced background. + if _, style := mockScreen.Cell(startX+0, 0); true { + fg, bg, _ := style.Decompose() + if fg != tcell.ColorLawnGreen { + t.Errorf("correct rune: expected lawn green fg, got %v", fg) + } + if bg != tcell.ColorDefault { + t.Errorf("correct rune: expected default (no forced) bg, got %v", bg) + } + } + + // 'X' typed against 'c' -> incorrect: red fg, no forced background. + if _, style := mockScreen.Cell(startX+2, 0); true { + fg, bg, _ := style.Decompose() + if fg != tcell.ColorRed { + t.Errorf("incorrect rune: expected red fg, got %v", fg) + } + if bg != tcell.ColorDefault { + t.Errorf("incorrect rune: expected default (no forced) bg, got %v", bg) + } + } + + // 'e' -> untyped: default style entirely, terminal palette shows through. + if _, style := mockScreen.Cell(startX+4, 0); true { + if style != tcell.StyleDefault { + t.Errorf("untyped rune: expected tcell.StyleDefault, got %v", style) + } + } +} From 7024081ea590e5886fd46285fabcdff0b61c9713 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 12:38:26 +0900 Subject: [PATCH 3/3] fix: Reveal tool results as whole blocks in the claude theme --- internal/themes/claude_theme.go | 110 ++++++++++++++++++++++++--- internal/themes/claude_theme_test.go | 83 ++++++++++++++++++++ 2 files changed, 184 insertions(+), 9 deletions(-) diff --git a/internal/themes/claude_theme.go b/internal/themes/claude_theme.go index b962565..5d56149 100644 --- a/internal/themes/claude_theme.go +++ b/internal/themes/claude_theme.go @@ -146,6 +146,94 @@ func claudeToolCount(active []cLine) int { return n } +// isResultKind reports whether a line belongs to a tool's result block: the +// ⎿ summary line plus any diff/output lines under it. In the real CLI these +// pop in together the instant the tool finishes, so they reveal as one unit. +func isResultKind(k cKind) bool { + switch k { + case cToolRes, cDiffDel, cDiffAdd, cOut: + return true + default: + return false + } +} + +// claudeGroupLines partitions active into reveal groups: a maximal +// consecutive run of result-kind lines (cToolRes/cDiffDel/cDiffAdd/cOut) is +// one atomic group (a tool's whole result block), and every other line +// (cHuman/cAsst/cToolCall) is its own group. A cBlank line attaches to the +// group that follows it (so spacing appears together with the next block); +// a trailing cBlank with nothing after it attaches to the previous group. +// A cBlank also always starts a new group boundary, even between two +// result-kind lines, so it never gets silently absorbed into a result run. +func claudeGroupLines(active []cLine) [][]cLine { + var groups [][]cLine + var current []cLine + var pendingBlanks []cLine + inResultRun := false + + flush := func() { + if len(current) > 0 { + groups = append(groups, current) + current = nil + } + } + + for _, ln := range active { + if ln.kind == cBlank { + pendingBlanks = append(pendingBlanks, ln) + continue + } + if isResultKind(ln.kind) && inResultRun && len(pendingBlanks) == 0 { + current = append(current, ln) + continue + } + flush() + current = append(current, pendingBlanks...) + pendingBlanks = nil + current = append(current, ln) + inResultRun = isResultKind(ln.kind) + if !inResultRun { + flush() + } + } + + if len(pendingBlanks) > 0 { + if len(current) > 0 { + current = append(current, pendingBlanks...) + } else if len(groups) > 0 { + groups[len(groups)-1] = append(groups[len(groups)-1], pendingBlanks...) + } else { + current = append(current, pendingBlanks...) + } + } + flush() + + return groups +} + +// groupCount returns how many reveal groups active partitions into. +func groupCount(active []cLine) int { + return len(claudeGroupLines(active)) +} + +// revealLineCount returns how many of active's lines are covered by the +// first n reveal groups (n clamped to [0, groupCount(active)]). +func revealLineCount(active []cLine, n int) int { + groups := claudeGroupLines(active) + if n <= 0 { + return 0 + } + if n >= len(groups) { + return len(active) + } + count := 0 + for _, g := range groups[:n] { + count += len(g) + } + return count +} + func (t *ClaudeTheme) ResetState(gs *domain.GameState) { gs.ResetCommon() gs.TargetSentence = gs.RandomSentence() @@ -212,14 +300,18 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat sc := claudeScenarios[st.scen%len(claudeScenarios)] - // How much of the active (in-progress) turn has streamed in so far. - reveal := len(sc.active) + // How much of the active (in-progress) turn has streamed in so far, in + // whole reveal groups: a tool's whole result block pops in at once, + // while every other line streams on its own. + totalGroups := groupCount(sc.active) + revealGroups := totalGroups if !gs.IsFinished { - reveal = st.tick / claudeRevealEvery - if reveal > len(sc.active) { - reveal = len(sc.active) + revealGroups = st.tick / claudeRevealEvery + if revealGroups > totalGroups { + revealGroups = totalGroups } } + reveal := revealLineCount(sc.active, revealGroups) convo := make([]cLine, 0, len(sc.history)+reveal) convo = append(convo, sc.history...) @@ -237,7 +329,7 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat prevWasTodo = isTodo } - t.drawStatus(renderer, gs, st, sc.active, reveal, statusRow, w, orange, faint) + t.drawStatus(renderer, gs, st, sc.active, revealGroups, totalGroups, statusRow, w, orange, faint) t.drawBox(renderer, boxTop, boxBottom, w, dim) t.drawInput(renderer, gs, wrapped, boxTop, boxRows, w, orange, dim) t.drawHint(renderer, gs, hintRow, w, dim, faint) @@ -300,7 +392,7 @@ func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine } } -func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState, st *ClaudeThemeState, active []cLine, reveal, statusRow, w int, orange, faint tcell.Style) { +func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState, st *ClaudeThemeState, active []cLine, revealGroups, totalGroups, statusRow, w int, orange, faint tcell.Style) { if statusRow < 0 { return } @@ -309,8 +401,8 @@ func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState, renderer.DrawText(0, statusRow, orange, ui.Truncate(gl.AsstDot+" Message sent", w)) return } - if reveal >= len(active) { - doneAt := len(active) * claudeRevealEvery + if revealGroups >= totalGroups { + doneAt := totalGroups * claudeRevealEvery green := tcell.StyleDefault.Foreground(tcell.ColorGreen) edits := claudeToolCount(active) editWord := "edits" diff --git a/internal/themes/claude_theme_test.go b/internal/themes/claude_theme_test.go index 62cf235..fb4ddf6 100644 --- a/internal/themes/claude_theme_test.go +++ b/internal/themes/claude_theme_test.go @@ -75,6 +75,89 @@ func TestClaudeScenarios_HasTexture(t *testing.T) { } } +// A tool's result block (the ⎿ line plus its diff/output lines) must reveal +// as one atomic group, not line by line: in scenario 1's active turn, the +// group after "Update(cache_test.go)" must bundle the "Updated..." result +// line together with all 3 diff lines. +func TestClaudeGroupLines_ResultBlockIsAtomic(t *testing.T) { + active := claudeScenarios[0].active + groups := claudeGroupLines(active) + + // active[0] = Update(cache_test.go) tool call -> its own group. + // active[1..4] = toolRes + 3 diff lines -> one atomic group. + if len(groups) < 2 { + t.Fatalf("expected at least 2 groups, got %d", len(groups)) + } + if len(groups[0]) != 1 || groups[0][0].kind != cToolCall { + t.Fatalf("group 0: want [cToolCall], got %v", groups[0]) + } + want := []cKind{cToolRes, cDiffDel, cDiffAdd, cDiffAdd} + if len(groups[1]) != len(want) { + t.Fatalf("group 1: want %d lines (result + 3 diffs), got %d: %v", len(want), len(groups[1]), groups[1]) + } + for i, k := range want { + if groups[1][i].kind != k { + t.Errorf("group 1 line %d: want kind %v, got %v", i, k, groups[1][i].kind) + } + } + + // Reveal step-by-step: revealing through group 0 shows only the tool + // call; the NEXT step must reveal the result line and all 3 diff lines + // together (4 more lines at once), not one at a time. + afterGroup0 := revealLineCount(active, 1) + afterGroup1 := revealLineCount(active, 2) + if afterGroup0 != 1 { + t.Errorf("after group 0: want 1 line revealed, got %d", afterGroup0) + } + if got := afterGroup1 - afterGroup0; got != 4 { + t.Errorf("group 1 step: want 4 lines revealed at once (result + 3 diffs), got %d", got) + } +} + +// A blank line attaches to the group that follows it, so the blank and its +// following block reveal together in a single step. +func TestClaudeGroupLines_BlankAttachesToFollowingGroup(t *testing.T) { + active := []cLine{ + {cToolCall, "Bash(go test ./...)"}, + {cOut, "ok"}, + {cBlank, ""}, + {cAsst, "Done."}, + } + groups := claudeGroupLines(active) + if len(groups) != 3 { + t.Fatalf("want 3 groups (tool call / result / blank+asst), got %d: %v", len(groups), groups) + } + last := groups[2] + if len(last) != 2 || last[0].kind != cBlank || last[1].kind != cAsst { + t.Fatalf("last group: want [cBlank, cAsst], got %v", last) + } + // The step that reveals the final group must reveal both the blank and + // the assistant line together, not the blank alone first. + beforeLast := revealLineCount(active, 2) + afterLast := revealLineCount(active, 3) + if got := afterLast - beforeLast; got != 2 { + t.Errorf("final step: want 2 lines revealed at once (blank + asst), got %d", got) + } +} + +// A trailing blank with nothing after it attaches to the previous group +// instead of forming its own (empty-looking) group. +func TestClaudeGroupLines_TrailingBlankAttachesToPreviousGroup(t *testing.T) { + active := []cLine{ + {cToolCall, "Read(fetch.go)"}, + {cToolRes, "Read 88 lines"}, + {cBlank, ""}, + } + groups := claudeGroupLines(active) + if len(groups) != 2 { + t.Fatalf("want 2 groups, got %d: %v", len(groups), groups) + } + last := groups[1] + if len(last) != 2 || last[0].kind != cToolRes || last[1].kind != cBlank { + t.Fatalf("last group: want [cToolRes, cBlank], got %v", last) + } +} + // recordingRenderer captures drawn text so render output can be inspected. type recordingRenderer struct { w, h int