diff --git a/CHANGELOG.md b/CHANGELOG.md index 08487ba8..e310f930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ GitHub Releases page; `0.8.0` is the new starting line. - **Login selector polish.** Configured `/login` providers render with distinct success/state styling; the background working indicator uses the braille spinner, and working tips wrap with a hanging indent under the verb. - **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. - **Readable diff context.** Unchanged context lines in file-edit diff snippets now render in the normal body-text color instead of muted grey, so edited-file previews are easier to read; added/removed lines are unchanged. +- **Cleaner slash command menu.** The slash command popup now has a blank line separating it from the input row, drops the repetitive `[command]`/`[shell]` tag (keeping the distinguishing `[skill]`/`[flow]` ones), and gains a persistent footer (`Enter to select · ↑/↓ to navigate · Esc to cancel`) set off by its own separator line. When the list scrolls, the footer folds in a `+N more` count instead of silently hiding entries, and the menu height adapts to the terminal. ## 0.42.0 (2026-06-12) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0cb90013 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# CLAUDE.md + +This repository's agent guidance lives in `AGENTS.md` (the portable, tracked standard injected +into Pythinker sessions via `PYTHINKER_AGENTS_MD`). Claude Code does not read `AGENTS.md` +automatically, so this file imports it — plus the machine-local overlay — to keep a single source +of truth. + +Read both, in order: + +1. **`AGENTS.md`** — non-negotiable repository rules. Always applies. +2. **`AGENTS.local`** — machine-specific / private local instructions (gitignored). Read it after + `AGENTS.md`. It may add workflow detail (e.g. the code-graph / graphify workflow) but must not + weaken or override the rules in `AGENTS.md`. + +@AGENTS.md + +@AGENTS.local diff --git a/docs/en/reference/telemetry.md b/docs/en/reference/telemetry.md index 8ceba6eb..8235ac8f 100644 --- a/docs/en/reference/telemetry.md +++ b/docs/en/reference/telemetry.md @@ -63,8 +63,14 @@ warning), and currently has no monitoring visibility. It: -1. Emits an OTel `error` event with `{site, exc_class, tool, **attrs}`. -2. Calls `sentry.capture_exception(exc)`. +1. Emits an OTel `error` event with `{site, exc_class, expected, tool, **attrs}`. +2. Calls `sentry.capture_exception(exc)` **only when the error is not expected**. + Expected user-environment failures — bad/expired credentials, exhausted + quotas, rate limits, request timeouts, offline network, abandoned OAuth + flows, MCP servers lacking an optional capability (see + `errors.is_expected_error`) — still flow to the OTel `error` stream with + `expected=True`, but are withheld from Sentry/Bugsink, which is reserved for + actionable defects. Both calls are wrapped in `contextlib.suppress(Exception)` so monitoring can never break the host program. diff --git a/src/pythinker_code/telemetry/__init__.py b/src/pythinker_code/telemetry/__init__.py index f84f4afc..d51a05e2 100644 --- a/src/pythinker_code/telemetry/__init__.py +++ b/src/pythinker_code/telemetry/__init__.py @@ -202,6 +202,26 @@ def flush_sync() -> None: """ if _sink is not None: _sink.flush_sync() + elif _event_queue: + # No sink was ever attached — e.g. a crash during startup, before + # attach_sink() runs. Best-effort direct emit so the crash/error event + # still reaches SigNoz (otel.emit_log no-ops if OTel was never inited). + try: + from pythinker_code.telemetry.sink import emit_events_to_otel + + for event in _event_queue: + if event.get("device_id") is None: + event["device_id"] = _device_id + if event.get("session_id") is None: + event["session_id"] = _session_id + emit_events_to_otel(list(_event_queue)) + # Only drop the buffer once the events have been handed off, so a + # failed emit leaves them intact for the vendor-SDK flush below. + _event_queue.clear() + except Exception as exc: + from pythinker_code.utils.logging import logger + + logger.debug("Crash-safe telemetry flush failed: {err}", err=exc) # Flush vendor SDKs last — they take the network hit. try: from pythinker_code.telemetry import otel as _otel diff --git a/src/pythinker_code/telemetry/sink.py b/src/pythinker_code/telemetry/sink.py index b30d0194..b552c4ec 100644 --- a/src/pythinker_code/telemetry/sink.py +++ b/src/pythinker_code/telemetry/sink.py @@ -49,6 +49,93 @@ def _flatten_event(event: dict[str, Any]) -> dict[str, Any]: return out +# Event names whose telemetry must be recorded at ERROR severity, so SigNoz +# severity filters and error dashboards can find them. Without this, track() +# forwards everything at the emit_log() default of INFO, leaving crashes and +# handled errors indistinguishable from product-analytics events. +_ERROR_EVENTS = frozenset({"error", "crash", "api_error"}) + +# Telemetry event name -> OTel severity. Names not listed stay INFO. +_EVENT_SEVERITY: dict[str, str] = { + **dict.fromkeys(_ERROR_EVENTS, "error"), + # A session that failed to load but fell back to a fresh state is degraded, + # not broken — surface it above INFO without crying ERROR. + "session_load_failed": "warning", +} + + +def _event_severity(event_name: str) -> str: + """Map a telemetry event name to an OTel severity (defaults to ``info``).""" + return _EVENT_SEVERITY.get(event_name, "info") + + +def _apply_canonical_error_attrs(event_name: str, attrs: dict[str, Any]) -> None: + """Add stable, queryable ``error.*`` attributes for error-like events. + + Call sites are inconsistent: crashes and API errors carry ``error_type`` + while handled errors carry ``exc_class`` — which, after flattening, become + ``property.error_type`` / ``property.exc_class``. Dashboards shouldn't have + to know which. Mirror the discriminator into canonical top-level keys while + leaving the original ``property.*`` values untouched. Mutates ``attrs``. + """ + if event_name not in _ERROR_EVENTS: + return + error_type = attrs.get("property.error_type") or attrs.get("property.exc_class") + if error_type is not None: + attrs.setdefault("error.type", error_type) + site = attrs.get("property.site") + if site is not None: + attrs.setdefault("error.site", site) + if "property.expected" in attrs: + attrs.setdefault("error.expected", attrs["property.expected"]) + # 'error' (handled) vs 'crash' (uncaught) vs 'api_error' (provider call). + attrs.setdefault("error.kind", event_name) + + +def emit_events_to_otel(events: list[dict[str, Any]]) -> None: + """Forward telemetry events to the OTel logs pipeline. + + Shared by :meth:`EventSink._emit_to_otel` and the crash-safe queue drain in + :func:`pythinker_code.telemetry.flush_sync`, so a startup crash that occurs + before any sink is attached still reaches SigNoz. ``otel.emit_log`` is a + no-op when the SDK was never initialized, so this is always safe to call. + """ + if not events: + return + try: + from pythinker_code.telemetry import otel as _otel + except Exception as exc: + logger.debug( + "Telemetry OTel import failed; dropping {n} events: {err}", + n=len(events), + err=exc, + ) + return + + for event in events: + event_name = str(event.get("event") or "event") + ts = event.get("timestamp") + ts_ns = int(ts * 1_000_000_000) if isinstance(ts, (int, float)) else None + try: + attrs = _flatten_event(event) + except TypeError as exc: + # Schema violation — drop, never retry. + logger.debug("Telemetry event dropped (non-primitive attr): {err}", err=exc) + continue + attrs.pop("event", None) + attrs.pop("timestamp", None) + _apply_canonical_error_attrs(event_name, attrs) + try: + _otel.emit_log( + name=event_name, + attributes=attrs, + severity=_event_severity(event_name), + timestamp_ns=ts_ns, + ) + except Exception: + logger.debug("OTel emit failed; event dropped") + + class EventSink: """Buffers telemetry events and flushes them in batches to OTel logs.""" @@ -155,29 +242,7 @@ async def _flush_async(self) -> None: self._emit_to_otel(events) def _emit_to_otel(self, events: list[dict[str, Any]]) -> None: - if not events: - return - try: - from pythinker_code.telemetry import otel as _otel - - for event in events: - ts = event.get("timestamp") - ts_ns = int(ts * 1_000_000_000) if isinstance(ts, (int, float)) else None - try: - attrs = _flatten_event(event) - except TypeError as exc: - # Schema violation — drop, never retry. - logger.debug("Telemetry event dropped (non-primitive attr): {err}", err=exc) - continue - attrs.pop("event", None) - attrs.pop("timestamp", None) - _otel.emit_log( - name=str(event.get("event") or "event"), - attributes=attrs, - timestamp_ns=ts_ns, - ) - except Exception: - logger.debug("OTel flush failed; events dropped") + emit_events_to_otel(events) def _schedule_async_flush(self) -> None: """Schedule an async flush from any thread.""" diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 94d9ff7e..5494dd74 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -467,14 +467,21 @@ def _display_meta(self, cmd: SlashCommand[Any]) -> str: if not self._annotate_meta: return cmd.description + # Only surface a kind tag when it distinguishes the entry from a plain + # command. Skills and flows are interleaved with commands in the agent + # menu, so their tag carries information; the generic command/shell scope + # is already obvious from the menu itself, so tagging every row is noise. if cmd.name.startswith("skill:"): - kind = "skill" + kind: str | None = "skill" elif cmd.name.startswith("flow:"): kind = "flow" else: - kind = self._command_scope + kind = None - parts = [f"[{kind}]", cmd.description] + parts: list[str] = [] + if kind is not None: + parts.append(f"[{kind}]") + parts.append(cmd.description) if cmd.aliases: parts.append(f"aliases: {', '.join('/' + alias for alias in cmd.aliases)}") return " ".join(part for part in parts if part) @@ -851,6 +858,14 @@ class SlashCommandMenuControl(UIControl): """Render slash command completions as a full-width menu that matches the shell UI.""" _MAX_EXPANDED_META_LINES = 3 + # One blank line is reserved above the list as breathing room from the input + # row, so the menu reads as its own region rather than crowding what's typed. + _GAP_LINES = 1 + # A persistent footer block at the bottom: a blank separator line plus the + # navigation legend (which folds in the overflow count when the list scrolls). + # The separator gives the legend the same breathing room as the top gap. + _FOOTER_LINES = 2 + _FOOTER_LEGEND = "Enter to select · ↑/↓ to navigate · Esc to cancel" def __init__( self, @@ -881,19 +896,27 @@ def preferred_height( if complete_state is None: return 0 completions = complete_state.completions + if not completions: + return 0 selected_index = complete_state.complete_index if selected_index is None: - return min(max_available_height, len(completions)) - menu_width = max(0, width - self._left_padding()) - marker_width = 2 - command_width = self._command_column_width(completions, menu_width, marker_width) - gap_width = 3 if menu_width > command_width + 6 else 1 - meta_width = max(0, menu_width - marker_width - command_width - gap_width) - selected_meta_lines = self._selected_meta_lines( - completions[selected_index].display_meta_text, - meta_width, - ) - return min(max_available_height, len(completions) + len(selected_meta_lines) - 1) + content_height = len(completions) + else: + menu_width = max(0, width - self._left_padding()) + marker_width = 2 + command_width = self._command_column_width(completions, menu_width, marker_width) + gap_width = 3 if menu_width > command_width + 6 else 1 + meta_width = max(0, menu_width - marker_width - command_width - gap_width) + selected_meta_lines = self._selected_meta_lines( + completions[selected_index].display_meta_text, + meta_width, + ) + content_height = (len(completions) - 1) + len(selected_meta_lines) + # Reserve the gap line above the list and the footer line below it. When + # the list is taller than the space the window allows, the window caps the + # height and create_content lays the list out within whatever rows remain. + chrome = self._GAP_LINES + self._FOOTER_LINES + return min(max_available_height, content_height + chrome) def create_content(self, width: int, height: int) -> UIContent: app = get_app_or_none() @@ -905,7 +928,6 @@ def create_content(self, width: int, height: int) -> UIContent: completions = complete_state.completions selected_index = complete_state.complete_index - available_rows = max(1, height) match_prefix_len = self._match_prefix_len(app) menu_width = max(0, width - self._left_padding()) @@ -914,16 +936,21 @@ def create_content(self, width: int, height: int) -> UIContent: gap_width = 3 if menu_width > command_width + 6 else 1 meta_width = max(0, menu_width - marker_width - command_width - gap_width) - rendered_lines: list[FormattedText] = [] - selected_line_index = 0 + total_rows = max(1, height) + # The gap line above the list and the footer line below it are always + # present, so the list itself lays out within the remaining rows. + item_rows = max(1, total_rows - self._GAP_LINES - self._FOOTER_LINES) + + rendered_lines: list[FormattedText] = [self._blank_line()] + cursor_y = 0 if selected_index is None: # Pre-highlight index 0 even before the user navigates: pressing # Enter accepts the first completion, so the visual state should # match that behavior. Without this the menu looks ambiguous (no # row highlighted) but Enter still commits the top row. - end = min(len(completions) - 1, available_rows - 1) - for index in range(0, end + 1): + shown = min(len(completions), item_rows) + for index in range(shown): rendered_lines.append( self._render_single_line_item( width=width, @@ -936,62 +963,83 @@ def create_content(self, width: int, height: int) -> UIContent: match_prefix_len=match_prefix_len, ) ) - - return UIContent( - get_line=lambda i: rendered_lines[i], - line_count=len(rendered_lines), - cursor_position=Point(x=0, y=0), + cursor_y = 1 if shown else 0 + hidden = len(completions) - shown + else: + selected_meta_lines = self._selected_meta_lines( + completions[selected_index].display_meta_text, + meta_width, ) - - selected_meta_lines = self._selected_meta_lines( - completions[selected_index].display_meta_text, - meta_width, - ) - start, end = self._visible_window_bounds( - completion_count=len(completions), - selected_index=selected_index, - available_rows=available_rows, - selected_item_height=len(selected_meta_lines), - ) - selected_line_index = 0 - - for index in range(start, end + 1): - completion = completions[index] - if index == selected_index: - selected_line_index = len(rendered_lines) - rendered_lines.extend( - self._render_selected_item_lines( + start, end = self._visible_window_bounds( + completion_count=len(completions), + selected_index=selected_index, + available_rows=item_rows, + selected_item_height=len(selected_meta_lines), + ) + for index in range(start, end + 1): + completion = completions[index] + if index == selected_index: + cursor_y = len(rendered_lines) + rendered_lines.extend( + self._render_selected_item_lines( + width=width, + completion=completion, + marker_width=marker_width, + command_width=command_width, + meta_width=meta_width, + gap_width=gap_width, + meta_lines=selected_meta_lines, + match_prefix_len=match_prefix_len, + ) + ) + continue + rendered_lines.append( + self._render_single_line_item( width=width, completion=completion, marker_width=marker_width, command_width=command_width, meta_width=meta_width, gap_width=gap_width, - meta_lines=selected_meta_lines, + is_current=False, match_prefix_len=match_prefix_len, ) ) - continue + hidden = len(completions) - (end - start + 1) - rendered_lines.append( - self._render_single_line_item( - width=width, - completion=completion, - marker_width=marker_width, - command_width=command_width, - meta_width=meta_width, - gap_width=gap_width, - is_current=False, - match_prefix_len=match_prefix_len, - ) + rendered_lines.append(self._blank_line()) + rendered_lines.append( + self._render_footer_line( + width=width, marker_width=marker_width, hidden_count=max(0, hidden) ) - + ) return UIContent( get_line=lambda i: rendered_lines[i], line_count=len(rendered_lines), - cursor_position=Point(x=0, y=selected_line_index), + cursor_position=Point(x=0, y=cursor_y), ) + def _blank_line(self) -> FormattedText: + return FormattedText([("class:slash-completion-menu", "")]) + + def _render_footer_line( + self, *, width: int, marker_width: int, hidden_count: int + ) -> FormattedText: + # Persistent navigation legend, rendered in the dim meta style and aligned + # under the command column. When the list scrolled, the count of hidden + # entries leads so it survives truncation on narrow terminals. + indent = self._left_padding() + marker_width + text = self._FOOTER_LEGEND + if hidden_count > 0: + text = f"+{hidden_count} more · {text}" + body = _truncate_to_width(text, max(0, width - indent)) + trailing = max(0, width - indent - get_cwidth(body)) + fragments: FormattedText = FormattedText() + fragments.append(("class:slash-completion-menu", " " * indent)) + fragments.append(("class:slash-completion-menu.meta", body)) + fragments.append(("class:slash-completion-menu", " " * trailing)) + return fragments + def _match_prefix_len(self, app: Any) -> int: document = getattr(getattr(app, "current_buffer", None), "document", None) if not isinstance(document, Document): @@ -2637,7 +2685,10 @@ def _install_slash_completion_menu(self) -> None: Window( content=self._slash_menu_control, dont_extend_height=True, - height=Dimension(max=10), + # Cap leaves room for the gap + separator + footer chrome (3 rows) + # while still showing ~9 commands; preferred_height clamps to the + # terminal's available height so it never overflows a short window. + height=Dimension(max=12), style="class:slash-completion-menu", ), filter=has_completions & slash_completion_filter, diff --git a/tests/telemetry/test_instrumentation.py b/tests/telemetry/test_instrumentation.py index d67b7c50..b20c3da7 100644 --- a/tests/telemetry/test_instrumentation.py +++ b/tests/telemetry/test_instrumentation.py @@ -689,7 +689,13 @@ def _flush_and_capture_attrs(sink: EventSink) -> dict[str, Any]: """Run flush_sync with otel.emit_log patched, return the first event's attrs.""" captured: list[dict[str, Any]] = [] - def _capture(*, name: str, attributes: dict[str, Any], timestamp_ns: int | None = None): + def _capture( + *, + name: str, + attributes: dict[str, Any], + severity: str = "info", + timestamp_ns: int | None = None, + ): captured.append(attributes) with patch("pythinker_code.telemetry.otel.emit_log", side_effect=_capture): diff --git a/tests/telemetry/test_telemetry.py b/tests/telemetry/test_telemetry.py index dcceaee7..c4dda12c 100644 --- a/tests/telemetry/test_telemetry.py +++ b/tests/telemetry/test_telemetry.py @@ -173,8 +173,16 @@ def _captured(self, sink: EventSink) -> list[dict[str, Any]]: """Run flush_sync with otel.emit_log mocked, return captured args.""" captured: list[dict[str, Any]] = [] - def _capture(*, name: str, attributes: dict[str, Any], timestamp_ns: int | None = None): - captured.append({"name": name, "attrs": attributes, "ts_ns": timestamp_ns}) + def _capture( + *, + name: str, + attributes: dict[str, Any], + severity: str = "info", + timestamp_ns: int | None = None, + ): + captured.append( + {"name": name, "attrs": attributes, "severity": severity, "ts_ns": timestamp_ns} + ) with patch("pythinker_code.telemetry.otel.emit_log", side_effect=_capture): sink.flush_sync() @@ -290,3 +298,140 @@ def test_list_property_raises_typeerror(self): with pytest.raises(TypeError): _flatten_event({"event": "x", "properties": {"items": [1, 2, 3]}}) + + +class TestErrorSeverity: + """Error-like events are emitted at ERROR/WARN severity with canonical + ``error.*`` attributes, so SigNoz dashboards can find and group them.""" + + def _captured(self, sink: EventSink) -> list[dict[str, Any]]: + captured: list[dict[str, Any]] = [] + + def _capture( + *, + name: str, + attributes: dict[str, Any], + severity: str = "info", + timestamp_ns: int | None = None, + ): + captured.append({"name": name, "attrs": attributes, "severity": severity}) + + with patch("pythinker_code.telemetry.otel.emit_log", side_effect=_capture): + sink.flush_sync() + return captured + + def test_handled_error_event_is_error_severity_with_canonical_attrs(self): + sink = EventSink(version="1.0.0") + sink.accept( + { + "event": "error", + "timestamp": 1.0, + "properties": {"site": "tool.read", "exc_class": "ValueError", "expected": False}, + } + ) + emission = self._captured(sink)[0] + assert emission["severity"] == "error" + attrs = emission["attrs"] + # Canonical keys derived from exc_class (handled errors don't set error_type). + assert attrs["error.type"] == "ValueError" + assert attrs["error.site"] == "tool.read" + assert attrs["error.expected"] is False + assert attrs["error.kind"] == "error" + # Original property.* values are preserved untouched. + assert attrs["property.exc_class"] == "ValueError" + + def test_crash_event_canonical_type_from_error_type_key(self): + sink = EventSink(version="1.0.0") + sink.accept( + { + "event": "crash", + "timestamp": 1.0, + "properties": {"error_type": "RuntimeError", "where": "startup"}, + } + ) + emission = self._captured(sink)[0] + assert emission["severity"] == "error" + assert emission["attrs"]["error.type"] == "RuntimeError" + assert emission["attrs"]["error.kind"] == "crash" + + def test_api_error_event_is_error_severity(self): + sink = EventSink(version="1.0.0") + sink.accept( + { + "event": "api_error", + "timestamp": 1.0, + "properties": {"error_type": "rate_limit", "status_code": 429}, + } + ) + emission = self._captured(sink)[0] + assert emission["severity"] == "error" + assert emission["attrs"]["error.type"] == "rate_limit" + + def test_session_load_failed_is_warning(self): + sink = EventSink(version="1.0.0") + sink.accept( + {"event": "session_load_failed", "timestamp": 1.0, "properties": {"reason": "OSError"}} + ) + emission = self._captured(sink)[0] + assert emission["severity"] == "warning" + # Not in the error set → no canonical error.* attributes. + assert "error.type" not in emission["attrs"] + + def test_regular_event_is_info_without_error_attrs(self): + sink = EventSink(version="1.0.0") + sink.accept({"event": "tool_call", "timestamp": 1.0, "properties": {"tool": "ReadFile"}}) + emission = self._captured(sink)[0] + assert emission["severity"] == "info" + assert not any(k.startswith("error.") for k in emission["attrs"]) + + +class TestCrashSafeFlush: + """flush_sync() drains the pre-sink event queue so a startup crash (before + attach_sink) still reaches SigNoz.""" + + def test_flush_sync_drains_event_queue_when_no_sink(self): + set_context(device_id="dev1", session_id="sess1") + track("crash", error_type="RuntimeError", where="startup") + assert len(telemetry_mod._event_queue) == 1 + assert telemetry_mod._sink is None + + captured: list[dict[str, Any]] = [] + + def _capture(*, name: str, attributes: dict[str, Any], severity: str = "info", **_): + captured.append({"name": name, "severity": severity, "attrs": attributes}) + + with patch("pythinker_code.telemetry.otel.emit_log", side_effect=_capture): + telemetry_mod.flush_sync() + + assert len(captured) == 1 + assert captured[0]["name"] == "crash" + assert captured[0]["severity"] == "error" + assert captured[0]["attrs"]["error.type"] == "RuntimeError" + # Queue drained so a second flush is a no-op. + assert len(telemetry_mod._event_queue) == 0 + + def test_flush_sync_no_sink_empty_queue_is_noop(self): + captured: list[dict[str, Any]] = [] + + def _capture(**kwargs): + captured.append(kwargs) + + with patch("pythinker_code.telemetry.otel.emit_log", side_effect=_capture): + telemetry_mod.flush_sync() + assert captured == [] + + def test_flush_sync_retains_queue_when_emit_raises(self): + """A failed crash-safe emit must not drop the buffered events, so the + later vendor-SDK flush (or a retry) can still send them.""" + set_context(device_id="dev1", session_id="sess1") + track("crash", error_type="RuntimeError", where="startup") + assert len(telemetry_mod._event_queue) == 1 + + with patch( + "pythinker_code.telemetry.sink.emit_events_to_otel", + side_effect=RuntimeError("boom"), + ): + telemetry_mod.flush_sync() + + # Emit failed, so the queue is left intact rather than silently cleared. + assert len(telemetry_mod._event_queue) == 1 diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index cc34b5d2..130f2681 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -1385,7 +1385,8 @@ def test_slash_menu_highlights_typed_command_prefix(monkeypatch: Any) -> None: ) content = SlashCommandMenuControl(left_padding=lambda: 0).create_content(width=60, height=5) - line = content.get_line(0) + # Line 0 is the blank gap row; the first command renders on line 1. + line = content.get_line(1) assert "".join(text for _, text, *_ in line).lstrip().startswith("❯ /model") highlighted = [(style, text) for style, text, *_ in line if "command.match" in style] diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index 436a81cf..f418a249 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -248,7 +248,9 @@ def test_completion_display_uses_canonical_command_name(): assert completions[0].display_meta_text == "help command" -def test_annotated_completion_meta_includes_kind_and_aliases(): +def test_annotated_command_meta_drops_generic_tag_but_keeps_aliases(): + """Plain commands no longer carry a redundant scope tag in the menu; the + description and aliases remain so the row stays informative.""" completer = SlashCommandCompleter( [_make_command("help", aliases=["h", "?"])], annotate_meta=True, @@ -258,7 +260,9 @@ def test_annotated_completion_meta_includes_kind_and_aliases(): completions = _completions(completer, "/h") assert len(completions) == 1 - assert completions[0].display_meta_text == "[shell] help command aliases: /h, /?" + assert completions[0].display_meta_text == "help command aliases: /h, /?" + assert "[shell]" not in completions[0].display_meta_text + assert "[command]" not in completions[0].display_meta_text def test_annotated_skill_completion_uses_skill_kind(): @@ -394,13 +398,90 @@ def test_slash_menu_preselects_first_item_when_index_unset(monkeypatch): "".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count) ] - assert content.line_count == len(completions) - assert content.cursor_position.y == 0 - # First row is highlighted, second row is not. - assert "❯" in rendered_lines[0] - assert "❯" not in rendered_lines[1] - assert "Ctrl-O" in rendered_lines[0] - assert rendered_lines[0].count("/editor") == 1 + # A blank gap line precedes the list; a blank separator and footer legend + # follow it, so the menu reads as its own region. + assert content.line_count == len(completions) + 3 + assert rendered_lines[0].strip() == "" + assert content.cursor_position.y == 1 + # First item row is highlighted, second is not. U+276F is the selection + # marker; built via chr() to avoid RUF001 ambiguous-glyph lint. + selected_marker = chr(0x276F) + assert selected_marker in rendered_lines[1] + assert selected_marker not in rendered_lines[2] + assert "Ctrl-O" in rendered_lines[1] + assert rendered_lines[1].count("/editor") == 1 + # Blank separator then the footer legend on the last two lines. + assert rendered_lines[-2].strip() == "" + assert rendered_lines[-1].strip() == "Enter to select · ↑/↓ to navigate · Esc to cancel" + + +def _slash_completions(count: int) -> list[Completion]: + return [ + Completion( + text=f"/cmd{i}", + start_position=0, + display=f"/cmd{i}", + display_meta=f"command number {i}", + ) + for i in range(count) + ] + + +def test_slash_menu_footer_folds_in_overflow_count_when_list_exceeds_height(monkeypatch): + """When more completions exist than fit, the footer leads with the hidden + count alongside the navigation legend instead of silently truncating.""" + completions = _slash_completions(20) + complete_state = SimpleNamespace(completions=completions, complete_index=None) + app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state)) + monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app) + + control = SlashCommandMenuControl(left_padding=lambda: 0) + content = control.create_content(width=80, height=5) + + rendered_lines = [ + "".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count) + ] + + # Gap line + visible items + blank separator + footer, within the 5-row budget. + assert content.line_count == 5 + assert rendered_lines[0].strip() == "" + assert rendered_lines[-2].strip() == "" + footer = rendered_lines[-1].strip() + visible_items = content.line_count - 3 # minus gap, separator, footer + assert footer.startswith(f"+{20 - visible_items} more · ") + assert footer.endswith("Enter to select · ↑/↓ to navigate · Esc to cancel") + + +def test_slash_menu_footer_shows_legend_without_count_when_list_fits(monkeypatch): + completions = _slash_completions(2) + complete_state = SimpleNamespace(completions=completions, complete_index=None) + app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state)) + monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app) + + control = SlashCommandMenuControl(left_padding=lambda: 0) + content = control.create_content(width=80, height=6) + + rendered_lines = [ + "".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count) + ] + + assert content.line_count == len(completions) + 3 # gap + items + separator + footer + assert rendered_lines[-2].strip() == "" + assert rendered_lines[-1].strip() == "Enter to select · ↑/↓ to navigate · Esc to cancel" + assert not any("more · " in line for line in rendered_lines) + + +def test_annotated_plain_command_meta_has_no_tag(): + completer = SlashCommandCompleter( + [_make_command("help")], + annotate_meta=True, + command_scope="command", + ) + + completions = _completions(completer, "/he") + + assert completions[0].display_meta_text == "help command" + assert "[command]" not in completions[0].display_meta_text def test_find_prompt_float_container_supports_conditional_container_shape():