Add thinking indicator and fix input visibility

- LLM progress indicator: animated status bar ('✦ LLM is weaving the
  narrative ✦') with rotating dots, shown during processing, hidden on
  completion. Disabled input shows 'LLM is thinking...' placeholder.
- Fix input not visible: added 'height: 1fr' to TabbedContent so the PLAY
  tab and its input widget fill available vertical space.
- Replace @work(thread=True) with threading.Thread for reliable worker
  execution across all environments (headless, test, TUI).
This commit is contained in:
Dejvino 2026-06-25 12:18:56 +02:00
parent 4b9078d41f
commit 7f69bf6349
2 changed files with 68 additions and 23 deletions

View File

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

View File

@ -10,14 +10,14 @@ from __future__ import annotations
import os import os
import random import random
import sys import sys
import threading
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from textual import on, work from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Input, Static, TabbedContent, TabPane from textual.widgets import Button, Input, Static, TabbedContent, TabPane
from textual.worker import Worker, WorkerState, get_current_worker
from rich.markdown import Markdown as RichMarkdown from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme from rich.theme import Theme
@ -327,6 +327,9 @@ class ChaosTUI(App):
overflow-y: auto; overflow-y: auto;
scrollbar-size-vertical: 2; scrollbar-size-vertical: 2;
} }
#main-tabs {
height: 1fr;
}
TabbedContent { TabbedContent {
background: #1a1a2a; background: #1a1a2a;
} }
@ -366,8 +369,19 @@ class ChaosTUI(App):
margin: 0 1; margin: 0 1;
min-width: 12; min-width: 12;
} }
#play-status {
background: #1a1a2a;
color: #e0b060;
padding: 0 2;
height: 1;
text-style: bold italic;
text-align: center;
}
#play-status.processing {
background: #2a1a0a;
color: #ffd93d;
}
#play-input { #play-input {
dock: bottom;
height: 3; height: 3;
background: #222222; background: #222222;
color: #e0d0c0; color: #e0d0c0;
@ -377,11 +391,10 @@ class ChaosTUI(App):
#play-input:focus { #play-input:focus {
border: solid #e0ad4c; border: solid #e0ad4c;
} }
#play-processing { #play-input:disabled {
background: #1a1a2a; background: #1a1a1a;
color: #888888; color: #666666;
padding: 1 2; border: solid #333333;
text-style: italic;
} }
/* Book tab */ /* Book tab */
@ -464,6 +477,10 @@ class ChaosTUI(App):
self._last_result: GenerationResult | None = None self._last_result: GenerationResult | None = None
self._is_processing: bool = False self._is_processing: bool = False
# Thinking animation
self._thinking_dots = 0
self._thinking_timer_handle = None
# Book viewer state # Book viewer state
self._book_page = 0 self._book_page = 0
self._book_pages = [] self._book_pages = []
@ -480,6 +497,7 @@ class ChaosTUI(App):
with VerticalScroll(id="play-scroll"): with VerticalScroll(id="play-scroll"):
yield Static("*Awaiting the fates...*", id="play-narrative") yield Static("*Awaiting the fates...*", id="play-narrative")
yield Horizontal(id="play-choices") yield Horizontal(id="play-choices")
yield Static("", id="play-status")
yield Input( yield Input(
placeholder="Type your action and press Enter...", placeholder="Type your action and press Enter...",
id="play-input", id="play-input",
@ -530,17 +548,21 @@ class ChaosTUI(App):
input_widget = self.query_one("#play-input", Input) input_widget = self.query_one("#play-input", Input)
input_widget.disabled = True input_widget.disabled = True
input_widget.placeholder = "LLM is thinking..."
self._set_narrative("✦ *The fates conspire...* ✦")
self._clear_choices() self._clear_choices()
self._show_thinking()
self._run_generation(player_action) # Run generation in a daemon thread so it doesn't block the UI
t = threading.Thread(
target=self._run_generation,
args=(player_action,),
daemon=True,
)
t.start()
@work(thread=True, exit_on_error=False)
def _run_generation(self, player_action: str | None) -> None: def _run_generation(self, player_action: str | None) -> None:
"""Worker thread: calls engine.generate() and posts result back.""" """Worker thread: calls engine.generate() and posts result back."""
worker = get_current_worker()
# Provide previous narrative as context on subsequent calls # Provide previous narrative as context on subsequent calls
last_narrative = self._last_narrative if self._last_narrative else None last_narrative = self._last_narrative if self._last_narrative else None
@ -549,16 +571,42 @@ class ChaosTUI(App):
last_narrative=last_narrative, last_narrative=last_narrative,
) )
if worker.is_cancelled:
return
self.call_from_thread(self._on_generation_done, result, player_action) self.call_from_thread(self._on_generation_done, result, player_action)
def _show_thinking(self) -> None:
"""Show the thinking indicator and start the animation timer."""
self._thinking_dots = 0
status = self.query_one("#play-status", Static)
status.add_class("processing")
status.update("✦ LLM is weaving the narrative ✦")
self._thinking_timer_handle = self.set_interval(
0.5, self._tick_thinking
)
def _hide_thinking(self) -> None:
"""Stop the animation and hide the thinking indicator."""
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 _tick_thinking(self) -> None:
"""Animate the thinking dots."""
if not self._is_processing:
return
self._thinking_dots = (self._thinking_dots + 1) % 4
dots = "." * self._thinking_dots
status = self.query_one("#play-status", Static)
status.update(f"✦ LLM is weaving the narrative{dots}")
def _on_generation_done( def _on_generation_done(
self, result: GenerationResult, player_action: str | None self, result: GenerationResult, player_action: str | None
) -> None: ) -> None:
"""Handle the completed generation on the main thread.""" """Handle the completed generation on the main thread."""
self._is_processing = False self._is_processing = False
self._hide_thinking()
if result.error: if result.error:
self._show_error(result.error) self._show_error(result.error)
@ -591,13 +639,10 @@ class ChaosTUI(App):
self._set_choices(result.choices) self._set_choices(result.choices)
self._enable_input() self._enable_input()
# Focus the input
input_widget = self.query_one("#play-input", Input)
input_widget.focus()
def _enable_input(self) -> None: def _enable_input(self) -> None:
input_widget = self.query_one("#play-input", Input) input_widget = self.query_one("#play-input", Input)
input_widget.disabled = False input_widget.disabled = False
input_widget.placeholder = "Type your action and press Enter..."
input_widget.value = "" input_widget.value = ""
input_widget.focus() input_widget.focus()