haiku.rag/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py

157 lines
4.9 KiB
Python

from typing import TYPE_CHECKING
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen
from textual.widgets import Button, Markdown, Static, TextArea
if TYPE_CHECKING:
from haiku.rag.agents.chat.state import ChatSessionState
class ContextModal(ModalScreen): # pragma: no cover
"""Modal screen for viewing/editing context.
Before first message (not locked, no session context): Edit initial context
After first message (locked or has session context): View session context
"""
BINDINGS = [
Binding("escape", "cancel", "Close", show=False),
Binding("ctrl+o", "cancel", "Close", show=False),
]
CSS = """
ContextModal {
align: center middle;
background: rgba(0, 0, 0, 0.5);
}
#context-container {
width: 70;
height: auto;
max-height: 32;
background: $surface;
border: tall $primary;
padding: 1 2;
}
#context-header {
height: auto;
margin-bottom: 1;
}
#context-description {
height: auto;
margin-bottom: 1;
color: $text-muted;
}
#context-editor {
height: 12;
min-height: 8;
max-height: 16;
}
#context-content {
height: 1fr;
max-height: 16;
scrollbar-gutter: stable;
}
#button-row {
height: auto;
margin-top: 1;
align: right middle;
}
#button-row Button {
margin-left: 1;
}
"""
class ContextUpdated(Message):
"""Emitted when the context is saved."""
def __init__(self, context: str) -> None:
super().__init__()
self.context = context
def __init__(
self, session_state: "ChatSessionState | None", is_locked: bool = False
) -> None:
super().__init__()
self.session_state = session_state
self._is_locked = is_locked
@property
def _is_edit_mode(self) -> bool:
"""Edit mode when not locked and no session context yet."""
has_session_context = (
self.session_state
and self.session_state.session_context
and self.session_state.session_context.summary
)
return not self._is_locked and not has_session_context
def compose(self) -> ComposeResult:
with Vertical(id="context-container"):
if self._is_edit_mode:
yield Static("[bold]Initial Context[/bold]", id="context-header")
yield Static(
"Set background context to guide the conversation. "
"This will be locked after you send your first message.",
id="context-description",
)
initial_value = ""
if self.session_state and self.session_state.initial_context:
initial_value = self.session_state.initial_context
yield TextArea(initial_value, id="context-editor")
with Horizontal(id="button-row"):
yield Button("Cancel", id="cancel-btn", variant="default")
yield Button("Save", id="save-btn", variant="primary")
else:
yield Static("[bold]Session Context[/bold]", id="context-header")
yield Static(
"What the assistant has learned from your conversation.",
id="context-description",
)
with VerticalScroll(id="context-content"):
yield Markdown(self._get_session_content())
with Horizontal(id="button-row"):
yield Button("Close", id="cancel-btn", variant="primary")
def _get_session_content(self) -> str:
if not self.session_state:
return "*No session state.*"
if not self.session_state.session_context:
return "*No session context yet. Ask a question first.*"
ctx = self.session_state.session_context
updated = (
ctx.last_updated.strftime("%Y-%m-%d %H:%M:%S")
if ctx.last_updated
else "unknown"
)
return f"**Last updated:** {updated}\n\n---\n\n{ctx.summary}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "cancel-btn":
self.action_cancel()
elif event.button.id == "save-btn":
self.action_save()
def action_cancel(self) -> None:
"""Cancel and close without saving."""
self.app.pop_screen()
def action_save(self) -> None:
"""Save context and close."""
editor = self.query_one("#context-editor", TextArea)
self.post_message(self.ContextUpdated(editor.text))
self.app.pop_screen()