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": {
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
tools/run.py
85
tools/run.py
@ -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()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user