f80d2e3f8e5530d4288488768b89a6ca1ee4f1be

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

ui: add dialogue view with push-to-talk and panels

Add the push-to-talk state machine (idle -> recording -> processing -> idle)
driven by a Space/Enter toggle. Begin and Finish run as tea.Cmd workers so the
event loop never blocks; a lightweight tick refreshes the recording elapsed
indicator while capturing only. The dialogue panel shows the romaji transcript,
NPC playback status, and per-turn judge score/feedback in a learning panel.
Service failures name the service and its configured URL.

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 {