#!/usr/bin/env python3 """ run.py — The Chaos TTRPG Session Client (Game Mode) Owns the TUI and game loop. Layout: PLAY (narrative + choices + input) | CHAR | LOG | BOOK tabs """ from __future__ import annotations import json import threading from textual import on from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Input, Static, TabbedContent, TabPane from rich.markdown import Markdown as RichMarkdown from rich.theme import Theme from engine import GameEngine from engine_lib.models import TurnResult, END_MARKER from engine_lib import state from run_utils import ( BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH, TODAY, REFRESH_SECS, clear_llm_log, ensure_log, load_book_pages, ) from run_ambience import AmbiencePlayer from run_widgets import ( app_ambience_player as _widget_player_ref, CharPane, StatusBar, TodoPane, TranscriptPane, ) # ── Global state ───────────────────────────────────────── app_ambience_player: AmbiencePlayer | None = None # ── UI Theme ───────────────────────────────────────────── MARKDOWN_THEME = Theme({ "markdown.h1": "bold #ff6b6b on #2a0000", "markdown.h2": "bold #ffd93d", "markdown.h3": "bold italic #6bcbff", "markdown.h4": "bold #95e1d3", "markdown.code": "bold #00d2d3 on #002222", "markdown.code_block": "on #1e1e2e", "markdown.block_quote": "dim italic #8395a7", "markdown.link": "underline #48dbfb", "markdown.item": "#ff9ff3", "markdown.strong": "bold #feca57", "markdown.horizontal_rule": "dim #555555", }) # ── The App ────────────────────────────────────────────── class ChaosTUI(App): TITLE = "The Chaos" CSS = """ Screen { background: #121212; } #banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; } #main { height: 100%; background: #111111; } #todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; } #todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height:5; overflow-y:auto; scrollbar-size-vertical:2; } #main-tabs { height: 1fr; } TabbedContent { background: #1a1a2a; } VerticalScroll { overflow-y: auto; scrollbar-size-vertical:2; scrollbar-color:#555; scrollbar-color-hover:#777; scrollbar-color-active:#999; } #char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; } #transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; } #play-narrative { background: #161616; color: #d8d8d8; padding: 1 2; height: auto; } #play-narrative.meta { background: #1a1a2e; color: #b0a0e0; border-top: solid #6b4fa0; border-bottom: solid #6b4fa0; } #play-status { background: #1a2a1a; color: #e0b060; padding: 0 2; height: 1; text-style: bold italic; text-align: center; } #play-status.processing { background: #2a1a0a; color: #ffd93d; } #play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; } #play-input:focus { border: solid #e0ad4c; } #play-input:disabled { background: #1a1a1a; color: #666; border: solid #333; } #book-header { background: #2d2d2d; color: #e0c080; text-style: bold; padding: 0 1; height: 1; } #book-nav { height: 3; background: #222; align: center middle; } #book-nav Button { width: 10; margin: 0 1; } #book-nav Button:disabled { color: #444; } #book-nav Button:hover { text-style: bold; } #book-nav-center { height: 3; width: 1fr; } #book-page-label { height: 1; color: #c0b090; text-style: bold; padding: 0 2; text-align: center; } #book-progress { height: 1; background: #1a1a1a; color: #e0b060; padding: 0 2; text-align: center; } #book-scroll { height: 1fr; } #book-content { background: #161616; color: #d8d8d8; padding: 0 2; } #status-bar { background: #222; color: #888; padding: 0 1; height: 1; text-style: italic; } #mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; } #mute-btn:hover { background: #3a3a3a; color: #ccc; } #mute-btn.muted { color: #ff6b6b; text-style: bold; } #end-game-btn { display: none; margin: 1 2; height: 3; background: #5a3a00; color: #ffd93d; border: solid #8a5a00; } #end-game-btn.visible { display: block; } #end-game-btn:hover { background: #7a4a00; } """ BINDINGS = [ ("ctrl+c", "quit", "Quit"), ("escape", "quit", "Quit"), ] def __init__(self, *args, no_music=False, **kwargs): super().__init__(*args, **kwargs) global app_ambience_player app_ambience_player = None if no_music else AmbiencePlayer() import run_widgets run_widgets.app_ambience_player = app_ambience_player self.engine = GameEngine() self._last_result: TurnResult | None = None self._is_processing: bool = False self._spinner_frames = ["◴", "◷", "◶", "◵"] self._thinking_frame = 0 self._thinking_timer_handle = None self._dm_action = "DM is preparing a response" self._book_page = 0 self._book_pages: list[str] = [] self._prev_page_count = 0 self._settings_loaded = False self._game_over = False def _load_settings(self) -> dict: defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0} if SETTINGS_PATH.exists(): try: data = json.loads(SETTINGS_PATH.read_text()) if isinstance(data, dict): defaults.update(data) except (json.JSONDecodeError, OSError): pass return defaults def _save_settings(self) -> None: if not self._settings_loaded: return active = self.query_one("#main-tabs", TabbedContent).active or "play-tab" data = { "active_tab": active, "music_muted": app_ambience_player.is_muted if app_ambience_player else False, "book_page": self._book_page, } try: SETTINGS_PATH.write_text(json.dumps(data, indent=2) + "\n") except OSError: pass def compose(self): yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") with Vertical(id="main"): yield Static("TODO", id="todo-header") yield TodoPane(id="todo-content") with TabbedContent(initial="play-tab", id="main-tabs"): with TabPane("PLAY", id="play-tab"): with VerticalScroll(id="play-scroll"): yield Static("*Awaiting the fates...*", id="play-narrative") yield Static("", id="play-status") yield Input(placeholder="Type your action and press Enter...", id="play-input") yield Button("Close the Book and Start a New One", id="end-game-btn", variant="warning") with TabPane("CHARACTER", id="char-tab"): with VerticalScroll(): yield CharPane(id="char-content") with TabPane("LOG", id="log-tab"): with VerticalScroll(): yield TranscriptPane(id="transcript") with TabPane("BOOK", id="book-tab"): yield Static("BOOK", id="book-header") with Vertical(id="book-section"): with Horizontal(id="book-nav"): yield Button("<< Prev", id="book-prev") with Vertical(id="book-nav-center"): yield Static("", id="book-page-label") yield Static("", id="book-progress") yield Button("Next >>", id="book-next") with VerticalScroll(id="book-scroll"): yield Static("", id="book-content") yield StatusBar(id="status-bar") yield Button("♫", id="mute-btn", classes="mute-button") def on_mount(self): clear_llm_log() ensure_log() self.console._theme = MARKDOWN_THEME self._init_book() self.set_interval(REFRESH_SECS, self._check_ambience) self.set_interval(REFRESH_SECS, self._reload_book) self.call_after_refresh(self._update_mute_button) self.call_after_refresh(self._apply_settings) self.call_after_refresh(self._begin_game) def _apply_settings(self) -> None: settings = self._load_settings() self._book_page = settings.get("book_page", 0) self._prev_page_count = len(self._book_pages) self._reload_book() if settings.get("music_muted") and app_ambience_player and not app_ambience_player.is_muted: app_ambience_player.toggle_mute() self._update_mute_button() try: self.query_one("#main-tabs", TabbedContent).active = settings.get("active_tab", "play-tab") except Exception: pass self._settings_loaded = True def _begin_game(self): self._game_over = False self._last_narrative: str = "" pages = load_book_pages() if pages and pages != ["*The story has not begun.*"]: parts = [] parts.append(pages[-1]) if CHANGES_PATH.exists(): saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()] if saved: parts.append(self._render_changes(saved)) self._set_narrative("\n\n".join(parts)) self._enable_input() return self._call_llm() def _archive_and_reset(self) -> None: """Archive the session and reset for a new game.""" self._game_over = False btn = self.query_one("#end-game-btn", Button) btn.remove_class("visible") archive_path = state.archive_session() self._book_pages = ["*The story has not begun.*"] self._book_page = 0 self._render_book_page() self._begin_game() self._save_settings() def _check_ambience(self): if app_ambience_player: app_ambience_player.poll() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "mute-btn": if app_ambience_player: app_ambience_player.toggle_mute() self._update_mute_button() self._save_settings() elif event.button.id == "end-game-btn": self._archive_and_reset() def _update_mute_button(self) -> None: btn = self.query_one("#mute-btn", Button) if app_ambience_player and app_ambience_player.is_muted: btn.label = "♪ muted" btn.classes = "muted" btn.tooltip = "Unmute music" else: btn.label = "♫" btn.classes = "" btn.tooltip = "Mute music" def _call_llm(self, player_action: str | None = None): if self._is_processing: return self._is_processing = True input_widget = self.query_one("#play-input", Input) input_widget.disabled = True input_widget.placeholder = "DM is at work..." self._show_thinking() t = threading.Thread(target=self._run_generation, args=(player_action,), daemon=True) t.start() def _run_generation(self, player_action: str | None) -> None: import traceback def on_thought(thought: str) -> None: self.call_from_thread(self._on_thought, thought) def on_action(action: str) -> None: self.call_from_thread(self._on_action, action) try: result = self.engine.generate_turn( player_action=player_action, on_thought=on_thought, on_action=on_action, ) except Exception as e: self.call_from_thread(self._on_generation_error, e, traceback.format_exc()) return self.call_from_thread(self._on_generation_done, result, player_action) def _show_thinking(self) -> None: self._dm_action = "DM is preparing a response" self._thinking_frame = 0 status = self.query_one("#play-status", Static) status.add_class("processing") status.update(f"✦ {self._spinner_frames[0]} {self._dm_action} ✦") self._thinking_timer_handle = self.set_interval(0.5, self._tick_thinking) def _hide_thinking(self) -> None: if self._thinking_timer_handle: self._thinking_timer_handle.stop() self._thinking_timer_handle = None status = self.query_one("#play-status", Static) status.remove_class("processing") status.update("") def _on_thought(self, thought: str) -> None: display = thought[:60] + "…" if len(thought) > 60 else thought self._dm_action = display self._thinking_frame = 0 status = self.query_one("#play-status", Static) status.add_class("processing") status.update(f"✦ {self._spinner_frames[0]} {display} ✦") def _on_action(self, action: str) -> None: self._dm_action = action self._thinking_frame = 0 status = self.query_one("#play-status", Static) status.add_class("processing") status.update(f"✦ {self._spinner_frames[0]} {action} ✦") def _tick_thinking(self) -> None: if not self._is_processing: return self._thinking_frame = (self._thinking_frame + 1) % len(self._spinner_frames) self.query_one("#play-status", Static).update( f"✦ {self._spinner_frames[self._thinking_frame]} {self._dm_action} ✦") def _on_generation_done(self, result: TurnResult, player_action: str | None) -> None: self._is_processing = False self._hide_thinking() if result.error: self._show_error(result.error, result.debug_info) return if result.is_meta: self._display_scene(result) self._enable_input() return if result.book_log: turn_num = state.archive_turn(result.book_log) if result.log_entry: state.append_log(f"- **Turn {turn_num}** — {result.log_entry}") else: summary = result.book_log.strip().split(chr(10))[0][:80] state.append_log(f"- **Turn {turn_num}** — {summary}") result.book_log = load_book_pages()[-1] elif result.log_entry: state.append_log(f"- {result.log_entry}") state.apply_state(result) if result.game_over: self._game_over = True self._set_narrative(result.book_log if result.book_log else "(The story has ended.)") inp = self.query_one("#play-input", Input) inp.disabled = True inp.placeholder = "The story has ended." btn = self.query_one("#end-game-btn", Button) btn.add_class("visible") return if result.book_log or not result.user_prompt: self._display_scene(result) else: if self._last_narrative: self._set_narrative(f"{self._last_narrative}\n\n---\n\n{result.user_prompt}") self._enable_input() else: self._display_scene(result) if result.book_log: self._last_result = result def _on_generation_error(self, error: Exception, traceback_str: str) -> None: self._is_processing = False self._hide_thinking() err_msg = f"{type(error).__name__}: {error}" self._show_error(err_msg, traceback_str) @staticmethod def _render_changes(changes: list[str]) -> str: return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes) def _display_scene(self, result: TurnResult) -> None: parts = [] if result.book_log: parts.append(result.book_log) if result.changes: parts.append(self._render_changes(result.changes)) if result.user_prompt: parts.append(f"---\n\n{result.user_prompt}") self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta) self._enable_input() def _enable_input(self, value: str = "") -> None: inp = self.query_one("#play-input", Input) inp.disabled = False inp.placeholder = "Type your action and press Enter..." inp.value = value inp.focus() def _set_narrative(self, text: str, meta: bool = False) -> None: self._last_narrative = text widget = self.query_one("#play-narrative", Static) widget.set_class(meta, "meta") widget.update(RichMarkdown(text)) self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False) def _show_error(self, error: str, debug_info: str = "") -> None: t = f"**Error:** {error}\n\n" + (f"**Debug Info:**\n\n{debug_info}\n\n" if debug_info else "") self._set_narrative(t + "Check your session/config.json and ensure your LLM provider is running.") self._enable_input(value=self._last_player_action if hasattr(self, '_last_player_action') else "") def on_input_submitted(self, event: Input.Submitted) -> None: action = event.value.strip() if not action or self._is_processing: event.stop() return event.stop() self._last_player_action = action self._call_llm(player_action=action) def _init_book(self): self._reload_book() self._render_book_page() def _reload_book(self): self._book_pages = load_book_pages() if len(self._book_pages) > self._prev_page_count: self._book_page = len(self._book_pages) - 1 self._prev_page_count = len(self._book_pages) self._book_page = max(0, min(self._book_page, len(self._book_pages) - 1)) self._render_book_page() def _render_book_page(self): if not self._book_pages: return self.query_one("#book-content").update(RichMarkdown(self._book_pages[self._book_page])) total = len(self._book_pages) self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}") pct = (self._book_page + 1) / total if total else 1 self.query_one("#book-progress").update(f"[{'█'*round(pct*20)}{'░'*(20-round(pct*20))}]") self.query_one("#book-prev").disabled = (self._book_page == 0) self.query_one("#book-next").disabled = (self._book_page == total - 1) def action_prev_page(self): if self._book_page > 0: self._book_page -= 1 self._render_book_page() self.query_one("#book-scroll").scroll_home(animate=False) def action_next_page(self): if self._book_page < len(self._book_pages) - 1: self._book_page += 1 self._render_book_page() self.query_one("#book-scroll").scroll_home(animate=False) @on(Button.Pressed, "#book-prev") def on_book_prev(self): self.action_prev_page() self._save_settings() @on(Button.Pressed, "#book-next") def on_book_next(self): self.action_next_page() self._save_settings() def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: self._save_settings() def main(): import argparse parser = argparse.ArgumentParser(description="The Chaos TUI") parser.add_argument('--no-music', action='store_true', help='Disable ambience music') ChaosTUI(no_music=parser.parse_args().no_music).run() if __name__ == '__main__': main()