Compare commits

...

10 Commits

Author SHA1 Message Date
Ari Lotter
2f230b5ad9 feat: add fast-path setup for nous account
adds a nous account specific fast flow & autolaunches into chat if
gateway isn't set up
2026-04-24 00:07:23 -04:00
Ari Lotter
bdc9b07c9d change: always run setup on no-config run
there's instructions on how to exit & do it manually, no point in asking
2026-04-24 00:06:48 -04:00
brooklyn!
6fdbf2f2d7 Merge pull request #14820 from NousResearch/bb/tui-at-fuzzy-match
fix(tui): @<name> fuzzy-matches filenames across the repo
2026-04-23 19:40:43 -05:00
Brooklyn Nicholson
0a679cb7ad fix(tui): restore voice/panic handlers + scope fuzzy paths to cwd
Two fixes on top of the fuzzy-@ branch:

(1) Rebase artefact: re-apply only the fuzzy additions on top of
    fresh `tui_gateway/server.py`. The earlier commit was cut from a
    base 58 commits behind main and clobbered ~170 lines of
    voice.toggle / voice.record handlers and the gateway crash hooks
    (`_panic_hook`, `_thread_panic_hook`). Reset server.py to
    origin/main and re-add only:
      - `_FUZZY_*` constants + `_list_repo_files` + `_fuzzy_basename_rank`
      - the new fuzzy branch in the `complete.path` handler

(2) Path scoping (Copilot review): `git ls-files` returns repo-root-
    relative paths, but completions need to resolve under the gateway's
    cwd. When hermes is launched from a subdirectory, the previous
    code surfaced `@file:apps/web/src/foo.tsx` even though the agent
    would resolve that relative to `apps/web/` and miss. Fix:
      - `git -C root rev-parse --show-toplevel` to get repo top
      - `git -C top ls-files …` for the listing
      - `os.path.relpath(top + p, root)` per result, dropping anything
        starting with `../` so the picker stays scoped to cwd-and-below
        (matches Cmd-P workspace semantics)
    `apps/web/src/foo.tsx` ends up as `@file:src/foo.tsx` from inside
    `apps/web/`, and sibling subtrees + parent-of-cwd files don't leak.

New test `test_fuzzy_paths_relative_to_cwd_inside_subdir` builds a
3-package mono-repo, runs from `apps/web/`, and verifies completion
paths are subtree-relative + outside-of-cwd files don't appear.

Copilot review threads addressed: #3134675504 (path scoping),
#3134675532 (`voice.toggle` regression), #3134675541 (`voice.record`
regression — both were stale-base artefacts, not behavioural changes).
2026-04-23 19:38:33 -05:00
Brooklyn Nicholson
41b4d69167 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/tui-at-fuzzy-match 2026-04-23 19:35:18 -05:00
brooklyn!
3f343cf7cf Merge pull request #14822 from NousResearch/bb/tui-inline-diff-segment-anchor
fix(tui): anchor inline_diff to the segment where the edit happened
2026-04-23 19:32:21 -05:00
Brooklyn Nicholson
4ae5b58cb1 fix(tui): restore voice handlers + address copilot review
Rebase-artefact cleanup on this branch:

- Restore `voice.status` and `voice.transcript` cases in
  createGatewayEventHandler plus the `voice` / `submission` /
  `composer.setInput` ctx destructuring. They were added to main in
  the 58-commit gap that this branch was originally cut behind;
  dropping them was unintentional.
- Rebase the test ctx shape to match main (voice.* fakes,
  submission.submitRef, composer.setInput) and apply the same
  segment-anchor test rewrites on top.
- Drop the `#14XXX` placeholder from the tool.complete comment;
  replace with a plain-English rationale.
- Rewrite the broken mid-word "pushInlineDiff- Segment" in
  turnController's dedupe comment to refer to
  pushInlineDiffSegment and `kind: 'diff'` plainly.
- Collapse the filter predicate in recordMessageComplete from a
  4-line if/return into one boolean expression — same semantics,
  reads left-to-right as a single predicate.

Copilot review threads resolved: #3134668789, #3134668805,
#3134668822.
2026-04-23 19:22:41 -05:00
Brooklyn Nicholson
2258a181f0 fix(tui): give inline_diff segments blank-line breathing room
Visual polish on top of the segment-anchor change: diff blocks were
butting up against the narration around them. Tag diff-only segments
with `kind: 'diff'` (extended on Msg) and give them `marginTop={1}` +
`marginBottom={1}` in MessageLine, matching the spacing we already
use for user messages. Also swaps the regex-based `diffSegmentBody`
check for an explicit `kind === 'diff'` guard so the dedupe path is
clearer.
2026-04-23 19:11:59 -05:00
Brooklyn Nicholson
11b2942f16 fix(tui): anchor inline_diff to the segment where the edit happened
Revisits #13729. That PR buffered each `tool.complete`'s inline_diff
and merged them into the final assistant message body as a fenced
```diff block. The merge-at-end placement reads as "the agent wrote
this after the summary", even when the edit fired mid-turn — which
is both misleading and (per blitz feedback) feels like noise tacked
onto the end of every task.

Segment-anchored placement instead:

- On tool.complete with inline_diff, `pushInlineDiffSegment` calls
  `flushStreamingSegment` first (so any in-progress narration lands
  as its own segment), then pushes the ```diff block as its own
  segment into segmentMessages. The diff is now anchored BETWEEN the
  narration that preceded the edit and whatever the agent streams
  afterwards, which is where the edit actually happened.
- `recordMessageComplete` no longer merges buffered diffs. The only
  remaining dedupe is "drop diff-only segments whose body the final
  assistant text narrates verbatim (or whose diff fence the final
  text already contains)" — same tradeoff as before, kept so an
  agent that narrates its own diff doesn't render two stacked copies.
- Drops `pendingInlineDiffs` and `queueInlineDiff` — buffer + end-
  merge machinery is gone; segmentMessages is now the only source
  of truth.

Side benefit: Ctrl+C interrupt (`interruptTurn`) iterates
segmentMessages, so diff segments are now preserved in the
transcript when the user cancels after an edit. Previously the
pending buffer was silently dropped on interrupt.

Reported by Teknium during blitz usage: "no diffs are ever at the
end because it didn't make this file edit after the final message".
2026-04-23 19:02:44 -05:00
Brooklyn Nicholson
b08cbc7a79 fix(tui): @<name> fuzzy-matches filenames across the repo
Typing `@appChrome` in the composer should surface
`ui-tui/src/components/appChrome.tsx` without requiring the user to
first type the full directory path — matches the Cmd-P behaviour
users expect from modern editors.

The gateway's `complete.path` handler was doing a plain
`os.listdir(".")` + `startswith` prefix match, so basenames only
resolved inside the current working directory. This reworks it to:

- enumerate repo files via `git ls-files -z --cached --others
  --exclude-standard` (fast, honours `.gitignore`); fall back to a
  bounded `os.walk` that skips common vendor / build dirs when the
  working dir isn't a git repo. Results cached per-root with a 5s
  TTL so rapid keystrokes don't respawn git processes.
- rank basenames with a 5-tier scorer: exact → prefix → camelCase
  / word-boundary → substring → subsequence. Shorter basenames win
  ties; shorter rel paths break basename-length ties.
- only take the fuzzy branch when the query is bare (no `/`), is a
  context reference (`@...`), and isn't `@folder:` — path-ish
  queries and folder tags fall through to the existing
  directory-listing path so explicit navigation intent is
  preserved.

Completion rows now carry `display = basename`,
`meta = directory`, so the picker renders
`appChrome.tsx  ui-tui/src/components` on one row (basename bold,
directory dim) — the meta column was previously "dir" / "" and is
a more useful signal for fuzzy hits.

Reported by Ben Barclay during the TUI v2 blitz test.
2026-04-23 19:01:27 -05:00
11 changed files with 580 additions and 156 deletions

View File

@@ -2821,6 +2821,7 @@ def _prompt_model_selection(
pricing: Optional[Dict[str, Dict[str, str]]] = None, pricing: Optional[Dict[str, Dict[str, str]]] = None,
unavailable_models: Optional[List[str]] = None, unavailable_models: Optional[List[str]] = None,
portal_url: str = "", portal_url: str = "",
allow_custom = True
) -> Optional[str]: ) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None. """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
@@ -2909,8 +2910,16 @@ def _prompt_model_selection(
from simple_term_menu import TerminalMenu from simple_term_menu import TerminalMenu
choices = [f" {_label(mid)}" for mid in ordered] choices = [f" {_label(mid)}" for mid in ordered]
choices.append(" Enter custom model name")
choices.append(" Skip (keep current)") custom_idx = None
if allow_custom:
custom_idx = len(choices)
choices.append(" Enter custom model name")
skip_idx = None
if current_model:
skip_idx = len(choices)
choices.append(" Skip (keep current)")
# Print the unavailable block BEFORE the menu via regular print(). # Print the unavailable block BEFORE the menu via regular print().
# simple_term_menu pads title lines to terminal width (causes wrapping), # simple_term_menu pads title lines to terminal width (causes wrapping),
@@ -2947,21 +2956,29 @@ def _prompt_model_selection(
print() print()
if idx < len(ordered): if idx < len(ordered):
return ordered[idx] return ordered[idx]
elif idx == len(ordered): if idx == custom_idx:
custom = input("Enter model name: ").strip() custom = input("Enter model name: ").strip()
return custom if custom else None return custom if custom else None
if idx == skip_idx:
return None
return None return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass pass
# Fallback: numbered list # Fallback: numbered list
print(menu_title) print(menu_title)
num_width = len(str(len(ordered) + 2)) n = len(ordered)
extra = []
if allow_custom:
extra.append("Enter custom model name")
if current_model:
extra.append("Skip (keep current)")
total = n + len(extra)
num_width = len(str(total))
for i, mid in enumerate(ordered, 1): for i, mid in enumerate(ordered, 1):
print(f" {i:>{num_width}}. {_label(mid)}") print(f" {i:>{num_width}}. {_label(mid)}")
n = len(ordered) for j, label in enumerate(extra, n + 1):
print(f" {n + 1:>{num_width}}. Enter custom model name") print(f" {j:>{num_width}}. {label}")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
if _unavailable: if _unavailable:
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
@@ -2973,18 +2990,19 @@ def _prompt_model_selection(
while True: while True:
try: try:
choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip() choice = input(f"Choice [1-{total}]: ").strip()
if not choice: if not choice:
return None return None
idx = int(choice) val = int(choice)
if 1 <= idx <= n: if 1 <= val <= n:
return ordered[idx - 1] return ordered[val - 1]
elif idx == n + 1: extra_idx = val - n - 1
custom = input("Enter model name: ").strip() if 0 <= extra_idx < len(extra):
return custom if custom else None if extra[extra_idx] == "Enter custom model name":
elif idx == n + 2: custom = input("Enter model name: ").strip()
return None return custom if custom else None
print(f"Please enter 1-{n + 2}") return None # skip
print(f"Please enter 1-{total}")
except ValueError: except ValueError:
print("Please enter a number") print("Please enter a number")
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
@@ -3260,7 +3278,6 @@ def _nous_device_code_login(
open_browser = False open_browser = False
print(f"Starting Hermes login via {pconfig.name}...") print(f"Starting Hermes login via {pconfig.name}...")
print(f"Portal: {portal_base_url}")
if insecure: if insecure:
print("TLS verification: disabled (--insecure)") print("TLS verification: disabled (--insecure)")
elif ca_bundle: elif ca_bundle:
@@ -3280,19 +3297,18 @@ def _nous_device_code_login(
interval = int(device_data["interval"]) interval = int(device_data["interval"])
print() print()
print("To continue:")
print(f" 1. Open: {verification_url}")
print(f" 2. If prompted, enter code: {user_code}")
if open_browser: if open_browser:
opened = webbrowser.open(verification_url) opened = webbrowser.open(verification_url)
if opened: if opened:
print(" (Opened browser for verification)") print("If you don't see a browser window open, navigate to this URL:")
else: else:
print(" Could not open browser automatically — use the URL above.") print("Navigate to this URL to continue:")
print(verification_url)
print(f"If you're prompted for a code, use {user_code}")
print()
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...") print(f"Waiting for approval (checking every {effective_interval}s)...")
token_data = _poll_for_token( token_data = _poll_for_token(
client=client, client=client,
@@ -3357,7 +3373,7 @@ def _nous_device_code_login(
raise raise
def _login_nous(args, pconfig: ProviderConfig) -> None: def login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow.""" """Nous Portal device authorization flow."""
timeout_seconds = getattr(args, "timeout", None) or 15.0 timeout_seconds = getattr(args, "timeout", None) or 15.0
insecure = bool(getattr(args, "insecure", False)) insecure = bool(getattr(args, "insecure", False))
@@ -3419,7 +3435,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
) )
model_ids = _PROVIDER_MODELS.get("nous", []) model_ids = _PROVIDER_MODELS.get("nous", [])
_portal = auth_state.get("portal_base_url", "")
print() print()
unavailable_models: list = [] unavailable_models: list = []
if model_ids: if model_ids:
pricing = get_pricing_for_provider("nous") pricing = get_pricing_for_provider("nous")
@@ -3428,14 +3447,17 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True, model_ids, pricing, free_tier=True,
) )
_portal = auth_state.get("portal_base_url", "") if not free_tier:
if model_ids: print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") if len(model_ids) > 1:
selected_model = _prompt_model_selection( selected_model = _prompt_model_selection(
model_ids, pricing=pricing, model_ids, pricing=pricing,
unavailable_models=unavailable_models, unavailable_models=unavailable_models,
portal_url=_portal, portal_url=_portal,
) allow_custom=not free_tier
)
else:
selected_model = model_ids[0]
elif unavailable_models: elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/") _url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.") print("No free models currently available.")

View File

@@ -1085,9 +1085,6 @@ def cmd_chat(args):
print( print(
"It looks like Hermes isn't configured yet -- no API keys or providers found." "It looks like Hermes isn't configured yet -- no API keys or providers found."
) )
print()
print(" Run: hermes setup")
print()
from hermes_cli.setup import ( from hermes_cli.setup import (
is_interactive_stdin, is_interactive_stdin,
@@ -1100,16 +1097,8 @@ def cmd_chat(args):
) )
sys.exit(1) sys.exit(1)
try: cmd_setup(args)
reply = input("Run setup now? [Y/n] ").strip().lower() return
except (EOFError, KeyboardInterrupt):
reply = "n"
if reply in ("", "y", "yes"):
cmd_setup(args)
return
print()
print("You can run 'hermes setup' at any time to configure.")
sys.exit(1)
# Start update check in background (runs while other init happens) # Start update check in background (runs while other init happens)
try: try:
@@ -2135,7 +2124,7 @@ def _model_flow_nous(config, current_model="", args=None):
resolve_nous_runtime_credentials, resolve_nous_runtime_credentials,
AuthError, AuthError,
format_auth_error, format_auth_error,
_login_nous, login_nous,
PROVIDER_REGISTRY, PROVIDER_REGISTRY,
) )
from hermes_cli.config import ( from hermes_cli.config import (
@@ -2148,8 +2137,6 @@ def _model_flow_nous(config, current_model="", args=None):
state = get_provider_auth_state("nous") state = get_provider_auth_state("nous")
if not state or not state.get("access_token"): if not state or not state.get("access_token"):
print("Not logged into Nous Portal. Starting login...")
print()
try: try:
mock_args = argparse.Namespace( mock_args = argparse.Namespace(
portal_url=getattr(args, "portal_url", None), portal_url=getattr(args, "portal_url", None),
@@ -2161,7 +2148,7 @@ def _model_flow_nous(config, current_model="", args=None):
ca_bundle=getattr(args, "ca_bundle", None), ca_bundle=getattr(args, "ca_bundle", None),
insecure=bool(getattr(args, "insecure", False)), insecure=bool(getattr(args, "insecure", False)),
) )
_login_nous(mock_args, PROVIDER_REGISTRY["nous"]) login_nous(mock_args, PROVIDER_REGISTRY["nous"])
# Offer Tool Gateway enablement for paid subscribers # Offer Tool Gateway enablement for paid subscribers
try: try:
_refreshed = load_config() or {} _refreshed = load_config() or {}
@@ -2212,7 +2199,7 @@ def _model_flow_nous(config, current_model="", args=None):
ca_bundle=None, ca_bundle=None,
insecure=False, insecure=False,
) )
_login_nous(mock_args, PROVIDER_REGISTRY["nous"]) login_nous(mock_args, PROVIDER_REGISTRY["nous"])
except Exception as login_exc: except Exception as login_exc:
print(f"Re-login failed: {login_exc}") print(f"Re-login failed: {login_exc}")
return return

View File

@@ -18,9 +18,10 @@ import shutil
import sys import sys
import copy import copy
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Literal, Optional, Dict, Any
from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.main import _model_flow_nous
from tools.tool_backend_helpers import managed_nous_tools_enabled from tools.tool_backend_helpers import managed_nous_tools_enabled
from utils import base_url_hostname from utils import base_url_hostname
from hermes_constants import get_optional_skills_dir from hermes_constants import get_optional_skills_dir
@@ -655,7 +656,7 @@ def _prompt_container_resources(config: dict):
def setup_model_provider(config: dict, *, quick: bool = False): def setup_model_provider(config: dict, *, quick: bool | Literal["nous_portal"] = False):
"""Configure the inference provider and default model. """Configure the inference provider and default model.
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``) Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
@@ -677,7 +678,11 @@ def setup_model_provider(config: dict, *, quick: bool = False):
# credential prompting, model selection, and config persistence. # credential prompting, model selection, and config persistence.
from hermes_cli.main import select_provider_and_model from hermes_cli.main import select_provider_and_model
try: try:
select_provider_and_model() if quick == "nous_portal":
config = load_config()
_model_flow_nous(config)
else:
select_provider_and_model()
except (SystemExit, KeyboardInterrupt): except (SystemExit, KeyboardInterrupt):
print() print()
print_info("Provider setup skipped.") print_info("Provider setup skipped.")
@@ -3030,11 +3035,15 @@ def run_setup_wizard(args):
config = load_config() config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [ setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)", "Nous Account setup — model & messaging (recommended)",
"Quick setup — provider, model & messaging",
"Full setup — configure everything", "Full setup — configure everything",
], 0) ], 0)
if setup_mode == 0: if setup_mode == 0:
_run_first_time_quick_setup(config, hermes_home, is_existing, nous_quick=True)
return
if setup_mode == 1:
_run_first_time_quick_setup(config, hermes_home, is_existing) _run_first_time_quick_setup(config, hermes_home, is_existing)
return return
@@ -3095,7 +3104,7 @@ def _resolve_hermes_chat_argv() -> Optional[list[str]]:
return None return None
def _offer_launch_chat(): def _offer_launch_chat(auto_launch = False):
"""Prompt the user to jump straight into chat after setup.""" """Prompt the user to jump straight into chat after setup."""
print() print()
if not prompt_yes_no("Launch hermes chat now?", True): if not prompt_yes_no("Launch hermes chat now?", True):
@@ -3109,7 +3118,7 @@ def _offer_launch_chat():
os.execvp(chat_argv[0], chat_argv) os.execvp(chat_argv[0], chat_argv)
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool, nous_quick=False):
"""Streamlined first-time setup: provider + model only. """Streamlined first-time setup: provider + model only.
Applies sensible defaults for TTS (Edge), terminal (local), agent Applies sensible defaults for TTS (Edge), terminal (local), agent
@@ -3117,7 +3126,7 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
``hermes setup <section>``. ``hermes setup <section>``.
""" """
# Step 1: Model & Provider (essential — skips rotation/vision/TTS) # Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True) setup_model_provider(config, quick="nous_portal" if nous_quick else True )
# Step 2: Apply defaults for everything else # Step 2: Apply defaults for everything else
_apply_default_agent_settings(config) _apply_default_agent_settings(config)
@@ -3150,7 +3159,9 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
_print_setup_summary(config, hermes_home) _print_setup_summary(config, hermes_home)
_offer_launch_chat() # if the user hasn't set up the gateway, assume they want to launch chat.
force_launch_chat = gateway_choice == 0
_offer_launch_chat(force_launch_chat)
def _run_quick_setup(config: dict, hermes_home): def _run_quick_setup(config: dict, hermes_home):

View File

@@ -571,7 +571,7 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
captured["ca_bundle"] = login_args.ca_bundle captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login) monkeypatch.setattr("hermes_cli.auth.login_nous", _fake_login)
hermes_main.cmd_model( hermes_main.cmd_model(
SimpleNamespace( SimpleNamespace(

View File

@@ -1,22 +1,28 @@
"""Regression tests for the TUI gateway's `complete.path` handler. """Regression tests for the TUI gateway's `complete.path` handler.
Reported during the TUI v2 blitz retest: typing `@folder:` (and `@folder` Reported during the TUI v2 blitz retest:
with no colon yet) still surfaced files alongside directories in the - typing `@folder:` (and `@folder` with no colon yet) surfaced files
TUI composer, because the gateway-side completion lives in alongside directories — the gateway-side completion lives in
`tui_gateway/server.py` and was never touched by the earlier fix to `tui_gateway/server.py` and was never touched by the earlier fix to
`hermes_cli/commands.py`. `hermes_cli/commands.py`.
- typing `@appChrome` required the full `@ui-tui/src/components/app…`
path to find the file — users expect Cmd-P-style fuzzy basename
matching across the repo, not a strict directory prefix filter.
Covers: Covers:
- `@folder:` only yields directories - `@folder:` only yields directories
- `@file:` only yields regular files - `@file:` only yields regular files
- Bare `@folder` / `@file` (no colon) lists cwd directly - Bare `@folder` / `@file` (no colon) lists cwd directly
- Explicit prefix is preserved in the completion text - Explicit prefix is preserved in the completion text
- `@<name>` with no slash fuzzy-matches basenames anywhere in the tree
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from tui_gateway import server from tui_gateway import server
@@ -33,6 +39,15 @@ def _items(word: str):
return [(it["text"], it["display"], it.get("meta", "")) for it in resp["result"]["items"]] return [(it["text"], it["display"], it.get("meta", "")) for it in resp["result"]["items"]]
@pytest.fixture(autouse=True)
def _reset_fuzzy_cache(monkeypatch):
# Each test walks a fresh tmp dir; clear the cached listing so prior
# roots can't leak through the TTL window.
server._fuzzy_cache.clear()
yield
server._fuzzy_cache.clear()
def test_at_folder_colon_only_dirs(tmp_path, monkeypatch): def test_at_folder_colon_only_dirs(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
_fixture(tmp_path) _fixture(tmp_path)
@@ -89,3 +104,176 @@ def test_bare_at_still_shows_static_refs(tmp_path, monkeypatch):
for expected in ("@diff", "@staged", "@file:", "@folder:", "@url:", "@git:"): for expected in ("@diff", "@staged", "@file:", "@folder:", "@url:", "@git:"):
assert expected in texts, f"missing static ref {expected!r} in {texts!r}" assert expected in texts, f"missing static ref {expected!r} in {texts!r}"
# ── Fuzzy basename matching ──────────────────────────────────────────────
# Users shouldn't have to know the full path — typing `@appChrome` should
# find `ui-tui/src/components/appChrome.tsx`.
def _nested_fixture(tmp_path: Path):
(tmp_path / "readme.md").write_text("x")
(tmp_path / ".env").write_text("x")
(tmp_path / "ui-tui/src/components").mkdir(parents=True)
(tmp_path / "ui-tui/src/components/appChrome.tsx").write_text("x")
(tmp_path / "ui-tui/src/components/appLayout.tsx").write_text("x")
(tmp_path / "ui-tui/src/components/thinking.tsx").write_text("x")
(tmp_path / "ui-tui/src/hooks").mkdir(parents=True)
(tmp_path / "ui-tui/src/hooks/useCompletion.ts").write_text("x")
(tmp_path / "tui_gateway").mkdir()
(tmp_path / "tui_gateway/server.py").write_text("x")
def test_fuzzy_at_finds_file_without_directory_prefix(tmp_path, monkeypatch):
"""`@appChrome` — with no slash — should surface the nested file."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
entries = _items("@appChrome")
texts = [t for t, _, _ in entries]
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
# Display is the basename, meta is the containing directory, so the
# picker can show `appChrome.tsx ui-tui/src/components` on one row.
row = next(r for r in entries if r[0] == "@file:ui-tui/src/components/appChrome.tsx")
assert row[1] == "appChrome.tsx"
assert row[2] == "ui-tui/src/components"
def test_fuzzy_ranks_exact_before_prefix_before_subseq(tmp_path, monkeypatch):
"""Better matches sort before weaker matches regardless of path depth."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
(tmp_path / "server.py").write_text("x") # exact basename match at root
texts = [t for t, _, _ in _items("@server")]
# Exact `server.py` beats `tui_gateway/server.py` (prefix match) — both
# rank 1 on basename but exact basename wins on the sort key; shorter
# rel path breaks ties.
assert texts[0] == "@file:server.py", texts
assert "@file:tui_gateway/server.py" in texts
def test_fuzzy_camelcase_word_boundary(tmp_path, monkeypatch):
"""Mid-basename camelCase pieces match without substring scanning."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
texts = [t for t, _, _ in _items("@Chrome")]
# `Chrome` starts a camelCase word inside `appChrome.tsx`.
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
def test_fuzzy_subsequence_catches_sparse_queries(tmp_path, monkeypatch):
"""`@uCo` → `useCompletion.ts` via subsequence, last-resort tier."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
texts = [t for t, _, _ in _items("@uCo")]
assert "@file:ui-tui/src/hooks/useCompletion.ts" in texts, texts
def test_fuzzy_at_file_prefix_preserved(tmp_path, monkeypatch):
"""Explicit `@file:` prefix still wins the completion tag."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
texts = [t for t, _, _ in _items("@file:appChrome")]
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
def test_fuzzy_skipped_when_path_has_slash(tmp_path, monkeypatch):
"""Any `/` in the query = user is navigating; keep directory listing."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
texts = [t for t, _, _ in _items("@ui-tui/src/components/app")]
# Directory-listing mode prefixes with `@file:` / `@folder:` per entry.
# It should only surface direct children of the named dir — not the
# nested `useCompletion.ts`.
assert any("appChrome.tsx" in t for t in texts), texts
assert not any("useCompletion.ts" in t for t in texts), texts
def test_fuzzy_skipped_when_folder_tag(tmp_path, monkeypatch):
"""`@folder:<name>` still lists directories — fuzzy scanner only walks
files (git-tracked + untracked), so defer to the dir-listing path."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
texts = [t for t, _, _ in _items("@folder:ui")]
# Root has `ui-tui/` as a directory; the listing branch should surface it.
assert any(t.startswith("@folder:ui-tui") for t in texts), texts
def test_fuzzy_hides_dotfiles_unless_asked(tmp_path, monkeypatch):
"""`.env` doesn't leak into `@env` but does show for `@.env`."""
monkeypatch.chdir(tmp_path)
_nested_fixture(tmp_path)
assert not any(".env" in t for t, _, _ in _items("@env"))
assert any(t.endswith(".env") for t, _, _ in _items("@.env"))
def test_fuzzy_caps_results(tmp_path, monkeypatch):
"""The 30-item cap survives a big tree."""
monkeypatch.chdir(tmp_path)
for i in range(60):
(tmp_path / f"mod_{i:03d}.py").write_text("x")
items = _items("@mod")
assert len(items) == 30
def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
"""When the gateway runs from a subdirectory of a git repo, fuzzy
completion paths must resolve under that cwd — not under the repo root.
Without this, `@appChrome` from inside `apps/web/` would suggest
`@file:apps/web/src/foo.tsx` but the agent (resolving from cwd) would
look for `apps/web/apps/web/src/foo.tsx` and fail. We translate every
`git ls-files` result back to a `relpath(root)` and drop anything
outside `root` so the completion contract stays "paths are cwd-relative".
"""
import subprocess
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True)
(tmp_path / "apps" / "web" / "src").mkdir(parents=True)
(tmp_path / "apps" / "web" / "src" / "appChrome.tsx").write_text("x")
(tmp_path / "apps" / "api" / "src").mkdir(parents=True)
(tmp_path / "apps" / "api" / "src" / "server.ts").write_text("x")
(tmp_path / "README.md").write_text("x")
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
# Run from `apps/web/` — completions should be relative to here, and
# files outside this subtree (apps/api, README.md at root) shouldn't
# appear at all.
monkeypatch.chdir(tmp_path / "apps" / "web")
texts = [t for t, _, _ in _items("@appChrome")]
assert "@file:src/appChrome.tsx" in texts, texts
assert not any("apps/web/" in t for t in texts), texts
server._fuzzy_cache.clear()
other_texts = [t for t, _, _ in _items("@server")]
assert not any("server.ts" in t for t in other_texts), other_texts
server._fuzzy_cache.clear()
readme_texts = [t for t, _, _ in _items("@README")]
assert not any("README.md" in t for t in readme_texts), readme_texts

View File

@@ -3256,6 +3256,162 @@ def _(rid, params: dict) -> dict:
# ── Methods: complete ───────────────────────────────────────────────── # ── Methods: complete ─────────────────────────────────────────────────
_FUZZY_CACHE_TTL_S = 5.0
_FUZZY_CACHE_MAX_FILES = 20000
_FUZZY_FALLBACK_EXCLUDES = frozenset(
{
".git",
".hg",
".svn",
".next",
".cache",
".venv",
"venv",
"node_modules",
"__pycache__",
"dist",
"build",
"target",
".mypy_cache",
".pytest_cache",
".ruff_cache",
}
)
_fuzzy_cache_lock = threading.Lock()
_fuzzy_cache: dict[str, tuple[float, list[str]]] = {}
def _list_repo_files(root: str) -> list[str]:
"""Return file paths relative to ``root``.
Uses ``git ls-files`` from the repo top (resolved via
``rev-parse --show-toplevel``) so the listing covers tracked + untracked
files anywhere in the repo, then converts each path back to be relative
to ``root``. Files outside ``root`` (parent directories of cwd, sibling
subtrees) are excluded so the picker stays scoped to what's reachable
from the gateway's cwd. Falls back to a bounded ``os.walk(root)`` when
``root`` isn't inside a git repo. Result cached per-root for
``_FUZZY_CACHE_TTL_S`` so rapid keystrokes don't respawn git processes.
"""
now = time.monotonic()
with _fuzzy_cache_lock:
cached = _fuzzy_cache.get(root)
if cached and now - cached[0] < _FUZZY_CACHE_TTL_S:
return cached[1]
files: list[str] = []
try:
top_result = subprocess.run(
["git", "-C", root, "rev-parse", "--show-toplevel"],
capture_output=True,
timeout=2.0,
check=False,
)
if top_result.returncode == 0:
top = top_result.stdout.decode("utf-8", "replace").strip()
list_result = subprocess.run(
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
capture_output=True,
timeout=2.0,
check=False,
)
if list_result.returncode == 0:
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
if not p:
continue
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/")
# Skip parents/siblings of cwd — keep the picker scoped
# to root-and-below, matching Cmd-P workspace semantics.
if rel.startswith("../"):
continue
files.append(rel)
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
except (OSError, subprocess.TimeoutExpired):
pass
if not files:
# Fallback walk: skip vendor/build dirs + dot-dirs so the walk stays
# tractable. Dotfiles themselves survive — the ranker decides based
# on whether the query starts with `.`.
try:
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [
d
for d in dirnames
if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")
]
rel_dir = os.path.relpath(dirpath, root)
for f in filenames:
rel = f if rel_dir == "." else f"{rel_dir}/{f}"
files.append(rel.replace(os.sep, "/"))
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
except OSError:
pass
with _fuzzy_cache_lock:
_fuzzy_cache[root] = (now, files)
return files
def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None:
"""Rank ``name`` against ``query``; lower is better. Returns None to reject.
Tiers (kind):
0 — exact basename
1 — basename prefix (e.g. `app` → `appChrome.tsx`)
2 — word-boundary / camelCase hit (e.g. `chrome` → `appChrome.tsx`)
3 — substring anywhere in basename
4 — subsequence match (every query char appears in order)
Secondary key is `len(name)` so shorter names win ties.
"""
if not query:
return (3, len(name))
nl = name.lower()
ql = query.lower()
if nl == ql:
return (0, len(name))
if nl.startswith(ql):
return (1, len(name))
# Word-boundary split: `foo-bar_baz.qux` → ["foo","bar","baz","qux"].
# camelCase split: `appChrome` → ["app","Chrome"]. Cheap approximation;
# falls through to substring/subsequence if it misses.
parts: list[str] = []
buf = ""
for ch in name:
if ch in "-_." or (ch.isupper() and buf and not buf[-1].isupper()):
if buf:
parts.append(buf)
buf = ch if ch not in "-_." else ""
else:
buf += ch
if buf:
parts.append(buf)
for p in parts:
if p.lower().startswith(ql):
return (2, len(name))
if ql in nl:
return (3, len(name))
i = 0
for ch in nl:
if ch == ql[i]:
i += 1
if i == len(ql):
return (4, len(name))
return None
@method("complete.path") @method("complete.path")
def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict:
@@ -3291,6 +3447,42 @@ def _(rid, params: dict) -> dict:
prefix_tag = "" prefix_tag = ""
path_part = query if is_context else query path_part = query if is_context else query
# Fuzzy basename search across the repo when the user types a bare
# name with no path separator — `@appChrome` surfaces every file
# whose basename matches, regardless of directory depth. Matches what
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
# path so explicit navigation intent is preserved.
if (
is_context
and path_part
and "/" not in path_part
and prefix_tag != "folder"
):
root = os.getcwd()
ranked: list[tuple[tuple[int, int], str, str]] = []
for rel in _list_repo_files(root):
basename = os.path.basename(rel)
if basename.startswith(".") and not path_part.startswith("."):
continue
rank = _fuzzy_basename_rank(basename, path_part)
if rank is None:
continue
ranked.append((rank, rel, basename))
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
tag = prefix_tag or "file"
for _, rel, basename in ranked[:30]:
items.append(
{
"text": f"@{tag}:{rel}",
"display": basename,
"meta": os.path.dirname(rel),
}
)
return _ok(rid, {"items": items})
expanded = _normalize_completion_path(path_part) if path_part else "." expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded: if expanded == "." or not expanded:
search_dir, match = ".", "" search_dir, match = ".", ""

View File

@@ -152,91 +152,79 @@ describe('createGatewayEventHandler', () => {
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
}) })
it('attaches inline_diff to the assistant completion body', () => { it('anchors inline_diff as its own segment where the edit happened', () => {
const appended: Msg[] = [] const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended)) const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new' const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const block = `\`\`\`diff\n${cleaned}\n\`\`\``
onEvent({ // Narration → tool → tool-complete → more narration → message-complete.
payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, // The diff MUST land between the two narration segments, not tacked
type: 'tool.start' // onto the final one.
} as any) onEvent({ payload: { text: 'Editing the file' }, type: 'message.delta' } as any)
onEvent({ onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
type: 'tool.complete'
} as any)
// Diff is buffered for message.complete and sanitized (ANSI stripped). // Diff is already committed to segmentMessages as its own segment.
expect(appended).toHaveLength(0) expect(appended).toHaveLength(0)
expect(turnController.pendingInlineDiffs).toEqual([cleaned]) expect(turnController.segmentMessages).toEqual([
{ role: 'assistant', text: 'Editing the file' },
{ kind: 'diff', role: 'assistant', text: block }
])
onEvent({ onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
payload: { text: 'patch applied' },
type: 'message.complete'
} as any)
// Diff is rendered in the same assistant message body as the completion. // Three transcript messages: pre-tool narration → diff (kind='diff',
expect(appended).toHaveLength(1) // so MessageLine gives it blank-line breathing room) → post-tool
expect(appended[0]).toMatchObject({ role: 'assistant' }) // narration. The final message does NOT contain a diff.
expect(appended[0]?.text).toContain('patch applied') expect(appended).toHaveLength(3)
expect(appended[0]?.text).toContain('```diff') expect(appended[0]?.text).toBe('Editing the file')
expect(appended[0]?.text).toContain(cleaned) expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
expect(appended[2]?.text).toBe('patch applied')
expect(appended[2]?.text).not.toContain('```diff')
}) })
it('does not append inline_diff twice when assistant text already contains it', () => { it('drops the diff segment when the final assistant text narrates the same diff', () => {
const appended: Msg[] = [] const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended)) const onEvent = createGatewayEventHandler(buildCtx(appended))
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\`` const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
onEvent({ onEvent({ payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
type: 'tool.complete'
} as any)
onEvent({
payload: { text: assistantText },
type: 'message.complete'
} as any)
// Only the final message — diff-only segment dropped so we don't
// render two stacked copies of the same patch.
expect(appended).toHaveLength(1) expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText) expect(appended[0]?.text).toBe(assistantText)
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1) expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
}) })
it('strips the CLI "┊ review diff" header from queued inline diffs', () => { it('strips the CLI "┊ review diff" header from inline diff segments', () => {
const appended: Msg[] = [] const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended)) const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({ onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'done' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1) // diff segment first (kind='diff'), final narration second
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).not.toContain('┊ review diff') expect(appended[0]?.text).not.toContain('┊ review diff')
expect(appended[0]?.text).toContain('--- a/foo.ts') expect(appended[0]?.text).toContain('--- a/foo.ts')
expect(appended[1]?.text).toBe('done')
}) })
it('suppresses inline_diff when assistant already wrote a diff fence', () => { it('drops the diff segment when assistant writes its own ```diff fence', () => {
const appended: Msg[] = [] const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended)) const onEvent = createGatewayEventHandler(buildCtx(appended))
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```' const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
onEvent({ onEvent({ payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
type: 'tool.complete'
} as any)
onEvent({
payload: { text: assistantText },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1) expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText) expect(appended[0]?.text).toBe(assistantText)
@@ -252,15 +240,18 @@ describe('createGatewayEventHandler', () => {
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' }, payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
type: 'tool.complete' type: 'tool.complete'
} as any) } as any)
onEvent({ onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
payload: { text: 'done' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1) // Two segments: the diff block (kind='diff', no tool row) and the final
expect(appended[0]?.tools?.[0]).toContain('Review Diff') // narration (tool row belongs here since pendingSegmentTools carries
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts') // across the flushStreamingSegment call).
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).toContain('```diff') expect(appended[0]?.text).toContain('```diff')
expect(appended[0]?.tools ?? []).toEqual([])
expect(appended[1]?.text).toBe('done')
expect(appended[1]?.tools?.[0]).toContain('Review Diff')
expect(appended[1]?.tools?.[0]).not.toContain('--- a/foo.ts')
}) })
it('shows setup panel for missing provider startup error', () => { it('shows setup panel for missing provider startup error', () => {

View File

@@ -385,10 +385,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return return
} }
// Keep inline diffs attached to the assistant completion body so // Anchor the diff to where the edit happened in the turn — between
// they render in the same message flow, not as a standalone system // the narration that preceded the tool call and whatever the agent
// artifact that can look out-of-place around tool rows. // streams afterwards. The previous end-merge put the diff at the
turnController.queueInlineDiff(inlineDiffText) // bottom of the final message even when the edit fired mid-turn,
// which read as "the agent wrote this after saying that".
turnController.pushInlineDiffSegment(inlineDiffText)
return return
} }

View File

@@ -19,6 +19,20 @@ const INTERRUPT_COOLDOWN_MS = 1500
const ACTIVITY_LIMIT = 8 const ACTIVITY_LIMIT = 8
const TRAIL_LIMIT = 8 const TRAIL_LIMIT = 8
// Extracts the raw patch from a diff-only segment produced by
// pushInlineDiffSegment. Used at message.complete to dedupe against final
// assistant text that narrates the same patch. Returns null for anything
// else so real assistant narration never gets touched.
const diffSegmentBody = (msg: Msg): null | string => {
if (msg.kind !== 'diff') {
return null
}
const m = msg.text.match(/^```diff\n([\s\S]*?)\n```$/)
return m ? m[1]! : null
}
export interface InterruptDeps { export interface InterruptDeps {
appendMessage: (msg: Msg) => void appendMessage: (msg: Msg) => void
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> } gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
@@ -40,7 +54,6 @@ class TurnController {
bufRef = '' bufRef = ''
interrupted = false interrupted = false
lastStatusNote = '' lastStatusNote = ''
pendingInlineDiffs: string[] = []
persistedToolLabels = new Set<string>() persistedToolLabels = new Set<string>()
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void> persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
protocolWarned = false protocolWarned = false
@@ -79,7 +92,6 @@ class TurnController {
this.activeTools = [] this.activeTools = []
this.streamTimer = clear(this.streamTimer) this.streamTimer = clear(this.streamTimer)
this.bufRef = '' this.bufRef = ''
this.pendingInlineDiffs = []
this.pendingSegmentTools = [] this.pendingSegmentTools = []
this.segmentMessages = [] this.segmentMessages = []
@@ -186,18 +198,35 @@ class TurnController {
}, REASONING_PULSE_MS) }, REASONING_PULSE_MS)
} }
queueInlineDiff(diffText: string) { pushInlineDiffSegment(diffText: string) {
// Strip CLI chrome the gateway emits before the unified diff (e.g. a // Strip CLI chrome the gateway emits before the unified diff (e.g. a
// leading "┊ review diff" header written by `_emit_inline_diff` for the // leading "┊ review diff" header written by `_emit_inline_diff` for the
// terminal printer). That header only makes sense as stdout dressing, // terminal printer). That header only makes sense as stdout dressing,
// not inside a markdown ```diff block. // not inside a markdown ```diff block.
const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim() const stripped = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
if (!text || this.pendingInlineDiffs.includes(text)) { if (!stripped) {
return return
} }
this.pendingInlineDiffs = [...this.pendingInlineDiffs, text] // Flush any in-progress streaming text as its own segment first, so the
// diff lands BETWEEN the assistant narration that preceded the edit and
// whatever the agent streams afterwards — not glued onto the final
// message. This is the whole point of segment-anchored diffs: the diff
// renders where the edit actually happened.
this.flushStreamingSegment()
const block = `\`\`\`diff\n${stripped}\n\`\`\``
// Skip consecutive duplicates (same tool firing tool.complete twice, or
// two edits producing the same patch). Keeping this cheap — deeper
// dedupe against the final assistant text happens at message.complete.
if (this.segmentMessages.at(-1)?.text === block) {
return
}
this.segmentMessages = [...this.segmentMessages, { kind: 'diff', role: 'assistant', text: block }]
patchTurnState({ streamSegments: this.segmentMessages })
} }
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
@@ -234,7 +263,6 @@ class TurnController {
this.idle() this.idle()
this.clearReasoning() this.clearReasoning()
this.clearStatusTimer() this.clearStatusTimer()
this.pendingInlineDiffs = []
this.pendingSegmentTools = [] this.pendingSegmentTools = []
this.segmentMessages = [] this.segmentMessages = []
this.turnTools = [] this.turnTools = []
@@ -245,31 +273,31 @@ class TurnController {
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const split = splitReasoning(rawText) const split = splitReasoning(rawText)
const finalText = split.text const finalText = split.text
// Skip appending if the assistant already narrated the diff inside a
// markdown fence of its own — otherwise we render two stacked diff
// blocks for the same edit.
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
const remainingInlineDiffs = assistantAlreadyHasDiff
? []
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
const inlineDiffBlock = remainingInlineDiffs.length
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
: ''
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = this.toolTokenAcc const savedToolTokens = this.toolTokenAcc
const tools = this.pendingSegmentTools const tools = this.pendingSegmentTools
const finalMessages = [...this.segmentMessages]
if (mergedText) { // Drop diff-only segments the agent is about to narrate in the final
// reply. Without this, a closing "here's the diff …" message would
// render two stacked copies of the same patch. Only touches segments
// with `kind: 'diff'` emitted by pushInlineDiffSegment — real
// assistant narration stays put.
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
const segments = this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const finalMessages = [...segments]
if (finalText) {
finalMessages.push({ finalMessages.push({
role: 'assistant', role: 'assistant',
text: mergedText, text: finalText,
thinking: savedReasoning || undefined, thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined, toolTokens: savedToolTokens || undefined,
@@ -300,7 +328,7 @@ class TurnController {
this.bufRef = '' this.bufRef = ''
patchTurnState({ activity: [], outcome: '' }) patchTurnState({ activity: [], outcome: '' })
return { finalMessages, finalText: mergedText, wasInterrupted } return { finalMessages, finalText, wasInterrupted }
} }
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
@@ -406,7 +434,6 @@ class TurnController {
this.bufRef = '' this.bufRef = ''
this.interrupted = false this.interrupted = false
this.lastStatusNote = '' this.lastStatusNote = ''
this.pendingInlineDiffs = []
this.pendingSegmentTools = [] this.pendingSegmentTools = []
this.protocolWarned = false this.protocolWarned = false
this.segmentMessages = [] this.segmentMessages = []
@@ -452,7 +479,6 @@ class TurnController {
this.endReasoningPhase() this.endReasoningPhase()
this.clearReasoning() this.clearReasoning()
this.activeTools = [] this.activeTools = []
this.pendingInlineDiffs = []
this.turnTools = [] this.turnTools = []
this.toolTokenAcc = 0 this.toolTokenAcc = 0
this.persistedToolLabels.clear() this.persistedToolLabels.clear()

View File

@@ -81,11 +81,16 @@ export const MessageLine = memo(function MessageLine({
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text> return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
})() })()
// Diff segments (emitted by pushInlineDiffSegment between narration
// segments) need a blank line on both sides so the patch doesn't butt up
// against the prose around it.
const isDiffSegment = msg.kind === 'diff'
return ( return (
<Box <Box
flexDirection="column" flexDirection="column"
marginBottom={msg.role === 'user' ? 1 : 0} marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0} marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment ? 1 : 0}
> >
{showDetails && ( {showDetails && (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>

View File

@@ -102,7 +102,7 @@ export interface ClarifyReq {
export interface Msg { export interface Msg {
info?: SessionInfo info?: SessionInfo
kind?: 'intro' | 'panel' | 'slash' | 'trail' kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
panelData?: PanelData panelData?: PanelData
role: Role role: Role
text: string text: string