Skip to content

LeanTea.Tui widget kit — brick-style TUI library + companion projects (chuhan, meta)#2

Merged
junjihashimoto merged 42 commits into
mainfrom
feat/gemini-mcp
Jul 1, 2026
Merged

LeanTea.Tui widget kit — brick-style TUI library + companion projects (chuhan, meta)#2
junjihashimoto merged 42 commits into
mainfrom
feat/gemini-mcp

Conversation

@junjihashimoto

@junjihashimoto junjihashimoto commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

This branch grew from the original "Gemini API client + MCP server for code review" into a broader arc. To keep this PR reviewable and to keep the lean-tea core library focused, the bulky downstream projects that were built alongside have been extracted into their own repos in the last commit. This PR now delivers the library-side work that stayed in this repo. The satellite repos are linked below for context and are self-contained (they pull lean-tea in via require ... from git).

Suggested review order

1. LeanTea/Tui/ — brick-style TUI widget kit (new library)

LeanTea/Tui/{Core,Combinator,Element,App,Test}.lean + Tests/TuiSpec.lean (tui_spec exe, 22 LSpec tests).

  • Widget m = pure render (w h : Nat) (focused : Bool) → Box + onKey : Key → Option m + optional children for tree walking.
  • Combinators: vbox / hbox / border / padding / withStyle / text.
  • Elements: button / input / panel / listView.
  • App.runWith — terminal raw mode + main loop + ANSI repaint + Tab-cycling focus.
  • Test.Session — pure sendKey / containsText / rowAt for regression tests without a real TTY.

Design notes worth keeping in mind:

  • Every widget is a pure function; the IO wrapper lives only in App.lean.
  • Combinators expose their children list so Widget.dispatchKey can walk the tree and route keys to the focused widget (without this, the root vbox's onKey returns none and swallows every key).
  • Session (the test harness) shares the exact same dispatch code with App.runWith, so what the LSpec suite verifies matches what the real terminal loop does.

Known v0 wart, called out in App.lean doc: combinators don't propagate focused through child render calls — visual focus highlight is cosmetic until a future visitor refactor. Key dispatch itself works correctly.

./.lake/build/bin/tui_spec → 22 passed, 0 failed.

2. examples/LlmChatTui/Main.lean refactor

Rebuilt on LeanTea.Tui as the first internal user of the widget kit. All rendering now goes through Style records + vbox / text / renderBoxAnsi. Line count went from 353 → 399 (didn't shrink — slash-command plumbing is unchanged and unavoidable), but the render half is now declarative and shares primitives with the meta_orchestrator TUI (below).

3. LeanTea/Cloud/Gemini.lean + examples/GeminiMcp/Serve.lean (from the original PR scope)

These pre-existed this branch's later work and are what the branch was originally opened for. Untouched by the split-out commit at the end.

4. Removed from this repo

The split-out commit (478f3e2) deletes examples/ChuHan/ and examples/MetaOrchestrator/ from the tree. They kept growing in complexity + asset weight in ways that were awkward to co-host with the library core:

  • Verilean/lean-tea-chuhan (public) — the 楚漢恋歌 (Chu-Han Love Song) narrative game. Six protagonists × 3 acts, ~35 endings, three mini-games + LLM-judged free-text step, photorealistic assets via FLUX.1-schnell + Stable Audio Open 1.0. Uses this repo's LeanTea + LeanJs libraries.
  • Verilean/lean-tea-meta (public) — meta_orchestrator: Gemini/LMStudio PM watching Claude-Code zellij panes. Pluggable LLM backends (openaiCompat / gemini). Slash-command REPL + full-screen TUI built on LeanTea.Tui (the library shipped here).
  • Verilean/lean-tea-private (private) — the previously .gitignored _private/ tree: Fight2d / Fight3d / Saber VRM apps + Mixamo tooling.

The relevant work-history commits (36×) for those satellites are preserved in this branch's log for review; they can be squashed on merge if desired, but they document the design iterations that led to the widget kit's shape.

Effect on repo weight

examples/ working tree: 1.4 GB → 40 MB. Nothing in the library core changed size — the 1.4 GB was the _private/ VRM/venv trees that were .gitignored anyway.

Test plan

  • lake build green on Ubuntu + macOS.
  • ./.lake/build/bin/tui_spec → 22 passed.
  • ./.lake/build/bin/llm_chat_tui --config … → banner + prompt render, /quit works.
  • Satellite repos lake update && lake build picks up the current lean-tea head.

Pure-Lean v1beta REST client (curl-shelled HTTPS, same pattern as
Auth.OAuth2) plus an MCP server fronting it. Default model
`gemini-2.5-pro` — selectable per-call via `CallOpts.model` and
per-server via `--model` flag / `GEMINI_MODEL` env.

The headline API is `Gemini.reviewMany`: bundles many workspace-
relative files into a Markdown prompt to exploit Pro's 2M-token
context for holistic cross-file review. Paths are validated via
`LeanTea.Net.SafePath`, so an adversarial MCP client can't make
the server read outside `--workspace`. The MCP layer pre-checks
SafePath and fails fast when every path is rejected — no wasted
Gemini call.

Five MCP tools: `gemini_ask`, `gemini_chat`, `gemini_review_files`,
`gemini_review_diff`, `gemini_list_models`. Same stdio + HTTP
transports as `tmux_mcp_serve` / `chrome_cdp_mcp_serve`.
Matches the existing `LeanTea/*.lean` convention. The two Japanese
strings that remain (`defaultReviewSystem` and the diff-review
default in `reviewDiff`) are intentional — they're prompts shipped
to Gemini and we want the model's review output in Japanese.
Both are overridable via `CallOpts.system` and the `prompt` arg.
defaultReviewSystem and the reviewDiff default prompt were the
last Japanese holdouts. Now everything in the file is English to
match the rest of LeanTea/*.lean. The default review output will
now also be English; pass `opts.system` to override if you want a
different language.
Three single-file demos sit on top of a new shared library,
`LeanTea.Llm.McpOrchestrator`:

- Spawns N MCP child processes (stdio) or attaches to HTTP MCPs.
- Aggregates every server's `tools/list` into one OpenAI tool
  catalogue with `<server>__<tool>` name prefixes so collisions can't
  happen and dispatch can route back to the owning child.
- `runTurn` drives the standard OpenAI tool-call loop until the LLM
  returns a final non-tool reply, with optional ProgressHooks the UI
  layers use for "thinking…" indicators and inline tool spinners.
- JSON config: `{model, baseUrl, system, servers: [{name, bin, args}]}`.
  Generalises `examples/BrowserAgent/Run.lean` from one hard-coded
  MCP to any list.

The orchestrator unsets `PORT` in spawned children — several LeanTEA
MCPs (gemini, tmux, browser) switch to HTTP mode when PORT is set,
which would silently break the stdio handshake and steal the parent's
port. Caught this the hard way during smoke testing.

Three UI shells:

- `llm_chat_cli` — stdin/stdout REPL, ANSI-coloured progress lines.
- `llm_chat_tui` — full-screen styled chat with ANSI clear+repaint per
  turn; cooked-mode line input (no raw-mode keyboard handling needed
  for chat UX); sticky status line via cursor save/restore.
- `llm_chat_web` — single inlined HTML page with bubble UI, /api/state
  and /api/send, single in-memory session.

Verified all three boot against `gemini_mcp_serve` over stdio and
correctly load its 5-tool catalogue. /api/send returns a clean
"connection refused" when LMStudio isn't running (the expected
failure mode for the wire-up smoke).

Example config at `examples/LlmChat/example-config.json` points at
LMStudio (`http://127.0.0.1:11211/v1` with Gemma 4) plus
chrome_cdp_mcp_serve and gemini_mcp_serve.
Orchestrator (`LeanTea.Llm.McpOrchestrator`):
- ChatMsg gets `images : Array String` (data URLs) for both user
  uploads and tool-result images.
- New `runTurnFull` accepts userImages; the old `runTurn` is a thin
  wrapper for zero-image cases.
- `extractToolImages` pulls every `image` content block out of an MCP
  tool result; the orchestrator records them on the tool ChatMsg AND
  appends a synthetic user image_url message so vision-capable LLMs
  can actually see what just got generated/captured.
- User messages with attached images now serialise as multi-block
  content (`[{type:"text"},{type:"image_url"}]`) per the OpenAI spec.

Sessions (`LeanTea.Llm.ChatStore`):
- One JSON file per session under `~/.cache/leantea-chat/` (override
  via `--store DIR` / `LLM_CHAT_STORE`).
- Auto-name from the first user message when not set explicitly.
- list/load/save/delete/rename. Summaries (without messages payload)
  for the sidebar; load fetches the full history when one is opened.
- Sortable timestamped IDs (`t<monoMs>-<rand4hex>`).

Web UI (`llm_chat_web`):
- Left sidebar with chat list, `+ new` button, per-row delete.
- Image attach via paste, drag-drop, or file picker; thumb strip
  above the input with per-thumb remove buttons.
- Bubbles render inline images (max 320px); tool-result images appear
  under the corresponding tool block.
- 6 new endpoints: GET/POST /api/sessions, GET/DELETE /api/sessions/:id,
  POST /api/sessions/:id/send.
- In-memory session cache so each send doesn't re-read the JSON file.

CLI (`llm_chat_cli`) and TUI (`llm_chat_tui`):
- `/attach <path>` stages an image for the next prompt (encoded via
  `LeanTea.Llm.Openai.imageDataUrlFromFile`); `/clear-attach` drops.
- `/sessions`, `/save [name]`, `/load <id>`, `/new` mirror the Web UI.
- Each turn auto-saves the session — closing the terminal mid-chat
  doesn't lose the conversation.

Verified end-to-end against an empty config and against
gemini_mcp_serve over stdio; the sessions sidebar listing, new/load/
delete cycle, and CLI `/sessions` / `/save` / `/load` all work.
New library `LeanTea.Llm.Policy` (~190 LOC):
- Rules are `{pattern, action}` where pattern is a tiny `*`-only glob
  matched against the prefixed tool name (`server__tool`); first match
  wins; no rule means `Decision.ask`.
- Persisted at `<storeDir>/policy.json`; load / save / append (dedups)
  / deleteAt; LiveRef wraps an IO.Ref + storeDir so UIs hot-edit the
  rules without restarting the orchestrator.

Orchestrator (`LeanTea.Llm.McpOrchestrator`):
- New `UserDecision` (allowOnce / denyOnce / allowAlways / denyAlways)
  and `PolicyConfig`.
- `ProgressHooks.onAsk : String → Json → IO UserDecision` invoked when
  policy returns `ask`; default rejects (`denyOnce`) so a hookless
  caller can't be exploited.
- `runTurnFull` now takes `PolicyConfig`; on `deny` it synthesises a
  tool-error result instead of throwing so the LLM can recover and
  pick a different tool.
- "Always allow / deny" answers append a new rule and persist.

CLI / TUI:
- `askDecision` reads `y/n/a/d` from stdin and maps to UserDecision.
- `/policy` lists current rules, `/policy-rm <n>` removes one.

Web (`llm_chat_web`):
- New endpoints: GET /api/pending (current ask, or null), POST
  /api/decision (resolves it), GET /api/policy, DELETE /api/policy/:n.
- onAsk hook pushes a PendingCall into an IO.Ref and sleeps 100ms
  polling until the browser answers. The browser side polls
  /api/pending every 400ms while a /api/send is in flight and shows
  a 4-button modal (deny once / deny always / allow always / allow
  once). Sidebar gets a "policy" panel listing live rules with
  per-row × to remove.
- This required adding `LeanTea.Net.Server.serveConcurrent` —
  each accepted connection now runs in `IO.asTask` so /api/send
  blocking on policy can be unblocked via a parallel /api/decision.
  Sequential `serve` is preserved for everything else (Reversi,
  Counter, Sheet, etc.) which don't need parallel handlers.

Smoke: policy CRUD via /api/policy verified end-to-end on disk
(create → list → delete → list shows it gone; policy.json reflects).
Full pending → decision round-trip needs a live LMStudio so it's
only verified by the type checker for now.
Seven tools, all workspace-bound via `LeanTea.Net.SafePath`:

  read-only (typically allowed globally):
    coder_read_file  — up to 64 KB / 800 lines
    coder_list_dir   — one-level listing
    coder_glob       — recursive suffix match; skips .git/node_modules/.lake
    coder_grep       — shells out to `grep -rnI`, file:line:text output
                       (the format LLMs are trained on); --include filter

  mutating (typically ask'd via Policy):
    coder_write_file — full rewrite, creates parent dirs
    coder_edit_file  — exact search-and-replace, fails on 0 or >1 matches
                       (same invariant Claude Code's Edit uses;
                       failure modes are LLM-recoverable)
    coder_run        — `sh -c CMD` with cwd=workspace, returns
                       stdout/stderr/exit interleaved with markers

Pair with `llm_chat_web` + `LeanTea.Llm.Policy` for Claude-Code-style
approve-before-write. Sample config + policy under examples/LlmChat/:

  coder-config.json  — Qwen2.5-Coder-32B via LMStudio + coder MCP
  coder-policy.json  — pre-allowed read-only tools so the agent can
                       explore freely; write/edit/run still prompt

Smoke verified end-to-end:
- initialize / tools/list (all 7 tools)
- read_file, glob, grep, run all return expected output
- SafePath rejects `../etc/passwd` before any IO
- edit_file enforces unique-match: 2 matches → error, 0 matches →
  error, 1 match → success and file content is exactly correct
New library `LeanTea.Agent.Playbook` (~290 LOC):
- Playbook wraps a `LeanTea.Agent.Script` with metadata: precondition
  (glob over observed screen tag), `estReward` prior, `maxBurst`
  consecutive-run cap, `timeoutMs`, `enabled`.
- Stats accumulator (runs / wins / losses / totalReward / recentWins
  ringbuffer) with `record / avgReward / winRate`.
- JSON I/O — one file per playbook under `<store>/playbooks/`, a
  shared `stats.json` for the bandit history. Hot-reloaded each tick.

New library `LeanTea.Agent.Conductor` (~320 LOC):
- Action executor maps `Script.Action` (clickXy / wait / screenshot /
  waitForScreen / toolCall) to orchestrator `callTool` invocations.
- UCB1 bandit (`avgReward + sqrt(2 ln N / n)`, with `estReward + 1e6`
  for unrun arms — guarantees each playbook gets tried once).
- `Observer` is pluggable (`Orchestrator → IO Observation`); built-in
  `jsObserver` evaluates a JS expression via browser MCP and treats
  its return as the screen tag.
- `Escalator` callback fires on no_match / exception / burst_cap; v1
  ships a default that just logs and skips 2 s.
- Loop is pausable / abortable via shared `IO.Ref` flags the dashboard
  flips.

New executable `agent_dashboard_serve` (~460 LOC):
- Single-page Web UI with tabs: Live (current playbook, screenshot,
  recent runs), Playbooks (table with win-rate + avg-reward + recent-
  wins sparkline + enable toggle), Rewards (cumulative chart).
- Endpoints: `/api/state`, `/api/playbooks`, `/api/live`,
  `/api/control` (pause/resume/abort/reset-stats),
  `/api/playbooks/toggle`.
- Boots the conductor loop in `IO.asTask`; uses `serveConcurrent`
  so control endpoints stay responsive while a playbook is running.

Toy game + 4 sample playbooks for end-to-end testing:
- `examples/AgentDashboard/game/index.html` — 3-button mini-game
  (Collect Coin +1 / Big Quest +10 / Rest refills energy). Exposes
  `window.__screen` / `__energy` / `__coins` / `__runs`. Reward
  hierarchy is intentional — bandit should converge to Big Quest
  when energy ≥ 3 and Rest otherwise.
- Sample playbooks: `coin`, `big_quest`, `rest`, `wait_battle`.
- `dashboard-config.json` (with browser MCP) and
  `dashboard-config-empty.json` (no MCP, for wiring smoke).

Smoke verified end-to-end without browser:
- `/api/state` / `/api/playbooks` / `/api/live` all return sane JSON
- Float parsing: `estReward: 10.0` → 10.0, not 100 (initial bug —
  JsonNumber stores mantissa+exponent and I was using just mantissa;
  fixed by round-tripping through `toString` + the local `parseFloat`)
- pause/resume control flips the flag
- Without a matching playbook the escalator fires `no_match` then
  `skipFor 2000ms` — loop stays responsive

Bundled `README.md` documents the "play the toy game" recipe:
launch Chrome with `--remote-debugging-port=9222`, serve the game
on any static port, point the dashboard at it.
A 2D action / RPG / strategy VN set in BCE 209-195 China. Six
playable protagonists, each with their wife arc + historical fate:

  Liu Bang   — The Charmer Who Plays the Long Game (人たらしの仮面・エゴ)
  Xiang Yu   — The Honest Tyrant
  Han Xin    — The Genius Who Cannot Read a Room
  Zhang Liang — The Sage Who Walked Away
  Xiao He    — The Accomplice
  Fan Zeng   — The Loyalist Whose Counsel Was Ignored

Central design device: two-layer dialogue (outer line + inner
monologue in 《angle brackets》, italic + grey). Liu Bang's public
face stays jovial while his interior reveals calculation; opposite
balance for Xiang Yu (rough outside, tender within); three layers
for Han Xin (polite / ambitious / "the child who wants to be
recognised"). The hidden 業 / 信頼 / 民心 / 嫁 gauges accumulate
silently from choices.

This commit is the MVP — framework + Act 1 of all six routes (~30
beats each playable end-to-end). i18n shape (ja / en) baked in from
day one; en strings empty for now, fall through to ja. Standard
LeanJs game shape — Game.leanjs compiles to JS, splice'd into
page.html by Serve.lean. localStorage save. Six bg palettes (no
art yet — gradient placeholders).

Concrete choices already implemented as proof of the design:
- Liu Bang banquet: lie about 賀銭一万 → marry 呂雉, OR honest →
  anonymous-life dead-end (the "correct" answer ends the route)
- Liu Bang's 蛇切り: free convicts → 芒碭山 fame, OR kill them →
  cold-alone dead-end (high 業, lost trust + minXin)
- Han Xin's 胯下: draw sword → executed dead-end, OR endure →
  meet 漂母 and (original character) 漂雲 → ambition path
- Zhang Liang's 黄石公 sandal test, Xiao He's "accomplice" reveal
  to his wife, Fan Zeng's first ignored counsel — each turns on a
  single choice that sets the tone of the route

Tech notes:
- Multi-line match arm bodies wrap in (...) because LeanJs parses
  the body via parseAdd (no let / if). Caught while building.
- Record types in LeanJs accept only single-identifier types, so
  `choices : Array` instead of `List Choice`.
- Discovered `\"` in extern js strings breaks the parser (no
  escape handling) — moved unused jstrAttr out, switched English
  quotes to `“` typographic so they don't terminate strings.
- New `sjoin` extern (`arr.join('')`) replaces deep `sconcat`
  chains everywhere — much easier to balance parens.

Playtime sizing for the full game: targeting ~8h per playthrough
with ~50% route sharing in the spine (Steins;Gate-style) — i.e.
2-3 hours of unique content per route. This MVP is ~20-30 min per
route. The remaining ~7h of content per route is planned across
~10-15 sessions; LLM-assisted drafting will speed bulk dialogue.

The action / RPG / strategy layers are scaffolded (canvas, stats
HUD, choice effects accumulate) but not yet wired to battles
beyond a placeholder canvas scene. That's Phase 2.

Boot: `./.lake/build/bin/chuhan_serve --port 8050 --dev`
Phase 2 content. Each Act 1 success path now transitions into Act 2
(failure / dead-end branches still terminate with their own endings).

Act 2 covers BCE 206-202, ~120 new beats total. Approximate route
density: Liu Bang ~25, Xiang Yu ~22, Han Xin ~24, Zhang Liang ~16,
Xiao He ~15, Fan Zeng ~18 (his life completes here, ending in death
of grief before Gaixia).

Key historical moments dramatized:
- 約法三章 / 鴻門の宴 (rendered three times — from Liu Bang's,
  Xiang Yu's, and Fan Zeng's POV, each showing the same scene
  through a different two-layer lens)
- 樊噲乱入 / 紀信が劉邦の身代わりに焼かれる
- 拝将壇 / 蕭何月下追韓信 / 背水之陣 (rendered twice — from
  Han Xin's and Xiao He's POV)
- 暗渡陳倉 / 龍且水攻め / 彭城3万 vs 56万
- 范増の玦三度 / 「子犬の輩、共に謀るに足らず」 / 陳平の離間策
  / 范増の憤死
- 韓信の假王要求 / 張良の足を踏む / 鴻溝の和約 / 追撃の選択

Hidden END unlocked from Han Xin's 蒯通の進言 choice:
**韓信西征 — 東来之軍神「韓」**. Han Xin breaks with both Chu and
Han, marches 30万 northwest, breaks the Yuezhi, crosses deserts and
snow mountains, meets Greco-Bactrian king Euthydemus (a historical
contemporary), debates phalanx vs back-to-water tactics. Spends 15
years in the western kingdoms with Piao Yun, has 3 children, leaves
a book on tactics. The Chinese record holds no chapter for him;
Samarkand documents preserve the name. Plays on the contemporary
of 垓下 = Zama (both BCE 202) and grounds the West in Bactria
where the Silk Road precursors already moved silk.

Hidden choice consequences in each route, e.g.:
- Liu Bang refuses 韓信 → withers in Hanzhong end
- Liu Bang stays at Hongmen → dies on the mat end
- Xiang Yu kills Liu Bang at Hongmen → 西楚帝国 alternate timeline
- Xiang Yu keeps the retreat at Julu → Qin holds the realm
- Han Xin kills the bully (Act 1) → executed end
- Xiao He runs for the gold → no maps, no Han, no history

12+ new ending IDs in endingTitle dispatch. 12+ new bg-* palette
classes in page.html (palace, jielo with ember animation, yingyang,
pavilion, tent, altar, burning, pengcheng, gorge, night, snow).

Verified:
- Source compiles to ~127 KB JS
- chuhan_serve boots, serves /, game.js includes liubangAct2
- All Act 1 success paths now route into their Act 2

Note: Act 3 is not yet written. The Act 2 spines end at:
- Liu Bang: 垓下前夜
- Xiang Yu: 鴻溝の和約 (just before pursuit)
- Han Xin: 垓下に向かう兵を整える
- Zhang Liang: 十面埋伏完成
- Xiao He: 韓信を「いずれ殺さねば」と内心に置く
- Fan Zeng: definitive end (death)

Run: `./.lake/build/bin/chuhan_serve --port 8050 --dev`
Mini-game framework:
- Step record gets `miniKind / miniGood / miniOk / miniBad`. Advancing
  into a step with miniKind != "" switches phase to 'minigame' and
  initializes per-kind state. On completion, score buckets to one
  of three outcome scenes (good/ok/bad thirds of the max).
- Three minis implemented:
  - face_read (Liu Bang): pick eyes/mouth/pose, score 0-6. Integrated
    into the banquet — 賀銭一万 choice now leads to a face-reading
    mini that decides if 呂公 reads "dragon face" / "above-average" /
    "boring face." Dragon = bonus marriage scene; ok = historical
    path; failed = 婚姻なし END.
  - jade_ring (Fan Zeng): 3 timed clicks within a moving window. RAF
    loop in page.html ticks the round timer. Hits 3/3 = 項羽 strikes
    (history diverges); 1-2 = historical path; 0 = "puppies, unfit."
    View built; story integration deferred to next pass.
  - beishui (Han Xin): 3-turn card sequence — 布陣 / 戦法 / 決戦.
    Card pattern 背水 → 黙 → 奇襲 = 9/9 perfect victory. Mid = win
    barely; low = defeat. View built; story integration deferred.

Save system (multi-slot + checkpoint):
- localStorage keys: chuhan-save-v2 (autosave), chuhan-slot-{1,2,3}
  (named), chuhan-checkpoint (auto-snapshot before each minigame
  AND on scene boundary).
- HUD adds a 💾 save menu (injected from page.html) with: save to
  any of 3 slots, load from any slot, load checkpoint (with state
  preview labels), full restart with confirm.
- Every keypress / click still autosaves to chuhan-save-v2.

LeanJs parser gotchas discovered + worked around:
- Numeric match patterns NOT supported (`| 0 => …`). Use if-chains
  on `turn == 0` etc. instead.
- Record fields require single-ident types (already known; reused).
- match arm bodies with `if-then-else` need to wrap the body in
  `( … )` because parseMatchBranch uses parseAdd (no if/let).

Verified: source compiles to ~150 KB JS, chuhan_serve boots cleanly,
HTML page serves with mini-game CSS in place.

Note: page.html still owns DOM event delegation, localStorage I/O,
and the RAF loop (these depend on browser-only APIs). View HTML
construction is all in LeanJs already; save-menu DOM injection is
in JS but is mostly a thin wrapper over LeanJs-rendered structure.
Further JS→LeanJs migration deferred — would require canvas /
fetch / localStorage externs.
Architecture: a Step can carry an `npcChat` field naming an NPC id.
Advancing into such a step switches phase → 'llm_chat'; the player
sends free-form text via a textarea, the browser POSTs to /api/ask,
and the LMStudio backend replies in-character. Click 終わる to exit
and continue to the step's `gotoScene`.

Backend (`examples/ChuHan/Serve.lean`):
- New POST /api/ask handler. Body `{npcId, sceneId, history, message}`.
- Per-NPC character card system prompt — 6 cards so far: 蕭何, 呂公,
  呂雉, 樊噲, 范増, 項伯. Each anchors to BCE 209-202, refuses
  modern anachronism, never reveals future events, replies short
  (3-5 sentences). Anachronisms get an in-character rebuff like
  「何の妖術じゃ?」.
- Uses `LeanTea.Llm.Openai` against LMStudio (default
  http://127.0.0.1:11211/v1, override via --lm-url or
  LMSTUDIO_BASE_URL). Model from LMSTUDIO_MODEL env or first
  served. temperature 0.85, max 400 tokens.
- Server upgraded to serveConcurrent — /api/ask can block for
  several seconds; concurrent server keeps the static page
  responsive.

LeanJs (Game.leanjs):
- New record Llm (active, npcId, npcName, npcColor, history,
  pending, sceneAfter, lastError) + initLlm + initLlmFor.
- Step gets `npcChat : String` field; new `npcChatStep` constructor.
- Advance case: nxt.npcChat != "" → phase=llm_chat with initLlmFor.
- New message handlers: llmSendUser, llmReply, llmError, llmEnd.
- New view viewLlmChat: chat history bubbles + textarea + send btn.
- Integrated into Liu Bang's banquet: after the face-read mini and
  Xiao He's "ご自愛を" subtext line, a chooseStep offers
  ★LLM自由会話★ "蕭何と少し話す" — clicking enters the chat phase.

Frontend (page.html):
- Chat CSS (bubbles, pending indicator, error banner, textarea).
- wireChatHandlers() binds the textarea + send button to a fetch
  /api/ask call, dispatches llmSendUser / llmReply / llmError to
  the LeanJs update loop.

Verified: source compiles, server boots, /api/ask returns clean
"connection refused" when LMStudio isn't running. Full
round-trip needs `LMSTUDIO_BASE_URL=http://localhost:11211/v1`
with a loaded model (qwen/qwen2.5 or gemma-3 etc.).

Run end-to-end:
  ./.lake/build/bin/chuhan_serve --dev
  # in browser, pick 劉邦 → banquet → 嘘 → face_read mini →
  # outcome → "蕭何と少し話す" → free chat
Mini-game story integrations:
- Han Xin's 背水之陣 — the binary "explain/silent" choice is now
  replaced by the beishui 3-turn card mini. Outcomes:
    perfect (7-9 pts) → +stat bonus + 「将軍の用兵、神の如し」
    won (4-6 pts) → historical narrow win
    failed (0-3 pts) → 韓信戦死 END
  Continuation extracted into `hanxin_act2_continue` so the rest of
  Act 2 (斉征伐 / 龍且水攻め / 假王要求 / 蒯通) still chains.
- Fan Zeng's 玉玦三度 — the narration of the three signal raises is
  now the jade_ring rhythm mini. Outcomes:
    perfect (3 hits) → 項羽が剣を抜き劉邦を斬る → 「西楚帝国」
                       歴史改変 END (隠し END)
    normal (1-2 hits) → 史実通り、樊噲乱入で劉邦逃走
    failed (0 hits) → 項羽完全無視
  Continuation extracted into `fanzeng_act2_continue`.

LLM TRPG — chat scenes added for the remaining 5 protagonists:
- 項羽 ↔ 范増 (戦略相談) — after meeting 虞姫, before Act 2
- 韓信 ↔ 蒯通 (三国鼎立を更に問う) — inserted before the binary
  決断 choice; the chat is purely flavor, then a new
  `hanxin_kuaitong_decide` scene holds the actual choice
- 張良 ↔ 黄石公 (謎の老人と問答) — after the sandal-fetch test
- 蕭何 ↔ 妙容 (夫婦の対話) — after the husband-wife scene in Act 1
- 范増 ↔ 項伯 (項羽の話) — after Act 1 wraps

Each adds a chooseStep with [chat / skip], where chat goto-scenes a
new `<char>_chat_<npc>` scene containing a single `npcChatStep`,
which returns to the main timeline on 終わる.

3 new character cards added in Serve.lean: 蒯通 (弁士、雄弁・冷静)、
黄石公 (仙人風、命令形、禅問答)、妙容 (蕭何の妻、芯が強い)。

Verified: source compiles to ~210 KB JS, chuhan_serve boots.
Both routes now complete from start to historical death.

項羽 Act 3 (BCE 202, ~14 beats):
- 垓下包囲、四面楚歌 (with the actual 「漢已得楚乎」 line)
- 虞美人剣舞: full 「力抜山兮気蓋世」 four-line poem + Yu Ji's
  response poem 「漢兵已略地」, then her dance and self-cut.
- 二十八騎まで減って東城で漢の一隊を斬る武勇シーン
- 烏江: 亭長が舟を出して「江東へ帰れ」と勧める
- ★ 選択肢: 渡る (隠し END: 江東隠遁、虞姫の骨と山中で生涯)
            渡らない (TRUE END: 烏江自刎、王翳に首を取らせる)
- 「天之亡我、我何渡為」 / 「籍与江東子弟八千人」 の actual lines
- 自刎、屍が漢兵 5 人に分けられる
- 劉邦が屍を見て涙、魯公の礼で葬る

劉邦 Act 3 (BCE 202-195, ~17 beats):
- 即位、内側の脅威 (韓信・彭越・黥布) を内心独白
- 韓信を斉王 → 楚王 → 淮陰侯と段階的に降格
- BCE 196 黥布反乱、自ら討伐、流矢で胸を負傷
- 沛に帰る、童子に大風歌を教えて自ら筑で歌う+踊って涙
  (歌詞 4 行きっちり)
- 呂后 + 蕭何が韓信を長楽宮に呼ぶ。「成也蕭何、敗也蕭何」
- 鐘室の処刑: 「天蓋なく、地敷きなく、金属の兵器なく、女に
  殺さしめよ」の有名な制約を再現。女たちが竹槍で刺殺。
- 韓信の最期: 「悔やまる、蒯通の計を用いざりしを」
- 帰国した劉邦の「ようやくか」内心+「呂雉、お主の手は俺と
  同じくらい血で濡れた」内省
- 医を退ける「命は天にあり、扁鵲と雖も何をか益せん」
- 呂后への後事 (蕭何→曹参→王陵+陳平→周勃) の有名な遺言
- 「その後は?」「お主の知るところに非ず」で打ち切る
- 崩御の最後の意識: 「俺は、人たらしのまま死ぬことが、出来なかった」
  ── Act 1 の伏線が、ここで初めて回収される

新 ending titles 3 つ:
- xiangyu_wujiang (TRUE END)
- xiangyu_crossed_recluse (★ 隠し END)
- liubang_died_emperor (TRUE END)

Verified: source compiles, build green.
韓信 Act 3 (BCE 202-196, ~22 beats):
- 凱旋して漂母の墓に千金を捧げる (Act 1 の伏線「いつかこの恩は千金で
  報いまする」が、ここで初めて回収される)
- 鍾離眛を匿う → 鍾離眛が自刎して首を渡す
- 楚王 → 淮陰侯に降格、長安蟄居
- 「多々益々弁ず」の有名な対話 — 「陛下は兵を率いるに能はず、将を
  率いるに能し」が処刑令状になる伏線
- 蕭何が呼びに来る — 「成也蕭何、敗也蕭何」の蕭何視点を韓信視点で
  ミラー、「御身の眼の輝きは、もう無い。これは罠だ。だが、御身の言葉を
  俺は今までも、これからも、断れぬ。それが俺の業だ」
- 鐘室の処刑、竹槍で刺殺 (劉邦 Act 3 と同じ event を韓信視点で)
- 最期: 「我、悔ゆらくは蒯通の計を用いざりしを」
- 漂母様への独白: 「千金は届けた。蕭何様――御身に出会ったから、俺は
  俺で居られたのです。それで、よかった」

張良 Act 3 (BCE 202-189, ~10 beats):
- 三万戸を辞退して「留の万戸」だけ受ける ─ 「飛鳥尽きて良弓蔵せられ」
  の故事を心中で唱える
- 辟穀・導引で黄老の道へ
- 商山四皓を呼んで太子を救う (戚夫人を不幸にすることへの内省も含む)
- 「私は留 (とどまる) であって、留めない」の名句
- BCE 189 ごろ没。妻 (オリキャラ「黒綺」) と山中で隠遁、共に旅立つ
- 「私は生きるために生きたのではない。為すために生きた」

蕭何 Act 3 (BCE 202-193, ~14 beats):
- 相国第一の論功行賞、武人たちの反発 — 「猟人と犬」の例え
- 妙容との夫婦の対話: 「あなたが、いずれ、韓信様を救うのではなく、
  葬る方の人になることを、私は知っておりました」
- 韓信を長楽宮へ連れて行く役 — Liu Bang Act 3 と韓信 Act 3 で
  3 視点目として登場、内心独白「成也蕭何、敗也蕭何――この句は、
  いずれ、こいつの墓碑銘になるであろう」
- BCE 195 投獄事件、釈放、劉邦からの「お主の名声が高すぎるのを抑えただけ」
- BCE 193 没、後継・曹参を推挙
- 最期: 「賭けは、勝った。だが、賭け金は、お主たち一族の名誉と、
  韓信の命だった」

新 ending titles 3 つ:
- hanxin_death_changle (TRUE END)
- zhangliang_immortal_path (TRUE END)
- xiaohe_died_chancellor (TRUE END)

これで全 6 ルートが Act 1-3 完成。鴻門 / 月下追韓信 / 鐘室といった
複数視点シーンが、3-4 ルートで違う色に見える構造に。

Verified: source compiles, build green.
Three new externs on the LeanJs side:
- lsLoad(key)   → parsed JSON or null
- lsSave(key,v) → JSON.stringify + setItem
- lsRemove(key) → removeItem

This lets the save-menu HTML render directly from LeanJs renderHud
(reading slot/checkpoint state via lsLoad on every render — labels
update automatically). Click handling reuses the existing data-msg
delegation; new update branches handle the 10 save messages
(toggleSaveMenu / saveSlot{1,2,3} / loadSlot{1,2,3} / loadCheckpoint
/ restart / reset). Side-effect of save-to-disk is invoked via a
`let _ := lsSave(...)` form (LeanJs's `;` is purely a let-terminator
so action sequencing has to go through let).

describeState moved to LeanJs (was JS). One small LeanJs helper
function with the same logic, called from renderSaveMenu to label
each slot.

page.html shrinks by ~80 lines:
- removed: describeState, refreshSaveSlots, injectSaveMenu,
  handleSaveAction, the outside-click handler
- net: render() loses three calls (injectSaveMenu, refreshSaveSlots,
  and a separate menu-DOM mutation step)

The .save-menu CSS in page.html stays since it targets the
LeanJs-rendered DOM directly.

Note: LeanJs gotcha — numeric match patterns aren't supported AND
the `;` sequencing trick required `let _ := f(); state`. Both were
worked around inline.

Verified: source compiles to ~239 KB JS, server boots, the rendered
HTML contains the save menu directly (10 grep hits for save-menu /
chuhan-slot tokens).
(1) Ending gallery — auto-records ENDs to localStorage as they're
reached. Title screen shows 「達成 END: N / 35」 + 「END ギャラリー」
button → list of all 35 endings with ✓/□ marks. New extern
lsRecordEnding fires from update on advance into a step with
ending != "". Records {endingId: timestamp} in chuhan-endings.

35 known endings: 6 ルート × Act 1-2-3 + dead-ends + bad-end +
mini-game outcomes + 隠し ENDs. All listed in allEndingIds.

(2) Test mode — `?scene=xxx` (and optional `&char=xxx`) URL
parameter jumps directly to a scene from the title. The char is
inferred from the scene-id prefix (`liubang_*` → liubang, etc.)
when omitted. New `jumpToScene` update message resets stats and
flags before jumping. Useful for QAing endings without replay.

Examples:
  /?scene=liubang_act3
  /?scene=hanxin_xizheng
  /?scene=xiangyu_wujiang_death
  /?scene=fanzeng_jade_perfect

(3) README — examples/ChuHan/README.md ~120 lines covering:
  - quick start (with and without LM Studio)
  - character/death table
  - mini-game mechanics
  - LLM chat NPC table + anachronism-guard test
  - save system (autosave, checkpoint, 3 slots, ending tracker)
  - test mode URLs
  - file layout
  - 6 LeanJs gotchas encountered while building (numeric match
    patterns unsupported, match-arm body uses parseAdd, string
    escapes broken in extern js, single-ident record types,
    let _ := f(); body for sequencing, 0-arg externs need dummy)
  - architecture flow diagram
  - run scripts (compile-direct, smoke endpoints)

Source now ~3760 LOC LeanJs, ~620 LOC page.html, ~210 LOC Serve.lean.
33 PNGs generated in ~7.5 minutes through ComfyUI Desktop's local
FLUX-schnell-fp8 endpoint (127.0.0.1:8188). Style: photorealistic
Han dynasty (BCE 200), painterly realism, cinematic lighting,
dark backgrounds for portraits.

Asset breakdown (examples/ChuHan/assets/, 23 MB total):
- char_*.png (9 protagonist + NPC portraits, 512×720 each)
    劉邦 項羽 韓信 張良 蕭何 范増 項伯 蒯通 黄石公
- wife_*.png (5 wife / partner portraits, 512×720)
    呂雉 虞姫 漂雲 黒綺 妙容
- bg_*.png (19 scene backgrounds, 1024×576)
    pei banquet house mountain camp court battlefield river snow
    palace jielo yingyang pavilion tent altar burning pengcheng
    gorge night

Generation pipeline (/tmp/gen_all.py): each image is a minimal
7-node FLUX workflow (CheckpointLoader → CLIPTextEncode ×2 →
EmptySD3LatentImage → KSampler @ 4 steps cfg=1.0 → VAEDecode →
SaveImage). Submit + poll /history + fetch /view. Deterministic
seeds so re-runs are reproducible.

Game wiring:

Serve.lean: new `/assets/*` route reads PNG/JPG/WEBP from
examples/ChuHan/assets/, sends with `image/png` MIME + 1h
cache header. Directory-traversal guard: rejects `..` segments
and `/` inside the relative path.

page.html: every `.bg-*` CSS class now uses background-image
url(/assets/bg_*.png) instead of the gradient placeholder.
Backgrounds bring the period setting to life immediately.
.sprite-img CSS uses object-fit:cover + a linear-gradient mask
so portraits fade to transparent at the bottom (clean against
the dialogue box). .sprite-name overlays the Japanese name at
the bottom of each portrait.

Game.leanjs: renderSprite gets a Japanese-name → asset path
mapping (spriteAssetForName). Named characters → <img> portrait;
unknown extras (e.g. "部下", "元囚人") fall back to the original
coloured block so the runtime never 404s.

Verified:
- All 33 PNGs exist (du -sh = 23M)
- HTML contains url(/assets/bg_pei.png) reference
- /assets/char_liubang.png returns 200 with image/png
- /assets/../../README.md traversal returns 404
- Build green
Generated 9 more PNGs (1280×720 each, ~9MB total) for the
ending screens — each TRUE END and 隠し END now plays as a
full-bleed dimmed photo with the title overlaid in cinematic
typography. Same pipeline as before (ComfyUI FLUX schnell @
4 steps), but at 1280×720 widescreen and with composition-heavy
prompts (multiple subjects, action moment, atmospheric).

Ending images:
- end_yuji_dance.png   — 虞姫剣舞 / 項羽 grief
- end_yuji_death.png   — 虞姫 fallen, 項羽 cradling her
- end_wujiang.png      — 烏江で自刎 (項羽)
- end_changle.png      — 長楽宮鐘室 (韓信)
- end_dafeng.png       — 沛で大風歌を歌う劉邦
- end_immortal.png     — 山に消える張良
- end_xiaohe.png       — 黄昏の蕭何邸、妙容と
- end_fanzeng.png      — 馬車中の范増 / 玉玦
- end_xizheng.png      — 西方の韓信 + 漂雲 (隠しEND)

viewEnding now uses .end-bg + .end-image + .end-overlay layout:
the image fills the screen at brightness 55%, the title sits
centered with heavy text-shadow against it. endingImageFor()
maps ending id → asset path; endings without a custom image
just get the dimmed-black overlay (no change in behavior).

Total asset count: 33 + 9 = 42 PNGs, ~32 MB. Generation time
for the ending batch: ~4 minutes.
Replaced the flat color block in `.char-portrait` with the FLUX-generated
`/assets/char_{id}.png` photo. Card portrait is now taller (200px),
slightly desaturated by default, snaps to full color + 1.04 zoom on hover.
Name overlay is rendered at the bottom with a gradient scrim.
Adds the playback half of the audio pipeline so any BGM file dropped into
`examples/ChuHan/assets/bgm_*.ogg` is picked up automatically:

* LeanJs gains `audioPlay/audioSfx/audioToggleMute/audioIsMuted` externs.
  A new `bgmForBg`/`bgmForPhase` derives a track id from the current
  phase + the active scene's `bg`, mapping the 17 backgrounds to 7 mood
  buckets (title, village, court, camp, battle, journey, grief, ending).
  `view(state)` calls `audioPlay(bgmForPhase(state))` on every render —
  it is idempotent JS-side so it never restarts the same track.

* `page.html` ships `window.chuhanAudio`: two cross-fading <audio>
  elements, 800ms fades, HEAD-probes each asset on boot so missing
  files stay silent, persists mute state in localStorage, and unlocks
  autoplay on first click/key.

* HUD gets a 🔊/🔇 mute toggle next to the language picker, wired via
  a new `toggleMute` msg.

* Serve.lean adds `audio/ogg`, `audio/mpeg`, `audio/wav` to the
  /assets MIME map.

Tracks themselves are generated separately by tools/gen_bgm.py against
ComfyUI + Stable Audio Open and will land in the next commit.
Generated via ComfyUI + Stable Audio Open 1.0:

* 8 BGM tracks (30-40s loops, ogg/vorbis q=4): title, village, court,
  camp, battle, journey, grief, ending — selected per scene from
  bgmForBg(). ~3.3 MB total.
* 5 SFX clips (≤3s): page (dialogue advance), chime (ending unlock),
  clash (sword/attack), drum (mini-game commit), coin (mini-game pick).
  ~160 KB total.

page.html grows a sfxForMsg(msg) dispatcher in the click delegation
and the keyboard handler so the UI feedback layer stays out of LeanJs.
Battle 'attack' (z/j) plays clash; entering 'ending' phase plays chime
once per transition.

The text encoder ships separately from the diffusion bundle in the HF
release, so the generator workflows wire CLIPLoader(t5_base, type=
"stable_audio") explicitly instead of relying on
CheckpointLoaderSimple's CLIP output (which is None).
\`def initState := { … }\` compiles to a plain object, so the
\`loadSave() || initState()\` fallback threw "initState is not a function"
on first visit (when localStorage was empty). The path was hidden as
long as a save existed.
The original typographic-quote workaround (e.g. '{“tag“:“openGallery“}')
emitted strings that browsers happily kept around but JSON.parse rejected
("Expected property name or '}' in JSON at position 1"), so every click
through the delegated handler silently threw — most visibly END ギャラリー
on the title screen.

LeanJs string literals can't contain ", so all 22 data-msg sites now go
through a single new helper:

  def msgAttr(o) := sjoin(["'", jstr(o), "'"])

The call site supplies `data-msg=` itself, e.g.

  "<button data-msg=", msgAttr({tag: "openGallery"}), ">…"

Two sites had to grow a `sjoin([…])` wrapper because their data-msg
hid inside an `if … then … else` whose branches must be single
expressions (not comma-separated lists).

Verified: 34 rendered data-msg values across title / select / scene /
gallery all JSON.parse cleanly.
\`.dialogue-box\` (bottom 20px, min-height 160 + padding) extended to
~bottom 218px and was rendered after \`.choices\` (bottom 200px), so the
dialogue panel sat on top of the lower portion of the choice list and
swallowed clicks. Pushed choices up to bottom 220 and gave them an
explicit z-index so they stay above whatever the dialogue box grows
to.
* \`.scene-bg::after\` is a full-screen darkening gradient. Its default
  \`pointer-events: auto\` was swallowing every click in mini-games,
  chat scenes and choice screens — clicking facebtns or any non-button
  area did nothing. Added \`pointer-events: none\` so clicks pass
  through to the actual buttons underneath.
* 呂公 (face-reader sage) had no entry in \`spriteAssetForName\`, so the
  meeting-呂公 dialogue rendered the coloured-block fallback. Generated
  a FLUX portrait and wired it up.
Every interactive control now goes through one of three LeanJs helpers
in Game.leanjs:

    button(kind, msg, label)             -- escaped text label
    buttonMod(kind, mod, msg, label)     -- + extra class for .active
    buttonHtml(kind, msg, htmlBody)      -- pre-rendered nested markup

`kind` is the single source of truth: it maps onto `.btn-<kind>` in
page.html and onto SFX_BY_KIND in the click handler. Adding a new
control is now \`button("face", {tag: …}, "…")\` + one CSS rule + one
SFX_BY_KIND entry — no more growing bespoke classes (`.facebtn`,
`.mini-jade`, `.bs-card`, `.chat-end`, `.menu-btn`, `.save-btn`,
`.char-card`, …) that would each have to be remembered separately
for styling and for SFX.

Kinds in use: primary, icon, menu, choice, face, jade, card, char.

* Game.leanjs: helpers added; all ~25 button call sites converted.
* page.html: \`.btn-*\` consolidation replaces 12 per-site CSS classes.
* page.html: \`sfxForMsg(msg)\` (dispatched by msg.tag, easy to miss
  when adding buttons) becomes \`sfxForButton(el)\` with one table
  keyed by kind class.

Net: -152 / +178. The LeanJs side actually got more declarative
(fewer hand-spelled \`<button class='…' data-msg='…'>\` strings) and
the CSS shed all the duplicated "background/border/padding/cursor"
fragments.
Same kind-driven idea as the button kit, applied to the next two
recurring shapes:

* panel(kind, title, body)  /  panelMod(kind, mod, title, body)
  Replaces \`.mini-card\` / \`.mini-card-wide\` / \`.chat-wrap\` /
  \`.dialogue-box\` with one \`.panel\` base + \`.panel-mini\`,
  \`.panel-mini.wide\`, \`.panel-chat\`, \`.panel-dialogue\` variants.
  Kinds in use: mini, chat, dialogue. Empty title skips the <h2>.

* portrait(src, alt, caption)
  Image + gradient-overlay caption used by the character-select card
  today; the same primitive can pick up sprite art later. Replaces
  the hand-spelled \`.char-portrait\` + \`.char-portrait-name\` pair
  (now just \`.portrait\` + \`.portrait-name\`).

Call sites converted: viewMiniFaceRead, viewMiniJadeRing,
viewMiniBeishui (wide mod), viewLlmChat, renderDialogueBox,
renderCharCard.

Verified: 83/83 rendered data-msg values still parse, every phase
renders the expected panel kinds and portrait images.
* Added char_ziying.png (子嬰, last Qin king at line 1264). Wired into
  spriteAssetForName so the surrender scene at 咸陽 stops rendering
  the colour-block fallback.

* New \`auditAssets\` in Serve.lean runs at server boot, scans
  Game.leanjs + page.html for every \`/assets/NAME.ext\` reference
  (skips partial \`/assets/X_\` prefixes used in string interpolation),
  and:
    - logs \`MISSING_ASSET: <name>\` to stderr,
    - rewrites examples/ChuHan/MISSING_ASSETS.txt so the canonical
      TODO list is always current — grep \`MISSING_ASSET:\` to find.

* Two complementary runtime fallbacks for assets the audit caught:
    - \`missingAsset(name)\` helper in Game.leanjs renders a loud
      magenta-striped placeholder.
    - page.html stage-level \`error\` capture swaps any 404'd <img>
      for the same placeholder, so an asset audit gap is visible
      in-game too.

Verified: clean audit with all current assets present.
The runtime audit caught \`/assets/<X>.ext\` references that didn't
resolve on disk, but nothing was catching named speakers that simply
had no portrait mapping (silent colour-block fallback). And neither
check ran at build time, so a freshly-deleted PNG would only surface
when the server next booted.

Two additions:

1. \`#eval compileTimeAssetCheck\` at the bottom of Serve.lean. Runs
   during Lean elaboration of the module — i.e. on \`lake build\`. If
   any \`/assets/X.ext\` is missing, elaboration throws and the build
   halts with a \`MISSING_ASSET:\` list. Skips gracefully when the
   working directory can't locate Game.leanjs (so people building
   from elsewhere don't get a false positive).

2. Extended \`auditAssets\` with a speaker-coverage pass. New helpers:
     * \`findSpeakers\` — parses \`say(bg, who, …)\`, \`think(...)\`,
       \`duo(bg, who, color, who2, …)\` calls, depth-tracked, and
       picks the 2nd (and 3rd for duo) non-hex quoted arg.
     * \`findCoveredSpeakers\` — pulls every \`name == "X"\` literal out
       of \`spriteAssetForName\` so the audit knows what's covered.
     * \`genericRoles\` allowlist — narrator stand-ins (部下, 老婆,
       漂母, …) that are intentionally portrait-less.
   Diff = speakers that appear in dialogue but lack a portrait. The
   list lands in MISSING_ASSETS.txt as \`MISSING_PORTRAIT:\` lines.

Current audit on this commit catches 10 real oversights: エウテュデモス
医者 呂馬童 夏侯嬰 曹参 樊噲 紀信 鍾離眛 韓生 項梁.

Verified: deleting an asset and rebuilding halts the build with the
expected \`MISSING_ASSET:\` message; restoring it builds cleanly.
The story has been close to history; player choices rarely twist it.
This adds a step kind where the *player writes the action* (Liu Bang's
apology to Xiang Yu, the surrender-day declaration at Xianyang) and the
LLM judges: good / ok / bad. Each outcome routes to a different scene.

The infrastructure (one step kind that can power any future fork):

* \`Step\` gains a single new field, \`aiResolveKind: String\`. Empty by
  default; a non-empty kind triggers phase \`ai_resolve\` on advance.
  No branch routing fields on Step — that lives in
  \`aiResolveGoto{Good,Ok,Bad}(kind)\` so changing where a fork lands is
  one-line per fork. Situation text lives in
  \`aiResolveSituation{Ja,En}(kind)\`.

* New \`Resolve\` record on state holds the in-flight prompt, the
  player's text, and the LLM verdict.

* Five update handlers: \`resolveSubmit\` (textarea → pending),
  \`resolveResult\` (verdict arrived), \`resolveError\` (network/llm
  fail), \`resolveAdvance\` (player clicks 次へ → goto mapped scene).

* New \`viewAiResolve\` panel: situation block, free-text input, then
  swaps to verdict + reasoning + 次へ once the LLM responds.

* \`/api/resolve\` in Serve.lean. Per-kind system prompt anchors the
  scene context; model is told to reply with strict JSON
  \`{outcome, reasoning}\`. Code-fence stripping + parse-fallback so a
  malformed reply still produces a playable \`ok\` verdict.

* \`wireResolveHandlers\` in page.html mirrors the chat-handler shape:
  POST \`/api/resolve\`, dispatch \`resolveResult\`/\`resolveError\`.

* New CSS for \`.resolve-situation/.resolve-echo/.resolve-outcome.{good,
  ok,bad}/.resolve-reasoning/.resolve-input\`.

Applied to one site so far:
- Liu Bang's 鴻門の宴 apology. Replaces the static A/B choice (flee
  vs stay) with free-text. Routes to:
    good → liubang_hongmen_escape_good (existing historical continuation,
           extracted out of liubangAct2 into its own scene),
    ok  → liubang_hongmen_escape_ok (new short bridge scene that flags
           the reputation hit and then chains into _good),
    bad → liubang_hongmen_died (existing).

Defined but not yet wired:
- liubang_xianyang_policy (子嬰 surrender-day declaration). System
  prompt + situation text + routing all exist; needs the corresponding
  scene rewrite + good/ok/bad continuation scenes.

Verified: every phase still compiles, every data-msg still parses,
node-side simulation steps through resolveSubmit → resolveResult →
resolveAdvance and lands on the right scene. End-to-end LMStudio
exchange not exercised here (LMStudio offline at commit time).
The RAF tick in page.html still queried \`.mini-card\` after the panel
helper refactor renamed that class to \`.panel-mini\`. The
\`querySelector\` returned null, the cheap inline re-render bailed,
and the cursor bar / status text stopped updating — the player saw a
frozen mini-game even though state was still ticking server-side.

Updated both call sites to the new class name.
A small polling agent for long-running, single-objective work
(e.g. a multi-week GPGPU optimisation pass). It watches one zellij
pane (where Claude Code lives), waits for the visible viewport's
FNV-1a hash to stay stable for --stall-secs, then wakes Gemini with:

  * the project goal (one CLI flag, e.g. "2× FlashAttention bwd on SM90"),
  * the last ~6 kB of the pane,
  * a short memo of past decisions and what changed after each.

Gemini returns strict JSON {action, reasoning, text} and the loop
dispatches: \`continue\` (don't interrupt), \`instruct\` (write the
text into the pane via \`zellij action write-chars\` + Enter), or
\`ask_user\` (prompt the human operator on stdin).

Layout:
  examples/MetaOrchestrator/
    Zellij.lean   — dump-screen / write-chars / submit / cheapHash
    Director.lean — Gemini prompt + verdict parsing
    Main.lean     — poll loop + stall detection + JSONL audit log
    README.md     — usage, knobs, deferred work

Built entirely on LeanTea bricks already in tree:
  - LeanTea.Cloud.Gemini — the decision LLM
  - IO.Process.spawn     — for the zellij CLI shell-out
  - Lean's String/Json   — for stall hash + verdict parsing

What's deferred:
  - Multi-pane / multi-agent (split out to a second loop is trivial
    given the Zellij/Director separation).
  - TUI — JSONL log is the only sink today.
  - Claude Code \`Stop\`-hook trigger instead of stall polling
    (cheaper but couples to Claude Code's hook config).
  - SQLite-backed long-term memory beyond the rolling 10-entry memo.

Verified: lake build green; CLI prints usage when invoked without
args. Live LLM round-trip not exercised in this commit (depends on
GEMINI_API_KEY + an outside-Claude-Code zellij shell to avoid the
"no active session" nested-subprocess quirk).
Previously the Director's memo history lived only in process memory.
A crash or any user-initiated stop dropped the entire context Gemini
had built up about the long-running goal. New behaviour:

* Every decision now also writes one JSONL line to \`--memo-log\` (a
  separate file from the audit log so the audit's free-form
  reasoning text doesn't bloat the replay path).

* Memos are tagged with \`sessionId\`. By default that's an 8-hex
  FNV-1a hash of the goal, so identical goals across runs share
  memory automatically. \`--session FOO\` lets you fork (same goal,
  two trial paths) and \`--fresh\` starts cold.

* On startup, \`loadMemos\` reads the memo log, filters by sessionId,
  and seeds the loop with the last 10 — Gemini sees its prior
  decisions immediately on resume.

* The previous memo's \`afterSummary\` is now auto-filled on each
  stall: \`"(pane changed)"\` or \`"(no change in pane)"\` based on
  the hash diff between the last decision and now. Richer
  summaries (tail of output, error markers) deferred.

Verified end-to-end: seeded a 2-memo log, ran the orchestrator
against a matching goal, banner shows \`resumed_memos=2\`. \`--fresh\`
ignores the log (\`resumed_memos=0\`).

Drive-by: switched the startup banner from stdout to stderr so the
output appears immediately when piped to a file or tail. (Lean's
stdout is fully buffered when not attached to a TTY.)
Replaces the single-pane CLI with a multi-agent controller. Each
managed Claude Code instance runs in its own IO.asTask polling loop
under its own AgentHandle (IO.Ref AgentState). The controller boots
from a JSON config file on startup and reads slash commands on stdin
to add / remove / stop / start agents at runtime.

New layout:
  Config.lean   — ManagedAgent + Config records, JSON codec, add/remove/find,
                  load/save. JSON is intentional: small dataset, human-editable,
                  git-diffable. SQLite is deferred until memos cross a few MB.
  Runtime.lean  — per-agent loop (IO.Ref-backed AgentState), spawn / stop /
                  snapshot / replyToUser. One file = one concern: the loop.
  Main.lean     — controller. Boots enabled agents from config, REPL on stdin:
                    /list /add /stop /start /remove /reply /save /load /quit

Per-agent files:
  <logDir>/<agentId>.memos.jsonl     — Director memos, replayable
  <logDir>/<agentId>.decisions.jsonl — full decision audit with reasoning

Removed the old single-pane CLI (--pane / --goal / --memo-log /
--session / --fresh): same use case is now `/add ID PANE GOAL...`
followed by `/save` to persist. The session id collapses into the
agent id (one less concept to track).

Verified end-to-end:
  1. Start with empty config → /add agent → /list shows it →
     /save writes config.json → /quit
  2. Restart with same --config → 1 agent auto-spawns from disk → memos
     resume per the existing replay logic.

TUI rendering and /web toggle are the next two commits; Runtime.snapshot
already returns the data they need, so the runtime layer is stable.
LlmChatTui and (incoming) MetaOrchestrator's TUI were going to roll
ANSI by hand — the same trap the ChuHan button kit fixed. This adds
a tiny widget framework so layout + key handling + style are tied
to one declaration. \`button("primary", .quit, "/quit")\` 1 line; the
rendering, focus highlight, and Enter handling all come along.

Files:
  LeanTea/Tui/Core.lean       — Box, Cell, Style, Key, Widget, focus walk
  LeanTea/Tui/Combinator.lean — vbox, hbox, border, padding, withStyle, text
  LeanTea/Tui/Element.lean    — button, input, panel, listView
  LeanTea/Tui/App.lean        — terminal raw mode + main loop + ANSI repaint
  LeanTea/Tui/Test.lean       — pure Session: render to Box, send keys, assert
  LeanTea/Tui.lean            — re-export

Design notes worth keeping in mind:

* Every widget is a pure function (\`Nat → Nat → Bool → Box\`) + a
  \`Key → Option msg\` handler. Combinators also expose their
  \`children\` so the app can walk the tree to find the focused
  widget and dispatch keys to it (\`Widget.dispatchKey\`).

* \`vbox\`/\`hbox\` honour \`prefHeight\`/\`prefWidth\` per child and
  split the remainder evenly. A new \`fitBox\` step crops or pads
  each child's render output to its allocation, so widgets whose
  renderer ignores the size hint (the common case) don't break the
  layout.

* The test harness is the same loop minus IO. \`mkSession app w h
  focusOrder\` returns a Session you can \`.sendKey .enter\`,
  \`.typeString "hello"\`, \`.containsText "…"\`, etc. — pure, fast,
  no TTY needed.

Wired into the LeanTea library + a new lean_exe target tui_spec
that runs as part of the LSpec battery.

Verified:
  ./.lake/build/bin/tui_spec → 22 passed, 0 failed
  Covers Box primitives (6), Combinators (4), Elements (7),
  App + Session round-trip (5) including Tab focus cycling and
  Enter dispatch through the widget tree.

Known v0 limitation (called out in App.lean):
  Combinators don't propagate \`focused\` through child render
  calls — only the focused leaf widget paints itself differently
  via its own onKey + the dispatch path. Visual focus highlight
  is cosmetic and the next iteration will plumb \`focusId\` into
  vbox/hbox/border render too.
Replaces the stdin REPL (kept as \`--repl\`) with a widget-based
TUI: agent table at the top, recent command output in the middle,
prompt at the bottom. Snapshots re-read on every keystroke so
background poll-count / status changes surface immediately when
the user interacts.

New file:
  examples/MetaOrchestrator/Tui.lean — TuiState + TuiMsg, widget
  tree, custom loop. Doesn't reuse \`Tui.App.runWith\` because
  update needs IO (it mutates Runtime + saves config).

Widget tree:
  vbox
    ├─ headerBar         (title + agent count, cyan strip)
    ├─ agentTable        (bordered vbox of one row per agent)
    ├─ messagesArea      (last ~8 command outputs, dimmed)
    └─ commandBar        (bordered "> " + input widget)

Slash commands are unchanged (/list /add /stop /start /remove
/reply /save /load /quit); \`dispatchCommand\` in Tui.lean now hosts
the same logic previously in Main's REPL but returns a list of
output lines that the TUI renders in-place.

Main.lean:
  * default → hand off to Tui.run rt
  * --repl  → keep the old stdin loop (useful in headless CI +
    when the terminal doesn't support alt-screen)

Verified:
  \`--repl\` still walks /list /save /quit against the seeded config.
  TUI mode needs a real TTY (alt-screen + raw mode) so it's not
  smoke-testable through a pipe — but every piece (widget kit,
  command dispatch, runtime snapshot) is covered by tui_spec + the
  existing REPL end-to-end.
The pre-widget-kit revision rolled its own \`dim/bold/cyan/…\` ANSI
helpers, a bespoke soft-wrap, and a hand-rolled \`repaint\` that
laid out banner + history + prompt via \`String.intercalate\`. Same
UX, but every colour or layout tweak meant editing an escape
sequence by hand.

Rebuilt in this commit:

  * All rendering goes through \`LeanTea.Tui\`:
    \`Style { fg := .cyan, bold := true }\` records instead of the
    old \`cyan (bold s)\` builder chain; \`vbox\` / \`text\` / a
    single \`renderBoxAnsi\` for the final serialisation. Adding a
    fourth colour bucket for a new role is now one \`Style\`
    declaration + one branch in \`messageRows\`.

  * \`Screen\` record holds every mutable field the view reads
    (model, nServers, nTools, history, status), so \`screen : Screen
    → Widget Unit\` is a pure function and \`paint : Screen → IO
    Unit\` is one \`renderBoxAnsi\`.

  * The progress-line ANSI trick (save/restore-cursor + short dim
    status) stays intact — it fires mid-turn during a blocking
    \`runTurnFull\` when we can't repaint the widget tree — but it
    now reads its style off the same \`Style\` records via a small
    \`styleAnsi\` helper, so the palette is one file to edit.

Line count: 353 → 399. Not the shrink I said I'd get; slash-command
plumbing is unchanged and unavoidably chatty. The real win is that
the rendering half is now declarative + shares its primitives with
meta_orchestrator's TUI, so future look-and-feel changes ship as
\`Style\` record edits, not escape-sequence archaeology.

Verified: builds green, \`llm_chat_tui\` with no args still prints
the usage; the widget kit's \`tui_spec\` (22 tests) already covers
the primitives this file leans on.
Same kind-driven idea as the button + panel kits, applied to the
remaining recurring shapes the mini-games and HUD were assembling
from scratch each time.

  * hpBar(kind, label, cur, max)
    Horizontal gauge: label · track · numeric. Kinds today:
      hp      — red→orange gradient, HP-style (beishui board)
    New kinds are one CSS \`.gauge-\<kind> .gauge-fill\` rule.

  * ringBar(progressPct, windowStartPct, windowWidthPct)
    The jade_ring's timing bar: reuses the same .gauge-track
    substrate plus two absolute overlays (window band + cursor).
    Emits .gauge-ring so future timing variants inherit the base
    without collision.

  * btnRow(kind, label, buttonsHtml)
    Inline row of controls with an optional leading label column.
    Kinds:
      face   — flex row with a fixed-width label + growing buttons
               (used in 顔相鑑定 for eyes/mouth/pose triples)
      inline — flex row, no label, small gap
               (HUD JA/EN toggle)
      menu   — vertical stacked column
               (available for save-pop et al. down the line)

  * dialogue(speaker, say, think, hint)
    The speaker + line + inner-monologue + advance-hint quad every
    VN beat renders. Empty speaker/think auto-skip so
    renderDialogueBox drops from 8 lines of null-check plumbing to
    one call.

Sites converted:
  renderDialogueBox      — dialogue()
  renderLang             — btnRow("inline", ...)
  faceRow                — btnRow("face", label, ...)
  viewMiniFaceRead rows  — btnRow("face", "目つき:", ...) x3
  ring progress bar      — ringBar(progress, wStart, wWidth)
  viewMiniBeishui bars   — hpBar("hp", "漢 (韓信)", ...) x2 (drops
                            the local bsHpBar def)

CSS consolidation:
  .facerow .facelabel                  →  .btn-row .btn-row-face + .btn-row-label
  .lang                                →  .btn-row-inline (HUD-scoped rule dropped)
  .ring-bar / .ring-window /           →  .gauge .gauge-ring + reused
    .ring-cursor                          .ring-window / .ring-cursor
  .bs-hp / .bs-hp-label / .bs-hp-bar   →  .gauge .gauge-hp + shared
    .bs-hp-bar > div / .bs-hp-num         .gauge-fill / .gauge-label / .gauge-num

Verified: 67/67 rendered data-msg values still parse; every phase
still emits the expected wrapper classes (btn-row-face x3 in the
face mini, gauge-hp x2 in beishui, gauge-ring / ring-window /
ring-cursor in jade_ring, dialogue-speaker + dialogue-line +
dialogue-inner present when a beat has all three).
Previously every LLM call was hard-wired to Gemini. Two frictions:

1. LMStudio + a local Gemma is more than enough for the polling
   loop's per-stall classifier (input: ~6 kB pane snapshot; output:
   3-key JSON). Paying for Gemini API on every 30-second stall
   over a long-running kernel-tuning session was wasteful.

2. But for a genuine review pass — "am I drifting, is this the
   right direction, what do the last 100 memos say" — you *do*
   want Gemini's 2 M context.

New shape:

* \`examples/MetaOrchestrator/Llm.lean\` — a small \`Backend\` sum:
    \`| openaiCompat (baseUrl model apiKey?)\`  ← LMStudio, Ollama, groq, OpenAI proper
    \`| gemini (model)\`                        ← Google v1beta
  Both satisfy \`Backend.ask (system user : String) : IO String\`.
  JSON codec + \`.describe\` for the TUI status line.

* \`Config\` gains \`decideBackend\` (default LMStudio at
  \`127.0.0.1:11211/v1\`) and \`reviewBackend\` (default
  \`gemini:gemini-2.5-pro\`). Both round-trip through JSON so
  routing is a one-line config edit.

* \`Director.decide\` now takes a \`Backend\` instead of a
  \`Gemini.Config\`. Runtime reads the current \`decideBackend\`
  freshly on every stall so the user can hot-swap backends via
  \`/load\` mid-session.

* New \`Director.review\`: free-form audit prompt, 1200-token
  budget, temperature 0.5. Reads the full memo log (\`loadMemos 100\`
  instead of the rolling in-memory 10) so the review sees the
  whole session, not the recent window.

* New \`/review AGENT_ID\` slash command in both the REPL and TUI
  dispatchers. Renders as one bullet per sentence in the TUI so
  the narrow message pane doesn't horizontally overflow.

Also drops the old \`geminiModel\` config key + the top-level
\`Gemini.Config.fromEnv!\` startup call — the Gemini env only fires
when \`reviewBackend\` is actually invoked.

Verified: fresh boot with no config file → correct defaults
printed (\`decide backend: openai:local-model\` +
\`review backend: gemini:gemini-2.5-pro\`) → \`/add\` + \`/save\`
round-trips the two backends through JSON verbatim. Live LLM
round-trip not exercised in this commit (LMStudio + Gemini both
available but not invoked from CI).
@junjihashimoto junjihashimoto changed the title Gemini API client + MCP server for code review ChuHan game + LeanTea.Tui + meta_orchestrator + backend routing Jul 1, 2026
Both projects grew large + distinct enough from lean-tea's core
library / examples that co-hosting them here was noisy. Each now
lives in its own repo and pulls this one in as a lake git
dependency:

  * 楚漢恋歌 (Chu-Han Love Song, 6-route narrative game)
    → https://github.com/Verilean/lean-tea-chuhan

  * meta_orchestrator (Gemini/LMStudio PM watching Claude-Code
    zellij panes)
    → https://github.com/Verilean/lean-tea-meta

The previously .gitignore'd examples/_private/ tree (Fight2d /
Fight3d / Saber / VN chat + Mixamo / VRM tooling) was also lifted
out into a private companion repo:

  * https://github.com/Verilean/lean-tea-private

Effect on this repo:
  * Removes the whole examples/ChuHan/ tree from git (37 MB of
    assets — regenerable via the ComfyUI scripts documented in
    the chuhan repo's ASSETS.md).
  * Removes examples/MetaOrchestrator/ (already covered by the
    new repo).
  * lakefile drops chuhan_serve + meta_orchestrator exe targets
    and Examples-library roots for ChuHan.Game +
    MetaOrchestrator.*.

Still building: lake build → 162/162.
@junjihashimoto junjihashimoto changed the title ChuHan game + LeanTea.Tui + meta_orchestrator + backend routing LeanTea.Tui widget kit — brick-style TUI library + companion projects (chuhan, meta) Jul 1, 2026
@junjihashimoto junjihashimoto merged commit 2d45714 into main Jul 1, 2026
1 check passed
@junjihashimoto junjihashimoto deleted the feat/gemini-mcp branch July 1, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant