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:
parent
4b9078d41f
commit
7f69bf6349
@ -1,8 +1,8 @@
|
||||
{
|
||||
"llm": {
|
||||
"model": "ollama/llama3.1",
|
||||
"api_key": null,
|
||||
"api_base": null,
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "not-needed",
|
||||
"api_base": "http://localhost:8080/v1",
|
||||
"temperature": 0.8
|
||||
}
|
||||
}
|
||||
|
||||
85
tools/run.py
85
tools/run.py
@ -10,14 +10,14 @@ from __future__ import annotations
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from textual import on, work
|
||||
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 textual.worker import Worker, WorkerState, get_current_worker
|
||||
from rich.markdown import Markdown as RichMarkdown
|
||||
from rich.theme import Theme
|
||||
|
||||
@ -327,6 +327,9 @@ class ChaosTUI(App):
|
||||
overflow-y: auto;
|
||||
scrollbar-size-vertical: 2;
|
||||
}
|
||||
#main-tabs {
|
||||
height: 1fr;
|
||||
}
|
||||
TabbedContent {
|
||||
background: #1a1a2a;
|
||||
}
|
||||
@ -366,8 +369,19 @@ class ChaosTUI(App):
|
||||
margin: 0 1;
|
||||
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 {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
background: #222222;
|
||||
color: #e0d0c0;
|
||||
@ -377,11 +391,10 @@ class ChaosTUI(App):
|
||||
#play-input:focus {
|
||||
border: solid #e0ad4c;
|
||||
}
|
||||
#play-processing {
|
||||
background: #1a1a2a;
|
||||
color: #888888;
|
||||
padding: 1 2;
|
||||
text-style: italic;
|
||||
#play-input:disabled {
|
||||
background: #1a1a1a;
|
||||
color: #666666;
|
||||
border: solid #333333;
|
||||
}
|
||||
|
||||
/* Book tab */
|
||||
@ -464,6 +477,10 @@ class ChaosTUI(App):
|
||||
self._last_result: GenerationResult | None = None
|
||||
self._is_processing: bool = False
|
||||
|
||||
# Thinking animation
|
||||
self._thinking_dots = 0
|
||||
self._thinking_timer_handle = None
|
||||
|
||||
# Book viewer state
|
||||
self._book_page = 0
|
||||
self._book_pages = []
|
||||
@ -480,6 +497,7 @@ class ChaosTUI(App):
|
||||
with VerticalScroll(id="play-scroll"):
|
||||
yield Static("*Awaiting the fates...*", id="play-narrative")
|
||||
yield Horizontal(id="play-choices")
|
||||
yield Static("", id="play-status")
|
||||
yield Input(
|
||||
placeholder="Type your action and press Enter...",
|
||||
id="play-input",
|
||||
@ -530,17 +548,21 @@ class ChaosTUI(App):
|
||||
|
||||
input_widget = self.query_one("#play-input", Input)
|
||||
input_widget.disabled = True
|
||||
input_widget.placeholder = "LLM is thinking..."
|
||||
|
||||
self._set_narrative("✦ *The fates conspire...* ✦")
|
||||
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:
|
||||
"""Worker thread: calls engine.generate() and posts result back."""
|
||||
worker = get_current_worker()
|
||||
|
||||
# Provide previous narrative as context on subsequent calls
|
||||
last_narrative = self._last_narrative if self._last_narrative else None
|
||||
|
||||
@ -549,16 +571,42 @@ class ChaosTUI(App):
|
||||
last_narrative=last_narrative,
|
||||
)
|
||||
|
||||
if worker.is_cancelled:
|
||||
return
|
||||
|
||||
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(
|
||||
self, result: GenerationResult, player_action: str | None
|
||||
) -> None:
|
||||
"""Handle the completed generation on the main thread."""
|
||||
self._is_processing = False
|
||||
self._hide_thinking()
|
||||
|
||||
if result.error:
|
||||
self._show_error(result.error)
|
||||
@ -591,13 +639,10 @@ class ChaosTUI(App):
|
||||
self._set_choices(result.choices)
|
||||
self._enable_input()
|
||||
|
||||
# Focus the input
|
||||
input_widget = self.query_one("#play-input", Input)
|
||||
input_widget.focus()
|
||||
|
||||
def _enable_input(self) -> None:
|
||||
input_widget = self.query_one("#play-input", Input)
|
||||
input_widget.disabled = False
|
||||
input_widget.placeholder = "Type your action and press Enter..."
|
||||
input_widget.value = ""
|
||||
input_widget.focus()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user