diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py index 25b613d..bdb10bc 100644 --- a/clients/terminal/cli.py +++ b/clients/terminal/cli.py @@ -283,16 +283,18 @@ try: session = await api.get_session(target) except Exception as exc: - # Try to find by prefix + # Exact id miss — try a prefix match. If there is not exactly one, + # surface the original error too (404 vs network vs server) rather + # than swallowing it into a generic "not found". try: sessions = await api.list_sessions() matches = [s for s in sessions if s["session_id"].startswith(target)] - if len(matches) == 1: - session = matches[0] - else: - raise exc except Exception: - click.secho(f"Session not found: {target}", fg="red", err=True) + matches = [] + if len(matches) == 1: + session = matches[0] + else: + click.secho(f"Session not found: {target} ({exc})", fg="red", err=True) return True state.set_session_id(session["session_id"]) click.secho( diff --git a/clients/terminal/tui/file_refs.py b/clients/terminal/tui/file_refs.py index 34ca6f5..7e58886 100644 --- a/clients/terminal/tui/file_refs.py +++ b/clients/terminal/tui/file_refs.py @@ -274,9 +274,12 @@ def _collect_files(path: Path, recursive: bool = False) -> Iterable[Path]: """Yield non-sensitive files inside a directory.""" iterator = path.rglob("*") if recursive else path.iterdir() - for p in sorted(iterator): - if p.is_file() and not _is_inside_sensitive_dir(p): - yield p + # Filter sensitive entries in the generator BEFORE sorted(), so a huge + # directory (deep project tree) does not get fully materialized into a list + # only to have most of it discarded — sort only the kept files. + kept = (p for p in iterator if p.is_file() and not _is_inside_sensitive_dir(p)) + for p in sorted(kept): + yield p def _display_path(path: Path, base: Path, root_dir: Path | None = None) -> str: diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index 28f21e3..d51247c 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -360,11 +360,17 @@ return Text(text, style=theme.text_dim.hex) pattern = str(args.get("pattern", "") or "") - # Pre-compile a case-insensitive literal match for highlighting. + # Pre-compile a match for highlighting. Literal by default; when the + # grep ran in regex mode, highlight with the actual regex so the marked + # spans match what the server found (a literal highlight of a regex + # pattern would mark nothing, diverging from the real matches). highlight = None if pattern: try: - highlight = re.compile(re.escape(pattern), re.IGNORECASE) + if args.get("regex"): + highlight = re.compile(pattern, re.IGNORECASE) + else: + highlight = re.compile(re.escape(pattern), re.IGNORECASE) except re.error: highlight = None diff --git a/clients/terminal/tui/renderers/tool.py b/clients/terminal/tui/renderers/tool.py index 16bffe2..f513489 100644 --- a/clients/terminal/tui/renderers/tool.py +++ b/clients/terminal/tui/renderers/tool.py @@ -15,6 +15,20 @@ from .base import ContentRenderer +# Cap a generic tool result so a tool with huge output (non-filesystem — those +# have their own renderer) does not flood the chat bubble. Keep the tail, with +# a marker, mirroring shell_runner's truncation. +_RESULT_MAX_LINES = 200 + + +def _truncate_result(text: str) -> str: + """Keep the last ``_RESULT_MAX_LINES`` lines of a tool result, with a marker.""" + lines = text.splitlines() + if len(lines) <= _RESULT_MAX_LINES: + return text + dropped = len(lines) - _RESULT_MAX_LINES + return f"... [{dropped} lines truncated]\n" + "\n".join(lines[-_RESULT_MAX_LINES:]) + class ToolStartedRenderer(ContentRenderer): """Render a tool call start marker. @@ -67,7 +81,10 @@ result = msg.get("result") color = theme.tool_success if success else theme.tool_error title = f"← {tool} {'✓' if success else '✗'}" - body = Text(str(result) if result is not None else "", style=theme.text_dim.hex) + body = Text( + _truncate_result(str(result) if result is not None else ""), + style=theme.text_dim.hex, + ) panel = Panel( body, title=title, diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 24df484..36e4166 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -174,7 +174,7 @@ # is the lenient path. try: session = await api.get_session(session_id) - return session["session_id"] + return session.get("session_id") except Exception as exc: self._chat_panel.handle_ws_event( {"type": "error", "message": f"Failed to resume session {session_id[:8]}: {exc}"} @@ -186,7 +186,7 @@ if saved: try: session = await api.get_session(saved) - return session["session_id"] + return session.get("session_id") except Exception: self._state.clear_session_id() @@ -198,8 +198,10 @@ {"type": "error", "message": f"Failed to create session: {exc}"} ) return None - self._state.set_session_id(session["session_id"]) - return session["session_id"] + sid = session.get("session_id") + if sid: + self._state.set_session_id(sid) + return sid async def attach_session(self, session_id: str) -> None: self._ctx.session_id = session_id diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index 8eb42af..7cfcee6 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -85,6 +85,12 @@ if hints is not None and hints.visible(): cmd = hints.current_match() if cmd is None: + # A bare "/" with no command name typed: do not auto-pick the first + # command in the registry — the user has chosen nothing. (If the + # hints list is open, current_match above already reflects their + # highlighted choice.) + if not text[1:].strip(): + return False from clients.terminal.tui.commands.registry import get_registry matches = get_registry().match(text[1:]) diff --git a/tests/clients/test_file_refs.py b/tests/clients/test_file_refs.py index 4611e67..28de1a1 100644 --- a/tests/clients/test_file_refs.py +++ b/tests/clients/test_file_refs.py @@ -48,6 +48,24 @@ assert "sub/util.py" in paths +def test_resolve_directory_skips_sensitive_subdir(sample_dir: Path) -> None: + """A sensitive subdir (e.g. .venv) is filtered out before sort, so its + files never get attached (regression for 5.P1).""" + (sample_dir / "sub" / "deep.py").write_text("deep\n", encoding="utf-8") + venv = sample_dir / ".venv" + venv.mkdir() + (venv / "site.py").write_text("should be skipped\n", encoding="utf-8") + + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("look @./") + paths = {a.display_path for a in result.attachments} + assert "main.py" in paths + assert "sub/util.py" in paths + assert "sub/deep.py" in paths + assert ".venv/site.py" not in paths + assert not any(".venv" in p for p in paths) + + def test_resolve_missing_file(sample_dir: Path) -> None: resolver = FileRefResolver(sample_dir) result = resolver.resolve("check @missing.py") diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index 798bce2..967c071 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -360,6 +360,34 @@ assert theme.accent.hex in styles_foo_bar +def test_grep_regex_mode_highlights_regex_matches() -> None: + """When the grep ran in regex mode, the highlight uses the actual regex so + the marked spans match what the server found — a literal highlight of a + regex pattern would mark nothing (regression for 2.B2).""" + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "grep", "pattern": r"\d+", "regex": True}, + "result": ( + "[2 matches for '\\d+' in /proj (1 files searched)]\n" + "src/a.py:3: value 42 here\n" + "src/b.py:7: count 7 total\n", + ), + "success": True, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Text) + # The numbers (regex matches) are highlighted in accent; the literal "\d+" + # text in the header plaque is dim (not matched as a literal). + styles_42 = _span_styles_at(body, "42") + assert theme.accent.hex in styles_42 + styles_count7 = _span_styles_at(body, "7 total") + assert theme.accent.hex in styles_count7 + + def test_grep_no_matches_renders_dim() -> None: set_active_theme("gnexus-dark") theme = get_active_theme() diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py index 28ae9eb..73479db 100644 --- a/tests/clients/test_input_box.py +++ b/tests/clients/test_input_box.py @@ -112,6 +112,16 @@ assert input_box._input.text == "/switch " +def test_tab_bare_slash_without_hints_does_not_auto_pick() -> None: + """A bare ``/`` with no hint list available must not auto-complete to the + first command in the registry — the user has chosen nothing (3.B3).""" + from clients.terminal.tui.widgets.input_box import _PromptInput + + p = _PromptInput(text="/", hints=None) + assert p._complete_command() is False + assert p.text == "/" + + @pytest.mark.anyio async def test_tab_does_not_complete_without_slash() -> None: async with NaviCodeTui(new_session=True).run_test() as pilot: diff --git a/tests/clients/test_terminal_client.py b/tests/clients/test_terminal_client.py index 9fc6951..95b3c11 100644 --- a/tests/clients/test_terminal_client.py +++ b/tests/clients/test_terminal_client.py @@ -237,3 +237,30 @@ result = runner.invoke(main, ["--resume", "sess-x", "--new-session"]) assert result.exit_code != 0 assert "mutually exclusive" in result.output + + def test_switch_surfaces_original_error_on_no_match( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """/switch with no exact + no unique prefix shows the original error cause, + not just a generic "not found" (regression for 6.B2).""" + import asyncio + + import clients.terminal.api as api_module + + async def boom(_sid: str) -> dict: + raise RuntimeError("server 500") + + async def no_sessions() -> list[dict]: + return [] + + monkeypatch.setattr(api_module, "get_session", boom) + monkeypatch.setattr(api_module, "list_sessions", no_sessions) + + from clients.terminal.cli import _handle_command + + state = StateManager(tmp_path) + state.set_session_id("sess-current") + asyncio.run(_handle_command("/switch zzz", state, client=object())) + out = capsys.readouterr().err + assert "Session not found: zzz" in out + assert "server 500" in out # original error preserved diff --git a/tests/clients/test_tool_result_renderer.py b/tests/clients/test_tool_result_renderer.py new file mode 100644 index 0000000..3f017f1 --- /dev/null +++ b/tests/clients/test_tool_result_renderer.py @@ -0,0 +1,50 @@ +"""Tests for the generic tool result renderer.""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.tool import ToolResultRenderer +from clients.terminal.tui.themes import set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def test_tool_result_truncates_long_output() -> None: + """A non-filesystem tool result with more than the line cap is truncated to + the tail with a marker, so a huge output does not flood the chat bubble + (regression for 2.P3).""" + set_active_theme("gnexus-dark") + renderer = ToolResultRenderer() + big = "\n".join(f"line {i}" for i in range(300)) + msg = { + "type": "tool_call", + "tool": "some_tool", + "result": big, + "success": True, + } + panel = renderer.render(msg) + text = _render_text(panel.renderable) + assert "lines truncated]" in text + # The tail is kept (last line present), the head is dropped (first line not). + assert "line 299" in text + assert "line 0" not in text + + +def test_tool_result_short_output_not_truncated() -> None: + set_active_theme("gnexus-dark") + renderer = ToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "some_tool", + "result": "just a few\nlines", + "success": True, + } + panel = renderer.render(msg) + text = _render_text(panel.renderable) + assert "truncated" not in text + assert "just a few" in text \ No newline at end of file diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 148c98e..eef554f 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -572,6 +572,24 @@ @pytest.mark.anyio +async def test_resolve_session_returns_none_when_response_lacks_session_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A malformed server response (no session_id) must not KeyError — returns + None so the caller skips attaching (regression for 5.B3).""" + import clients.terminal.api as api_module + + async def fake_get_session(sid): + return {"profile_id": "navi_code"} # no session_id key + + monkeypatch.setattr(api_module, "get_session", fake_get_session) + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + result = await pilot.app._resolve_session("sess-x", None, False) + assert result is None + + +@pytest.mark.anyio async def test_activity_indicator_runs_during_turn() -> None: """stream_start starts the spinner; stream_end stops it.""" async with NaviCodeTui(new_session=True).run_test() as pilot: