fix llm config for ramalama, add safeguards, spinner animation

- Fix last_narrative -> last_prompt bug in generate() and generate_stream()
- Add **kwargs guard to build_user_message() to catch wrong param names
- Extract _set_llm_env() helper that always sets API key (fallback placeholder)
- Add 30s timeout to all litellm.completion() calls
- Update config model to openai/deepseek-r1 for ramalama
- Replace shifting dots spinner with fixed-character shape morph
This commit is contained in:
Dejvino 2026-06-25 19:21:55 +02:00
parent d265dfc7f7
commit 4c968f8096
3 changed files with 230 additions and 154 deletions

View File

@ -1,8 +1,8 @@
{ {
"llm": { "llm": {
"model": "openai/gpt-4o-mini", "model": "openai/deepseek-r1",
"api_key": "not-needed", "api_key": null,
"api_base": "http://localhost:8080/v1", "api_base": "http://localhost:8080/v1",
"temperature": 0.8 "temperature": 0.7
} }
} }

View File

@ -35,6 +35,7 @@ TODAY = date.today().isoformat()
# ── Structured output ────────────────────────────────────────────────────── # ── Structured output ──────────────────────────────────────────────────────
@dataclass @dataclass
class GenerationResult: class GenerationResult:
"""Legacy result — kept for backward compat with CLI main()."""
narrative: str narrative: str
choices: list[str] = field(default_factory=list) choices: list[str] = field(default_factory=list)
log_entry: Optional[str] = None log_entry: Optional[str] = None
@ -46,6 +47,20 @@ class GenerationResult:
error: Optional[str] = None error: Optional[str] = None
@dataclass
class TurnResult:
"""Output of a complete turn via finalize_turn tool."""
book_log: str = ""
user_prompt: str = ""
ambience: Optional[str] = None
character_updates: Optional[str] = None
world_updates: Optional[str] = None
journal_add: list[str] = field(default_factory=list)
journal_done: list[str] = field(default_factory=list)
error: Optional[str] = None
debug_info: str = ""
# ── DM System Prompt Template ────────────────────────────────────────────── # ── DM System Prompt Template ──────────────────────────────────────────────
SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo card-based rules-light fantasy TTRPG. Your job is to narrate an immersive, responsive story for one player character. SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo card-based rules-light fantasy TTRPG. Your job is to narrate an immersive, responsive story for one player character.
@ -83,50 +98,46 @@ Favourable +1, Risky -1, Desperate -2, Well-prepared +1, Poor visibility -1, Rel
### Exploration ### Exploration
6 ten-minute watches per hour. Each meaningful action advances a watch. 6 ten-minute watches per hour. Each meaningful action advances a watch.
## Output Format ## How Turns Work
IMPORTANT: End every response with a JSON fenced code block:
```json Each turn follows this sequence:
{ 1. The player's action or response is given to you.
"log_entry": "- **time of day** — brief description of what happened.", 2. Think about what happens. Read game state files, roll dice, or ask the player to roll.
"ambience": "ambience_name_or_null", 3. When ready, call **finalize_turn** to complete the turn.
"character_updates": null,
"world_updates": null,
"journal_add": [],
"journal_done": []
}
```
Rules for the JSON block: The **finalize_turn** tool produces all data for this turn:
- **log_entry**: One-line log entry summarizing this turn's action. - **book_log** Narrative of what happened this turn. Appended to the story book.
- **ambience**: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. Set to null to keep current. - **user_prompt** What the player sees next: describe the situation and ask what they do.
- **character_updates**: ONLY include if HP, cash, gear, or stats changed. Provide the FULL updated character sheet markdown. Otherwise null. - **ambience** One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
- **world_updates**: ONLY include if NPCs, locations, or world state changed. Provide the FULL updated world markdown. Otherwise null. - **character_updates** Full character sheet (ONLY if HP/cash/gear/stats changed, otherwise omit).
- **journal_add**: New TODO items to add. - **world_updates** Full world state (ONLY if NPCs/locations/threads changed, otherwise omit).
- **journal_done**: TODO items that are now completed. - **journal_add** New TODO items.
- **journal_done** Completed TODO items.
IMPORTANT: You MUST call **finalize_turn** to end the turn. Until then you will be called again to continue thinking and gathering information.
## Available Tools ## Available Tools
You may use these tools before writing your final JSON block. Tool calls go in their own fenced code block: Tool calls go in their own fenced code block:
```tool ```tool
{"tool": "tool_name", "args": {...}} {"tool": "tool_name", "args": {...}}
``` ```
You can call tools any number of times, one per block. Available tools: You may also show reasoning inline:
- **read_file** Read a game state file. `{"file": "character|world|book|log|journal"}`
- **roll** Roll dice (outcome is shown to player). `{"dice": "2d6", "modifier": "-1"}`
- **think** Internal reasoning (shown in game status bar). `{"thought": "Your reasoning here"}`
- **player_roll** Ask the player to roll physical dice. **Only use when the player's action has an uncertain outcome that the dice should decide.** `{"dice": "2d6", "reason": "why"}`
You may also show reasoning inline with a separate fence:
```thought ```thought
Your reasoning here Your reasoning here
``` ```
Call tools as needed, then end with the final JSON block. Tools available:
Every tool call **must** include a `"dm_status"` string in `args` a short, public-facing description of what the DM is doing (e.g. `"consulting the archives"`, `"examining the wound"`, `"calculating the odds"`). The player sees this in the UI. Keep it vague never reveal what the DM is actually reading or learning.
- **read_file** Read a game state file. `{"file": "character|world|book|log|journal", "dm_status": "..."}`
- **roll** Auto-roll dice (outcome shown in status). `{"dice": "2d6", "modifier": "-1", "dm_status": "..."}`
- **player_roll** Ask the player to roll physical dice. **Use when the outcome is uncertain.** `{"dice": "2d6", "reason": "why", "dm_status": "..."}`
- **finalize_turn** **Complete the turn.** Provide all turn data as args. **Must include** `"dm_status"`.
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state. When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state.
@ -202,6 +213,15 @@ class GameEngine:
def temperature(self) -> float: def temperature(self) -> float:
return self.config.get("llm", {}).get("temperature", 0.8) return self.config.get("llm", {}).get("temperature", 0.8)
def _set_llm_env(self) -> None:
"""Set provider-specific env vars for litellm."""
prefix = self.model.split("/")[0].upper()
import os
key = self.api_key or "sk-placeholder"
os.environ[f"{prefix}_API_KEY"] = key
if self.api_base:
os.environ[f"{prefix}_API_BASE"] = self.api_base
# ── Context Assembly ──────────────────────────────────────────────── # ── Context Assembly ────────────────────────────────────────────────
def _read_file(self, path: Path) -> str: def _read_file(self, path: Path) -> str:
@ -243,42 +263,46 @@ class GameEngine:
def build_user_message( def build_user_message(
self, self,
player_action: str | None = None, player_action: str | None = None,
last_narrative: str | None = None, last_prompt: str | None = None,
**kwargs: str | None,
) -> str: ) -> str:
"""Build the user message for this turn's LLM call.""" """Build the user message for this turn's LLM call."""
if kwargs:
raise TypeError(
f"build_user_message() got unexpected keyword arguments: "
f"{set(kwargs)}. Did you mean 'last_prompt' instead of one of these?"
)
parts = [] parts = []
if last_narrative:
parts.append(f"## Previously\n{last_narrative}") if last_prompt:
parts.append(f"## Situation\n{last_prompt}")
if player_action: if player_action:
parts.append(f"## Player Action\n{player_action}") parts.append(f"## Player Action\n{player_action}")
has_existing_story = bool( has_existing_story = bool(
self._read_file(BOOK_PATH).strip() self._read_file(BOOK_PATH).strip()
) if not last_narrative else True ) if not last_prompt else True
if not player_action and not last_narrative: if not player_action and not last_prompt:
if has_existing_story: if has_existing_story:
parts.append( parts.append(
"## Instructions\n" "## Instructions\n"
"Continue the story from where it left off. Describe " "Continue the story from where it left off. Think, "
"what happens next based on the current situation. " "gather information, then call finalize_turn."
"Use tools as needed, then end with a JSON block."
) )
else: else:
parts.append( parts.append(
"## Instructions\n" "## Instructions\n"
"Establish the opening scene. Dillion is at the " "Establish the opening scene. Dillion is at the "
"Splintered Tankard in the Keep. Describe the " "Splintered Tankard in the Keep. Describe the "
"setting and let the player decide what to do. " "setting, then call finalize_turn."
"Use tools as needed, then end with a JSON block."
) )
else: else:
parts.append( parts.append(
"## Instructions\n" "## Instructions\n"
"Describe the outcome of the player's action using game " "Describe the outcome of the player's action using game "
"mechanics where appropriate. Let the player decide their " "mechanics where appropriate. Think, gather information, "
"next move freely. Use tools as needed, then end with a " "then call finalize_turn to complete the turn."
"JSON block."
) )
return "\n\n".join(parts) return "\n\n".join(parts)
@ -297,7 +321,7 @@ class GameEngine:
""" """
system = self.build_system_prompt() system = self.build_system_prompt()
user = self.build_user_message( user = self.build_user_message(
player_action=player_action, last_narrative=last_narrative player_action=player_action, last_prompt=last_narrative
) )
messages = [ messages = [
@ -316,15 +340,7 @@ class GameEngine:
) )
# Set API key / base if provided # Set API key / base if provided
if self.api_key: self._set_llm_env()
# litellm reads env vars or we can pass via kwargs
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try: try:
response = litellm.completion( response = litellm.completion(
@ -332,6 +348,7 @@ class GameEngine:
messages=messages, messages=messages,
temperature=self.temperature, temperature=self.temperature,
stream=False, stream=False,
timeout=30,
) )
text = response.choices[0].message.content or "" text = response.choices[0].message.content or ""
except Exception as e: except Exception as e:
@ -353,7 +370,7 @@ class GameEngine:
""" """
system = self.build_system_prompt() system = self.build_system_prompt()
user = self.build_user_message( user = self.build_user_message(
player_action=player_action, last_narrative=last_narrative player_action=player_action, last_prompt=last_narrative
) )
messages = [ messages = [
@ -369,14 +386,7 @@ class GameEngine:
}) })
return return
if self.api_key: self._set_llm_env()
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try: try:
response = litellm.completion( response = litellm.completion(
@ -384,6 +394,7 @@ class GameEngine:
messages=messages, messages=messages,
temperature=self.temperature, temperature=self.temperature,
stream=True, stream=True,
timeout=30,
) )
full_text = "" full_text = ""
for chunk in response: for chunk in response:
@ -415,8 +426,24 @@ class GameEngine:
"description": "Ask the player to physically roll dice and enter the result.", "description": "Ask the player to physically roll dice and enter the result.",
"args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"}, "args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"},
}, },
"finalize_turn": {
"description": "Complete the turn with all required data.",
"args": {
"book_log": "Narrative of what happened (appended to story book)",
"user_prompt": "What the player sees next — describe and ask what they do",
"ambience": "Optional: soundscape name",
"character_updates": "Optional: full character sheet if changed",
"world_updates": "Optional: full world state if changed",
"journal_add": "Optional: list of new TODO items",
"journal_done": "Optional: list of completed TODO items",
},
},
} }
def _tool_think(self, args: dict) -> str:
"""Think tool — content is displayed via dm_status in the status bar."""
return ""
def _tool_read_file(self, args: dict) -> str: def _tool_read_file(self, args: dict) -> str:
filename = (args or {}).get("file", "") filename = (args or {}).get("file", "")
paths = { paths = {
@ -454,7 +481,12 @@ class GameEngine:
@staticmethod @staticmethod
def _describe_tool_action(tool_name: str, args: dict) -> str: def _describe_tool_action(tool_name: str, args: dict) -> str:
"""Return a user-facing status message for a tool call.""" """Return a user-facing status message for a tool call.
Prefer the LLM-provided dm_status otherwise fall back to a generic description."""
dm_status = (args or {}).get("dm_status")
if dm_status:
return f"DM is {dm_status}..."
read_descriptions = { read_descriptions = {
"character": "reading the character sheet", "character": "reading the character sheet",
"world": "consulting the world map", "world": "consulting the world map",
@ -474,8 +506,6 @@ class GameEngine:
elif tool_name == "player_roll": elif tool_name == "player_roll":
dice = (args or {}).get("dice", "1d6") dice = (args or {}).get("dice", "1d6")
desc = f"asking you to roll {dice}" desc = f"asking you to roll {dice}"
elif tool_name == "think":
desc = "pausing to think"
else: else:
desc = f"using {tool_name}" desc = f"using {tool_name}"
return f"DM is {desc}..." return f"DM is {desc}..."
@ -484,6 +514,7 @@ class GameEngine:
fn_map = { fn_map = {
"read_file": self._tool_read_file, "read_file": self._tool_read_file,
"roll": self._tool_roll, "roll": self._tool_roll,
"think": self._tool_think,
} }
fn = fn_map.get(tool_name) fn = fn_map.get(tool_name)
if not fn: if not fn:
@ -526,26 +557,25 @@ class GameEngine:
def generate_with_tools( def generate_with_tools(
self, self,
player_action: str | None = None, player_action: str | None = None,
last_narrative: str | None = None, last_prompt: str | None = None,
on_thought: callable = None, on_thought: callable = None,
on_action: callable = None, on_action: callable = None,
on_player_roll: callable = None, on_player_roll: callable = None,
) -> GenerationResult: ) -> TurnResult:
""" """
Multi-turn generation with tool-use loop. Multi-turn generation with tool-use loop.
The LLM can output ```thought blocks (reasoning), ```tool blocks The LLM can output ```thought blocks, call ```tool blocks, and
(tool calls), and a final ```json block. If any tool is called, MUST call **finalize_turn** to complete the turn. Until then the
the result is fed back and the LLM is re-invoked. The loop ends loop continues feeding tool results back.
when the LLM outputs a ```json block with no tool calls.
`on_thought` is called for each thought block (may be from a `on_thought` / `on_action` may be called from a worker thread
worker thread use call_from_thread in the TUI). use call_from_thread in the TUI.
""" """
system = self.build_system_prompt() system = self.build_system_prompt()
user = self.build_user_message( user = self.build_user_message(
player_action=player_action, player_action=player_action,
last_narrative=last_narrative, last_prompt=last_prompt,
) )
messages: list[dict] = [ messages: list[dict] = [
@ -553,47 +583,102 @@ class GameEngine:
{"role": "user", "content": user}, {"role": "user", "content": user},
] ]
self._set_llm_env()
try: try:
import litellm import litellm
except ImportError: except ImportError:
return GenerationResult(narrative="", error="litellm not installed") return TurnResult(error="litellm not installed")
if self.api_key:
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
max_rounds = 10 max_rounds = 10
debug_entries: list[str] = []
for round_idx in range(max_rounds): for round_idx in range(max_rounds):
round_log: list[str] = [f"── Round {round_idx + 1} ──"]
try: try:
response = litellm.completion( response = litellm.completion(
model=self.model, model=self.model,
messages=messages, messages=messages,
temperature=self.temperature, temperature=self.temperature,
stream=False, stream=False,
timeout=30,
) )
text = response.choices[0].message.content or "" text = response.choices[0].message.content or ""
except Exception as e: except Exception as e:
return GenerationResult(narrative="", error=f"LLM call failed: {e}") return TurnResult(error=f"LLM call failed: {e}")
# Thoughts
thoughts = self._extract_thoughts(text) thoughts = self._extract_thoughts(text)
if thoughts:
round_log.append(f" thoughts: {len(thoughts)}")
for t in thoughts: for t in thoughts:
if on_thought: if on_thought:
on_thought(t.strip()) on_thought(t.strip())
# Tool calls
tool_calls = self._extract_tool_calls(text) tool_calls = self._extract_tool_calls(text)
final_data = self._extract_final_json(text) finalize_call: dict | None = None
other_calls: list[dict] = []
if tool_calls:
results = []
for tc in tool_calls: for tc in tool_calls:
if tc.get("tool") == "finalize_turn":
finalize_call = tc
else:
other_calls.append(tc)
# Log tool call summary
if tool_calls:
names = [tc.get("tool", "?") for tc in tool_calls]
round_log.append(f" tools: {', '.join(names)}")
# finalize_turn present → validate and return
if finalize_call:
args = finalize_call.get("args", {})
errs = []
if not args.get("dm_status"):
errs.append("dm_status is required")
if not args.get("book_log"):
errs.append("book_log is required")
if not args.get("user_prompt"):
errs.append("user_prompt is required")
if errs:
round_log.append(f" finalize_turn validation errors: {', '.join(errs)}")
debug_entries.append("\n".join(round_log))
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": f"## Validation Error\nfinalize_turn missing: {', '.join(errs)}. Please provide all required fields and call finalize_turn again."
})
continue
return TurnResult(
book_log=args.get("book_log", ""),
user_prompt=args.get("user_prompt", ""),
ambience=args.get("ambience"),
character_updates=args.get("character_updates"),
world_updates=args.get("world_updates"),
journal_add=args.get("journal_add", []),
journal_done=args.get("journal_done", []),
)
# Execute other tools
if other_calls:
results = []
for tc in other_calls:
name = tc.get("tool", "?") name = tc.get("tool", "?")
args = tc.get("args", {}) args = tc.get("args", {})
# dm_status is required on every tool call
if not args.get("dm_status"):
err_msg = (
f"**Validation Error:** Tool `{name}` missing required `dm_status`. "
f"Describe what the DM is doing (e.g. "
f'`"dm_status": "consulting the archives"`). Please retry.'
)
results.append(err_msg)
round_log.append(f" {name}: MISSING dm_status")
continue
if on_action: if on_action:
on_action(self._describe_tool_action(name, args)) on_action(self._describe_tool_action(name, args))
if name == "player_roll" and on_player_roll: if name == "player_roll" and on_player_roll:
@ -604,32 +689,28 @@ class GameEngine:
else: else:
result = self._execute_tool(name, args) result = self._execute_tool(name, args)
results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}") results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}")
round_log.append(f" {name}: OK")
messages.append({"role": "assistant", "content": text}) messages.append({"role": "assistant", "content": text})
messages.append({ messages.append({
"role": "user", "role": "user",
"content": "## Tool Results\n\n" + "\n\n".join(results), "content": "## Tool Results\n\n" + "\n\n".join(results),
}) })
continue # Another round debug_entries.append("\n".join(round_log))
continue
# No tool calls → parse the final JSON # No tools, no finalize → remind LLM
if final_data: round_log.append(" no tool calls — prompted to use tools")
return GenerationResult( debug_entries.append("\n".join(round_log))
narrative=text[: text.rfind("```json")].strip(), messages.append({"role": "assistant", "content": text})
choices=final_data.get("choices", []), messages.append({
log_entry=final_data.get("log_entry"), "role": "user",
ambience=final_data.get("ambience"), "content": "## Instructions\nUse tools to gather information or call **finalize_turn** to complete the turn."
character_updates=final_data.get("character_updates"), })
world_updates=final_data.get("world_updates"),
journal_add=final_data.get("journal_add", []),
journal_done=final_data.get("journal_done", []),
)
# Fallback: no tool calls, no JSON → normal parse debug_text = "\n\n".join(debug_entries)
return self.parse_response(text) return TurnResult(
error=f"Turn loop exceeded max rounds ({max_rounds}). Below is a debug log of what the LLM did each round:\n\n{debug_text}",
return GenerationResult( debug_info=debug_text,
narrative="",
error="Tool loop exceeded max rounds (10)",
) )
# ── Response Parsing ──────────────────────────────────────────────── # ── Response Parsing ────────────────────────────────────────────────
@ -686,8 +767,8 @@ class GameEngine:
# ── State Persistence ─────────────────────────────────────────────── # ── State Persistence ───────────────────────────────────────────────
def apply_state(self, result: GenerationResult) -> None: def apply_state(self, result: TurnResult) -> None:
"""Write state changes from a GenerationResult to disk.""" """Write state changes from a TurnResult to disk."""
if result.character_updates: if result.character_updates:
CHAR_PATH.write_text(result.character_updates.strip() + "\n") CHAR_PATH.write_text(result.character_updates.strip() + "\n")
@ -695,9 +776,6 @@ class GameEngine:
if result.world_updates: if result.world_updates:
WORLD_PATH.write_text(result.world_updates.strip() + "\n") WORLD_PATH.write_text(result.world_updates.strip() + "\n")
if result.log_entry:
self.append_log(result.log_entry)
if result.ambience: if result.ambience:
AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n") AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n")

View File

@ -23,7 +23,7 @@ from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme from rich.theme import Theme
# ── Game engine ───────────────────────────────────────── # ── Game engine ─────────────────────────────────────────
from engine import GameEngine, GenerationResult from engine import GameEngine, GenerationResult, TurnResult
# ── Optional miniaudio ──────────────────────────────────── # ── Optional miniaudio ────────────────────────────────────
try: try:
@ -533,12 +533,13 @@ class ChaosTUI(App):
self.engine = GameEngine() self.engine = GameEngine()
# Game loop state # Game loop state
self._last_narrative: str = "" self._last_prompt: str = ""
self._last_result: GenerationResult | None = None self._last_result: TurnResult | None = None
self._is_processing: bool = False self._is_processing: bool = False
# Thinking animation # Thinking animation
self._thinking_dots = 0 self._spinner_frames = ["", "", "", ""]
self._thinking_frame = 0
self._thinking_timer_handle = None self._thinking_timer_handle = None
self._dm_action = "DM is weaving the narrative" self._dm_action = "DM is weaving the narrative"
@ -605,7 +606,7 @@ class ChaosTUI(App):
# ── Game Loop ───────────────────────────────────────── # ── Game Loop ─────────────────────────────────────────
def _call_llm(self, player_action: str | None = None): def _call_llm(self, player_action: str | None = None):
"""Called when we need new content from the LLM (scene or resolution).""" """Called when the player has acted — sends their action to the LLM."""
if self._is_processing: if self._is_processing:
return return
self._is_processing = True self._is_processing = True
@ -626,7 +627,7 @@ class ChaosTUI(App):
def _run_generation(self, player_action: str | None) -> None: def _run_generation(self, player_action: str | None) -> None:
"""Worker thread: calls engine.generate_with_tools() and posts result back.""" """Worker thread: calls engine.generate_with_tools() and posts result back."""
last_narrative = self._last_narrative if self._last_narrative else None last_prompt = self._last_prompt if self._last_prompt else None
def on_thought(thought: str) -> None: def on_thought(thought: str) -> None:
self.call_from_thread(self._on_thought, thought) self.call_from_thread(self._on_thought, thought)
@ -636,7 +637,7 @@ class ChaosTUI(App):
result = self.engine.generate_with_tools( result = self.engine.generate_with_tools(
player_action=player_action, player_action=player_action,
last_narrative=last_narrative, last_prompt=last_prompt,
on_thought=on_thought, on_thought=on_thought,
on_action=on_action, on_action=on_action,
on_player_roll=self._on_player_roll, on_player_roll=self._on_player_roll,
@ -647,10 +648,11 @@ class ChaosTUI(App):
def _show_thinking(self) -> None: def _show_thinking(self) -> None:
"""Show the thinking indicator and start the animation timer.""" """Show the thinking indicator and start the animation timer."""
self._dm_action = "DM is weaving the narrative" self._dm_action = "DM is weaving the narrative"
self._thinking_dots = 0 self._thinking_frame = 0
status = self.query_one("#play-status", Static) status = self.query_one("#play-status", Static)
status.add_class("processing") status.add_class("processing")
status.update(f"{self._dm_action}") spinner = self._spinner_frames[0]
status.update(f"{spinner} {self._dm_action}")
self._thinking_timer_handle = self.set_interval( self._thinking_timer_handle = self.set_interval(
0.5, self._tick_thinking 0.5, self._tick_thinking
) )
@ -668,18 +670,20 @@ class ChaosTUI(App):
"""Display a thought from the DM in the status bar.""" """Display a thought from the DM in the status bar."""
display = thought[:60] + "" if len(thought) > 60 else thought display = thought[:60] + "" if len(thought) > 60 else thought
self._dm_action = display self._dm_action = display
self._thinking_dots = 0 self._thinking_frame = 0
status = self.query_one("#play-status", Static) status = self.query_one("#play-status", Static)
status.add_class("processing") status.add_class("processing")
status.update(f"{display}") spinner = self._spinner_frames[0]
status.update(f"{spinner} {display}")
def _on_action(self, action: str) -> None: def _on_action(self, action: str) -> None:
"""Display a DM action (tool call) in the status bar.""" """Display a DM action (tool call) in the status bar."""
self._dm_action = action self._dm_action = action
self._thinking_dots = 0 self._thinking_frame = 0
status = self.query_one("#play-status", Static) status = self.query_one("#play-status", Static)
status.add_class("processing") status.add_class("processing")
status.update(f"{action}") spinner = self._spinner_frames[0]
status.update(f"{spinner} {action}")
def _on_player_roll(self, dice: str, reason: str) -> str: def _on_player_roll(self, dice: str, reason: str) -> str:
"""Called from worker thread. Shows roll popup, blocks until player responds.""" """Called from worker thread. Shows roll popup, blocks until player responds."""
@ -702,49 +706,42 @@ class ChaosTUI(App):
self.push_screen(RollModal(dice, reason), on_dismiss) self.push_screen(RollModal(dice, reason), on_dismiss)
def _tick_thinking(self) -> None: def _tick_thinking(self) -> None:
"""Animate the thinking dots on the current DM action.""" """Animate the spinner on the current DM action."""
if not self._is_processing: if not self._is_processing:
return return
self._thinking_dots = (self._thinking_dots + 1) % 4 self._thinking_frame = (self._thinking_frame + 1) % len(self._spinner_frames)
dots = "." * self._thinking_dots spinner = self._spinner_frames[self._thinking_frame]
status = self.query_one("#play-status", Static) status = self.query_one("#play-status", Static)
status.update(f"{self._dm_action}{dots}") status.update(f"{spinner} {self._dm_action}")
def _on_generation_done( def _on_generation_done(
self, result: GenerationResult, player_action: str | None self, result: TurnResult, player_action: str | None
) -> None: ) -> None:
"""Handle the completed generation on the main thread.""" """Handle the completed turn on the main thread."""
self._is_processing = False self._is_processing = False
self._hide_thinking() self._hide_thinking()
if result.error: if result.error:
self._show_error(result.error) self._show_error(result.error, result.debug_info)
return return
# If this was a resolution (player acted), archive the previous turn # Archive the turn's book log
if player_action and self._last_narrative: if result.book_log:
archive_text = ( self.engine.archive_turn(result.book_log)
f"{self._last_narrative}\n\n"
f"---\n\n"
f"**Player chose:** {player_action}\n\n"
f"{result.narrative}"
)
self.engine.archive_turn(archive_text)
# Apply state changes # Apply state changes
if result.character_updates or result.world_updates:
self.engine.apply_state(result) self.engine.apply_state(result)
# Display the scene # Display the next user prompt
self._display_scene(result) self._display_scene(result)
# Store for next turn # Store for next turn
self._last_narrative = result.narrative self._last_prompt = result.user_prompt
self._last_result = result self._last_result = result
def _display_scene(self, result: GenerationResult) -> None: def _display_scene(self, result: TurnResult) -> None:
"""Update the UI with a new scene.""" """Update the UI with the next user prompt."""
self._set_narrative(result.narrative) self._set_narrative(result.user_prompt)
self._enable_input() self._enable_input()
def _enable_input(self) -> None: def _enable_input(self) -> None:
@ -761,11 +758,12 @@ class ChaosTUI(App):
scroll = self.query_one("#play-scroll", VerticalScroll) scroll = self.query_one("#play-scroll", VerticalScroll)
scroll.scroll_home(animate=False) scroll.scroll_home(animate=False)
def _show_error(self, error: str) -> None: def _show_error(self, error: str, debug_info: str = "") -> None:
self._set_narrative( text = f"**Error:** {error}\n\n"
f"**Error:** {error}\n\n" if debug_info:
"Check your session/config.json and ensure your LLM provider is running." text += f"**Debug Info:**\n\n{debug_info}\n\n"
) text += "Check your session/config.json and ensure your LLM provider is running."
self._set_narrative(text)
self._enable_input() self._enable_input()
# ── Input handling ──────────────────────────────────── # ── Input handling ────────────────────────────────────