Skip to content
Open
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
74 changes: 52 additions & 22 deletions internal/themes/claude_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
"github.com/namest504/termtype/internal/domain"
"github.com/namest504/termtype/internal/ui"
)
Expand All @@ -26,8 +27,12 @@ type ClaudeTheme struct{}
type ClaudeThemeState struct {
tick int
scen int // index into claudeScenarios for this round
verb int // index into claudeVerbs for this round's spinner label
}

// claudeVerbs are the whimsical gerunds the spinner cycles between rounds.
var claudeVerbs = []string{"Pondering", "Wrangling", "Brewing", "Musing", "Cerebrating", "Vibing"}

const claudePromptWidth = 3

// How many ticks before each new line of the active turn is revealed.
Expand All @@ -38,7 +43,8 @@ type cKind int
const (
cHuman cKind = iota
cAsst
cTool
cToolCall // ⏺ Name(args) — a tool invocation
cToolRes // ⎿ result line under a call
)

type cLine struct {
Expand All @@ -58,46 +64,51 @@ var claudeScenarios = []claudeScenario{
history: []cLine{
{cHuman, "split the monolith handler into smaller functions"},
{cAsst, "I'll extract request parsing and validation first."},
{cTool, "Read handler.go (210 lines)"},
{cTool, "Updated handler.go (+96 -120)"},
{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."},
},
active: []cLine{
{cAsst, "I'll add graceful shutdown so in-flight work can finish."},
{cTool, "Read server.go (142 lines)"},
{cTool, "Updated server.go (+38 -6)"},
{cTool, "Updated main.go (+9 -1)"},
{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."},
},
},
{
history: []cLine{
{cHuman, "add retry with backoff to the fetcher"},
{cAsst, "I'll wrap the client call in a retry loop."},
{cTool, "Read fetch.go (88 lines)"},
{cTool, "Updated fetch.go (+41 -7)"},
{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."},
},
active: []cLine{
{cAsst, "I'll make the timeout configurable next."},
{cTool, "Read config.go (54 lines)"},
{cTool, "Updated config.go (+12 -2)"},
{cTool, "Updated fetch.go (+6 -3)"},
{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."},
},
},
{
history: []cLine{
{cHuman, "why is the cache test flaky"},
{cAsst, "The TTL assertion races the clock; I'll inject a fake timer."},
{cTool, "Read cache_test.go (131 lines)"},
{cTool, "Updated cache_test.go (+18 -9)"},
{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."},
},
active: []cLine{
{cAsst, "I'll run the suite to confirm the flake is gone."},
{cTool, "go test ./... (42 tests)"},
{cTool, "PASS in 3.1s"},
{cToolCall, "Bash(go test ./internal/cache/ -count=20)"},
{cToolRes, "ok — 20 runs, no failures"},
{cAsst, "All green - want me to check the other suites?"},
},
},
Expand All @@ -106,7 +117,7 @@ var claudeScenarios = []claudeScenario{
func claudeToolCount(active []cLine) int {
n := 0
for _, l := range active {
if l.kind == cTool {
if l.kind == cToolCall {
n++
}
}
Expand All @@ -124,6 +135,7 @@ func (t *ClaudeTheme) ResetState(gs *domain.GameState) {
// A fresh round picks a scenario and replays its stream from the top.
st.tick = 0
st.scen = rand.Intn(len(claudeScenarios))
st.verb = rand.Intn(len(claudeVerbs))
}

func (t *ClaudeTheme) OnTick(gs *domain.GameState) {
Expand Down Expand Up @@ -210,16 +222,26 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat

func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine, white, orange, faint tcell.Style) {
gl := ui.Glyphs()
green := tcell.StyleDefault.Foreground(tcell.ColorGreen)
switch ln.kind {
case cHuman:
renderer.DrawText(0, y, faint, "> ")
renderer.DrawText(2, y, white, ui.Truncate(ln.text, w-2))
case cAsst:
renderer.DrawText(0, y, orange, gl.AsstDot)
renderer.DrawText(2, y, white, ui.Truncate(ln.text, w-2))
case cTool:
case cToolCall:
renderer.DrawText(0, y, green, gl.AsstDot)
renderer.DrawText(2, y, white, ui.Truncate(ln.text, w-2))
case cToolRes:
text := ln.text
if rest, ok := strings.CutPrefix(text, "TODO_DONE "); ok {
text = gl.TodoDone + " " + rest
} else if rest, ok := strings.CutPrefix(text, "TODO_OPEN "); ok {
text = gl.TodoOpen + " " + rest
}
renderer.DrawText(2, y, faint, gl.ToolBranch)
renderer.DrawText(4, y, faint, ui.Truncate(ln.text, w-4))
renderer.DrawText(4, y, faint, ui.Truncate(text, w-4))
}
}

Expand All @@ -239,10 +261,13 @@ func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState,
renderer.DrawText(0, statusRow, green, ui.Truncate(msg, w))
return
}
frame := gl.Spinner[st.tick%len(gl.Spinner)]
frame := gl.StarSpinner[st.tick%len(gl.StarSpinner)]
verb := claudeVerbs[st.verb%len(claudeVerbs)]
head := fmt.Sprintf("%c %s%s ", frame, verb, gl.Ellipsis)
tokens := float64(300+st.tick*137) / 1000.0
msg := fmt.Sprintf("%c Working%s %ds %s %s %.1fk tokens %s esc to interrupt", frame, gl.Ellipsis, st.tick, gl.Sep, gl.Up, tokens, gl.Sep)
renderer.DrawText(0, statusRow, orange, ui.Truncate(msg, w))
tail := fmt.Sprintf("(%ds %s %s %.1fk tokens %s esc to interrupt)", st.tick, gl.Sep, gl.Up, tokens, gl.Sep)
renderer.DrawText(0, statusRow, orange, ui.Truncate(head, w))
renderer.DrawText(runewidth.StringWidth(head), statusRow, faint, ui.Truncate(tail, w-runewidth.StringWidth(head)))
}

func (t *ClaudeTheme) drawBox(renderer domain.Renderer, top, bottom, w int, style tcell.Style) {
Expand Down Expand Up @@ -306,5 +331,10 @@ func (t *ClaudeTheme) drawHint(renderer domain.Renderer, gs *domain.GameState, h
renderer.DrawText(0, hintRow, dim, ui.Truncate(result, w))
return
}
renderer.DrawText(0, hintRow, faint, ui.Truncate(gl.Send+" send when complete esc menu", w))
left := "? for shortcuts " + gl.Sep + " esc menu"
right := gl.FastFwd + " accept edits on (shift+tab to cycle)"
renderer.DrawText(0, hintRow, faint, ui.Truncate(left, w))
if lw, rw := runewidth.StringWidth(left), runewidth.StringWidth(right); lw+rw+2 <= w {
renderer.DrawText(w-rw, hintRow, faint, right)
}
}
42 changes: 42 additions & 0 deletions internal/themes/claude_theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package themes

import (
"strings"
"testing"
)

// 시나리오 데이터가 최신 CLI 형식을 따르는지 — 렌더 없이 데이터로 검증
func TestClaudeScenarios_ModernFormat(t *testing.T) {
for i, sc := range claudeScenarios {
lines := append(append([]cLine{}, sc.history...), sc.active...)
var calls, results int
for _, ln := range lines {
switch ln.kind {
case cToolCall:
calls++
// "Update Todos" is a real no-argument tool call in the CLI and is
// exempt from the Name(args) shape.
if ln.text != "Update Todos" && (!strings.Contains(ln.text, "(") || !strings.HasSuffix(ln.text, ")")) {
t.Errorf("scenario %d: tool call %q must be Name(args)", i, ln.text)
}
case cToolRes:
results++
}
}
if calls == 0 || results == 0 {
t.Errorf("scenario %d: needs tool calls and results", i)
}
}
}

// 한 시나리오에는 Todo 장면이 있어야 한다
func TestClaudeScenarios_HasTodoScene(t *testing.T) {
for _, sc := range claudeScenarios {
for _, ln := range append(append([]cLine{}, sc.history...), sc.active...) {
if ln.kind == cToolCall && strings.Contains(ln.text, "Update Todos") {
return
}
}
}
t.Fatal("no scenario contains an Update Todos scene")
}
15 changes: 15 additions & 0 deletions internal/ui/glyphs.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type GlyphSet struct {
Sep string // inline separator between fields
ChartDot string // wpm graph: data point
ChartTick string // wpm graph: y-axis tick

StarSpinner []rune // claude theme: asterisk-morph spinner frames
TodoDone string // claude theme: completed todo checkbox
TodoOpen string // claude theme: pending todo checkbox
FastFwd string // claude theme: accept-edits indicator (2 cells)
}

var unicodeGlyphs = GlyphSet{
Expand All @@ -50,6 +55,11 @@ var unicodeGlyphs = GlyphSet{
Sep: "·",
ChartDot: "●",
ChartTick: "┤",

StarSpinner: []rune("✢✳✶✻✽✻✶✳"),
TodoDone: "☒",
TodoOpen: "☐",
FastFwd: "⏵⏵",
}

var asciiGlyphs = GlyphSet{
Expand All @@ -74,6 +84,11 @@ var asciiGlyphs = GlyphSet{
Sep: "-",
ChartDot: "*",
ChartTick: "|",

StarSpinner: []rune(`|/-\`),
TodoDone: "[x]",
TodoOpen: "[ ]",
FastFwd: ">>",
}

var useASCII bool
Expand Down
14 changes: 14 additions & 0 deletions internal/ui/glyphs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ func TestGlyphs_Toggle(t *testing.T) {
t.Errorf("ascii BoxV must be one rune, got %q", Glyphs().BoxV)
}
}

func TestGlyphs_NewClaudeGlyphs(t *testing.T) {
SetASCII(false)
g := Glyphs()
if len(g.StarSpinner) == 0 || g.TodoDone == "" || g.TodoOpen == "" || g.FastFwd == "" {
t.Fatalf("unicode glyph set missing new fields: %+v", g)
}
SetASCII(true)
defer SetASCII(false)
a := Glyphs()
if len(a.StarSpinner) == 0 || a.TodoDone != "[x]" || a.TodoOpen != "[ ]" {
t.Fatalf("ascii fallback wrong: %+v", a)
}
}
Loading