f80d2e3f8e5530d4288488768b89a6ca1ee4f1be
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/internal/ui/app.go b/internal/ui/app.go
2index f0777cc02e477b9eabd554862d0b169213433b7d..0f0d3f900e8221d3ca29aa185749c212ae8822dd 100644
3--- a/internal/ui/app.go
4+++ b/internal/ui/app.go
5@@ -1,18 +1,50 @@
6 package ui
7
8 import (
9+ "context"
10+ "errors"
11+ "fmt"
12+ "strings"
13+ "time"
14+
15 "github.com/charmbracelet/bubbletea"
16
17 "japanese/internal/availability"
18 "japanese/internal/game"
19 )
20
21+// tickInterval refreshes the recording elapsed indicator. It is short enough to
22+// feel live but cheap; it only runs while a recording is in progress.
23+const tickInterval = 200 * time.Millisecond
24+
25+type talkPhase int
26+
27+const (
28+ phaseIdle talkPhase = iota
29+ phaseRecording
30+ phaseProcessing
31+)
32+
33+type beginResultMsg struct{ err error }
34+type finishResultMsg struct{ res game.TurnResult }
35+type tickMsg struct{ t time.Time }
36+
37 type model struct {
38 state *game.State
39 orch *game.Orchestrator
40 services []availability.Service
41 recordCapSec int
42- status string
43+
44+ phase talkPhase
45+ recSince time.Time
46+
47+ transcriptRomaji string
48+ hasJudge bool
49+ judgeScore int
50+ judgeFeedback string
51+ judgeErr string
52+ npcStatus string
53+ status string
54 }
55
56 func NewModel(state *game.State, orch *game.Orchestrator, services []availability.Service, recordCapSec int) *model {
57@@ -21,30 +53,111 @@ func NewModel(state *game.State, orch *game.Orchestrator, services []availabilit
58 orch: orch,
59 services: services,
60 recordCapSec: recordCapSec,
61- status: "move: arrows / WASD q: quit",
62 }
63 }
64
65 func (m *model) Init() tea.Cmd { return nil }
66
67 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68- k, ok := msg.(tea.KeyMsg)
69- if !ok {
70+ var cmds []tea.Cmd
71+ switch v := msg.(type) {
72+ case tea.KeyMsg:
73+ if c := m.handleKey(v); c != nil {
74+ cmds = append(cmds, c)
75+ }
76+ case beginResultMsg:
77+ m.applyBegin(v.err)
78+ case finishResultMsg:
79+ m.applyFinish(v.res)
80+ case tickMsg:
81+ if m.phase == phaseRecording {
82+ cmds = append(cmds, nextTick())
83+ }
84+ default:
85 return m, nil
86 }
87+ return m, batch(cmds)
88+}
89+
90+func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
91 switch {
92 case k.Type == tea.KeyEsc || isRune(k, 'q'):
93- return m, tea.Quit
94+ return tea.Quit
95 case k.Type == tea.KeyUp || isRune(k, 'w'):
96- m.state.Move(game.Up)
97+ m.tryMove(game.Up)
98 case k.Type == tea.KeyDown || isRune(k, 's'):
99- m.state.Move(game.Down)
100+ m.tryMove(game.Down)
101 case k.Type == tea.KeyLeft || isRune(k, 'a'):
102- m.state.Move(game.Left)
103+ m.tryMove(game.Left)
104 case k.Type == tea.KeyRight || isRune(k, 'd'):
105diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
106new file mode 100644
107index 0000000000000000000000000000000000000000..a170a93bfa5448185aa4fb1a3e70e2c9d0620c8d
108--- /dev/null
109+++ b/internal/ui/app_test.go
110@@ -0,0 +1,128 @@
111+package ui
112+
113+import (
114+ "context"
115+ "errors"
116+ "testing"
117+
118+ "github.com/charmbracelet/bubbletea"
119+
120+ "japanese/internal/game"
121+ "japanese/internal/llm"
122+)
123+
124+const testMapJSON = `{"name":"t","rows":["###","#A.","###"],"start":{"row":1,"col":1},"locations":[{"id":"ramen","marker":"A","label":"Ramen"}]}`
125+
126+func testState(t *testing.T) *game.State {
127+ t.Helper()
128+ m, err := game.ParseMap([]byte(testMapJSON))
129+ if err != nil {
130+ t.Fatalf("parse map: %v", err)
131+ }
132+ return game.NewState(m)
133+}
134+
135+type fakeSpeech struct{}
136+
137+func (fakeSpeech) Begin(ctx context.Context) error { return nil }
138+func (fakeSpeech) End(ctx context.Context) (game.Transcript, error) {
139+ return game.Transcript{Raw: "ra", Romaji: "ra"}, nil
140+}
141+
142+type fakeNPC struct{}
143+
144+func (fakeNPC) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, error) {
145+ return llm.NPCReply{Romaji: "romaji-reply", Kana: "kana", English: "english-reply"}, nil
146+}
147+
148+type fakeJudge struct{}
149+
150+func (fakeJudge) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
151+ return llm.JudgeResult{Score: 82, Feedback: "fine romaji feedback"}, nil
152+}
153+
154+type fakeOut struct{}
155+
156+func (fakeOut) Speak(ctx context.Context, kana string) error { return nil }
157+
158+func buildModel(t *testing.T) (*model, *game.State) {
159+ t.Helper()
160+ st := testState(t)
161+ orch := game.NewOrchestrator(
162+ st,
163+ map[string]game.PersonaView{"ramen": {Description: "d", Situation: "s"}},
164+ fakeSpeech{}, fakeNPC{}, fakeJudge{}, fakeOut{},
165+ )
166+ m := NewModel(st, orch, nil, 10)
167+ return m, st
168+}
169+
170+func spaceKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} }
171+
172+func TestToggleStateMachine(t *testing.T) {
173+ m, _ := buildModel(t)
174+
175+ if m.phase != phaseIdle {
176+ t.Fatalf("initial phase = %v, want idle", m.phase)
177+ }
178+
179+ // idle -> recording
180+ if _, cmd := m.Update(spaceKey()); m.phase != phaseRecording || cmd == nil {
181+ t.Fatalf("after space: phase=%v cmd=%v; want recording + worker cmd", m.phase, cmd == nil)
182+ }
183+
184+ // begin succeeds: stay recording
185+ if _, _ = m.Update(beginResultMsg{err: nil}); m.phase != phaseRecording {
186+ t.Fatalf("after begin ok: phase=%v; want recording", m.phase)
187+ }
188+
189+ // recording -> processing
190+ if _, cmd := m.Update(spaceKey()); m.phase != phaseProcessing || cmd == nil {
191+ t.Fatalf("after 2nd space: phase=%v cmd=%v; want processing + worker cmd", m.phase, cmd == nil)
192+ }
193+
194+ // finish result -> idle
195+ res := game.TurnResult{Transcript: game.Transcript{Romaji: "ra"}, NPC: llm.NPCReply{English: "english-reply"}}
196+ if _, _ = m.Update(finishResultMsg{res: res}); m.phase != phaseIdle {
197+ t.Fatalf("after finish: phase=%v; want idle", m.phase)
198+ }
199+ if m.transcriptRomaji != "ra" {
200+ t.Fatalf("transcript = %q; want ra", m.transcriptRomaji)
201+ }
202+}
203+
204+func TestToggleBeginFailureResetsToIdle(t *testing.T) {
205+ m, _ := buildModel(t)
206+ if _, _ = m.Update(spaceKey()); m.phase != phaseRecording {
207+ t.Fatalf("phase=%v; want recording", m.phase)
208+ }
209+ if _, _ = m.Update(beginResultMsg{err: errors.New("no mic")}); m.phase != phaseIdle {
210diff --git a/internal/ui/render.go b/internal/ui/render.go
211index 1a9e27170255ea3ff63901088138697a11e5726a..d6a7e136cf06370569942bebf690d396f079e3b4 100644
212--- a/internal/ui/render.go
213+++ b/internal/ui/render.go
214@@ -21,6 +21,9 @@ type serviceLine struct {
215 detail string
216 }
217
218+// controlsHint is the idle status line. It only lists keys wired in this build.
219+const controlsHint = "move: arrows / WASD Space: talk q: quit"
220+
221 // viewState is the plain, render-only snapshot the pure render functions take.
222 // It holds no pointers and no game types, so rendering is trivially pure.
223 type viewState struct {
224@@ -30,6 +33,22 @@ type viewState struct {
225 activeID string
226 locs []locLine
227
228+ phaseWord string
229+ recElapsedSec int
230+ recCapSec int
231+
232+ transcriptRomaji string
233+ npcStatus string
234+ romajiShown bool
235+ englishShown bool
236+ npcRomaji string
237+ npcEnglish string
238+
239+ hasJudge bool
240+ judgeScore int
241+ judgeFeedback string
242+ judgeErr string
243+
244 services []serviceLine
245 status string
246 }
247@@ -41,9 +60,11 @@ func render(vs viewState) string {
248 b.WriteString(line)
249 b.WriteByte('\n')
250 }
251+ b.WriteString(dialogueBlock(vs.transcriptRomaji, vs.npcStatus, vs.romajiShown, vs.englishShown, vs.npcRomaji, vs.npcEnglish))
252+ b.WriteString(learningBlock(vs.hasJudge, vs.judgeScore, vs.judgeFeedback, vs.judgeErr))
253 b.WriteString(serviceBlock(vs.services))
254 b.WriteByte('\n')
255- b.WriteString(vs.status)
256+ b.WriteString(statusLine(vs.status, vs.phaseWord, vs.recElapsedSec, vs.recCapSec))
257 return b.String()
258 }
259
260@@ -81,6 +102,34 @@ func legendBlock(locs []locLine, activeID string) string {
261 return b.String()
262 }
263
264+func dialogueBlock(transcript, npcStatus string, romajiShown, englishShown bool, npcRomaji, npcEnglish string) string {
265+ var b strings.Builder
266+ b.WriteString("Dialogue\n")
267+ fmt.Fprintf(&b, " You said: %s\n", valueOrNone(transcript))
268+ fmt.Fprintf(&b, " NPC: %s\n", valueOrNone(npcStatus))
269+ if romajiShown {
270+ fmt.Fprintf(&b, " ROMAJI: %s\n", valueOrNone(npcRomaji))
271+ }
272+ if englishShown {
273+ fmt.Fprintf(&b, " ENGLISH: %s\n", valueOrNone(npcEnglish))
274+ }
275+ return b.String()
276+}
277+
278+func learningBlock(hasJudge bool, score int, feedback, judgeErr string) string {
279+ var b strings.Builder
280+ b.WriteString("Learning\n")
281+ if judgeErr != "" {
282+ fmt.Fprintf(&b, " %s\n", judgeErr)
283+ } else if hasJudge {
284+ fmt.Fprintf(&b, " Score: %d/100\n", score)
285+ fmt.Fprintf(&b, " Feedback: %s\n", valueOrNone(feedback))
286+ } else {
287+ b.WriteString(" (no feedback yet)\n")
288+ }
289+ return b.String()
290+}
291+
292 func serviceBlock(svcs []serviceLine) string {
293 var b strings.Builder
294 for _, s := range svcs {
295@@ -96,3 +145,23 @@ func serviceBlock(svcs []serviceLine) string {
296 }
297 return b.String()
298 }
299+
300+func statusLine(base, phaseWord string, recElapsedSec, recCapSec int) string {
301+ switch phaseWord {
302+ case "recording":
303+ return fmt.Sprintf("Recording %ds / %ds — press Space to stop", recElapsedSec, recCapSec)
304+ case "processing":
305+ return "Processing turn…"
306+ }
307+ if base != "" {
308+ return base + " " + controlsHint
309+ }
310+ return controlsHint
311+}
312+
313+func valueOrNone(s string) string {