marvin-sw-text-adventure/marvin_sw_text_adventure/tui/app.py
2025-11-22 22:17:19 -06:00

407 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import time
from typing import Optional
import marvin
import pendulum
import pyperclip
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, VerticalScroll
from textual.events import Enter, Leave
from textual.logging import TextualHandler
from textual.message import Message
from textual.reactive import reactive
from textual.screen import ModalScreen, Screen
from textual.widgets import Button, Input, Label, Markdown
from marvin_sw_text_adventure.console import console
from marvin_sw_text_adventure.game import (
Game,
StarWarsCharacter,
create_game,
did_complete_mission,
get_next_mission,
)
logging.basicConfig(
level="NOTSET",
handlers=[TextualHandler()],
)
class ResponseHover(Message):
pass
class ResponseBody(Markdown):
text: str = ""
def update(self, markdown: str):
self.text = markdown
super().update(markdown)
def on_enter(self):
self.post_message(ResponseHover())
class Response(Container):
body = None
stream_finished: bool = False
def __init__(self, message: marvin.models.threads.Message, **kwargs) -> None:
classes = kwargs.setdefault("classes", "")
kwargs["classes"] = f"{classes} response".strip()
self.message = message
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
self.body = ResponseBody(self.message.content, classes="response-body markdown")
self.body.border_title = (
"You" if self.message.role == "user" else self.message.name
)
self.body.border_subtitle = (
pendulum.instance(self.message.timestamp).in_tz("local").format("h:mm:ss A")
)
self.border_title = "Edit message"
yield self.body
with Horizontal(classes="edit-buttons-container hidden"):
yield Button("Copy", variant="default", id="copy-message")
yield Button("Delete", variant="error", id="delete-message")
def on_click(self):
self.action_toggle_buttons()
def on_response_hover(self, event: ResponseHover):
"""
This is an "enter" event bubbled up from the ResponseBody, since the
default "Leave" is triggered when hovering on child widgets. This keeps
the hover class even when hovering on the child widget.
"""
self.add_class("response-hover")
def on_enter(self, event: Enter):
self.add_class("response-hover")
def on_leave(self, event: Leave):
self.remove_class("response-hover")
def action_toggle_buttons(self):
self.toggle_class("show-edit-buttons")
self.body.toggle_class("show-edit-buttons")
self.query_one(".edit-buttons-container").toggle_class("hidden")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "copy-message":
pyperclip.copy(self.message.content)
self.action_toggle_buttons()
elif event.button.id == "delete-message":
self.app.push_screen(ConfirmMessageDeleteScreen(self.message.id))
class UserResponse(Response):
def __init__(self, message: marvin.models.threads.Message, **kwargs) -> None:
classes = kwargs.setdefault("classes", "")
kwargs["classes"] = f"{classes} user-response".strip()
super().__init__(message=message, **kwargs)
class BotResponse(Response):
def __init__(self, message: marvin.models.threads.Message, **kwargs) -> None:
classes = kwargs.setdefault("classes", "")
kwargs["classes"] = f"{classes} bot-response".strip()
super().__init__(message=message, **kwargs)
class Hud(Container):
...
def compose(self) -> ComposeResult:
with VerticalScroll(id="hud"):
with Container(id="character"):
yield Label("character name", id="character-name")
yield Label("character health", id="character-health")
yield Label("imperial credits", id="imperial-credits")
with Container(id="ship"):
yield Label("ship name", id="ship-name")
yield Label("ship health", id="ship-health")
yield Label("ship fuel", id="ship-fuel")
class Conversation(Container):
# bot_name = reactive(None, layout=True)
# def watch_bot_name(self, bot_name: str):
# with self.app.batch_update():
# bot_name_label = self.query_one("#empty-thread-bot-name", Label)
# bot_description_label = self.query_one(
# "#empty-thread-bot-description", Label
# )
# if bot_name:
# bot_name_label.update(
# f"Send a message to [bold green]{self.bot_name}[/]!"
# )
# if self.app.bot.description:
# bot_description_label.update(f"{self.app.bot.description}")
# bot_description_label.remove_class("hidden")
# else:
# bot_description_label.add_class("hidden")
# else:
# bot_description_label.add_class("hidden")
# bot_name_label.update("Send a message to start a thread...")
def compose(self) -> ComposeResult:
input = Input(placeholder="What do you do ", id="message-input")
input.focus()
yield input
with VerticalScroll(id="messages"):
with Container(id="message-top"):
with Container(id="empty-thread-container"):
yield Label("", id="empty-thread-bot-name")
yield Label("", id="empty-thread-bot-description")
async def add_response(self, response: Response, scroll: bool = True) -> None:
messages = self.app.query_one("Conversation #messages", VerticalScroll)
# wait for the responses to be fully mounted before scrolling
# to avoid issues with rendering Markdown
await messages.mount(response)
if scroll:
messages.scroll_end(duration=0.2)
# show / hide the empty thread message
empty = self.app.query_one("Conversation #empty-thread-container")
empty.add_class("hidden")
def clear_responses(self) -> None:
responses = self.app.query("Response")
for response in responses:
response.remove()
# self.bot_name = getattr(self.app.bot, "name")
empty = self.query_one("Conversation #empty-thread-container")
empty.remove_class("hidden")
class CreateScreen(ModalScreen):
def __init__(self, message: str, **kwargs) -> None:
self.message = message
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
with Container(id="create"):
yield Label(self.message, id="create-label")
class MainScreen(Screen):
BINDINGS = [
("escape", "focus_threads", "Focus on Threads"),
("n", "new_thread", "New Thread"),
("k", "scroll_up_messages", "Scroll Up"),
("j", "scroll_down_messages", "Scroll Down"),
("u", "page_up_messages", "Page Up"),
("d", "page_down_messages", "Page Down"),
]
action: Optional[str] = reactive(None, always_update=True, layout=True)
def action_focus_threads(self) -> None:
self.app.query_one("#threads", Threads).focus()
def action_focus_message(self) -> None:
self.app.query_one("#message-input", Input).focus()
def action_scroll_up_messages(self) -> None:
messages = self.query_one("Conversation #messages", VerticalScroll)
messages.scroll_up(duration=0.1)
def action_scroll_down_messages(self) -> None:
messages = self.query_one("Conversation #messages", VerticalScroll)
messages.scroll_down(duration=0.1)
def action_page_up_messages(self) -> None:
messages = self.query_one("Conversation #messages", VerticalScroll)
messages.scroll_page_up(duration=0.1)
def action_page_down_messages(self) -> None:
messages = self.query_one("Conversation #messages", VerticalScroll)
messages.scroll_page_down(duration=0.1)
def compose(self) -> ComposeResult:
# yield Sidebar(id="sidebar")
yield Hud(id="hud")
yield Conversation(id="conversation")
async def on_mount(self):
if not marvin.settings.openai_api_key.get_secret_value():
self.set_timer(0.5, self.action_show_settings_screen)
conversation = self.query_one("Conversation", Conversation)
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=f"{self.app.character.name} from {self.app.character.home_planet} - {self.app.character.backstory}",
)
)
)
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=f"Mission: {self.app.character.mission.description}",
)
)
)
async def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.disabled:
return
elif not event.input.value:
return
action = event.input.value
event.input.value = ""
conversation = self.query_one("Conversation", Conversation)
await conversation.add_response(
UserResponse(
marvin.models.threads.Message(name="User", role="user", content=action)
)
)
self.action = action
async def watch_action(self, action: str) -> None:
if action is None:
return
conversation = self.query_one("Conversation", Conversation)
result = did_complete_mission(self.app.character, action)
self.app.character.previous_missions.append(
(self.app.character.mission, result)
)
self.app.character.previous_missions = self.app.character.previous_missions[-5:]
self.app.character.imperial_credits -= result.imperial_credits_spent
self.app.character.imperial_credits += result.imperial_credits_earned
self.app.character.health -= result.health_lost
self.app.character.health += result.health_gained
self.app.character.ship.fuel_level -= result.fuel_used
message = ""
message += f"Your mission has been completed"
message += result.story
message += f"You earned {result.imperial_credits_earned} imperial credits"
message += f"You spent {result.imperial_credits_spent} imperial credits"
message += f"You gained {result.health_gained} health"
message += f"You lost {result.health_lost} health"
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=result.story,
)
)
)
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=f"You earned {result.imperial_credits_earned} imperial credits, spent {result.imperial_credits_spent} imperial credits, gained {result.health_gained} hp, and lost {result.health_lost} hp.",
)
)
)
self.app.character.mission = get_next_mission(
self.app.character, action, result.success
)
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=f"Next Mission: {self.app.character.mission.description} imperial credits, spent {result.imperial_credits_spent} imperial credits, gained {result.health_gained} hp, and lost {result.health_lost}",
)
)
)
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "show-settings":
self.action_show_settings_screen()
elif event.button.id == "show-bots":
self.action_show_bots_screen()
elif event.button.id == "create-new-thread":
self.action_new_thread()
elif event.button.id == "delete-thread":
await self.action_delete_thread()
elif event.button.id == "quit":
self.app.exit()
class SWTextAdventure(App):
CSS_PATH = ["app.css"]
character: Optional[StarWarsCharacter] = reactive(
None, always_update=True, layout=True
)
game_type: Optional[str] = reactive(None, always_update=True, layout=True)
game: Optional[Game] = reactive(None, always_update=True, layout=True)
game_ready: bool = reactive(False)
is_ready: bool = False
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.game_type = "star-wars"
async def on_ready(self) -> None:
self.push_screen(MainScreen())
async def watch_game_type(self) -> None:
self.push_screen(CreateScreen(message="starting"))
time.sleep(1)
if self.character is None:
self.push_screen(CreateScreen(message="creating character"))
self.character = StarWarsCharacter.parse_file("character.json")
# self.character=create_character()
if self.game is None:
self.push_screen(
CreateScreen(message=f"creating game for {self.character.name}")
)
self.game = create_game # (self.character)
# self.pop_screen()
self.game_ready = True
self.is_ready = True
async def watch_action(self) -> None:
...
async def watch_game_ready(self, game_ready: bool) -> None:
console.log(f"Game ready: {game_ready}")
def run_app():
import os
import sys
from textual.features import parse_features
dev = "--dev" in sys.argv
features = set(parse_features(os.environ.get("TEXTUAL", "")))
if dev:
features.add("debug")
features.add("devtools")
os.environ["TEXTUAL"] = ",".join(sorted(features))
app = SWTextAdventure()
app.run()
if __name__ == "__main__":
run_app()