This commit is contained in:
Waylon Walker 2025-11-22 22:17:19 -06:00
parent a6f36ca6f9
commit c9f7d54e07
13 changed files with 1803 additions and 42 deletions

View file

@ -2,9 +2,8 @@ import typer
from marvin_sw_text_adventure.cli.common import verbose_callback
from marvin_sw_text_adventure.cli.game import game_app
# from marvin_sw_text_adventure.cli.config import config_app
# from marvin_sw_text_adventure.cli.tui import tui_app
from marvin_sw_text_adventure.cli.tui import tui_app
app = typer.Typer(
name="marvin_sw_text_adventure",
@ -12,7 +11,7 @@ app = typer.Typer(
)
app.add_typer(game_app)
# app.add_typer(config_app)
# app.add_typer(tui_app)
app.add_typer(tui_app)
def version_callback(value: bool) -> None:

View file

@ -2,6 +2,7 @@ import atexit
from typing import List, Tuple
from marvin import ai_fn
from numpy import character
import pydantic
from pydantic import Field
from rich.panel import Panel
@ -9,7 +10,6 @@ from rich.prompt import Prompt
from rich.table import Table
from marvin_sw_text_adventure.console import console
from numpy import character
@ai_fn

View file

@ -1,18 +1,341 @@
Screen {
.hidden {
display: none;
}
#create {
align: center middle;
layers: main footer;
width: 100%;
height: 100%;
}
#create #create-label {
align: center middle;
text-align: center;
border-title-align: center;
padding: 1 2;
color: $text;
border: round gray 90%;
}
DebugInfo {
min-height: 10;
}
Sidebar {
height: 100vh;
width: auto;
min-width: 20;
background: $secondary-background-darken-2;
dock: left;
margin-right: 1;
layer: main;
background: $panel;
width: 35;
height: 100%;
border-right: vkey $background;
}
Footer {
layer: footer;
Sidebar Button {
width: 1fr;
}
Sidebar Button#settings {
dock: bottom;
}
Sidebar #meta-buttons-container {
height: auto;
}
Sidebar #quit {
background: $error 25%;
}
Sidebar #quit:hover {
background: $error 50%;
}
Sidebar TabbedContent {
height: 100%;
}
Sidebar TabPane {
padding: 0;
}
Sidebar TabbedContent ContentSwitcher {
height: 1fr;
}
Sidebar OptionList {
height: 1fr;
}
Hud #hud {
height: 3;
width: 100%;
background: $surface;
background: blue;
layout: horizontal;
}
Hud #character {
background: red;
}
Hud #ship {
background: gold;
}
Conversation {
height: 90%;
width: 100%;
background: $panel;
}
Conversation Input {
dock: bottom;
}
Response {
height: auto;
width: 100%;
}
Response.response-hover {
background: $boost;
}
Response.show-edit-buttons {
border: round $warning 50%;
border-title-align: center;
background: $boost;
padding: 2 4;
margin: 1 3;
}
ResponseBody {
height: auto;
border: round gray 90%;
padding: 1 2 0 2;
margin: 1 3;
width: 80%;
}
Response Horizontal {
height: auto;
width: 100%;
align: center middle;
}
Response Button {
margin: 0 2;
}
ResponseBody.show-edit-buttons {
width: 100%;
}
UserResponse {
align-horizontal: right;
}
UserResponse ResponseBody {
background: lightslategray 10%;
}
BotResponse ResponseBody {
border: skyblue 50%;
background: $primary 30%;
}
ModalScreen {
align: center middle;
}
SettingsDialogue,
BotsDialogue,
DatabaseUpgradeDialogue,
ConfirmMessageDeleteDialogue {
background: $panel;
margin: 1 2;
padding: 2 4;
width: 70%;
min-width: 60;
height: auto;
border: thick $primary 80%;
}
SettingsDialogue TabbedContent {
margin: 4;
padding: 2;
}
SettingsDialogue TabPane {
height: 10;
}
DatabaseUpgradeDialogue {
border: thick red 50%;
padding: 2 4;
}
DatabaseUpgradeDialogue Label {
/* color: darkorange; */
width: 100%;
margin: 2 0;
}
ConfirmMessageDeleteDialogue {
width: auto;
}
ConfirmMessageDeleteDialogue Horizontal {
height: auto;
width: 100%;
align: center middle;
margin: 2 0 0 0;
}
ConfirmMessageDeleteDialogue .confirm-delete-message {
text-align: center;
width: 100%;
}
ConfirmMessageDeleteDialogue Button {
margin: 0 2;
}
#message-top {
align: center middle;
}
#empty-thread-container {
align: center middle;
min-width: 50%;
width: auto;
padding: 4 10;
border: round $secondary;
height: auto;
/* background: $boost; */
color: $text;
border-title-align: center;
}
#empty-thread-bot-name {
width: auto;
}
#empty-thread-bot-description {
border-left: solid $secondary 60%;
padding-left: 1;
margin-top: 1;
height: auto;
text-style: italic;
max-width: 100%;
/* width: 100%; */
}
#bot-name-container {
align: center middle;
height: 4;
border-bottom: solid $secondary;
}
#bot-name {
color: $success;
text-style: bold;
}
.sidebar-title {
height: 3;
text-align: center;
/* text-style: bold; */
width: 100%;
padding-top: 1;
}
.settings-container {
width: 100%;
height: auto;
border: round white 40%;
margin: 2 1;
padding: 1 2;
}
SettingsDialogue .api-key-info {
width: 100%;
margin: 2 1 0 1;
}
SettingsDialogue .error {
color: red;
}
BotsDialogue {
width: 80%;
height: 70%;
}
BotsDialogue Horizontal {
height: auto;
}
BotsDialogue Button {
margin-right: 2;
}
BotsDialogue #install-default-bots {
padding: 0 2;
}
BotsOptionList {
border: solid white 50%;
background: $boost;
margin: 1 2;
}
BotInfo {
border: solid white 50%;
column-span: 2;
height: 100%;
width: 100%;
}
BotInfo TextTable {
height: 100%;
width: 100%;
overflow: auto auto;
padding: 1 2 1 2;
}
BotInfo TextTable .label {
padding-right: 1;
color: gray;
}
#bots-info-container {
layout: grid;
grid-size: 3;
overflow-y: scroll;
}
BotsList {
margin: 1 2;
}
BotInfo {
margin: 1 2;
background: $boost;
border: solid $secondary;
}
#bot-info-outer-container {
margin: 2 3 2 3;
}
BotInfo .bot-info-container {
margin-bottom: 2;
height: auto;
}
BotInfo .label {
text-style: bold;
}
BotInfo .text {
border-left: solid $secondary 60%;
padding-left: 1;
margin-top: 1;
/* height: auto; */
text-style: italic;
max-width: 100%;
width: auto;
}

View file

@ -1,44 +1,389 @@
from pathlib import Path
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
from textual.css.query import NoMatches
from textual.widgets import Footer, Static
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.config import config
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,
)
config["tui"] = {}
config["tui"]["bindings"] = {}
logging.basicConfig(
level="NOTSET",
handlers=[TextualHandler()],
)
class Sidebar(Static):
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:
yield Container(
Static("sidebar"),
id="sidebar",
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
class Tui(App):
"""A Textual app to manage requests."""
async def watch_action(self, action: str) -> None:
if action is None:
return
conversation = self.query_one("Conversation", Conversation)
CSS_PATH = Path("__file__").parent / "app.css"
BINDINGS = [tuple(b.values()) for b in config["tui"]["bindings"]]
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"
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Container(Static("hello world"))
yield Footer()
await conversation.add_response(
BotResponse(
marvin.models.threads.Message(
role="bot",
name="sw-text-adventure",
bot_id=123,
content=result.story,
)
)
)
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
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.",
)
)
)
def action_toggle_sidebar(self):
try:
self.query_one("PromptSidebar").remove()
except NoMatches:
self.mount(Sidebar())
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():
@ -54,7 +399,7 @@ def run_app():
features.add("devtools")
os.environ["TEXTUAL"] = ",".join(sorted(features))
app = Tui()
app = SWTextAdventure()
app.run()