refactor(ui): rewrite ConfirmationApp with three-pane layout

- Implement Pending Facts, LLM Input, and Context Pane using Textual
- Add keyboard shortcuts for Accept, Reject, and Edit actions
This commit is contained in:
2026-05-27 20:05:29 -07:00
parent b25f82cefc
commit 58f736a5f8
+179 -270
View File
@@ -3,6 +3,8 @@ from typing import List, Optional, Union
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.screen import ModalScreen
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -19,61 +21,88 @@ from src.persistence.characters import update_character_state
from src.persistence.lore import update_lore 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): class ConfirmationApp(App):
CSS = """ CSS = """
#main-container { #main-container {
layout: vertical;
height: 100%; height: 100%;
} }
#top-pane { #pending-facts-table {
height: 70%; 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%; 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; height: auto;
margin-bottom: 1; border: double white;
} background: #222;
padding: 2;
#actions-container {
height: auto;
layout: horizontal;
align: center middle; align: center middle;
} }
#edit-container { #modal-actions {
display: none;
height: auto; height: auto;
layout: vertical; margin-top: 1;
border: solid; align: right;
padding: 1;
} }
Button { #edit-input {
margin: 0 1; margin: 1 0;
}
#llm-input {
width: 100%;
}
ListItem Static {
border: solid grey;
margin: 1 0;
padding: 1;
} }
""" """
BINDINGS = [ BINDINGS = [
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("a", "accept", "Accept"),
("r", "reject", "Reject"),
("e", "edit", "Edit"),
("enter", "send", "Send"),
] ]
def __init__( def __init__(
@@ -81,287 +110,167 @@ class ConfirmationApp(App):
result: Optional[ExtractionResult] = None, result: Optional[ExtractionResult] = None,
proposal_queue: Optional[asyncio.Queue] = None, proposal_queue: Optional[asyncio.Queue] = None,
context_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__() super().__init__()
self.result = result self.result = result
self.proposal_queue = proposal_queue self.proposal_queue = proposal_queue
self.context_queue = context_queue self.context_queue = context_queue
self.query_queue = query_queue
self.response_queue = response_queue
self.pending_updates: List[Union[LoreUpdate, CharacterStateUpdate]] = [] self.pending_updates: List[Union[LoreUpdate, CharacterStateUpdate]] = []
if result: if result:
# Populate pending updates from result
self.pending_updates.extend(result.lore_updates) self.pending_updates.extend(result.lore_updates)
self.pending_updates.extend(result.character_updates) self.pending_updates.extend(result.character_updates)
self.selected_index = -1
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Container( yield Vertical(
DataTable(id="pending-facts-table"),
Vertical( Vertical(
Horizontal( Input(placeholder="Message LLM...", id="llm-input"),
Vertical( id="llm-input-container",
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"), ListView(id="context-pane"),
id="middle-pane",
),
id="main-container", id="main-container",
),
Footer(),
) )
yield Footer()
def on_mount(self) -> None: 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.cursor_type = "row"
table.add_columns("Type", "Target", "Update") table.add_columns("Type", "Target", "Content")
for i, update in enumerate(self.pending_updates): for i, update in enumerate(self.pending_updates):
if isinstance(update, LoreUpdate): self.add_update_to_table(update, i)
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()
if self.proposal_queue: if self.proposal_queue:
self.run_worker(self.poll_proposal_queue, thread=False) self.run_worker(self.poll_proposal_queue, thread=False)
if self.context_queue: if self.context_queue:
self.run_worker(self.poll_context_queue, thread=False) 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: self.query_one("#llm-input", Input).focus()
"""
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}")
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
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): if isinstance(update, LoreUpdate):
table.add_row( table.add_row(
"Lore", "Lore", update.entity_name or "General", update.content, key=str(index)
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)
if isinstance(update, LoreUpdate):
details_text.update(
f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}"
)
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): elif isinstance(update, CharacterStateUpdate):
change_text = f"HP: {update.hp_change or 0}" change_text = f"HP: {update.hp_change or 0}"
if update.status_effects_added: if update.status_effects_added:
change_text += f", Added: {', '.join(update.status_effects_added)}" change_text += f", Added: {', '.join(update.status_effects_added)}"
if update.status_effects_removed: if update.status_effects_removed:
change_text += f", Removed: {', '.join(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, async def poll_proposal_queue(self) -> None:
# Actually let's go back to detail view while True:
self.query_one("#edit-container", Vertical).styles.display = "none" try:
self.query_one("#details-container", Vertical).styles.display = "block" 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 def handle_proposal_result(self, result: ExtractionResult) -> None:
details_text = self.query_one("#details-text", Static) 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): if isinstance(update, LoreUpdate):
details_text.update( update_lore(update)
f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}"
)
elif isinstance(update, CharacterStateUpdate): elif isinstance(update, CharacterStateUpdate):
details_text.update( update_character_state(update)
f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}"
) 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: def remove_update(self, index: int) -> None:
# Remove from the pending list
del self.pending_updates[index] del self.pending_updates[index]
self.refresh_table()
# Clear and refill the table def refresh_table(self) -> None:
table = self.query_one("#update-table", DataTable) table = self.query_one("#pending-facts-table", DataTable)
table.clear() table.clear()
for i, update in enumerate(self.pending_updates): for i, update in enumerate(self.pending_updates):
if isinstance(update, LoreUpdate): self.add_update_to_table(update, i)
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.")