diff --git a/src/ui/tui.py b/src/ui/tui.py index 6669200..1df3014 100644 --- a/src/ui/tui.py +++ b/src/ui/tui.py @@ -3,6 +3,8 @@ from typing import List, Optional, Union from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical +from textual.message import Message +from textual.screen import ModalScreen from textual.widgets import ( Button, DataTable, @@ -19,61 +21,88 @@ from src.persistence.characters import update_character_state from src.persistence.lore import update_lore +class EditModal(ModalScreen): + def __init__(self, initial_text: str, on_save: callable): + super().__init__() + self.initial_text = initial_text + self.on_save = on_save + + def compose(self) -> ComposeResult: + with Vertical(id="modal-container"): + yield Label("Edit Fact Content:") + yield Input(value=self.initial_text, id="edit-input") + with Horizontal(id="modal-actions"): + yield Button("Save", id="btn-save") + yield Button("Cancel", id="btn-cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-save": + edit_input = self.query_one("#edit-input", Input) + self.on_save(edit_input.value) + self.dismiss() + elif event.button.id == "btn-cancel": + self.dismiss() + + class ConfirmationApp(App): CSS = """ #main-container { + layout: vertical; height: 100%; } - #top-pane { - height: 70%; + #pending-facts-table { + height: 40%; + border: solid white; } - #left-pane { + #llm-input-container { + height: 10%; + border: solid white; + padding: 1; + } + + #context-pane { + height: 50%; + border: solid white; + } + + #modal-container { width: 60%; - border: solid; - padding: 1; - } - - #right-pane { - width: 40%; - border: solid; - padding: 1; - layout: vertical; - } - - #middle-pane { - height: 30%; - border: solid; - padding: 1; - } - - #details-container { height: auto; - margin-bottom: 1; - } - - #actions-container { - height: auto; - layout: horizontal; + border: double white; + background: #222; + padding: 2; align: center middle; } - #edit-container { - display: none; + #modal-actions { height: auto; - layout: vertical; - border: solid; - padding: 1; + margin-top: 1; + align: right; } - Button { - margin: 0 1; + #edit-input { + margin: 1 0; + } + + #llm-input { + width: 100%; + } + + ListItem Static { + border: solid grey; + margin: 1 0; + padding: 1; } """ BINDINGS = [ ("q", "quit", "Quit"), + ("a", "accept", "Accept"), + ("r", "reject", "Reject"), + ("e", "edit", "Edit"), + ("enter", "send", "Send"), ] def __init__( @@ -81,287 +110,167 @@ class ConfirmationApp(App): result: Optional[ExtractionResult] = None, proposal_queue: Optional[asyncio.Queue] = None, context_queue: Optional[asyncio.Queue] = None, + query_queue: Optional[asyncio.Queue] = None, + response_queue: Optional[asyncio.Queue] = None, ): super().__init__() self.result = result self.proposal_queue = proposal_queue self.context_queue = context_queue + self.query_queue = query_queue + self.response_queue = response_queue self.pending_updates: List[Union[LoreUpdate, CharacterStateUpdate]] = [] if result: - # Populate pending updates from result self.pending_updates.extend(result.lore_updates) self.pending_updates.extend(result.character_updates) - self.selected_index = -1 - def compose(self) -> ComposeResult: - yield Container( + yield Vertical( + DataTable(id="pending-facts-table"), Vertical( - Horizontal( - Vertical( - DataTable(id="update-table"), - id="left-pane", - ), - Vertical( - Vertical( - Label("Details:", id="details-label"), - Static("No update selected", id="details-text"), - id="details-container", - ), - Vertical( - Label("Edit Value:"), - Input(id="edit-input"), - Button("Save Edit", id="save-edit"), - id="edit-container", - ), - Horizontal( - Button("Accept", id="btn-accept"), - Button("Reject", id="btn-reject"), - Button("Edit", id="btn-edit"), - id="actions-container", - ), - id="right-pane", - ), - ), - Horizontal( - ListView(id="context-pane"), - id="middle-pane", - ), - id="main-container", + Input(placeholder="Message LLM...", id="llm-input"), + id="llm-input-container", ), - Footer(), + ListView(id="context-pane"), + id="main-container", ) + yield Footer() def on_mount(self) -> None: - table = self.query_one("#update-table", DataTable) + table = self.query_one("#pending-facts-table", DataTable) table.cursor_type = "row" - table.add_columns("Type", "Target", "Update") + table.add_columns("Type", "Target", "Content") for i, update in enumerate(self.pending_updates): - if isinstance(update, LoreUpdate): - table.add_row( - "Lore", update.entity_name or "General", update.content, key=str(i) - ) - elif isinstance(update, CharacterStateUpdate): - change_text = f"HP: {update.hp_change or 0}" - if update.status_effects_added: - change_text += f", Added: {', '.join(update.status_effects_added)}" - if update.status_effects_removed: - change_text += ( - f", Removed: {', '.join(update.status_effects_removed)}" - ) - table.add_row("Char", update.character_name, change_text, key=str(i)) - - if self.pending_updates: - self.handle_row_highlight(0) - self.query_one("#btn-accept", Button).focus() + self.add_update_to_table(update, i) if self.proposal_queue: self.run_worker(self.poll_proposal_queue, thread=False) - if self.context_queue: self.run_worker(self.poll_context_queue, thread=False) + if self.response_queue: + self.run_worker(self.poll_response_queue, thread=False) - async def poll_proposal_queue(self) -> None: - """ - Background worker that polls the proposal queue for new extraction results. - """ - while True: - try: - result = await self.proposal_queue.get() - self.add_result(result) - # Signal that the item has been processed - if hasattr(self.proposal_queue, "task_done"): - self.proposal_queue.task_done() - except Exception as e: - # Log error but keep the worker running - self.log(f"Error polling proposal queue: {e}") + self.query_one("#llm-input", Input).focus() - async def poll_context_queue(self) -> None: - """ - Background worker that polls the context queue for new RAG updates. - """ - while True: - try: - update = await self.context_queue.get() - context_pane = self.query_one("#context-pane", ListView) - - # Format the update for display - display_text = f"Query: {update.query}\nSource: {update.source}\n\n{update.snippet}" - - # Add a new ListItem widget to the top of the ListView for 'most recent' - context_pane.mount(ListItem(Label(display_text), index=0)) - - if hasattr(self.context_queue, "task_done"): - self.context_queue.task_done() - except Exception as e: - self.log(f"Error polling context queue: {e}") - - def add_result(self, result: ExtractionResult) -> None: - """ - Adds results from the LLM processor to the TUI table. - """ - table = self.query_one("#update-table", DataTable) - start_index = len(self.pending_updates) - - for update in result.lore_updates + result.character_updates: - self.pending_updates.append(update) - actual_index = len(self.pending_updates) - 1 - - if isinstance(update, LoreUpdate): - table.add_row( - "Lore", - update.entity_name or "General", - update.content, - key=str(actual_index), - ) - elif isinstance(update, CharacterStateUpdate): - change_text = f"HP: {update.hp_change or 0}" - if update.status_effects_added: - change_text += f", Added: {', '.join(update.status_effects_added)}" - if update.status_effects_removed: - change_text += ( - f", Removed: {', '.join(update.status_effects_removed)}" - ) - table.add_row( - "Char", update.character_name, change_text, key=str(actual_index) - ) - - # If the table was previously empty and we added updates, focus the first one. - if start_index == 0 and self.pending_updates: - self.handle_row_highlight(0) - self.query_one("#btn-accept", Button).focus() - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - self.handle_row_highlight(event.cursor_row) - - def handle_row_highlight(self, row: int) -> None: - self.selected_index = row - if self.selected_index < 0 or self.selected_index >= len(self.pending_updates): - return - - update = self.pending_updates[self.selected_index] - - details_text = self.query_one("#details-text", Static) + def add_update_to_table( + self, update: Union[LoreUpdate, CharacterStateUpdate], index: int + ) -> None: + table = self.query_one("#pending-facts-table", DataTable) if isinstance(update, LoreUpdate): - details_text.update( - f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}" + table.add_row( + "Lore", update.entity_name or "General", update.content, key=str(index) ) - elif isinstance(update, CharacterStateUpdate): - details_text.update( - f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}" - ) - - # Reset to detail view - self.query_one("#edit-container", Vertical).styles.display = "none" - self.query_one("#details-container", Vertical).styles.display = "block" - - def on_button_pressed(self, event: Button.Pressed) -> None: - if self.selected_index == -1: - return - - update = self.pending_updates[self.selected_index] - - if event.button.id == "btn-accept": - if isinstance(update, LoreUpdate): - update_lore(update) - elif isinstance(update, CharacterStateUpdate): - update_character_state(update) - - self.remove_update(self.selected_index) - - elif event.button.id == "btn-reject": - self.remove_update(self.selected_index) - - elif event.button.id == "btn-edit": - self.show_edit_mode(update) - - elif event.button.id == "save-edit": - self.save_edit(update) - - def show_edit_mode(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None: - edit_input = self.query_one("#edit-input", Input) - - if isinstance(update, LoreUpdate): - edit_input.value = update.content - elif isinstance(update, CharacterStateUpdate): - # For simplicity, only allow editing HP change in this TUI - edit_input.value = str(update.hp_change or 0) - - self.query_one("#edit-container", Vertical).styles.display = "block" - self.query_one("#details-container", Vertical).styles.display = "none" - - def save_edit(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None: - new_val = self.query_one("#edit-input", Input).value - - if isinstance(update, LoreUpdate): - update.content = new_val - elif isinstance(update, CharacterStateUpdate): - try: - update.hp_change = int(new_val) - except ValueError: - # Ignore invalid integer input - pass - - # Refresh the table - table = self.query_one("#update-table", DataTable) - # Textual DataTable doesn't have a simple 'update_row', so we clear and refill - # or we can use update_cell. - - # Update the table row - if isinstance(update, LoreUpdate): - table.update_cell(self.selected_index, 2, update.content) elif isinstance(update, CharacterStateUpdate): change_text = f"HP: {update.hp_change or 0}" if update.status_effects_added: change_text += f", Added: {', '.join(update.status_effects_added)}" if update.status_effects_removed: change_text += f", Removed: {', '.join(update.status_effects_removed)}" - table.update_cell(self.selected_index, 2, change_text) + table.add_row("Char", update.character_name, change_text, key=str(index)) - self.show_edit_mode(update) # just to refresh the value maybe? No,’ - # Actually let's go back to detail view - self.query_one("#edit-container", Vertical).styles.display = "none" - self.query_one("#details-container", Vertical).styles.display = "block" + async def poll_proposal_queue(self) -> None: + while True: + try: + result = await self.proposal_queue.get() + self.handle_proposal_result(result) + if hasattr(self.proposal_queue, "task_done"): + self.proposal_queue.task_done() + except Exception as e: + self.log(f"Error polling proposal queue: {e}") - # Update details text - details_text = self.query_one("#details-text", Static) + def handle_proposal_result(self, result: ExtractionResult) -> None: + table = self.query_one("#pending-facts-table", DataTable) + for update in result.lore_updates + result.character_updates: + index = len(self.pending_updates) + self.pending_updates.append(update) + self.add_update_to_table(update, index) + + async def poll_context_queue(self) -> None: + while True: + try: + update = await self.context_queue.get() + display_text = f"Query: {update.query}\nSource: {update.source}\n\n{update.snippet}" + context_list = self.query_one("#context-pane", ListView) + context_list.append(ListItem(Static(display_text))) + if hasattr(self.context_queue, "task_done"): + self.context_queue.task_done() + except Exception as e: + self.log(f"Error polling context queue: {e}") + + async def poll_response_queue(self) -> None: + while True: + try: + answer = await self.response_queue.get() + self.notify(answer) + if hasattr(self.response_queue, "task_done"): + self.response_queue.task_done() + except Exception as e: + self.log(f"Error polling response queue: {e}") + + def action_send(self) -> None: + input_widget = self.query_one("#llm-input", Input) + text = input_widget.value + if text and self.query_queue: + self.query_queue.put_nowait(text) + input_widget.value = "" + + def action_accept(self) -> None: + table = self.query_one("#pending-facts-table", DataTable) + row_index = table.cursor_row + if row_index < 0 or row_index >= len(self.pending_updates): + return + + update = self.pending_updates[row_index] if isinstance(update, LoreUpdate): - details_text.update( - f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}" - ) + update_lore(update) elif isinstance(update, CharacterStateUpdate): - details_text.update( - f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}" - ) + update_character_state(update) + + self.remove_update(row_index) + + def action_reject(self) -> None: + table = self.query_one("#pending-facts-table", DataTable) + row_index = table.cursor_row + if row_index < 0 or row_index >= len(self.pending_updates): + return + + self.remove_update(row_index) + + def action_edit(self) -> None: + table = self.query_one("#pending-facts-table", DataTable) + row_index = table.cursor_row + if row_index < 0 or row_index >= len(self.pending_updates): + return + + update = self.pending_updates[row_index] + initial_text = "" + if isinstance(update, LoreUpdate): + initial_text = update.content + elif isinstance(update, CharacterStateUpdate): + initial_text = str(update.hp_change or 0) + + def save_callback(new_text: str): + if isinstance(update, LoreUpdate): + update.content = new_text + elif isinstance(update, CharacterStateUpdate): + try: + update.hp_change = int(new_text) + except ValueError: + pass + + # Update the table + self.refresh_table() + + self.push_screen(EditModal(initial_text, save_callback)) def remove_update(self, index: int) -> None: - # Remove from the pending list del self.pending_updates[index] + self.refresh_table() - # Clear and refill the table - table = self.query_one("#update-table", DataTable) + def refresh_table(self) -> None: + table = self.query_one("#pending-facts-table", DataTable) table.clear() - for i, update in enumerate(self.pending_updates): - if isinstance(update, LoreUpdate): - table.add_row( - "Lore", update.entity_name or "General", update.content, key=str(i) - ) - elif isinstance(update, CharacterStateUpdate): - change_text = f"HP: {update.hp_change or 0}" - if update.status_effects_added: - change_text += f", Added: {', '.join(update.status_effects_added)}" - if update.status_effects_removed: - change_text += ( - f", Removed: {', '.join(update.status_effects_removed)}" - ) - table.add_row("Char", update.character_name, change_text, key=str(i)) - - if self.pending_updates: - self.handle_row_highlight(0) - self.query_one("#btn-accept", Button).focus() - else: - self.selected_index = -1 - self.query_one("#details-text", Static).update("All updates processed.") + self.add_update_to_table(update, i)