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
60 changes: 60 additions & 0 deletions internal/themes/ascii_theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package themes

import (
"testing"

"github.com/namest504/termtype/internal/domain"
"github.com/namest504/termtype/internal/ui"
)

// TestThemesHonorASCIIContract renders every registered theme, in both its
// typing and finished states, with ui.SetASCII(true) active, and asserts no
// rune above the ASCII range (127) ever lands on the grid. This is the
// enforcement test for the ASCII contract: any theme that draws a decorative
// glyph or em dash without checking ui.IsASCII() should fail it.
func TestThemesHonorASCIIContract(t *testing.T) {
prevASCII := ui.IsASCII()
ui.SetASCII(true)
t.Cleanup(func() { ui.SetASCII(prevASCII) })

sentences := []string{"the quick brown fox jumps over the lazy dog"}

for name, theme := range Themes {
for _, size := range []struct{ w, h int }{{100, 30}, {40, 16}} {
t.Run(name, func(t *testing.T) {
gs := &domain.GameState{Sentences: sentences}
theme.ResetState(gs)
gs.TargetSentence = sentences[0]

r := newGridRenderer(size.w, size.h)

// Typing state, partway through.
gs.UserInput = "the quick brown"
gs.IsFinished = false
theme.UpdateScreen(r, gs)
assertGridIsASCII(t, r, name, "typing")

// Finished state.
gs.UserInput = sentences[0]
gs.IsFinished = true
gs.WPM = 61.2
gs.Accuracy = 97.5
gs.FinalDurS = 12
r2 := newGridRenderer(size.w, size.h)
theme.UpdateScreen(r2, gs)
assertGridIsASCII(t, r2, name, "finished")
})
}
}
}

func assertGridIsASCII(t *testing.T, r *gridRenderer, theme, state string) {
t.Helper()
for y := 0; y < r.h; y++ {
for x := 0; x < r.w; x++ {
if ch := r.grid[y][x]; ch > 127 {
t.Errorf("theme %q (%s state): non-ASCII rune %q at (%d,%d) with ui.SetASCII(true)", theme, state, ch, x, y)
}
}
}
}
34 changes: 27 additions & 7 deletions internal/themes/claude_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ var claudeScenarios = []claudeScenario{
{cToolCall, "Update(server.go)"},
{cToolRes, "Updated server.go with 38 additions and 6 removals"},
{cToolCall, "Bash(go test ./...)"},
{cToolRes, "ok 42 tests passed"},
{cToolRes, "ok - 42 tests passed"},
{cAsst, "Done - the server now drains connections on SIGTERM."},
},
},
Expand Down Expand Up @@ -108,7 +108,7 @@ var claudeScenarios = []claudeScenario{
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"},
{cToolRes, "ok - 20 runs, no failures"},
{cAsst, "All green - want me to check the other suites?"},
},
},
Expand Down Expand Up @@ -205,11 +205,14 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat

// Render the conversation bottom-anchored, ending just above the status row.
firstRow := statusRow - len(convo)
prevWasTodo := false
for i, ln := range convo {
y := firstRow + i
isTodo := ln.kind == cToolRes && isTodoLine(ln.text)
if y >= 0 && y < statusRow {
t.drawConvoLine(renderer, y, w, ln, white, orange, faint)
t.drawConvoLine(renderer, y, w, ln, white, orange, faint, prevWasTodo && isTodo)
}
prevWasTodo = isTodo
}

t.drawStatus(renderer, gs, st, sc.active, reveal, statusRow, w, orange, faint)
Expand All @@ -220,7 +223,13 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat
renderer.Show()
}

func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine, white, orange, faint tcell.Style) {
// isTodoLine reports whether a tool-result line is one of the TODO_* scene
// lines drawn under an "Update Todos" call.
func isTodoLine(text string) bool {
return strings.HasPrefix(text, "TODO_DONE ") || strings.HasPrefix(text, "TODO_OPEN ")
}

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)
switch ln.kind {
Expand All @@ -240,7 +249,13 @@ func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine
} else if rest, ok := strings.CutPrefix(text, "TODO_OPEN "); ok {
text = gl.TodoOpen + " " + rest
}
renderer.DrawText(2, y, faint, gl.ToolBranch)
// Consecutive TODO_* lines belong to the same "Update Todos" scene, so
// only the first prints the branch marker; the rest indent to match.
if skipBranch {
renderer.DrawText(2, y, faint, strings.Repeat(" ", runewidth.StringWidth(gl.ToolBranch)))
} else {
renderer.DrawText(2, y, faint, gl.ToolBranch)
}
renderer.DrawText(4, y, faint, ui.Truncate(text, w-4))
}
}
Expand All @@ -257,7 +272,12 @@ func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState,
if reveal >= len(active) {
doneAt := len(active) * claudeRevealEvery
green := tcell.StyleDefault.Foreground(tcell.ColorGreen)
msg := fmt.Sprintf("%s responded in %ds %s %d edits %s esc to interrupt", gl.Check, doneAt, gl.Sep, claudeToolCount(active), gl.Sep)
edits := claudeToolCount(active)
editWord := "edits"
if edits == 1 {
editWord = "edit"
}
msg := fmt.Sprintf("%s responded in %ds %s %d %s", gl.Check, doneAt, gl.Sep, edits, editWord)
renderer.DrawText(0, statusRow, green, ui.Truncate(msg, w))
return
}
Expand Down Expand Up @@ -331,7 +351,7 @@ func (t *ClaudeTheme) drawHint(renderer domain.Renderer, gs *domain.GameState, h
renderer.DrawText(0, hintRow, dim, ui.Truncate(result, w))
return
}
left := "? for shortcuts " + gl.Sep + " esc menu"
left := "esc menu " + gl.Sep + " ? for shortcuts"
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 {
Expand Down
48 changes: 48 additions & 0 deletions internal/themes/hex_editor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,54 @@ func TestHexResultEncodesStats(t *testing.T) {
}
}

// TestHexResultStaysOnScreen is a regression test: with a long target (e.g.
// a 250-word Time Attack "words" target) or a short terminal, the old
// drawResult computed startRow from the FULL target length rather than the
// window drawTarget actually draws, so it could land past h and draw
// nothing -- the round ended with no visible stats. drawResult must clamp
// the stat rows onto the screen in both cases.
func TestHexResultStaysOnScreen(t *testing.T) {
longTarget := strings.Repeat("word ", 250)
longTarget = strings.TrimSpace(longTarget)

cases := []struct {
name string
w, h int
target string
}{
{"long target, normal terminal", 100, 24, longTarget},
{"short terminal, normal target", 100, 16, "the quick brown fox jumps over the lazy dog"},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
theme := &HexTheme{}
gs := &domain.GameState{Sentences: []string{c.target}}
theme.ResetState(gs)
gs.TargetSentence = c.target
gs.UserInput = c.target
gs.IsFinished = true
gs.WPM = 61.2
gs.Accuracy = 97.5
gs.FinalDurS = 12

r := newGridRenderer(c.w, c.h)
theme.UpdateScreen(r, gs)

found := false
for y := 0; y < c.h && !found; y++ {
line := string(r.grid[y])
if strings.Contains(line, "wpm") {
found = true
}
}
if !found {
t.Errorf("%s: expected the stat text (\"wpm\") to appear somewhere on a %dx%d screen, found nothing", c.name, c.w, c.h)
}
})
}
}

func TestHexWindow(t *testing.T) {
cases := []struct {
name string
Expand Down
15 changes: 13 additions & 2 deletions internal/themes/hex_editor_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,19 @@ func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, st
addrStyle := tcell.StyleDefault.Foreground(tcell.ColorBlue)

stats := []byte(ui.ResultText(gs))
targetRows := (len([]byte(gs.TargetSentence)) + 15) / 16
startRow := state.StartLine + targetRows + 1 // one blank dump row of breathing room
_, visible := hexWindow(len([]byte(gs.TargetSentence)), len([]byte(gs.UserInput)), h-state.StartLine-1)
startRow := state.StartLine + visible + 1 // one blank dump row of breathing room

statRows := (len(stats) + 15) / 16
if statRows < 1 {
statRows = 1
}
if startRow+statRows > h {
startRow = h - statRows
}
if startRow < 0 {
startRow = 0
}

for r := 0; r*16 < len(stats); r++ {
y := startRow + r
Expand Down
47 changes: 31 additions & 16 deletions internal/themes/log_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
var logLevels = []string{"INFO", "WARN", "DEBUG", "ERROR"}
var sources = []string{"auth-service", "api-gateway", "db-connector", "cache-worker", "metrics-agent"}

func formatAsLogLine(sentence string) (string, string, string) {
ts := time.Now().Format("2006-01-02T15:04:05Z")
func formatAsLogLine(ts time.Time, sentence string) (string, string, string) {
tsStr := ts.Format("2006-01-02T15:04:05Z")
level := logLevels[rand.Intn(len(logLevels))]
source := sources[rand.Intn(len(sources))]
prefix := fmt.Sprintf("[%s] [%s] [%s] ", ts, level, source)
prefix := fmt.Sprintf("[%s] [%s] [%s] ", tsStr, level, source)
return prefix + sentence, prefix, sentence
}

Expand Down Expand Up @@ -50,7 +50,7 @@ func (t *LogTheme) ResetState(gs *domain.GameState) {
gs.CustomState = logState

selectedSentence := gs.RandomSentence()
fullLog, prefix, sentence := formatAsLogLine(selectedSentence)
fullLog, prefix, sentence := formatAsLogLine(time.Now(), selectedSentence)

logState.targetLogLine = fullLog
logState.logPrefix = prefix
Expand Down Expand Up @@ -78,7 +78,7 @@ func (t *LogTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState)
}

func (t *LogTheme) calculateTargetY(h int) int {
numLogs := h - 4
numLogs := h - 5
if numLogs < 0 {
numLogs = 0
}
Expand All @@ -87,13 +87,25 @@ func (t *LogTheme) calculateTargetY(h int) int {

func (t *LogTheme) drawBackgroundLogs(renderer domain.Renderer, gs *domain.GameState, logState *LogThemeState, h int) {
// Dynamically adjust the number of log lines to the terminal height.
numLogs := h - 4 // reserve space for top margin, target line, result line, etc.
numLogs := h - 5 // reserve space for top margin, target line, result lines, etc.
if numLogs < 0 {
numLogs = 0
}
for len(logState.backgroundLogs) < numLogs {
newLog, _, _ := formatAsLogLine(gs.RandomPoolSentence())
logState.backgroundLogs = append([]string{newLog}, logState.backgroundLogs...)
if needed := numLogs - len(logState.backgroundLogs); needed > 0 {
now := time.Now()
newLines := make([]string, needed)
// Stagger each newly generated line into the past, oldest at the top
// and closest to now at the bottom, so the log reads as a stream of
// events rather than a wall of identical timestamps. Offsets
// accumulate from the bottom up so timestamps stay monotonically
// increasing top to bottom.
var offset time.Duration
for i := needed - 1; i >= 0; i-- {
offset += time.Duration(3+rand.Intn(20)) * time.Second
newLog, _, _ := formatAsLogLine(now.Add(-offset), gs.RandomPoolSentence())
newLines[i] = newLog
}
logState.backgroundLogs = append(newLines, logState.backgroundLogs...)
}
if len(logState.backgroundLogs) > numLogs {
logState.backgroundLogs = logState.backgroundLogs[len(logState.backgroundLogs)-numLogs:]
Expand Down Expand Up @@ -143,11 +155,14 @@ func (t *LogTheme) drawResultLine(renderer domain.Renderer, gs *domain.GameState
renderer.HideCursor()
renderer.DrawText(1, targetY, tcell.StyleDefault.Foreground(tcell.ColorDimGray), logState.targetLogLine)

resultLog := fmt.Sprintf("[%s] [DEBUG] [metrics-agent] Round finished. WPM: %.2f, Accuracy: %.2f%%", time.Now().Format("2006-01-02T15:04:05Z"), gs.WPM, gs.Accuracy)
renderer.DrawText(1, targetY+1, getStyleForLogLevel("DEBUG"), resultLog)

guideText := "Press Enter for the next round, Esc for the menu."
renderer.DrawText(1, targetY+3, tcell.StyleDefault, guideText)
now := time.Now().Format("2006-01-02T15:04:05Z")
line1 := fmt.Sprintf("[%s] [INFO] [typing-daemon] session complete - %.0f wpm, %.1f%% acc, %.0fs",
now, gs.WPM, gs.Accuracy, gs.FinalDurS)
line2 := fmt.Sprintf("[%s] [INFO] [typing-daemon] process exited (0)", now)
line3 := fmt.Sprintf("[%s] [INFO] [typing-daemon] waiting for input - enter: restart, esc: menu", now)
renderer.DrawText(1, targetY+1, getStyleForLogLevel("INFO"), line1)
renderer.DrawText(1, targetY+2, getStyleForLogLevel("INFO"), line2)
renderer.DrawText(1, targetY+3, getStyleForLogLevel("INFO"), line3)
}

// OnTick gives the LogTheme a real-time scrolling effect.
Expand All @@ -162,8 +177,8 @@ func (t *LogTheme) OnTick(gs *domain.GameState) {
return
}

// Append a new log and remove the oldest one.
newLog, _, _ := formatAsLogLine(gs.RandomPoolSentence())
// Append a new log (stamped with the current time) and remove the oldest one.
newLog, _, _ := formatAsLogLine(time.Now(), gs.RandomPoolSentence())
logState.backgroundLogs = append(logState.backgroundLogs[1:], newLog)
}

Expand Down
Loading
Loading