diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 2fd87b6..d269e49 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -80,6 +80,11 @@ self.items.append(item) return item + if msg_type == "status": + item = ChatItem(kind="status", content=msg.get("content", "")) + self.items.append(item) + return item + if msg_type in ("stream_end", "context_compressed", "heartbeat", "session_sync"): return None diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index fc30a6e..e54dc36 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status def default_registry() -> RendererRegistry: @@ -16,6 +16,7 @@ reg.register(tool.ToolStartedRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) + reg.register(status.StatusRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) reg.register(artifact.ArtifactRenderer()) diff --git a/clients/terminal/tui/renderers/status.py b/clients/terminal/tui/renderers/status.py new file mode 100644 index 0000000..776cefd --- /dev/null +++ b/clients/terminal/tui/renderers/status.py @@ -0,0 +1,22 @@ +"""Renderer for status events.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class StatusRenderer(ContentRenderer): + """Render a system status line dimmed.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "status" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") or str(msg) + return Text(f"• {text}", style=theme.text_dim.hex) diff --git a/clients/terminal/tui/screens/command_palette.py b/clients/terminal/tui/screens/command_palette.py index 163d95f..455b004 100644 --- a/clients/terminal/tui/screens/command_palette.py +++ b/clients/terminal/tui/screens/command_palette.py @@ -134,7 +134,7 @@ name_line = f"/{meta.name}{aliases_text}" line = Label(f"{name_line}{keybind_text}", classes="cmd-line") line.renderable = self._render_rich_line(name_line, meta.description, keybind_text) - return ListItem(line, id=f"palette-item-{index}-{meta.name}") + return ListItem(line) def _render_rich_line(self, name: str, description: str, keybind: str): from rich.text import Text @@ -167,6 +167,7 @@ def on_input_changed(self, event: Input.Changed) -> None: self._filter(event.value) + self.refresh() def on_input_submitted(self, event: Input.Submitted) -> None: if self._filtered: diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index fb5563a..b928c4e 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -89,6 +89,7 @@ def on_mount(self) -> None: self.apply_theme() self.run_worker(self._startup) + self._input_box.focus_input() def _register_textual_themes(self) -> None: """Register every Navi theme as a Textual theme so $tui-* variables resolve.""" diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index b38e341..e0ad614 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -78,6 +78,10 @@ renderables.append( self._registry.render({"type": "error", "message": item.content}) ) + elif item.kind == "status": + renderables.append( + self._registry.render({"type": "status", "content": item.content}) + ) else: renderables.append( self._registry.render({"type": "plain", "content": item.content}) diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index b2b54e5..d9964f2 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -3,13 +3,13 @@ from __future__ import annotations from textual.app import ComposeResult -from textual.containers import Horizontal +from textual.containers import Vertical from textual.widgets import Input, Static from clients.terminal.tui.events import UserSubmitted -class InputBox(Horizontal): +class InputBox(Vertical): """Bottom prompt frame with input field.""" DEFAULT_CSS = """ @@ -21,30 +21,45 @@ color: $tui-text; padding: 0 1; } - InputBox Input { - height: auto; + InputBox > Input { + height: 3; + width: 100%; border: none; background: $tui-surface; color: $tui-text; + padding: 0; } - InputBox .prompt-char { - color: $tui-prompt-border; + InputBox > Input:focus { + border: none; + background-tint: transparent; + } + InputBox > Input > .input--placeholder { + color: $tui-text-dim; + } + InputBox > Input > .input--cursor { + background: $tui-text; + color: $tui-background; text-style: bold; } + InputBox > Input > .input--selection { + background: $tui-selection; + color: $tui-background; + } """ def __init__(self) -> None: super().__init__() - self._prompt = Static("┃", classes="prompt-char") self._input = Input(placeholder="Ask anything...", classes="input-field") def compose(self) -> ComposeResult: - yield self._prompt yield self._input def on_mount(self) -> None: self._input.focus() + def on_input_changed(self, event: Input.Changed) -> None: + self._input.refresh(layout=False) + def on_input_submitted(self, event: Input.Submitted) -> None: text = event.value.strip() if text: diff --git a/clients/terminal/tui/ws_bridge.py b/clients/terminal/tui/ws_bridge.py index 8e56c3b..d20bd18 100644 --- a/clients/terminal/tui/ws_bridge.py +++ b/clients/terminal/tui/ws_bridge.py @@ -19,6 +19,7 @@ self.session_id = session_id self._client = NaviWebSocketClient(session_id) self._receive_task: asyncio.Task | None = None + self._input_task: asyncio.Task | None = None self._connected = False @property @@ -35,6 +36,7 @@ self._connected = True self._notify(True, "") self._receive_task = asyncio.create_task(self._receive_loop()) + self._input_task = asyncio.create_task(self._client.input_loop()) except Exception as exc: self._connected = False self._notify(False, str(exc)) @@ -47,6 +49,14 @@ except asyncio.CancelledError: pass self._receive_task = None + if self._input_task: + self._input_task.cancel() + try: + await self._input_task + except asyncio.CancelledError: + pass + self._input_task = None + self._client.stop_input() await self._client.close() self._connected = False self._notify(False, "") diff --git a/navi/core/agent.py b/navi/core/agent.py index 3678f9c..25af328 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -495,88 +495,6 @@ worker=type(worker).__name__, exc_info=True) return events - async def _do_compress_and_save( - self, - session, - llm: LLMBackend, - model: str, - session_id: str, - reason: str, - keep_recent_messages: int | None = None, - ) -> ContextCompressed | None: - """Compress session.context and persist it, returning a UI event when it changed.""" - count_before = len(session.context) - result = await self._compressor.compress_session( - context=session.context, - llm=llm, - model=model, - temperature=settings.context_summary_temperature, - keep_recent=settings.context_keep_recent, - max_tokens=settings.context_summary_max_tokens, - keep_recent_messages=keep_recent_messages, - ) - if result is None: - return None - new_context, summary_text = result - - # Mark messages that are no longer part of the LLM context - new_context_ids = {id(m) for m in new_context} - for msg in session.messages: - if id(msg) not in new_context_ids and msg.role != "system": - msg.is_context = False - - # The summary returned by the compressor must also live in messages so - # save() writes it to the normalized table, but it is not displayed. - summary_msg = next((m for m in new_context if m.is_summary), None) - if summary_msg and summary_msg not in session.messages: - summary_msg.is_display = False - session.messages.append(summary_msg) - - # UI marker showing that compression happened - session.messages.append(Message( - role="system", - is_compression=True, - is_context=False, - content=summary_text, - )) - - session.context = new_context - session.context_token_count = self._compressor.estimate_context_tokens(new_context) - await self._sessions.save(session) - - # Archive old messages if the hot table exceeds the configured window. - if settings.session_messages_window > 0 and session.db_next_sequence > settings.session_messages_window: - threshold = session.db_next_sequence - settings.session_messages_window - archived = await self._sessions.archive_old_messages(session_id, threshold) - if archived > 0: - log.info( - "agent.archive_messages", - session_id=session_id, - archived=archived, - threshold=threshold, - ) - # Remove archived messages from in-memory lists so they don't - # reappear as phantom UPDATEs on the next save() or bloat memory. - session.messages = [m for m in session.messages if m.sequence_number >= threshold] - session.context = [m for m in session.context if m.sequence_number >= threshold] - session.archive_threshold = threshold - - log.info( - "agent.context_compress", - session_id=session_id, - reason=reason, - before=count_before, - after=len(new_context), - ) - - return ContextCompressed( - messages_before=count_before, - messages_after=len(new_context), - summary=summary_text, - context_tokens=session.context_token_count, - max_context_tokens=settings.ollama_num_ctx, - ) - def _tool_list( self, scope: "ToolScopeConfig", @@ -600,12 +518,16 @@ context_tokens=self._compressor.estimate_context_tokens(session.context), max_context_tokens=settings.ollama_num_ctx, ) - event = await self._do_compress_and_save( + event = await self._compressor.compress_and_save_session( session=session, + session_store=self._sessions, llm=llm, model=profile.model, + temperature=settings.context_summary_temperature, session_id=session_id, reason="preturn", + keep_recent=settings.context_keep_recent, + max_tokens=settings.context_summary_max_tokens, ) if event: yield event @@ -629,12 +551,16 @@ context_tokens=estimated_tokens, max_context_tokens=settings.ollama_num_ctx, ) - event = await self._do_compress_and_save( + event = await self._compressor.compress_and_save_session( session=session, + session_store=self._sessions, llm=llm, model=profile.model, + temperature=settings.context_summary_temperature, session_id=session_id, reason="midturn", + keep_recent=settings.context_keep_recent, + max_tokens=settings.context_summary_max_tokens, keep_recent_messages=max(12, settings.context_keep_recent * 2), ) if event: diff --git a/navi/core/compressor.py b/navi/core/compressor.py index 78d2b25..b519f91 100644 --- a/navi/core/compressor.py +++ b/navi/core/compressor.py @@ -15,6 +15,8 @@ from datetime import datetime, timezone from navi.llm.base import LLMBackend, Message +from navi.config import settings +from navi.events import ContextCompressed _SUMMARIZE_SYSTEM = ( "You are summarizing a conversation history to free up context space. " @@ -427,17 +429,7 @@ return new_context, summary_text def check_context_size(self, context: list[Message]) -> None: - """Raise ContextTooLargeError before an LLM call if the context is dangerously large. - - Uses a conservative character-based estimate (~3 chars per token for text). - Images are counted at 500 tokens each (rough vision-model estimate). - - Checks against the *remaining* budget, not a fixed percentage of the window: - available_for_input = ollama_num_ctx - output_reserve - where output_reserve is a fixed token headroom reserved for the model's response. - This correctly accounts for sessions where conversation history already consumes - a large portion of the window. - """ + """Raise ContextTooLargeError before an LLM call if the context is dangerously large.""" from navi.config import settings from navi.exceptions import ContextTooLargeError @@ -445,7 +437,6 @@ return output_reserve = settings.output_reserve_tokens - total = self.estimate_context_tokens(context) available = settings.ollama_num_ctx - output_reserve @@ -460,3 +451,77 @@ f"output_reserve {output_reserve:,}). " "Split the file into smaller parts or delegate to a subagent." ) + + async def compress_and_save_session( + self, + session, + session_store, + llm: LLMBackend, + model: str, + temperature: float, + session_id: str, + reason: str, + keep_recent: int, + max_tokens: int | None = None, + keep_recent_messages: int | None = None, + ) -> ContextCompressed | None: + """Compresses the session context and persists the changes to the session store.""" + count_before = len(session.context) + result = await self.compress_session( + context=session.context, + llm=llm, + model=model, + temperature=temperature, + keep_recent=keep_recent, + max_tokens=max_tokens, + keep_recent_messages=keep_recent_messages, + ) + if result is None: + return None + + new_context, summary_text = result + + # Mark messages in session.messages as not context if they are no longer in new_context + # and are not system messages. + new_context_ids = {id(m) for m in new_context} + for msg in session.messages: + if msg.role != "system" and id(msg) not in new_context_ids: + msg.is_context = False + + # Add the summary message to session.messages if it's not already there + summary_msg = next((m for m in new_context if m.is_summary), None) + if summary_msg and summary_msg not in session.messages: + summary_msg.is_display = False + session.messages.append(summary_msg) + + # Add a system message with is_compression=True and content=summary_text + session.messages.append( + Message( + role="system", + content=summary_text, + is_compression=True, + is_context=False, + created_at=datetime.now(timezone.utc), + ) + ) + + session.context = new_context + session.context_token_count = self.estimate_context_tokens(new_context) + await session_store.save(session) + + # Archive old messages if the hot table exceeds the configured window. + if settings.session_messages_window > 0 and session.db_next_sequence > settings.session_messages_window: + threshold = session.db_next_sequence - settings.session_messages_window + archived = await session_store.archive_old_messages(session_id, threshold) + if archived > 0: + session.messages = [m for m in session.messages if m.sequence_number >= threshold] + session.context = [m for m in session.context if m.sequence_number >= threshold] + session.archive_threshold = threshold + + return ContextCompressed( + messages_before=count_before, + messages_after=len(new_context), + summary=summary_text, + context_tokens=session.context_token_count, + max_context_tokens=settings.ollama_num_ctx, + ) diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 21f4a40..37ee734 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -51,6 +51,26 @@ @pytest.mark.anyio +async def test_input_text_is_rendered_with_visible_color() -> None: + """Typed text in the input field is rendered with a visible foreground color.""" + async with NaviCodeTui(new_session=True).run_test(size=(80, 24)) as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.focus() + await pilot.press("h", "i", " ", "n", "a", "v", "i") + await pilot.pause() + assert input_box._input.value == "hi navi" + strip = input_box._input.render_line(0) + segments = list(strip) + rendered_text = "".join(seg.text for seg in segments) + assert "hi navi" in rendered_text + # Ensure foreground and background are not identical so text is visible. + for seg in segments: + if "hi navi" in seg.text and seg.style: + assert seg.style.color != seg.style.bgcolor + + +@pytest.mark.anyio async def test_ws_event_renders_in_chat() -> None: """A synthetic WebSocket stream_delta is added to the assistant response.""" async with NaviCodeTui(new_session=True).run_test() as pilot: