diff --git a/.github/scripts/generate_star_history.py b/.github/scripts/generate_star_history.py new file mode 100644 index 0000000000..19ec09ae52 --- /dev/null +++ b/.github/scripts/generate_star_history.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Generate a self-hosted GitHub star-history chart for the README.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import urllib.request +from pathlib import Path + + +def fetch_stars(repository: str, token: str) -> list[dt.date]: + url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100" + dates: list[dt.date] = [] + while url: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github.star+json", + "Authorization": f"Bearer {token}", + "User-Agent": "jcode-star-history", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request) as response: + for star in json.load(response): + dates.append(dt.datetime.fromisoformat(star["starred_at"].replace("Z", "+00:00")).date()) + links = response.headers.get("Link", "") + url = "" + for link in links.split(","): + if 'rel="next"' in link: + url = link[link.index("<") + 1 : link.index(">")] + break + return dates + + +def render_svg(repository: str, dates: list[dt.date]) -> str: + if not dates: + raise RuntimeError("GitHub returned no stargazers") + dates.sort() + start, end = dates[0], max(dates[-1], dt.date.today()) + span = max((end - start).days, 1) + width, height = 800, 420 + left, right, top, bottom = 72, 24, 38, 58 + plot_w, plot_h = width - left - right, height - top - bottom + + points: list[tuple[dt.date, int]] = [] + for index, day in enumerate(dates, 1): + if index == len(dates) or dates[index] != day: + points.append((day, index)) + if points[-1][0] < end: + points.append((end, len(dates))) + + def x(day: dt.date) -> float: + return left + (day - start).days / span * plot_w + + max_stars = len(dates) + grid_max = ((max_stars + 4999) // 5000) * 5000 or 1 + + def y(value: int) -> float: + return top + (1 - value / grid_max) * plot_h + + path = " ".join( + ("M" if index == 0 else "L") + f" {x(day):.1f} {y(count):.1f}" + for index, (day, count) in enumerate(points) + ) + area = f"{path} L {x(end):.1f} {top + plot_h:.1f} L {x(start):.1f} {top + plot_h:.1f} Z" + + y_ticks = [] + for index in range(5): + value = round(grid_max * index / 4) + yy = y(value) + label = f"{value / 1000:g}k" if value >= 1000 else str(value) + y_ticks.append(f'{label}') + + x_ticks = [] + years = range(start.year, end.year + 1) + for year in years: + day = max(start, dt.date(year, 1, 1)) + if day > end: + continue + xx = x(day) + x_ticks.append(f'{day.year}') + + return f''' +{repository} star history +GitHub stars over time, currently {max_stars:,} + + +GitHub stars over time +{''.join(y_ticks)}{''.join(x_ticks)} + + +{max_stars:,} stars + +''' + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default="1jehuang/jcode") + parser.add_argument("--output", type=Path, default=Path("docs/images/star-history.svg")) + args = parser.parse_args() + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_svg(args.repo, fetch_stars(args.repo, token))) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 633e26f115..a99581aa2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: name: Quality Guardrails runs-on: ubuntu-latest timeout-minutes: 45 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -26,9 +28,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: @@ -137,6 +140,9 @@ jobs: - name: Compile release automation scripts run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py + - name: Test fork-compatible workflow contracts + run: python3 -m unittest -v scripts/test_fork_ci_workflow.py + build: name: Build & Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -158,6 +164,7 @@ jobs: # pinning it here makes every step use this job's installed stable toolchain. env: RUSTUP_TOOLCHAIN: stable + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} strategy: fail-fast: false matrix: @@ -175,9 +182,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: @@ -406,6 +414,8 @@ jobs: name: Build & Test (windows-latest) runs-on: windows-latest timeout-minutes: 150 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -413,9 +423,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: ilammy/msvc-dev-cmd@v1 with: @@ -659,6 +670,8 @@ jobs: name: Windows Cross-Target Check (Linux) runs-on: ubuntu-latest timeout-minutes: 35 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -666,9 +679,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml index e2a02fd0ce..84865e7b5f 100644 --- a/.github/workflows/require-issue.yml +++ b/.github/workflows/require-issue.yml @@ -39,6 +39,20 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; + + try { + const repositoryQuery = `query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { hasIssuesEnabled } + }`; + const repositoryResult = await github.graphql(repositoryQuery, { owner, repo }); + if (repositoryResult?.repository?.hasIssuesEnabled === false) { + core.info('Repository issues are disabled; skipping linked-issue requirement.'); + return; + } + } catch (err) { + core.warning(`Could not query repository issue capability: ${err}`); + } + const text = `${pr.title || ''}\n\n${pr.body || ''}`; // Collect candidate issue numbers that belong to THIS repo. diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml new file mode 100644 index 0000000000..e251d9acdb --- /dev/null +++ b/.github/workflows/update-star-history.yml @@ -0,0 +1,34 @@ +name: Update star history + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-star-history + cancel-in-progress: true + +jobs: + update: + if: github.repository == '1jehuang/jcode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Generate chart using repository-authorized star data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/generate_star_history.py + - name: Commit updated chart + run: | + if git diff --quiet -- docs/images/star-history.svg; then + exit 0 + fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add docs/images/star-history.svg + git commit -m "docs: update star history chart" + git push diff --git a/Cargo.lock b/Cargo.lock index 0d6fe824e3..ec7b736f87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2099,6 +2099,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -2753,6 +2765,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2786,6 +2807,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -3355,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.75.5" +version = "0.78.0" dependencies = [ "anyhow", "async-stream", @@ -3568,6 +3598,7 @@ dependencies = [ "rand 0.9.3", "regex", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -4869,6 +4900,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -7280,6 +7322,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index f7f0da8c87..bc6fbd7da8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.75.5" +version = "0.78.0" description = "Possibly the greatest coding agent ever built โ€” blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/README.md b/README.md index ef8191ed67..a25e696591 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The most intelligent harness 1jehuang/jcode | Trendshift -Stargazers over time +jcode GitHub stars over time jcode YC launch video @@ -365,13 +365,14 @@ There are two ways to set one up: jcode login --provider # for example: jcode login --provider openrouter + jcode login --provider orcarouter jcode login --provider deepseek jcode login --provider opencode # OpenCode Zen jcode login --provider moonshotai jcode login --provider meta-muse # Meta Model API / Muse Spark ``` - Built-in OpenAI-compatible profile ids include: `openrouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. + Built-in OpenAI-compatible profile ids include: `openrouter`, `orcarouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. - **Any other endpoint** โ€” point jcode at an arbitrary OpenAI-compatible API (hosted or local) with `jcode login --provider openai-compatible` or the scriptable `jcode provider add` command described below. @@ -456,6 +457,34 @@ id = "my-model-id" context_window = 128000 ``` +Anthropic Messages-compatible gateways use the same named-profile surface with +`type = "anthropic-compatible"`. The profile can select bearer, custom-header, +or no authentication and attach gateway-specific headers to every request: + +```toml +[provider] +default_provider = "corp-claude" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude] +type = "anthropic-compatible" +base_url = "https://gateway.example.com/anthropic/v1" +auth = "bearer" +api_key_env = "CORP_CLAUDE_TOKEN" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude.headers] +x-tenant-id = "tenant-42" + +[[providers.corp-claude.models]] +id = "claude-sonnet-4-6" +context_window = 200000 +``` + +For direct environment-based configuration, `ANTHROPIC_BASE_URL` overrides the +non-OAuth Messages endpoint and `ANTHROPIC_AUTH_TOKEN` is sent as a bearer token. +Claude OAuth traffic always continues to use Anthropic's official endpoints. + ##### Extra request-body fields (`extra_body`) Some OpenAI-compatible backends require non-standard top-level request fields. For example, NVIDIA NIM DeepSeek-V4 reasoning models (`deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`) only enable thinking when the request includes `chat_template_kwargs`; without it they reply without reasoning (or, for some deployments, hang). jcode lets you inject arbitrary top-level fields two ways. @@ -580,7 +609,7 @@ The above image is the first page of provider logins ### Supported provider - **Native / first-party style providers:** `claude`, `openai`, `copilot`, `gemini`, `azure`, `alibaba-coding-plan` -- **Aggregator / compatibility providers:** `openrouter`, `openai-compatible` +- **Aggregator / compatibility providers:** `openrouter`, `orcarouter`, `openai-compatible` - **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google` Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second. diff --git a/TELEMETRY.md b/TELEMETRY.md index 85d7e667f9..f85e421d06 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -95,10 +95,18 @@ Recent telemetry additions also include: coarse onboarding steps, explicit thumb | Field | Example | Purpose | |-------|---------|----------| | `event` | `"feedback"` | Event type | -| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` | +| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback submitted with `/feedback ...` or the `maintainer_feedback` agent tool | | `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present | | `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present | +The `maintainer_feedback` tool is available only as another explicit telemetry +path: it obeys the same telemetry opt-out as `/feedback` and sends no event when +telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and +private data, and label whether the report originated with the user, the agent, +or both. User-originated and mixed reports are rejected unless the user explicitly +approved sharing them; agent-only technical observations do not need per-report +approval. Jcode does not attach transcript content, repository files, or paths. + ### Sponsored Discovery Event One event is sent after each `discover_tools` attempt. A random per-request ID diff --git a/changelog/index.json b/changelog/index.json index ce60244021..b00f25c0e6 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,25 @@ { "entries": [ + { + "version": "0.78.0", + "date": "2026-08-18" + }, + { + "version": "0.77.2", + "date": "2026-08-18" + }, + { + "version": "0.77.1", + "date": "2026-08-17" + }, + { + "version": "0.77.0", + "date": "2026-08-17" + }, + { + "version": "0.76.0", + "date": "2026-08-14" + }, { "version": "0.75.5", "date": "2026-08-12" diff --git a/changelog/v0.76.0.json b/changelog/v0.76.0.json new file mode 100644 index 0000000000..6eddfa64dd --- /dev/null +++ b/changelog/v0.76.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.76.0", + "date": "2026-08-14", + "title": "Transcript privacy and broader provider support", + "highlights": [ + "Opt-in transcript telemetry now supports privacy-preserving collection with automatic secret redaction", + "Anthropic-compatible provider profiles can now connect to additional compatible services", + "Grok Build login can now be completed directly inside the TUI" + ], + "improvements": [ + "Startup update checks can now be disabled in configuration", + "Transient provider retries are more resilient and configurable", + "Z.AI Coding Plan supports reasoning effort controls and safely handles text-only models" + ], + "fixes": [ + "OpenRouter responses preserve tool outputs even when the corresponding tool call is unavailable", + "Prompt files are deduplicated and oversized skill context is clipped", + "Repeated paste placeholders expand correctly", + "Self-development builds promote artifacts to the correct paths", + "Grok model lists refresh after login and use the current managed backend" + ] +} diff --git a/changelog/v0.77.0.json b/changelog/v0.77.0.json new file mode 100644 index 0000000000..dbeabd6ff7 --- /dev/null +++ b/changelog/v0.77.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.77.0", + "date": "2026-08-17", + "title": "Background visibility and expanded authentication", + "highlights": [ + "Background tasks now appear in the pinned status band, report intermediate progress reliably, and wake stalled agents automatically", + "Cursor and Grok Build now support native authentication flows, with OrcaRouter available as a provider profile", + "Duplicate provider accounts now receive memorable animal names in the account picker" + ], + "improvements": [ + "Todo intent and understanding are shown inline with clearer status colors and expandable pinned details", + "Pinned todo, copy, and edit-expand controls can now be activated with the mouse", + "Session search finds late transcript content and preserves longer search prefixes", + "The SDK now exposes persisted session titles and reasoning-effort updates", + "Bash output is collapsed by default with a cleaner presentation", + "Users can submit privacy-conscious maintainer feedback after explicit consent" + ], + "fixes": [ + "Pasted content remains visible after sending", + "Hyphenated MCP tool names dispatch correctly", + "Repeated OpenRouter tool outputs are preserved", + "Provider selection honors an explicitly requested CLI provider", + "Setup hotkey uninstall actions are honored", + "macOS swarm Option shortcuts work correctly" + ] +} diff --git a/changelog/v0.77.1.json b/changelog/v0.77.1.json new file mode 100644 index 0000000000..6a6743ff96 --- /dev/null +++ b/changelog/v0.77.1.json @@ -0,0 +1,7 @@ +{ + "version": "0.77.1", + "date": "2026-08-17", + "fixes": [ + "The remote release command now detects the current repository and follows its own release conventions instead of assuming Jcode-specific tooling" + ] +} diff --git a/changelog/v0.77.2.json b/changelog/v0.77.2.json new file mode 100644 index 0000000000..cd51675abc --- /dev/null +++ b/changelog/v0.77.2.json @@ -0,0 +1,13 @@ +{ + "version": "0.77.2", + "date": "2026-08-18", + "improvements": [ + "Inline diff previews now show the affected file path for clearer review context" + ], + "fixes": [ + "One-shot sessions now close automatically after completing their response", + "Ambient launches now fall back gracefully when opening a visible terminal fails", + "Spawned agents keep their prompt when an explicitly blank initial message is supplied", + "MiniMax authentication now uses the correct API key variable while preserving existing credentials" + ] +} diff --git a/changelog/v0.78.0.json b/changelog/v0.78.0.json new file mode 100644 index 0000000000..05e7b5a9fa --- /dev/null +++ b/changelog/v0.78.0.json @@ -0,0 +1,13 @@ +{ + "version": "0.78.0", + "date": "2026-08-18", + "highlights": [ + "Harness API and SDK clients can now receive images embedded in transcript messages" + ], + "improvements": [ + "Todo quality checks now give shorter, clearer guidance and avoid repeatedly blocking final responses" + ], + "fixes": [ + "Completed hook observers are now cleaned up reliably instead of accumulating over time" + ] +} diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 0f7ea142a7..b872bdecd9 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -233,6 +233,9 @@ pub struct Agent { mcp_late_register_resolved: bool, /// Override system prompt (used by ambient mode to inject a custom prompt) system_prompt_override: Option, + /// AGENTS.md is session bootstrap input. Keep the captured text stable so + /// tool writes do not mutate the provider's cacheable prefix mid-session. + agents_md_snapshot: (Option, crate::prompt::ContextInfo), /// Whether memory features are enabled for this session memory_enabled: bool, /// One-step undo snapshot captured before the most recent rewind. @@ -255,6 +258,15 @@ pub struct Agent { } impl Agent { + fn refresh_agents_md_snapshot(&mut self) { + let working_dir = self + .session + .working_dir + .as_deref() + .map(std::path::Path::new); + self.agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); + } + fn should_track_client_cache(&self) -> bool { match std::env::var("JCODE_TRACK_CLIENT_CACHE") { Ok(value) => { @@ -273,6 +285,8 @@ impl Agent { disabled_tools: HashSet, ) -> Self { let skills = SkillRegistry::shared_snapshot(); + let working_dir = session.working_dir.as_deref().map(std::path::Path::new); + let agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); let initial_provider_model = provider.model(); let agent = Self { provider, @@ -299,6 +313,7 @@ impl Agent { locked_tools: None, mcp_late_register_resolved: false, system_prompt_override: None, + agents_md_snapshot, memory_enabled: crate::config::config().features.memory, rewind_undo_snapshot: None, stdin_request_tx: None, diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs index c96536450c..f4b0f91639 100644 --- a/crates/jcode-app-core/src/agent/prompting.rs +++ b/crates/jcode-app-core/src/agent/prompting.rs @@ -107,12 +107,13 @@ impl Agent { .as_ref() .map(std::path::PathBuf::from); - let (mut split, _context_info) = crate::prompt::build_system_prompt_split( + let (mut split, _context_info) = crate::prompt::build_system_prompt_split_with_agents_md( skill_prompt.as_deref(), &available_skills, self.session.is_canary, memory_prompt, working_dir.as_deref(), + self.agents_md_snapshot.clone(), ); self.append_current_turn_system_reminder(&mut split); diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs index 7f5aea34b8..d2c54a6447 100644 --- a/crates/jcode-app-core/src/agent/provider.rs +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -242,6 +242,7 @@ impl Agent { return; } self.session.working_dir = Some(dir.to_string()); + self.refresh_agents_md_snapshot(); self.session.refresh_initial_session_context_message(); self.log_env_snapshot("working_dir"); } diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index 0c3168a32f..1f0dd756c7 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -210,6 +210,7 @@ impl Agent { new_session.ensure_initial_session_context_message(); self.session = new_session; + self.refresh_agents_md_snapshot(); self.reconcile_explicit_provider_pin_route(); self.reset_runtime_state_for_session_change(); self.provider_session_id = None; @@ -627,6 +628,7 @@ impl Agent { // Restore provider_session_id for Claude CLI session resume self.provider_session_id = session.provider_session_id.clone(); self.session = session; + self.refresh_agents_md_snapshot(); crate::tool::clear_session_tool_policy(&previous_session_id); crate::tool::set_session_tool_policy( &self.session.id, diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs index 4ad30adeb7..eb1e9e10b0 100644 --- a/crates/jcode-app-core/src/ambient/runner.rs +++ b/crates/jcode-app-core/src/ambient/runner.rs @@ -887,17 +887,55 @@ impl AmbientRunnerHandle { /// Run a single ambient cycle. Returns the cycle result. async fn run_cycle(&self, provider: &Arc) -> anyhow::Result { + self.run_cycle_with_visible_launcher(provider, config().ambient.visible, || { + let jcode_bin = + std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); + + std::process::Command::new("kitty") + .args([ + "--title", + "๐Ÿค– jcode ambient cycle", + "-e", + &jcode_bin.to_string_lossy(), + "ambient", + "run-visible", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + }) + .await + } + + async fn run_cycle_with_visible_launcher( + &self, + provider: &Arc, + visible: bool, + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { let started_at = Utc::now(); - let visible = config().ambient.visible; self.set_running_detail("gathering context").await; let (system_prompt, initial_message) = self.build_cycle_context(provider).await?; // Visible mode: spawn a full TUI instead of running headlessly if visible { - return self - .run_cycle_visible(started_at, system_prompt, initial_message) - .await; + match self + .run_cycle_visible( + started_at, + system_prompt.clone(), + initial_message.clone(), + launch_visible, + ) + .await? + { + VisibleCycleOutcome::Completed(result) => return Ok(*result), + VisibleCycleOutcome::FallBackHeadless => {} + } } // Headless mode: run agent directly @@ -986,12 +1024,16 @@ impl AmbientRunnerHandle { } /// Run a visible ambient cycle by spawning a full TUI in a kitty window. - async fn run_cycle_visible( + async fn run_cycle_visible( &self, started_at: chrono::DateTime, system_prompt: String, initial_message: String, - ) -> anyhow::Result { + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { use crate::ambient::VisibleCycleContext; self.set_running_detail("launching visible TUI").await; @@ -1008,25 +1050,9 @@ impl AmbientRunnerHandle { let _ = std::fs::remove_file(&result_path); } - // Find the jcode binary - let jcode_bin = - std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); - // Spawn kitty with `jcode ambient run-visible` logging::info("Ambient visible: spawning kitty with jcode TUI"); - let child = std::process::Command::new("kitty") - .args([ - "--title", - "๐Ÿค– jcode ambient cycle", - "-e", - &jcode_bin.to_string_lossy(), - "ambient", - "run-visible", - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn(); + let child = launch_visible(); match child { Ok(mut child) => { @@ -1046,25 +1072,29 @@ impl AmbientRunnerHandle { crate::storage::read_json::(&result_path) { let _ = std::fs::remove_file(&result_path); - return Ok(AmbientCycleResult { - started_at, - ended_at: Utc::now(), - ..result - }); + return Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + started_at, + ended_at: Utc::now(), + ..result + }, + ))); } // No result file โ€” user closed the window without end_ambient_cycle - Ok(AmbientCycleResult { - summary: "Visible cycle ended (user closed window)".to_string(), - memories_modified: 0, - compactions: 0, - proactive_work: None, - next_schedule: None, - started_at, - ended_at: Utc::now(), - status: CycleStatus::Incomplete, - conversation: None, - }) + Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + summary: "Visible cycle ended (user closed window)".to_string(), + memories_modified: 0, + compactions: 0, + proactive_work: None, + next_schedule: None, + started_at, + ended_at: Utc::now(), + status: CycleStatus::Incomplete, + conversation: None, + }, + ))) } Err(e) => { logging::warn(&format!( @@ -1072,12 +1102,17 @@ impl AmbientRunnerHandle { e )); // Fall back to headless mode - Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e)) + Ok(VisibleCycleOutcome::FallBackHeadless) } } } } +enum VisibleCycleOutcome { + Completed(Box), + FallBackHeadless, +} + // --------------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs index 94146aaa0d..867e77b99c 100644 --- a/crates/jcode-app-core/src/ambient/runner_tests.rs +++ b/crates/jcode-app-core/src/ambient/runner_tests.rs @@ -7,6 +7,7 @@ use anyhow::Result; use async_stream::stream; use async_trait::async_trait; use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; @@ -121,6 +122,45 @@ async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() { let _ = task.await; } +async fn assert_visible_launch_error_falls_back(error_kind: std::io::ErrorKind) { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let provider: Arc = Arc::new(StreamingTestProvider::default()); + let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new())); + let launch_attempted = Arc::new(AtomicBool::new(false)); + let launch_attempted_in_callback = launch_attempted.clone(); + + let result = runner + .run_cycle_with_visible_launcher(&provider, true, move || { + launch_attempted_in_callback.store(true, Ordering::SeqCst); + Err(std::io::Error::from(error_kind)) + }) + .await + .expect("failed visible launch should continue as a headless cycle"); + + assert!(launch_attempted.load(Ordering::SeqCst)); + assert!( + result.conversation.is_some(), + "headless fallback should capture an agent conversation" + ); + assert!( + result.summary.contains("forced end after 2 attempts"), + "headless fallback should return the headless agent result" + ); +} + +#[tokio::test] +async fn unsupported_visible_launch_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::Unsupported).await; +} + +#[tokio::test] +async fn missing_visible_launcher_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::NotFound).await; +} + #[tokio::test] async fn spawn_target_creates_one_child_session_and_runs_task() { let _guard = crate::storage::lock_test_env(); diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 36fec332ba..44d2468db5 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -52,9 +52,9 @@ mod util; pub(super) use self::await_members_state::AwaitMembersRuntime; use self::background_tasks::{ dispatch_background_task_completion, dispatch_background_task_progress, - dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail, - dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, - dispatch_ui_activity, + dispatch_background_task_stalled, dispatch_swarm_await_completion, + dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status, + dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity, }; use self::debug::{ClientConnectionInfo, ClientDebugState}; use self::debug_jobs::DebugJob; @@ -2178,6 +2178,19 @@ impl Server { Ok(BusEvent::BackgroundTaskProgress(task)) => { dispatch_background_task_progress(&task, &swarm_members).await; } + Ok(BusEvent::BackgroundTaskStalled(task)) => { + dispatch_background_task_stalled( + &task, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } Ok(BusEvent::SwarmAwaitCompleted(event)) => { dispatch_swarm_await_completion( &event, diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs index 6b68c8a86a..b39bb03e7f 100644 --- a/crates/jcode-app-core/src/server/background_tasks.rs +++ b/crates/jcode-app-core/src/server/background_tasks.rs @@ -89,6 +89,86 @@ pub(super) async fn dispatch_background_task_completion( } } +/// Deliver a stall-watchdog wake for a background task that has gone quiet. +/// +/// Mirrors completion delivery: optionally notify attached clients, then wake +/// an idle agent or queue a soft interrupt for a busy one. The task is still +/// running; the message tells the agent to inspect and decide. +#[expect( + clippy::too_many_arguments, + reason = "background task stall delivery needs session, interrupt, and swarm status state" +)] +pub(super) async fn dispatch_background_task_stalled( + task: &crate::bus::BackgroundTaskStalled, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc>>, + swarms_by_id: &Arc>>>, + event_history: &Arc>>, + event_counter: &Arc, + swarm_event_tx: &broadcast::Sender, +) { + let notification = crate::message::format_background_task_stalled_markdown(task); + + if task.notify + && fanout_session_event( + swarm_members, + &task.session_id, + ServerEvent::Notification { + from_session: "background_task".to_string(), + from_name: Some("background task".to_string()), + notification_type: NotificationType::Message { + scope: Some("background_task".to_string()), + channel: None, + tldr: None, + }, + message: notification.clone(), + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for background task stall on session {}", + task.session_id + )); + } + + if task.wake + && !run_live_turn_if_idle( + &task.session_id, + ¬ification, + Some( + "A background task for this session has produced no output or progress for its stall window. Inspect it and decide whether to keep waiting, fix it, or cancel it." + .to_string(), + ), + sessions, + LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await + && !queue_soft_interrupt_for_session( + &task.session_id, + notification.clone(), + false, + SoftInterruptSource::BackgroundTask, + soft_interrupt_queues, + sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to deliver background task stall to session {}", + task.session_id + )); + } +} + /// Deliver the result of a backgrounded `swarm await_members` watcher to the /// requesting session. Mirrors background-task completion delivery: optionally /// notify attached clients, then wake an idle agent or queue a soft interrupt diff --git a/crates/jcode-app-core/src/tool/bash.rs b/crates/jcode-app-core/src/tool/bash.rs index 3cdbd2b5fc..c12688d6d2 100644 --- a/crates/jcode-app-core/src/tool/bash.rs +++ b/crates/jcode-app-core/src/tool/bash.rs @@ -696,6 +696,10 @@ struct BashInput { notify: bool, #[serde(default)] wake: bool, + /// For background runs: wake the agent after this many seconds with no + /// new output and no progress events. Resets on activity. + #[serde(default)] + stall_wake_seconds: Option, /// Set only when re-issuing a call the gate refused (#604). #[serde(default)] justification: Option, @@ -1289,6 +1293,23 @@ impl BashTool { } else { "Notifications disabled. Use `bg` tool to check status." }; + + let stall_msg = match params.stall_wake_seconds { + Some(requested) => { + match crate::background::global() + .arm_stall_watchdog(&info.task_id, requested) + .await + { + Some(effective) => format!( + "Stall watchdog armed: you will be woken after {}s with no output or progress (resets on activity).\n", + effective + ), + None => String::new(), + } + } + None => String::new(), + }; + let output = format!( "Command started in background.\n\n\ Task ID: {}\n\ @@ -1296,7 +1317,7 @@ impl BashTool { Output file: {}\n\ Status file: {}\n\n\ {}\n\ - To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ + {}To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ To check progress immediately: use the `bg` tool with action=\"status\" and task_id=\"{}\"\n\ To see output: use the `read` tool on the output file, or `bg` with action=\"output\"\n\n\ {}", @@ -1305,6 +1326,7 @@ impl BashTool { info.output_file.display(), info.status_file.display(), notify_msg, + stall_msg, info.task_id, info.task_id, BACKGROUND_PROGRESS_GUIDANCE, diff --git a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs index 699be85b1a..b9c0f551e1 100644 --- a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs +++ b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs @@ -75,6 +75,10 @@ pub(super) fn bash_parameters_schema() -> serde_json::Value { "type": "boolean", "description": "Wake on completion." }, + "stall_wake_seconds": { + "type": "integer", + "description": "With run_in_background: wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." + }, "justification": { "type": "string", "description": "Only when re-issuing a command the destructive gate refused; explain which user request it serves." diff --git a/crates/jcode-app-core/src/tool/bg.rs b/crates/jcode-app-core/src/tool/bg.rs index 22a519f4c2..db0e7ea615 100644 --- a/crates/jcode-app-core/src/tool/bg.rs +++ b/crates/jcode-app-core/src/tool/bg.rs @@ -74,6 +74,10 @@ struct BgInput { /// Whether to wake on completion when using watch/delivery (default: true) #[serde(default)] wake: Option, + /// For watch/delivery: also arm a stall watchdog that wakes the agent after + /// this many seconds with no new output or progress (resets on activity) + #[serde(default)] + stall_wake_seconds: Option, /// Max seconds to block when using wait (default: 60, capped at 3600) #[serde(default)] max_wait_seconds: Option, @@ -333,6 +337,7 @@ fn wait_reason_label(reason: background::BackgroundTaskWaitReason) -> &'static s background::BackgroundTaskWaitReason::Finished => "finished", background::BackgroundTaskWaitReason::Progress => "progress", background::BackgroundTaskWaitReason::Checkpoint => "checkpoint", + background::BackgroundTaskWaitReason::Stalled => "stalled", background::BackgroundTaskWaitReason::Timeout => "timeout", } } @@ -491,6 +496,7 @@ impl Tool for BgTool { "dry_run": { "type": "boolean", "description": "For cleanup, report what would be removed without deleting." }, "notify": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to notify on completion. Defaults to true." }, "wake": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to wake on completion. Defaults to true." }, + "stall_wake_seconds": { "type": "integer", "description": "For delivery/watch: also wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." }, "max_wait_seconds": { "type": "integer", "description": "For wait: max seconds to block. Default 60, cap 3600, 0 = immediate check." }, "return_on_progress": { "type": "boolean", "description": "For wait: return on the first progress/checkpoint event too. Defaults to true." }, "wait_mode": { "type": "string", "enum": ["any", "all", "first_failure"], "description": "For multi-task wait, return on any completion, all completions, or first failure. Defaults to any." }, @@ -663,22 +669,37 @@ impl Tool for BgTool { .remove(0); let notify = params.notify.unwrap_or_else(default_watch_notify); let wake = params.wake.unwrap_or_else(default_watch_wake); + let stall_armed = match params.stall_wake_seconds { + Some(requested) => manager.arm_stall_watchdog(&task_id, requested).await, + None => None, + }; match manager.update_delivery(&task_id, notify, wake).await? { - Some(task) => Ok(ToolOutput::new(format!( - "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}", - task_id, - status_label(&task.status), - task.notify, - task.wake - )) - .with_title(format!("bg delivery {}", task_id)) - .with_metadata(json!({ - "task_id": task.task_id, - "task": task_metadata(manager, &task), - "status": status_label(&task.status), - "notify": task.notify, - "wake": task.wake, - }))), + Some(task) => { + let stall_line = match stall_armed { + Some(effective) => format!( + "\nStall watchdog: wake after {}s of no output/progress (resets on activity)", + effective + ), + None => String::new(), + }; + Ok(ToolOutput::new(format!( + "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}{}", + task_id, + status_label(&task.status), + task.notify, + task.wake, + stall_line + )) + .with_title(format!("bg delivery {}", task_id)) + .with_metadata(json!({ + "task_id": task.task_id, + "task": task_metadata(manager, &task), + "status": status_label(&task.status), + "notify": task.notify, + "wake": task.wake, + "stall_wake_seconds": stall_armed, + }))) + } None => Err(anyhow::anyhow!("Task not found: {}", task_id)), } } @@ -760,6 +781,9 @@ impl Tool for BgTool { background::BackgroundTaskWaitReason::Checkpoint => { "Background task emitted a checkpoint event.\n\n".to_string() } + background::BackgroundTaskWaitReason::Stalled => { + "Background task stall watchdog fired: no output or progress for its stall window. The task is still running; inspect it and decide whether to keep waiting or cancel.\n\n".to_string() + } background::BackgroundTaskWaitReason::Timeout => format!( "No terminal event before max wait of {}s. Check again with `bg action=\"wait\" task_id=\"{}\"` or inspect status/output.\n\n", capped_wait, task_id diff --git a/crates/jcode-app-core/src/tool/communicate.rs b/crates/jcode-app-core/src/tool/communicate.rs index 0235f6f66f..548a88449b 100644 --- a/crates/jcode-app-core/src/tool/communicate.rs +++ b/crates/jcode-app-core/src/tool/communicate.rs @@ -1902,7 +1902,16 @@ struct CommunicateInput { impl CommunicateInput { fn spawn_initial_message(&self) -> Option { - self.initial_message.clone().or_else(|| self.prompt.clone()) + self.initial_message + .as_ref() + .filter(|message| !message.trim().is_empty()) + .cloned() + .or_else(|| { + self.prompt + .as_ref() + .filter(|prompt| !prompt.trim().is_empty()) + .cloned() + }) } fn required_spawn_label(&self) -> anyhow::Result { diff --git a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs index b20f536147..e636fdfc64 100644 --- a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs +++ b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs @@ -20,6 +20,27 @@ fn spawn_initial_message_accepts_prompt_alias_and_prefers_explicit_initial_messa preferred.spawn_initial_message().as_deref(), Some("preferred") ); + + for blank_initial_message in ["", " \t\n"] { + let from_prompt: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": blank_initial_message, + "prompt": "fallback" + })) + .expect("spawn payload should deserialize"); + assert_eq!( + from_prompt.spawn_initial_message().as_deref(), + Some("fallback") + ); + } + + let blank_messages: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": "", + "prompt": " " + })) + .expect("spawn payload should deserialize"); + assert_eq!(blank_messages.spawn_initial_message(), None); } #[test] diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs new file mode 100644 index 0000000000..b34ebd9851 --- /dev/null +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -0,0 +1,230 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +const MAX_SUMMARY_CHARS: usize = 240; +const MAX_DETAILS_CHARS: usize = 1500; + +pub struct MaintainerFeedbackTool; + +impl MaintainerFeedbackTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct FeedbackInput { + category: FeedbackCategory, + origin: FeedbackOrigin, + user_confirmed: bool, + summary: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackCategory { + Bug, + Praise, + Suggestion, + Usability, + Other, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackOrigin { + User, + Agent, + Mixed, +} + +fn limited(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + bail!("{label} must not be empty"); + } + if value.chars().count() > max { + bail!("{label} must be at most {max} characters"); + } + Ok(value.to_string()) +} + +fn payload(input: FeedbackInput) -> Result { + if matches!(input.origin, FeedbackOrigin::User | FeedbackOrigin::Mixed) && !input.user_confirmed + { + bail!("user_confirmed must be true for user or mixed-origin feedback"); + } + let summary = limited(&input.summary, "summary", MAX_SUMMARY_CHARS)?; + let details = input + .details + .as_deref() + .map(|value| limited(value, "details", MAX_DETAILS_CHARS)) + .transpose()?; + let category = format!("{:?}", input.category).to_ascii_lowercase(); + let origin = format!("{:?}", input.origin).to_ascii_lowercase(); + let mut text = format!("[agent feedback; category={category}; origin={origin}] {summary}"); + if let Some(details) = details { + text.push_str("\n\n"); + text.push_str(&details); + } + Ok(text) +} + +#[async_trait] +impl Tool for MaintainerFeedbackTool { + fn name(&self) -> &str { + "maintainer_feedback" + } + + fn description(&self) -> &str { + "Send product feedback to Jcode's maintainer. Respects telemetry settings." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["category", "origin", "user_confirmed", "summary"], + "properties": { + "intent": super::intent_schema_property(), + "category": { + "type": "string", + "enum": ["bug", "praise", "suggestion", "usability", "other"], + "description": "Kind of feedback." + }, + "origin": { + "type": "string", + "enum": ["user", "agent", "mixed"], + "description": "Whether this reflects the user's words, the agent's observation, or both." + }, + "user_confirmed": { + "type": "boolean", + "description": "True only if the user approved sharing user-originated feedback. Agent observations may use false." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SUMMARY_CHARS, + "description": "Self-contained maintainer-facing summary. Paraphrase rather than quoting the user." + }, + "details": { + "type": "string", + "minLength": 1, + "maxLength": MAX_DETAILS_CHARS, + "description": "Optional reproduction steps or expected versus actual behavior. Never include private data." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let text = payload(serde_json::from_value(input)?)?; + if !crate::telemetry::is_enabled() { + return Ok(ToolOutput::new( + "Feedback was not sent because telemetry is disabled. The user can use /telemetry to change that setting.", + )); + } + crate::telemetry::record_feedback(&text); + Ok(ToolOutput::new( + "Feedback queued for the Jcode maintainer. Thank you.", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_labels_and_formats_feedback() { + let text = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::Mixed, + user_confirmed: true, + summary: "The new picker is much easier to use".into(), + details: Some("The labels make model differences clear.".into()), + }) + .unwrap(); + assert_eq!( + text, + "[agent feedback; category=praise; origin=mixed] The new picker is much easier to use\n\nThe labels make model differences clear." + ); + } + + #[test] + fn payload_rejects_empty_and_oversized_fields() { + let empty = payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + user_confirmed: false, + summary: " ".into(), + details: None, + }); + assert!(empty.unwrap_err().to_string().contains("must not be empty")); + + let oversized = payload(FeedbackInput { + category: FeedbackCategory::Other, + origin: FeedbackOrigin::User, + user_confirmed: true, + summary: "x".repeat(MAX_SUMMARY_CHARS + 1), + details: None, + }); + assert!(oversized.unwrap_err().to_string().contains("at most 240")); + } + + #[test] + fn schema_requires_provenance_and_carries_privacy_guidance() { + let tool = MaintainerFeedbackTool::new(); + let schema = tool.parameters_schema(); + assert_eq!( + schema["required"], + json!(["category", "origin", "user_confirmed", "summary"]) + ); + assert_eq!( + schema["properties"]["origin"]["enum"], + json!(["user", "agent", "mixed"]) + ); + assert!( + schema["properties"]["summary"]["description"] + .as_str() + .unwrap() + .contains("Paraphrase") + ); + assert!( + schema["properties"]["details"]["description"] + .as_str() + .unwrap() + .contains("Never include private data") + ); + assert!(tool.description().contains("telemetry settings")); + } + + #[test] + fn user_origin_requires_explicit_confirmation() { + let error = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::User, + user_confirmed: false, + summary: "The user likes the new workflow".into(), + details: None, + }) + .unwrap_err(); + assert!(error.to_string().contains("user_confirmed must be true")); + + assert!( + payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + user_confirmed: false, + summary: "The agent observed a reproducible tool error".into(), + details: None, + }) + .is_ok() + ); + } +} diff --git a/crates/jcode-app-core/src/tool/mcp.rs b/crates/jcode-app-core/src/tool/mcp.rs index e296d63740..ed62cfd7ba 100644 --- a/crates/jcode-app-core/src/tool/mcp.rs +++ b/crates/jcode-app-core/src/tool/mcp.rs @@ -189,9 +189,8 @@ impl McpManagementTool { } else { for (_, tool) in server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(server, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } @@ -280,9 +279,8 @@ impl McpManagementTool { ); for (_, tool) in &server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server_name, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(&server_name, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } @@ -291,8 +289,9 @@ impl McpManagementTool { // Register the new tools in the registry if let Some(ref registry) = self.registry { let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; + let server_prefix = crate::mcp::dispatch_name(&server_name, ""); for (name, tool) in mcp_tools { - if name.starts_with(&format!("mcp__{}__", server_name)) { + if name.starts_with(&server_prefix) { registry.register(name, tool).await; } } @@ -347,7 +346,7 @@ impl McpManagementTool { // Unregister tools for this server if let Some(ref registry) = self.registry { let removed = registry - .unregister_prefix(&format!("mcp__{}__", server_name)) + .unregister_prefix(&crate::mcp::dispatch_name(&server_name, "")) .await; crate::logging::event_info( "MCP_LIFECYCLE", diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index a7411246e0..68d59693a2 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -14,6 +14,7 @@ mod debug_socket; mod discover; mod discover_secrets; mod edit; +mod feedback; mod gmail; mod goal; pub mod inflight; @@ -246,6 +247,12 @@ impl Registry { websearch::WebSearchTool::new, ); Self::insert_tool_timed(&mut m, &mut timings, "invalid", invalid::InvalidTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "maintainer_feedback", + feedback::MaintainerFeedbackTool::new, + ); Self::insert_tool_timed( &mut m, &mut timings, diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs index 3b014b9be0..bee0ec475b 100644 --- a/crates/jcode-app-core/src/tool/selfdev/tests.rs +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -1200,6 +1200,7 @@ async fn build_ignores_stale_pending_requests_when_computing_queue_position() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write stale status file"); @@ -1300,6 +1301,7 @@ fn reconcile_pending_state_maps_superseded_background_status() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write superseded status file"); @@ -1389,6 +1391,7 @@ fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write running status file"); diff --git a/crates/jcode-app-core/src/tool/tests.rs b/crates/jcode-app-core/src/tool/tests.rs index d2ce304c67..a9585d7eeb 100644 --- a/crates/jcode-app-core/src/tool/tests.rs +++ b/crates/jcode-app-core/src/tool/tests.rs @@ -1,6 +1,7 @@ #![cfg_attr(test, allow(clippy::await_holding_lock))] use super::*; + use crate::message::{Message, ToolDefinition}; use crate::provider::{EventStream, Provider}; use async_trait::async_trait; @@ -31,6 +32,19 @@ impl Provider for MockProvider { } } +#[tokio::test] +async fn maintainer_feedback_tool_is_registered() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + assert!( + registry + .tool_names() + .await + .iter() + .any(|name| name == "maintainer_feedback") + ); +} + #[tokio::test] async fn test_tool_definitions_are_sorted() { // Create registry with mock provider diff --git a/crates/jcode-background-types/src/lib.rs b/crates/jcode-background-types/src/lib.rs index 4971e0bc06..3d34c56de3 100644 --- a/crates/jcode-background-types/src/lib.rs +++ b/crates/jcode-background-types/src/lib.rs @@ -43,8 +43,10 @@ impl BackgroundTaskProgress { pub fn normalize(mut self) -> Self { if let (Some(current), Some(total)) = (self.current, self.total) && total > 0 - && self.percent.is_none() { + // Counts are the directly measurable work units, so they are the + // source of truth when a producer also supplies a percentage. This + // prevents contradictory updates such as `2/10` paired with 80%. let computed = (current as f64 / total as f64) * 100.0; self.percent = Some(((computed * 100.0).round() / 100.0) as f32); } @@ -64,6 +66,29 @@ impl BackgroundTaskProgress { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measurable_counts_override_a_conflicting_reported_percent() { + let progress = BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(80.0), + message: None, + current: Some(2), + total: Some(10), + unit: Some("tests".into()), + eta_seconds: None, + updated_at: "now".into(), + source: BackgroundTaskProgressSource::Reported, + } + .normalize(); + + assert_eq!(progress.percent, Some(20.0)); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BackgroundTaskProgressEvent { pub task_id: String, @@ -89,6 +114,29 @@ pub struct BackgroundTaskCompleted { pub wake: bool, } +/// Event sent when a stall watchdog notices a background task has produced no +/// output bytes and no progress events for its configured stall window. +/// +/// This is a "check on me" signal, not a terminal event: the task is still +/// running as far as the manager can tell. The watchdog fires at most once per +/// silence episode; new output or progress re-arms it. +#[derive(Debug, Clone)] +pub struct BackgroundTaskStalled { + pub task_id: String, + pub tool_name: String, + pub display_name: Option, + pub session_id: String, + /// Configured stall window in seconds. + pub stall_wake_seconds: u64, + /// How long the task has been running when the watchdog fired. + pub running_secs: f64, + /// Tail of the output file at fire time (already truncated). + pub output_tail: String, + pub output_file: PathBuf, + pub notify: bool, + pub wake: bool, +} + /// Render a one-line human-readable progress display: an optional progress bar, /// a textual summary, and the source label (for example `[##--] 50% (reported)`). pub fn format_progress_display(progress: &BackgroundTaskProgress, width: usize) -> String { diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index c66d20fced..128247ee4f 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -43,6 +43,7 @@ anyhow = "1" libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } regex = "1" +rusqlite = { version = "0.32", features = ["bundled"] } urlencoding = "2" # URL encoding for web search uuid = { version = "1", features = ["v4", "v5"] } proctitle = "0.1" @@ -151,3 +152,5 @@ global-hotkey = "0.7" # MultiProvider routing tests exercise the real OpenRouter/OpenAI-compatible # runtime through the composition-root registry, exactly like the binary. jcode-provider-openrouter-runtime = { path = "../jcode-provider-openrouter-runtime" } +# Paused-time (auto-advancing virtual clock) tests for the background stall watchdog. +tokio = { version = "1", features = ["test-util"] } diff --git a/crates/jcode-base/src/auth/account_store.rs b/crates/jcode-base/src/auth/account_store.rs index d33269dc6a..0956a7ade5 100644 --- a/crates/jcode-base/src/auth/account_store.rs +++ b/crates/jcode-base/src/auth/account_store.rs @@ -31,8 +31,26 @@ pub fn runtime_active_override(prefix: &str) -> Option { .and_then(|overrides| overrides.get(prefix).cloned()) } +/// Memorable, provider-independent account names. Keeping this list fixed makes +/// labels stable across restarts and gives the same ordinal account the same +/// animal for every provider (for example `claude-otter` and `openai-otter`). +const ACCOUNT_ANIMALS: &[&str] = &[ + "otter", "fox", "panda", "wolf", "owl", "lynx", "badger", "raven", "tiger", "koala", "falcon", + "gecko", "bison", "heron", "moose", "orca", "rabbit", "yak", "zebra", "beaver", "cougar", + "dolphin", "ibis", "jaguar", "lemur", "marten", "newt", "quail", "seal", "wombat", "alpaca", + "penguin", +]; + pub fn canonical_account_label(prefix: &str, index: usize) -> String { - format!("{prefix}-{index}") + let animal = index + .checked_sub(1) + .and_then(|index| ACCOUNT_ANIMALS.get(index).copied()); + match animal { + Some(animal) => format!("{prefix}-{animal}"), + // Extremely large account sets remain unique without making the common + // case less friendly. + None => format!("{prefix}-animal-{index}"), + } } pub fn next_account_label(prefix: &str, account_count: usize) -> String { @@ -240,12 +258,12 @@ mod tests { ); assert!(outcome.changed); - assert_eq!(accounts[0].label, "openai-1"); - assert_eq!(accounts[1].label, "openai-2"); - assert_eq!(active.as_deref(), Some("openai-2")); + assert_eq!(accounts[0].label, "openai-otter"); + assert_eq!(accounts[1].label, "openai-fox"); + assert_eq!(active.as_deref(), Some("openai-fox")); assert_eq!( outcome.canonical_override_label.as_deref(), - Some("openai-1") + Some("openai-otter") ); } @@ -265,8 +283,16 @@ mod tests { |account, label| account.label = label, ); - assert_eq!(label, "claude-1"); - assert_eq!(accounts[0].label, "claude-1"); - assert_eq!(active.as_deref(), Some("claude-1")); + assert_eq!(label, "claude-otter"); + assert_eq!(accounts[0].label, "claude-otter"); + assert_eq!(active.as_deref(), Some("claude-otter")); + } + + #[test] + fn account_labels_use_animals_and_stay_unique_after_the_named_pool() { + assert_eq!(canonical_account_label("claude", 1), "claude-otter"); + assert_eq!(canonical_account_label("claude", 2), "claude-fox"); + assert_eq!(canonical_account_label("openai", 32), "openai-penguin"); + assert_eq!(canonical_account_label("openai", 33), "openai-animal-33"); } } diff --git a/crates/jcode-base/src/auth/claude.rs b/crates/jcode-base/src/auth/claude.rs index 7c1fc5517c..7c01e31b35 100644 --- a/crates/jcode-base/src/auth/claude.rs +++ b/crates/jcode-base/src/auth/claude.rs @@ -327,7 +327,7 @@ pub fn load_auth_file() -> Result { if relabel_accounts(&mut auth) { crate::logging::info( - "Renaming Claude accounts to numbered labels (claude-1, claude-2, ...)", + "Renaming Claude accounts to animal labels (claude-otter, claude-fox, ...)", ); save_auth_file(&auth)?; } diff --git a/crates/jcode-base/src/auth/claude_tests.rs b/crates/jcode-base/src/auth/claude_tests.rs index eeed2d188a..ae0a6a7ed1 100644 --- a/crates/jcode-base/src/auth/claude_tests.rs +++ b/crates/jcode-base/src/auth/claude_tests.rs @@ -82,7 +82,7 @@ fn jcode_path_respects_jcode_home() { } #[test] -fn load_auth_file_renames_existing_labels_to_numbered_scheme() { +fn load_auth_file_renames_existing_labels_to_animal_scheme() { let _lock = crate::storage::lock_test_env(); let temp = tempfile::TempDir::new().unwrap(); let _home = EnvVarGuard::set("JCODE_HOME", temp.path()); @@ -117,9 +117,9 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() { .iter() .map(|account| account.label.as_str()) .collect::>(), - vec!["claude-1", "claude-2"] + vec!["claude-otter", "claude-fox"] ); - assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-2")); + assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-fox")); } #[test] diff --git a/crates/jcode-base/src/auth/codex.rs b/crates/jcode-base/src/auth/codex.rs index e0d9f37592..25c53cea9d 100644 --- a/crates/jcode-base/src/auth/codex.rs +++ b/crates/jcode-base/src/auth/codex.rs @@ -169,7 +169,7 @@ pub fn load_auth_file() -> Result { if relabel_accounts(&mut auth) { crate::logging::info( - "Renaming OpenAI accounts to numbered labels (openai-1, openai-2, ...)", + "Renaming OpenAI accounts to animal labels (openai-otter, openai-fox, ...)", ); save_auth_file(&auth)?; } diff --git a/crates/jcode-base/src/auth/codex_tests.rs b/crates/jcode-base/src/auth/codex_tests.rs index a9c69b81f8..a235351baa 100644 --- a/crates/jcode-base/src/auth/codex_tests.rs +++ b/crates/jcode-base/src/auth/codex_tests.rs @@ -396,7 +396,7 @@ fn load_credentials_reads_legacy_oauth_without_changing_external_permissions() { } #[test] -fn load_auth_file_renames_existing_labels_to_numbered_scheme() { +fn load_auth_file_renames_existing_labels_to_animal_scheme() { let _lock = crate::storage::lock_test_env(); let temp = tempfile::TempDir::new().unwrap(); let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); @@ -429,7 +429,7 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() { .iter() .map(|account| account.label.as_str()) .collect::>(), - vec!["openai-1", "openai-2"] + vec!["openai-otter", "openai-fox"] ); - assert_eq!(auth.active_openai_account.as_deref(), Some("openai-2")); + assert_eq!(auth.active_openai_account.as_deref(), Some("openai-fox")); } diff --git a/crates/jcode-base/src/auth/cursor.rs b/crates/jcode-base/src/auth/cursor.rs index e21fb65eed..c58438d669 100644 --- a/crates/jcode-base/src/auth/cursor.rs +++ b/crates/jcode-base/src/auth/cursor.rs @@ -4,8 +4,6 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; -use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; const CURSOR_API_BASE: &str = "https://api2.cursor.sh"; @@ -15,7 +13,6 @@ const CURSOR_API_BASE: &str = "https://api2.cursor.sh"; // `JCODE_CURSOR_CLIENT_VERSION` if Cursor moves the floor again. const CURSOR_DIRECT_CLIENT_VERSION_DEFAULT: &str = "3.8.24"; const CURSOR_OAUTH_CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; -const CURSOR_EXTERNAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); pub const CURSOR_AUTH_FILE_SOURCE_ID: &str = "cursor_auth_json"; pub const CURSOR_VSCDB_SOURCE_ID: &str = "cursor_vscdb"; @@ -101,29 +98,6 @@ pub fn has_cursor_native_auth() -> bool { load_access_token_from_env_or_file().is_ok() || has_cursor_vscdb_token() || has_cursor_api_key() } -/// Check whether the local Cursor Agent CLI reports an authenticated session. -/// -/// Full auth status may spend a little time probing external commands, while -/// `AuthStatus::check_fast()` intentionally skips this path for UI responsiveness. -pub fn has_authenticated_cli_session() -> bool { - let command = std::env::var_os("JCODE_CURSOR_CLI_PATH") - .unwrap_or_else(|| std::ffi::OsString::from("cursor-agent")); - let command_label = command.to_string_lossy(); - if !super::command_exists(&command_label) { - return false; - } - - let mut status_command = Command::new(&command); - status_command.arg("status"); - let Ok(Some(output)) = - command_output_with_timeout(&mut status_command, CURSOR_EXTERNAL_COMMAND_TIMEOUT) - else { - return false; - }; - - status_output_indicates_authenticated(output.status.success(), &output.stdout, &output.stderr) -} - /// Check whether a trusted Cursor auth.json contains a usable direct access token. pub fn has_cursor_auth_file_token() -> bool { let Ok(file_path) = cursor_auth_file_path() else { @@ -262,49 +236,28 @@ fn cursor_vscdb_paths() -> Vec { .collect() } -/// Read a key from a vscdb file using the sqlite3 CLI. +/// Read a key from Cursor's SQLite state directly. Authentication must not +/// depend on an external `sqlite3` executable being installed. fn read_vscdb_key(db_path: &PathBuf, key: &str) -> Result { - let mut command = Command::new("sqlite3"); - command.arg(db_path).arg(format!( - "SELECT value FROM ItemTable WHERE key = '{}';", - key - )); - let output = command_output_with_timeout(&mut command, CURSOR_EXTERNAL_COMMAND_TIMEOUT) - .context("Failed to run sqlite3 (is it installed?)")? - .ok_or_else(|| anyhow::anyhow!("sqlite3 timed out reading {}", db_path.display()))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("sqlite3 failed: {}", stderr.trim()); - } - - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let connection = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY + | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX + | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .with_context(|| format!("Failed to open Cursor state at {}", db_path.display()))?; + let value: String = connection + .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |row| { + row.get(0) + }) + .with_context(|| format!("Key '{key}' not found in {}", db_path.display()))?; + let value = value.trim().to_string(); if value.is_empty() { anyhow::bail!("Key '{}' not found or empty in {}", key, db_path.display()); } Ok(value) } -fn command_output_with_timeout(command: &mut Command, timeout: Duration) -> Result> { - let mut child = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let start = std::time::Instant::now(); - - loop { - if child.try_wait()?.is_some() { - return child.wait_with_output().map(Some).map_err(Into::into); - } - if start.elapsed() >= timeout { - let _ = child.kill(); - let _ = child.wait(); - return Ok(None); - } - std::thread::sleep(Duration::from_millis(25)); - } -} - /// Load Cursor API key. Checks in order: /// 1. `CURSOR_API_KEY` env var /// 2. Saved key in `~/.config/jcode/cursor.env` @@ -700,37 +653,6 @@ fn timestamp_header_now() -> String { URL_SAFE_NO_PAD.encode(bytes) } -fn status_output_indicates_authenticated(success: bool, stdout: &[u8], stderr: &[u8]) -> bool { - let combined = format!( - "{}\n{}", - String::from_utf8_lossy(stdout), - String::from_utf8_lossy(stderr) - ) - .to_ascii_lowercase(); - - if combined.contains("not authenticated") - || combined.contains("login required") - || combined.contains("not logged in") - || combined.contains("unauthenticated") - { - return false; - } - - if !success { - return false; - } - - if combined.contains("authenticated") - || combined.contains("account") - || combined.contains("email") - || combined.contains("endpoint") - { - return true; - } - - success -} - #[cfg(test)] #[path = "cursor_tests.rs"] mod tests; diff --git a/crates/jcode-base/src/auth/cursor_tests.rs b/crates/jcode-base/src/auth/cursor_tests.rs index 41ba79bfdc..3f3accc0ab 100644 --- a/crates/jcode-base/src/auth/cursor_tests.rs +++ b/crates/jcode-base/src/auth/cursor_tests.rs @@ -233,44 +233,28 @@ fn load_access_token_from_auth_file_does_not_change_external_permissions() { } #[test] -fn status_output_detects_authenticated_session() { - assert!(status_output_indicates_authenticated( - true, - b"Authenticated\nAccount: user@example.com\nEndpoint: production", - b"" - )); -} - -#[test] -fn status_output_detects_missing_authentication() { - assert!(!status_output_indicates_authenticated( - true, - b"Not authenticated. Run cursor-agent login.", - b"" - )); -} - -#[test] -fn status_output_requires_successful_exit_for_authentication_keywords() { - assert!(!status_output_indicates_authenticated( - false, - b"Account: user@example.com\nEndpoint: production", - b"cursor-agent status failed" - )); -} - -#[cfg(unix)] -#[test] -fn external_auth_command_timeout_returns_none() { - let mut command = std::process::Command::new("sh"); - command.arg("-c").arg("sleep 2; echo late"); - - let start = std::time::Instant::now(); - let output = command_output_with_timeout(&mut command, std::time::Duration::from_millis(50)) - .expect("timeout helper should not error"); +fn reads_cursor_state_with_embedded_sqlite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.vscdb"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + ("cursorAuth/accessToken", "native-token"), + ) + .unwrap(); + drop(connection); - assert!(output.is_none()); - assert!(start.elapsed() < std::time::Duration::from_secs(1)); + assert_eq!( + read_vscdb_key(&path, "cursorAuth/accessToken").unwrap(), + "native-token" + ); } fn load_key_from_file(path: &PathBuf) -> Result { diff --git a/crates/jcode-base/src/auth/doctor.rs b/crates/jcode-base/src/auth/doctor.rs index 6ae59cfbe8..26fe3500bc 100644 --- a/crates/jcode-base/src/auth/doctor.rs +++ b/crates/jcode-base/src/auth/doctor.rs @@ -170,10 +170,32 @@ pub fn recommended_actions( "Run runtime verification: jcode auth-test --provider {}", provider.id )), - Some(record) if !record.success => actions.push(format!( - "Inspect runtime readiness: jcode auth-test --provider {}", - provider.id - )), + Some(record) if !record.success => { + let summary = record.summary.to_ascii_lowercase(); + if summary.contains("402") + || summary.contains("payment required") + || summary.contains("balance exhausted") + { + actions.push( + "Restore the provider's billing or usage balance. Re-authenticating will not fix this failure." + .to_string(), + ); + } else if summary.contains("429") + || summary.contains("quota exhausted") + || summary.contains("rate limit") + || summary.contains("too many requests") + { + actions.push( + "Wait for or increase the provider quota before retrying. Re-authenticating will not fix this failure." + .to_string(), + ); + } else { + actions.push(format!( + "Inspect runtime readiness: jcode auth-test --provider {}", + provider.id + )); + } + } Some(record) if validation_is_stale(record.checked_at_ms) => actions.push(format!( "Refresh stale runtime verification: jcode auth-test --provider {}", provider.id @@ -263,6 +285,40 @@ mod tests { ); } + #[test] + fn billing_validation_failure_does_not_recommend_reauthentication() { + let mut assessment = base_assessment(); + let validation = assessment.last_validation.as_mut().unwrap(); + validation.success = false; + validation.provider_smoke_ok = Some(false); + validation.summary = "provider_smoke: API error (status 402 Payment Required): Grok Build usage balance exhausted".to_string(); + + let actions = recommended_actions( + crate::provider_catalog::login_providers() + .iter() + .copied() + .find(|provider| provider.id == "grok-build") + .unwrap(), + &assessment, + None, + ); + assert!( + actions + .iter() + .any(|action| action.contains("billing or usage balance")) + ); + assert!( + actions + .iter() + .any(|action| action.contains("Re-authenticating will not fix")) + ); + assert!( + !actions + .iter() + .any(|action| action.contains("Inspect runtime readiness")) + ); + } + #[test] fn stale_validation_marks_provider_as_needing_attention() { let mut assessment = base_assessment(); diff --git a/crates/jcode-base/src/auth/grok_build.rs b/crates/jcode-base/src/auth/grok_build.rs index 71e39b4df9..ef2dcaa373 100644 --- a/crates/jcode-base/src/auth/grok_build.rs +++ b/crates/jcode-base/src/auth/grok_build.rs @@ -6,11 +6,188 @@ //! not need to install the `grok` CLI or put it on `PATH`. use anyhow::{Context, Result, bail}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; pub const CLI_PATH_ENV: &str = "JCODE_GROK_CLI_PATH"; const PRIMARY_BASE_URL: &str = "https://x.ai/cli"; const FALLBACK_BASE_URL: &str = "https://storage.googleapis.com/grok-build-public-artifacts/cli"; +const OAUTH_ISSUER: &str = "https://auth.x.ai"; +const OAUTH_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const OAUTH_SCOPES: &str = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write"; + +#[derive(Clone, Debug, Deserialize)] +pub struct DeviceAuthorization { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: Option, + pub expires_in: u64, + #[serde(default = "default_poll_interval")] + pub interval: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenError { + error: String, + error_description: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct JwtClaims { + sub: Option, + email: Option, + given_name: Option, +} + +#[derive(Debug, Serialize)] +struct StoredCredential { + key: String, + auth_mode: &'static str, + create_time: String, + user_id: String, + email: Option, + coding_data_retention_opt_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + first_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, + oidc_issuer: &'static str, + oidc_client_id: &'static str, +} + +fn default_poll_interval() -> u64 { + 5 +} + +fn oauth_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + request + .header("x-grok-client-version", "1.0.3") + .header("x-grok-client-surface", "ui") + .header( + "user-agent", + format!("jcode/{} grok-shell/1.0.3", env!("CARGO_PKG_VERSION")), + ) +} + +pub async fn initiate_device_login(client: &reqwest::Client) -> Result { + oauth_headers(client.post(format!("{OAUTH_ISSUER}/oauth2/device/code"))) + .form(&[ + ("client_id", OAUTH_CLIENT_ID), + ("scope", OAUTH_SCOPES), + ("referrer", "grok-build"), + ]) + .send() + .await? + .error_for_status()? + .json() + .await + .context("invalid xAI device authorization response") +} + +pub async fn complete_device_login( + client: &reqwest::Client, + authorization: &DeviceAuthorization, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(authorization.expires_in.max(600)); + let mut interval = authorization.interval.max(1); + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + if tokio::time::Instant::now() >= deadline { + bail!("xAI device authorization expired"); + } + let response = oauth_headers(client.post(format!("{OAUTH_ISSUER}/oauth2/token"))) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", authorization.device_code.as_str()), + ("client_id", OAUTH_CLIENT_ID), + ]) + .send() + .await?; + let status = response.status(); + let body = response.bytes().await?; + if status.is_success() { + let tokens: TokenResponse = + serde_json::from_slice(&body).context("invalid xAI token response")?; + return save_tokens(tokens); + } + let error: TokenError = serde_json::from_slice(&body) + .with_context(|| format!("xAI token request failed with {status}"))?; + match error.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval += 5; + continue; + } + _ => bail!( + "xAI login failed: {}", + error.error_description.unwrap_or(error.error) + ), + } + } +} + +fn save_tokens(tokens: TokenResponse) -> Result<()> { + let claims = tokens + .access_token + .split('.') + .nth(1) + .and_then(|part| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(part) + .ok() + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default(); + let now = chrono::Utc::now(); + let credential = StoredCredential { + key: tokens.access_token, + auth_mode: "oidc", + create_time: now.to_rfc3339(), + user_id: claims.sub.unwrap_or_default(), + email: claims.email, + coding_data_retention_opt_out: false, + first_name: claims.given_name, + refresh_token: tokens.refresh_token, + expires_at: tokens + .expires_in + .map(|seconds| (now + chrono::Duration::seconds(seconds as i64)).to_rfc3339()), + oidc_issuer: OAUTH_ISSUER, + oidc_client_id: OAUTH_CLIENT_ID, + }; + let home = std::env::var_os("GROK_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".grok"))) + .context("No home directory available for Grok Build credentials")?; + std::fs::create_dir_all(&home)?; + let path = home.join("auth.json"); + let mut credentials = std::fs::read(&path) + .ok() + .and_then(|bytes| { + serde_json::from_slice::>(&bytes).ok() + }) + .unwrap_or_default(); + credentials.insert( + format!("{OAUTH_ISSUER}::{OAUTH_CLIENT_ID}"), + serde_json::to_value(credential)?, + ); + let temporary = path.with_extension(format!("json.tmp-{}", std::process::id())); + std::fs::write(&temporary, serde_json::to_vec_pretty(&credentials)?)?; + crate::platform::set_permissions_owner_only(&temporary)?; + std::fs::rename(&temporary, &path)?; + Ok(()) +} fn managed_cli_path() -> Result { let name = if cfg!(windows) { "grok.exe" } else { "grok" }; diff --git a/crates/jcode-base/src/auth/mod.rs b/crates/jcode-base/src/auth/mod.rs index b4e0f99eff..0d707a8e49 100644 --- a/crates/jcode-base/src/auth/mod.rs +++ b/crates/jcode-base/src/auth/mod.rs @@ -1161,9 +1161,7 @@ fn probe_cursor_status(status: &mut AuthStatus, mode: AuthProbeMode) { AuthProbeMode::Full => { let cursor_has_api_key = cursor::has_cursor_api_key(); let cursor_has_native_auth = cursor::has_cursor_native_auth(); - let cursor_has_cli_auth = - !cursor_has_native_auth && cursor::has_authenticated_cli_session(); - status.cursor = if cursor_has_native_auth || cursor_has_cli_auth { + status.cursor = if cursor_has_native_auth { AuthState::Available } else if cursor_has_api_key { AuthState::Expired @@ -1172,7 +1170,7 @@ fn probe_cursor_status(status: &mut AuthStatus, mode: AuthProbeMode) { }; } AuthProbeMode::Fast => { - // Avoid the vscdb/sqlite and CLI probes in fast UI paths. + // Avoid the vscdb probe in fast UI paths. let cursor_has_api_key = cursor::has_cursor_api_key(); let cursor_has_file_or_env_auth = cursor::load_access_token_from_env_or_file().is_ok(); status.cursor = if cursor_has_file_or_env_auth || cursor_has_api_key { diff --git a/crates/jcode-base/src/background.rs b/crates/jcode-base/src/background.rs index e0ed1cf181..e8ee8c9085 100644 --- a/crates/jcode-base/src/background.rs +++ b/crates/jcode-base/src/background.rs @@ -5,7 +5,7 @@ use crate::bus::{ BackgroundTaskCompleted, BackgroundTaskProgress, BackgroundTaskProgressEvent, - BackgroundTaskProgressSource, BackgroundTaskStatus, Bus, BusEvent, + BackgroundTaskStatus, Bus, BusEvent, }; use anyhow::Result; use chrono::{DateTime, Utc}; @@ -15,7 +15,7 @@ use std::sync::Arc; use std::time::Instant; use tokio::fs::{self, File}; use tokio::io::AsyncWriteExt; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{Mutex, RwLock, watch}; use tokio::task::JoinHandle; use tokio::time::{Duration, Instant as TokioInstant, MissedTickBehavior}; @@ -35,6 +35,13 @@ use model::{ /// Manages background task execution pub struct BackgroundTaskManager { tasks: Arc>>, + /// Serializes progress status read-modify-write cycles so concurrent output + /// readers cannot overwrite a newer high-water mark with a stale update. + progress_updates: Arc>, + /// Live stall watchdogs by task id. Each entry is a spawned monitor that + /// fires a [`BusEvent::BackgroundTaskStalled`] after its task produces no + /// output bytes and no progress events for the configured window. + stall_watchdogs: Arc>>>, output_dir: PathBuf, } @@ -46,6 +53,8 @@ impl BackgroundTaskManager { std::fs::create_dir_all(&output_dir).ok(); Self { tasks: Arc::new(RwLock::new(HashMap::new())), + progress_updates: Arc::new(Mutex::new(())), + stall_watchdogs: Arc::new(RwLock::new(HashMap::new())), output_dir, } } @@ -387,6 +396,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; self.write_status_file(&info.status_file, &status).await; Self::publish_task_started_activity( @@ -456,6 +466,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; if let Ok(json) = serde_json::to_string_pretty(&initial_status) { let _ = std::fs::write(&status_path, json); @@ -531,6 +542,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: prior_progress, event_history: prior_event_history, + stall_wake_seconds: None, }; push_task_event( &mut final_status, @@ -658,6 +670,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; if let Ok(json) = serde_json::to_string_pretty(&initial_status) { let _ = std::fs::write(&status_path, json); @@ -742,6 +755,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: prior_progress, event_history: prior_event_history, + stall_wake_seconds: None, }; push_task_event( &mut final_status, @@ -958,6 +972,17 @@ impl BackgroundTaskManager { }); } } + Ok(BusEvent::BackgroundTaskStalled(event)) if event.task_id == task_id => { + // A stall is always worth returning: the caller + // should decide whether to keep waiting or act. + let task = self.status(task_id).await?; + return Some(BackgroundTaskWaitResult { + reason: BackgroundTaskWaitReason::Stalled, + task, + progress_event: None, + event_record: None, + }); + } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let task = self.status(task_id).await?; if task.status != BackgroundTaskStatus::Running { @@ -1026,26 +1051,44 @@ impl BackgroundTaskManager { progress: BackgroundTaskProgress, event_kind: BackgroundTaskEventKind, ) -> Result> { + let _progress_update_guard = self.progress_updates.lock().await; let status_path = self.status_path_for(task_id); let Some(mut status) = self.read_status_file(&status_path).await else { return Ok(None); }; - let progress = progress.normalize(); + let mut progress = progress.normalize(); if let Some(existing) = status.progress.as_ref() { if progress_equivalent(existing, &progress) { return Ok(Some(status)); } + // A determinate bar represents progress through the whole task. A + // lower later value would make that representation knowingly + // inconsistent, regardless of whether it was explicitly reported + // or inferred from output. Keep the last trustworthy high-water + // mark instead of allowing the UI to move backwards. + if let (Some(existing_percent), Some(new_percent)) = + (existing.percent, progress.percent) + && new_percent < existing_percent + { + return Ok(Some(status)); + } + let existing_is_more_determinate = existing.percent.is_some() || matches!((existing.current, existing.total), (_, Some(total)) if total > 0); let new_is_less_determinate = progress.percent.is_none() && !matches!((progress.current, progress.total), (_, Some(total)) if total > 0); - if existing_is_more_determinate - && new_is_less_determinate - && matches!(progress.source, BackgroundTaskProgressSource::ParsedOutput) - { - return Ok(Some(status)); + if existing_is_more_determinate && new_is_less_determinate { + // Preserve the measurable high-water mark while still allowing + // a new checkpoint/phase message to reach status and clients. + progress.kind = existing.kind.clone(); + progress.percent = existing.percent; + progress.current = existing.current; + progress.total = existing.total; + if progress.unit.is_none() { + progress.unit.clone_from(&existing.unit); + } } } @@ -1069,6 +1112,163 @@ impl BackgroundTaskManager { Ok(Some(status)) } + /// How often a stall watchdog samples output size and progress state. + const STALL_POLL_INTERVAL: Duration = Duration::from_secs(5); + /// Minimum accepted stall window, to keep watchdogs from spamming wakes. + pub const MIN_STALL_WAKE_SECONDS: u64 = 30; + + /// Arm (or re-arm) a stall watchdog for a running task. + /// + /// The watchdog fires a [`BusEvent::BackgroundTaskStalled`] after the task + /// produces no new output bytes and no new progress/checkpoint events for + /// `stall_wake_seconds`. Any activity resets the countdown. It fires at + /// most once per silence episode and re-arms itself when activity resumes, + /// so a task that stalls, recovers, and stalls again produces one wake per + /// stall. The watchdog exits when the task leaves `Running`. + /// + /// Returns the effective (clamped) window, or `None` when the task does + /// not exist or is no longer running. + pub async fn arm_stall_watchdog(&self, task_id: &str, stall_wake_seconds: u64) -> Option { + let stall_wake_seconds = stall_wake_seconds.max(Self::MIN_STALL_WAKE_SECONDS); + let status_path = self.status_path_for(task_id); + let mut status = self.read_status_file(&status_path).await?; + if status.status != BackgroundTaskStatus::Running { + return None; + } + status.stall_wake_seconds = Some(stall_wake_seconds); + self.write_status_file(&status_path, &status).await; + + let task_id_owned = task_id.to_string(); + let output_path = self.output_path_for(task_id); + let status_path_clone = status_path.clone(); + let started_at = status.started_at.clone(); + let watchdogs = Arc::clone(&self.stall_watchdogs); + + let handle = tokio::spawn(async move { + let mut last_len = fs::metadata(&output_path) + .await + .map(|meta| meta.len()) + .unwrap_or(0); + let mut last_event_count: Option = None; + let mut quiet_since = TokioInstant::now(); + let mut fired_this_episode = false; + let window = Duration::from_secs(stall_wake_seconds); + let mut poll = tokio::time::interval(Self::STALL_POLL_INTERVAL); + poll.set_missed_tick_behavior(MissedTickBehavior::Skip); + // interval fires immediately on first tick; consume it. + poll.tick().await; + + loop { + poll.tick().await; + + let Ok(content) = fs::read_to_string(&status_path_clone).await else { + break; + }; + let Ok(status) = serde_json::from_str::(&content) else { + break; + }; + if status.status != BackgroundTaskStatus::Running { + break; + } + // Disarmed externally (e.g. re-armed with a new window by a + // newer watchdog that overwrote this one's registry entry and + // cleared the field first): keep it simple, exit if cleared. + if status.stall_wake_seconds != Some(stall_wake_seconds) { + break; + } + + let output_len = fs::metadata(&output_path) + .await + .map(|meta| meta.len()) + .unwrap_or(0); + let event_count = status.event_history.len(); + let activity = output_len != last_len + || last_event_count.is_some_and(|prior| prior != event_count); + last_len = output_len; + last_event_count = Some(event_count); + + if activity { + quiet_since = TokioInstant::now(); + fired_this_episode = false; + continue; + } + + if fired_this_episode || quiet_since.elapsed() < window { + continue; + } + fired_this_episode = true; + + let running_secs = DateTime::parse_from_rfc3339(&started_at) + .ok() + .and_then(|started| (Utc::now() - started.with_timezone(&Utc)).to_std().ok()) + .map(|duration| duration.as_secs_f64()) + .unwrap_or_default(); + let output_tail = fs::read_to_string(&output_path) + .await + .map(|output| { + let tail: Vec<&str> = output.lines().rev().take(20).collect(); + tail.into_iter().rev().collect::>().join("\n") + }) + .unwrap_or_default(); + let output_tail = if output_tail.len() > 2000 { + crate::util::truncate_str(&output_tail, 2000).to_string() + } else { + output_tail + }; + + Bus::global().publish(BusEvent::BackgroundTaskStalled( + crate::bus::BackgroundTaskStalled { + task_id: status.task_id.clone(), + tool_name: status.tool_name.clone(), + display_name: status.display_name.clone(), + session_id: status.session_id.clone(), + stall_wake_seconds, + running_secs, + output_tail, + output_file: output_path.clone(), + // Arming a stall watchdog is an explicit "wake me if + // this goes quiet" request, independent of the task's + // completion-delivery flags. + notify: true, + wake: true, + }, + )); + } + + watchdogs.write().await.remove(&task_id_owned); + }); + + // Replace any existing watchdog for this task. + if let Some(prior) = self + .stall_watchdogs + .write() + .await + .insert(task_id.to_string(), handle) + { + prior.abort(); + } + + Some(stall_wake_seconds) + } + + /// Disarm the stall watchdog for a task, if one is armed. + pub async fn disarm_stall_watchdog(&self, task_id: &str) -> bool { + let removed = self.stall_watchdogs.write().await.remove(task_id); + let disarmed = removed.is_some(); + if let Some(handle) = removed { + handle.abort(); + } + let status_path = self.status_path_for(task_id); + if let Some(mut status) = self.read_status_file(&status_path).await + && status.stall_wake_seconds.is_some() + { + status.stall_wake_seconds = None; + self.write_status_file(&status_path, &status).await; + return true; + } + disarmed + } + /// Update delivery behavior for an existing background task. /// /// This supports retroactively enabling notify/wake after the task was already started. @@ -1146,6 +1346,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; let event_status = final_status.status.clone(); let event_exit_code = final_status.exit_code; @@ -1270,6 +1471,7 @@ impl BackgroundTaskManager { event_history: prior_status .map(|status| status.event_history) .unwrap_or_default(), + stall_wake_seconds: None, }; push_task_event( &mut final_status, diff --git a/crates/jcode-base/src/background/model.rs b/crates/jcode-base/src/background/model.rs index 2633b1d839..61252bf187 100644 --- a/crates/jcode-base/src/background/model.rs +++ b/crates/jcode-base/src/background/model.rs @@ -80,6 +80,10 @@ pub struct TaskStatusFile { pub progress: Option, #[serde(default)] pub event_history: Vec, + /// Stall watchdog window in seconds, when armed: the agent is woken after + /// this long with no new output bytes and no progress events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stall_wake_seconds: Option, } fn default_true() -> bool { @@ -201,6 +205,8 @@ pub enum BackgroundTaskWaitReason { Finished, Progress, Checkpoint, + /// The task's stall watchdog fired while this wait was blocked. + Stalled, Timeout, } diff --git a/crates/jcode-base/src/background/tests.rs b/crates/jcode-base/src/background/tests.rs index 34934679d1..ac9158fd5f 100644 --- a/crates/jcode-base/src/background/tests.rs +++ b/crates/jcode-base/src/background/tests.rs @@ -155,6 +155,143 @@ async fn update_progress_persists_status_and_emits_bus_event() -> Result<()> { )) } +#[tokio::test] +async fn update_progress_keeps_the_determinate_high_water_mark() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-monotonic-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let progress = |percent| BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(percent), + message: None, + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + + manager + .update_progress(&info.task_id, progress(60.0)) + .await?; + let status = manager + .update_progress(&info.task_id, progress(25.0)) + .await? + .ok_or_else(|| anyhow!("task should exist"))?; + + assert_eq!(status.progress.and_then(|value| value.percent), Some(60.0)); + Ok(()) +} + +#[tokio::test] +async fn update_progress_preserves_high_water_mark_through_reported_checkpoint() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-checkpoint-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let mut progress = BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(60.0), + message: Some("running".into()), + current: Some(6), + total: Some(10), + unit: Some("tests".into()), + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + manager + .update_progress(&info.task_id, progress.clone()) + .await?; + + progress.kind = BackgroundTaskProgressKind::Indeterminate; + progress.percent = None; + progress.current = None; + progress.total = None; + progress.unit = None; + progress.message = Some("linking".into()); + let status = manager + .update_checkpoint(&info.task_id, progress) + .await? + .ok_or_else(|| anyhow!("task should exist"))?; + let stored = status.progress.ok_or_else(|| anyhow!("progress missing"))?; + + assert_eq!(stored.percent, Some(60.0)); + assert_eq!(stored.current, Some(6)); + assert_eq!(stored.total, Some(10)); + assert_eq!(stored.message.as_deref(), Some("linking")); + Ok(()) +} + +#[tokio::test] +async fn concurrent_progress_updates_cannot_overwrite_the_high_water_mark() -> Result<()> { + let tmp = tempdir()?; + let manager = Arc::new(BackgroundTaskManager::with_output_dir( + tmp.path().to_path_buf(), + )); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-concurrent-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let update = |percent| BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(percent), + message: None, + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + let first = manager.update_progress(&info.task_id, update(80.0)); + let second = manager.update_progress(&info.task_id, update(20.0)); + tokio::try_join!(first, second)?; + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(status.progress.and_then(|value| value.percent), Some(80.0)); + Ok(()) +} + #[tokio::test] async fn wait_returns_when_task_finishes() -> Result<()> { let tmp = tempdir()?; @@ -287,6 +424,7 @@ fn running_status_fixture(task_id: &str, session_id: &str) -> TaskStatusFile { wake: false, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, } } @@ -562,3 +700,127 @@ async fn abort_live_tasks_for_reload_keeps_naturally_finished_status() -> Result ); Ok(()) } + +#[tokio::test(start_paused = true)] +async fn stall_watchdog_fires_and_wait_returns_stalled() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + + let info = manager + .spawn_with_notify( + "bash", + Some("quiet task".to_string()), + "session-stall-fire", + false, + false, + |_output_path| async move { + // Hang well past the stall window without producing output. + sleep(Duration::from_secs(3600)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + // Requested window below the minimum is clamped up. + let effective = manager + .arm_stall_watchdog(&info.task_id, 1) + .await + .ok_or_else(|| anyhow!("watchdog should arm on a running task"))?; + assert_eq!(effective, BackgroundTaskManager::MIN_STALL_WAKE_SECONDS); + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("status should exist"))?; + assert_eq!(status.stall_wake_seconds, Some(effective)); + + let wait_result = manager + .wait(&info.task_id, Duration::from_secs(600), false) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(wait_result.reason, BackgroundTaskWaitReason::Stalled); + assert_eq!(wait_result.task.status, BackgroundTaskStatus::Running); + + manager.cancel(&info.task_id).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn stall_watchdog_does_not_fire_for_task_that_finishes() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let mut bus_rx = Bus::global().subscribe(); + + let info = manager + .spawn_with_notify( + "bash", + None, + "session-stall-no-fire", + false, + false, + |output_path| async move { + sleep(Duration::from_secs(10)).await; + tokio::fs::write(&output_path, "done").await?; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + manager + .arm_stall_watchdog(&info.task_id, 30) + .await + .ok_or_else(|| anyhow!("watchdog should arm on a running task"))?; + + let wait_result = manager + .wait(&info.task_id, Duration::from_secs(600), false) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(wait_result.reason, BackgroundTaskWaitReason::Finished); + + // Give the watchdog time to notice the terminal status and exit. + sleep(Duration::from_secs(30)).await; + while let Ok(event) = bus_rx.try_recv() { + if let BusEvent::BackgroundTaskStalled(event) = event { + assert_ne!( + event.task_id, info.task_id, + "watchdog must not fire for a task that finished within its window" + ); + } + } + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn disarm_stall_watchdog_clears_state() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + + let info = manager + .spawn_with_notify( + "bash", + None, + "session-stall-disarm", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(3600)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + manager + .arm_stall_watchdog(&info.task_id, 45) + .await + .ok_or_else(|| anyhow!("watchdog should arm"))?; + assert!(manager.disarm_stall_watchdog(&info.task_id).await); + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("status should exist"))?; + assert_eq!(status.stall_wake_seconds, None); + + manager.cancel(&info.task_id).await?; + Ok(()) +} diff --git a/crates/jcode-base/src/bus.rs b/crates/jcode-base/src/bus.rs index 66bda7ed9f..c02a8efef0 100644 --- a/crates/jcode-base/src/bus.rs +++ b/crates/jcode-base/src/bus.rs @@ -3,7 +3,8 @@ use crate::side_panel::SidePanelSnapshot; use crate::todo::TodoItem; pub use jcode_background_types::{ BackgroundTaskCompleted, BackgroundTaskProgress, BackgroundTaskProgressEvent, - BackgroundTaskProgressKind, BackgroundTaskProgressSource, BackgroundTaskStatus, + BackgroundTaskProgressKind, BackgroundTaskProgressSource, BackgroundTaskStalled, + BackgroundTaskStatus, }; pub use jcode_batch_types::{BatchProgress, BatchSubcallProgress, BatchSubcallState}; use serde::{Deserialize, Serialize}; @@ -405,6 +406,8 @@ pub enum BusEvent { BackgroundTaskCompleted(BackgroundTaskCompleted), /// Background task reported progress BackgroundTaskProgress(BackgroundTaskProgressEvent), + /// Background task stall watchdog fired: no output/progress for its window + BackgroundTaskStalled(BackgroundTaskStalled), /// A backgrounded `swarm await_members` watcher reached a terminal result. SwarmAwaitCompleted(SwarmAwaitCompleted), /// Usage report fetched from providers diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index ec762cff6e..0e66952ff8 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -56,6 +56,9 @@ scroll_prompt_down = "ctrl+]" # Scroll bookmark toggle (stash position, jump to bottom, press again to return) scroll_bookmark = "ctrl+g" +# Auto-poke toggle. Set "" to disable. +auto_poke_toggle = "ctrl+p" + # Optional fallback scroll bindings (useful on macOS terminals that forward Command) # Leave unset by default; on macOS Cmd+K / Cmd+J move up / down by prompt instead. scroll_up_fallback = "" @@ -154,6 +157,9 @@ debug_socket = false # Set false here or set JCODE_NO_EMOJI=1 for ASCII fallbacks. emoji = true +# Usage percentage wording: "left" (default) or "used". +usage_display = "left" + # Show thinking/reasoning content (default: false) show_thinking = false @@ -188,6 +194,10 @@ prompt_entry_animation = true # results directly in the chat. # show_agentgrep_output = false +# Show up to the last three non-empty lines of bash output beneath the tool +# summary (default: false). +# show_bash_output = false + # Show the dimmed technical detail (command, file path, args) next to the # model-provided intent on tool rows (default: false). When false, tool rows # with an intent show just the intent; rows without an intent still show the diff --git a/crates/jcode-base/src/config/display_summary.rs b/crates/jcode-base/src/config/display_summary.rs index 45741d2b87..0d7cefd923 100644 --- a/crates/jcode-base/src/config/display_summary.rs +++ b/crates/jcode-base/src/config/display_summary.rs @@ -27,6 +27,7 @@ impl Config { - Prompt up: `{}` - Prompt down: `{}` - Scroll bookmark: `{}` +- Auto-poke toggle: `{}` - Workspace left: `{}` - Workspace down: `{}` - Workspace up: `{}` @@ -143,6 +144,11 @@ impl Config { self.keybindings.scroll_prompt_up, self.keybindings.scroll_prompt_down, self.keybindings.scroll_bookmark, + if self.keybindings.auto_poke_toggle.trim().is_empty() { + "disabled" + } else { + self.keybindings.auto_poke_toggle.trim() + }, self.keybindings.workspace_left, self.keybindings.workspace_down, self.keybindings.workspace_up, diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 3817023bfa..c010439326 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -282,6 +282,11 @@ impl Config { self.display.show_agentgrep_output = parsed; } } + if let Ok(v) = std::env::var("JCODE_SHOW_BASH_OUTPUT") { + if let Some(parsed) = parse_env_bool(&v) { + self.display.show_bash_output = parsed; + } + } if let Ok(v) = std::env::var("JCODE_TOOL_CALL_DETAILS") { if let Some(parsed) = parse_env_bool(&v) { self.display.tool_call_details = parsed; diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index 3646115a69..c712d47200 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -81,6 +81,24 @@ fn auto_poke_feature_defaults_on_and_parses_false() { assert!(!cfg.features.auto_poke); } +#[test] +fn auto_poke_toggle_key_defaults_parses_and_reports_disabled() { + assert_eq!(Config::default().keybindings.auto_poke_toggle, "ctrl+p"); + + let remapped: Config = toml::from_str("[keybindings]\nauto_poke_toggle = \"alt+p\"\n") + .expect("keybindings.auto_poke_toggle should parse"); + assert_eq!(remapped.keybindings.auto_poke_toggle, "alt+p"); + + let disabled: Config = toml::from_str("[keybindings]\nauto_poke_toggle = \"\"\n") + .expect("an empty auto-poke toggle should parse"); + assert!(disabled.keybindings.auto_poke_toggle.is_empty()); + assert!( + disabled + .display_string() + .contains("- Auto-poke toggle: `disabled`") + ); +} + #[test] fn auto_poke_environment_override_uses_standard_boolean_values() { let _guard = crate::storage::lock_test_env(); diff --git a/crates/jcode-base/src/hooks.rs b/crates/jcode-base/src/hooks.rs index 2bf5e9d010..32a02938b6 100644 --- a/crates/jcode-base/src/hooks.rs +++ b/crates/jcode-base/src/hooks.rs @@ -233,10 +233,13 @@ pub fn dispatch_observer(event: HookEvent) { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); match crate::platform::spawn_detached(&mut cmd) { - Ok(_) => crate::logging::debug(&format!( - "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", - event.session_id - )), + Ok(child) => { + crate::platform::reap_detached(child); + crate::logging::debug(&format!( + "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", + event.session_id + )); + } Err(error) => crate::logging::warn(&format!( "Hook '{event_name}' command '{command_line}' failed to start: {error}" )), @@ -619,6 +622,51 @@ mod tests { assert_eq!(recorded, "turn_end|ses_obs|ok|1"); } + #[cfg(target_os = "linux")] + #[test] + fn observer_dispatch_reaps_completed_hook() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let record = temp.path().join("pid.txt"); + let script = write_executable_script( + temp.path(), + "record-pid.sh", + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$$\" > {}\n", + crate::terminal_launch::sh_escape(&record.to_string_lossy()) + ), + ); + + let previous = std::env::var_os("JCODE_HOOK_TURN_END"); + crate::env::set_var("JCODE_HOOK_TURN_END", script.to_string_lossy().to_string()); + dispatch_observer(HookEvent::new("turn_end").session_id("ses_reap")); + + let mut pid: Option = None; + for _ in 0..100 { + pid = std::fs::read_to_string(&record) + .ok() + .and_then(|value| value.strip_suffix('\n').and_then(|pid| pid.parse().ok())); + if pid.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + match previous { + Some(value) => crate::env::set_var("JCODE_HOOK_TURN_END", value), + None => crate::env::remove_var("JCODE_HOOK_TURN_END"), + } + + let pid = pid.expect("hook should record its pid"); + let process = std::path::PathBuf::from(format!("/proc/{pid}")); + for _ in 0..100 { + if !process.exists() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("completed hook process {pid} was not reaped"); + } + #[cfg(unix)] #[test] fn observer_dispatch_runs_each_configured_command() { diff --git a/crates/jcode-base/src/mcp/client.rs b/crates/jcode-base/src/mcp/client.rs index 68dd469890..31ff03cc15 100644 --- a/crates/jcode-base/src/mcp/client.rs +++ b/crates/jcode-base/src/mcp/client.rs @@ -26,6 +26,21 @@ pub struct McpHandle { } impl McpHandle { + /// Test-only constructor: a handle that is not backed by any process. + #[cfg(test)] + pub(crate) fn new_dummy_for_tests(name: String) -> Self { + let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::(8); + Self { + name, + request_id: Arc::new(AtomicU64::new(1)), + pending: Arc::new(Mutex::new(HashMap::new())), + writer_tx, + server_info: Arc::new(std::sync::RwLock::new(None)), + capabilities: Arc::new(std::sync::RwLock::new(ServerCapabilities::default())), + tools: Arc::new(std::sync::RwLock::new(Vec::new())), + } + } + /// Send a request and wait for response pub async fn request(&self, method: &str, params: Option) -> Result { let id = self.request_id.fetch_add(1, Ordering::SeqCst); diff --git a/crates/jcode-base/src/mcp/mod.rs b/crates/jcode-base/src/mcp/mod.rs index 0f7063ce63..0d23520364 100644 --- a/crates/jcode-base/src/mcp/mod.rs +++ b/crates/jcode-base/src/mcp/mod.rs @@ -16,4 +16,4 @@ pub use manager::McpManager; pub use pool::{SharedMcpPool, get_shared_pool, init_shared_pool}; pub use protocol::*; pub use schema_cache::{McpSchemaCache, fingerprint_config}; -pub use tool::{McpTool, create_mcp_tools, create_mcp_tools_from_cached}; +pub use tool::{McpTool, create_mcp_tools, create_mcp_tools_from_cached, dispatch_name}; diff --git a/crates/jcode-base/src/mcp/pool.rs b/crates/jcode-base/src/mcp/pool.rs index a0d69d2b27..3adeecceed 100644 --- a/crates/jcode-base/src/mcp/pool.rs +++ b/crates/jcode-base/src/mcp/pool.rs @@ -25,6 +25,7 @@ struct FailedConnectRecord { failed_at: Instant, } +#[derive(Debug)] enum ConnectAttempt { Connected, Leader(Arc), @@ -287,7 +288,29 @@ impl SharedMcpPool { } if self.handles.read().await.contains_key(name) { - return ConnectAttempt::Connected; + // The pool may hold a handle whose child process died (e.g. the + // MCP server crashed or was killed externally). Detect that here + // so a reconnect spawns a fresh process instead of returning the + // stale handle, which would make every tool call fail with + // "Failed to send request" (writer channel closed). + let mut clients = self.clients.lock().await; + let dead = match clients.get_mut(name) { + Some(client) => !client.is_running(), + None => true, // handle without client -> treat as dead + }; + if dead { + clients.remove(name); + drop(clients); + let mut handles = self.handles.write().await; + handles.remove(name); + let mut refs = self.ref_counts.lock().await; + refs.remove(name); + let mut errors = self.last_errors.write().await; + errors.remove(name); + } else { + drop(clients); + return ConnectAttempt::Connected; + } } let notify = Arc::new(Notify::new()); @@ -494,6 +517,36 @@ mod tests { assert!(Arc::ptr_eq(&first_notify, &second_notify)); } + #[tokio::test] + async fn begin_connect_replaces_dead_client() { + // A handle whose child process has exited must not be returned as + // "Connected": every tool call on it would fail with a closed writer + // channel. begin_connect should drop the stale client/handle and + // return Leader so a fresh process is spawned. + let pool = Arc::new(SharedMcpPool::new(McpConfig::default())); + + // A handle with no backing client is treated as dead (crashed + // process): begin_connect must clean it up and return Leader. + pool.handles.write().await.insert( + "stale".to_string(), + crate::mcp::McpHandle::new_dummy_for_tests("stale".to_string()), + ); + pool.ref_counts.lock().await.insert("stale".to_string(), 1); + + let attempt = pool.begin_connect("stale").await; + match attempt { + ConnectAttempt::Leader(_) => { + // Stale handle was cleaned up; a fresh connect will spawn. + } + other => panic!("stale handle must yield Leader, got {other:?}"), + } + + // The stale handle must be gone from the pool. + assert!(!pool.handles.read().await.contains_key("stale")); + assert!(!pool.clients.lock().await.contains_key("stale")); + assert!(!pool.ref_counts.lock().await.contains_key("stale")); + } + #[tokio::test] async fn connect_all_skips_non_shared_servers() { // Issue #557: shared:false servers are owned per-session and must not diff --git a/crates/jcode-base/src/mcp/protocol.rs b/crates/jcode-base/src/mcp/protocol.rs index 5a56265799..5f6564c408 100644 --- a/crates/jcode-base/src/mcp/protocol.rs +++ b/crates/jcode-base/src/mcp/protocol.rs @@ -607,6 +607,7 @@ impl McpConfig { Self::import_from_codex_once(); let mut merged = Self::default(); + let claude_mcp_enabled = std::env::var_os("JCODE_DISABLE_CLAUDE_MCP").is_none(); // Load jcode's own global config (~/.jcode/mcp.json) if let Ok(jcode_dir) = crate::storage::jcode_dir() { @@ -620,7 +621,9 @@ impl McpConfig { // Claude Code user/global config (~/.claude.json): top-level mcpServers // plus per-project entries for the project directory. - if let Ok(claude_json) = crate::storage::user_home_path(".claude.json") { + if claude_mcp_enabled + && let Ok(claude_json) = crate::storage::user_home_path(".claude.json") + { if claude_json.exists() { let cwd = project_dir.map(std::path::Path::to_path_buf); let config = Self::load_claude_json(&claude_json, cwd.as_deref()); @@ -637,7 +640,9 @@ impl McpConfig { // Older Claude Code global config is also a live source. Reading it on // every load preserves compatibility without copying any inline env // values into ~/.jcode/mcp.json. - if let Ok(claude_mcp) = crate::storage::user_home_path(".claude/mcp.json") { + if claude_mcp_enabled + && let Ok(claude_mcp) = crate::storage::user_home_path(".claude/mcp.json") + { if claude_mcp.exists() && let Ok(config) = Self::load_from_file(&claude_mcp) { diff --git a/crates/jcode-base/src/mcp/protocol_tests.rs b/crates/jcode-base/src/mcp/protocol_tests.rs index 4394d3dcfd..9dcadef294 100644 --- a/crates/jcode-base/src/mcp/protocol_tests.rs +++ b/crates/jcode-base/src/mcp/protocol_tests.rs @@ -757,6 +757,62 @@ fn legacy_claude_config_is_live_and_deletions_do_not_leave_a_snapshot() { result.expect("legacy Claude live-source assertions"); } +#[test] +fn disabling_claude_mcp_skips_both_live_sources_but_preserves_jcode_sources() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let previous_disable = std::env::var_os("JCODE_DISABLE_CLAUDE_MCP"); + let home = tempfile::tempdir().expect("home tempdir"); + let project = tempfile::tempdir().expect("project tempdir"); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_DISABLE_CLAUDE_MCP", "1"); + + std::fs::write( + home.path().join("mcp.json"), + r#"{"mcpServers":{"jcode-global":{"command":"jcode-global"}}}"#, + ) + .expect("write jcode global config"); + std::fs::create_dir_all(project.path().join(".jcode")).expect("create jcode project dir"); + std::fs::write( + project.path().join(".jcode/mcp.json"), + r#"{"mcpServers":{"jcode-project":{"command":"jcode-project"}}}"#, + ) + .expect("write jcode project config"); + + let external = home.path().join("external"); + std::fs::create_dir_all(external.join(".claude")).expect("create Claude config dirs"); + std::fs::write( + external.join(".claude.json"), + r#"{"mcpServers":{"claude-current":{"command":"claude-current"}}}"#, + ) + .expect("write current Claude config"); + std::fs::write( + external.join(".claude/mcp.json"), + r#"{"mcpServers":{"claude-legacy":{"command":"claude-legacy"}}}"#, + ) + .expect("write legacy Claude config"); + + let result = std::panic::catch_unwind(|| { + let config = McpConfig::load_for_dir(Some(project.path())); + assert!(config.servers.contains_key("jcode-global")); + assert!(config.servers.contains_key("jcode-project")); + assert!(!config.servers.contains_key("claude-current")); + assert!(!config.servers.contains_key("claude-legacy")); + }); + + if let Some(previous_home) = previous_home { + crate::env::set_var("JCODE_HOME", previous_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(previous_disable) = previous_disable { + crate::env::set_var("JCODE_DISABLE_CLAUDE_MCP", previous_disable); + } else { + crate::env::remove_var("JCODE_DISABLE_CLAUDE_MCP"); + } + result.expect("Claude MCP opt-out assertions"); +} + #[test] fn mcp_source_logs_explain_provenance_without_config_values() { let live = McpConfig::live_claude_log_message(2, "~/.claude.json"); diff --git a/crates/jcode-base/src/mcp/tool.rs b/crates/jcode-base/src/mcp/tool.rs index 9a2683caf9..1777e23b48 100644 --- a/crates/jcode-base/src/mcp/tool.rs +++ b/crates/jcode-base/src/mcp/tool.rs @@ -106,6 +106,10 @@ impl Tool for McpTool { } } +pub fn dispatch_name(server_name: &str, tool_name: &str) -> String { + format!("mcp__{}__{}", server_name, tool_name).replace('-', "_") +} + /// Create tools from an MCP manager pub async fn create_mcp_tools(manager: Arc>) -> Vec<(String, Arc)> { let mgr = manager.read().await; @@ -114,7 +118,7 @@ pub async fn create_mcp_tools(manager: Arc>) -> Vec<(String, let mut tools = Vec::new(); for (server_name, tool_def) in all_tools { - let prefixed_name = format!("mcp__{}__{}", server_name, tool_def.name); + let prefixed_name = dispatch_name(&server_name, &tool_def.name); let mcp_tool = McpTool::new(server_name, tool_def, Arc::clone(&manager)); tools.push((prefixed_name, Arc::new(mcp_tool) as Arc)); } @@ -133,7 +137,7 @@ pub fn create_mcp_tools_from_cached( tool_defs .iter() .map(|tool_def| { - let prefixed_name = format!("mcp__{}__{}", server_name, tool_def.name); + let prefixed_name = dispatch_name(server_name, &tool_def.name); let mcp_tool = McpTool::new( server_name.to_string(), tool_def.clone(), @@ -143,3 +147,20 @@ pub fn create_mcp_tools_from_cached( }) .collect() } + +#[cfg(test)] +mod tests { + use super::dispatch_name; + + #[test] + fn hyphenated_mcp_names_are_safe_for_the_standard_dispatcher() { + assert_eq!( + dispatch_name("context7", "resolve-library-id"), + "mcp__context7__resolve_library_id" + ); + assert_eq!( + dispatch_name("hyphenated-server", "query-docs"), + "mcp__hyphenated_server__query_docs" + ); + } +} diff --git a/crates/jcode-base/src/message.rs b/crates/jcode-base/src/message.rs index a4d563dd47..14444a909a 100644 --- a/crates/jcode-base/src/message.rs +++ b/crates/jcode-base/src/message.rs @@ -20,11 +20,13 @@ mod notifications; pub use notifications::{ ParsedBackgroundTaskNotification, ParsedBackgroundTaskProgressNotification, - background_task_display_label, background_task_status_notice, - format_background_task_notification_markdown, format_background_task_progress_markdown, + ParsedBackgroundTaskStartedNotification, background_task_display_label, + background_task_status_notice, format_background_task_notification_markdown, + format_background_task_progress_markdown, format_background_task_stalled_markdown, format_input_shell_result_markdown, format_model_refresh_progress_markdown, input_shell_status_notice, parse_background_task_notification_markdown, - parse_background_task_progress_notification_markdown, strip_ansi_escape_sequences, + parse_background_task_progress_notification_markdown, + parse_background_task_started_notification_markdown, strip_ansi_escape_sequences, }; fn compile_static_regex(pattern: &str) -> Option { diff --git a/crates/jcode-base/src/message/notifications.rs b/crates/jcode-base/src/message/notifications.rs index 4dd1135410..6e0c2a12a4 100644 --- a/crates/jcode-base/src/message/notifications.rs +++ b/crates/jcode-base/src/message/notifications.rs @@ -286,6 +286,53 @@ pub fn format_background_task_progress_markdown(task: &BackgroundTaskProgressEve ) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBackgroundTaskStartedNotification { + pub task_id: String, + pub label: String, +} + +pub fn parse_background_task_started_notification_markdown( + content: &str, +) -> Option { + let header = content.trim().lines().next()?.trim(); + let rest = header.strip_prefix("**Background task started** `")?; + let (task_id, label) = rest.split_once("` ยท `")?; + let label = label.strip_suffix('`')?; + if task_id.is_empty() || label.is_empty() { + return None; + } + Some(ParsedBackgroundTaskStartedNotification { + task_id: task_id.to_string(), + label: label.to_string(), + }) +} + +pub fn format_background_task_stalled_markdown( + task: &jcode_background_types::BackgroundTaskStalled, +) -> String { + let mut message = format!( + "**Background task stalled** `{}` ยท {} ยท no output or progress for {}s (running {:.0}s total)", + task.task_id, + background_task_header_label(&task.tool_name, task.display_name.as_deref()), + task.stall_wake_seconds, + task.running_secs, + ); + + if let Some(tail) = normalize_background_task_preview(&task.output_tail) { + message.push_str(&format!("\n\nLast output:\n```text\n{}\n```", tail)); + } else { + message.push_str("\n\n_No output captured yet._"); + } + + message.push_str(&format!( + "\n\nThe task is still running. Inspect it (`bg action=\"status\" task_id=\"{}\"`, `bg action=\"output\" task_id=\"{}\"`) and decide whether to keep waiting or cancel it (`bg action=\"cancel\" task_id=\"{}\"`). The watchdog re-arms if output resumes.", + task.task_id, task.task_id, task.task_id + )); + + message +} + pub fn format_model_refresh_progress_markdown(detail: &str, percent: Option) -> String { let detail = detail.trim(); let progress = percent diff --git a/crates/jcode-base/src/message/tests.rs b/crates/jcode-base/src/message/tests.rs index f9b3741abf..21c72a9a9f 100644 --- a/crates/jcode-base/src/message/tests.rs +++ b/crates/jcode-base/src/message/tests.rs @@ -853,3 +853,50 @@ fn reasoning_trace_serde_round_trip() { other => panic!("expected ReasoningTrace, got {other:?}"), } } + +#[test] +fn format_background_task_stalled_markdown_renders_tail_and_guidance() { + let rendered = + format_background_task_stalled_markdown(&jcode_background_types::BackgroundTaskStalled { + task_id: "stall01".to_string(), + tool_name: "bash".to_string(), + display_name: Some("long training run".to_string()), + session_id: "session".to_string(), + stall_wake_seconds: 300, + running_secs: 1234.5, + output_tail: "epoch 3/10\nloss 0.42\n".to_string(), + output_file: std::path::PathBuf::from("/tmp/output.log"), + notify: true, + wake: true, + }); + + assert!(rendered.contains( + "**Background task stalled** `stall01` ยท `long training run` (`bash`) ยท no output or progress for 300s (running 1234s total)" + )); + assert!(rendered.contains("```text\nepoch 3/10\nloss 0.42\n```")); + assert!(rendered.contains("bg action=\"status\" task_id=\"stall01\"")); + assert!(rendered.contains("bg action=\"cancel\" task_id=\"stall01\"")); + assert!(rendered.contains("re-arms if output resumes")); +} + +#[test] +fn format_background_task_stalled_markdown_handles_empty_tail() { + let rendered = + format_background_task_stalled_markdown(&jcode_background_types::BackgroundTaskStalled { + task_id: "stall02".to_string(), + tool_name: "bash".to_string(), + display_name: None, + session_id: "session".to_string(), + stall_wake_seconds: 60, + running_secs: 61.0, + output_tail: String::new(), + output_file: std::path::PathBuf::from("/tmp/output.log"), + notify: true, + wake: true, + }); + + assert!(rendered.contains( + "**Background task stalled** `stall02` ยท `bash` ยท no output or progress for 60s" + )); + assert!(rendered.contains("_No output captured yet._")); +} diff --git a/crates/jcode-base/src/platform.rs b/crates/jcode-base/src/platform.rs index cef0380f81..1d6f42a659 100644 --- a/crates/jcode-base/src/platform.rs +++ b/crates/jcode-base/src/platform.rs @@ -424,6 +424,26 @@ pub fn spawn_detached(cmd: &mut std::process::Command) -> std::io::Result, working_dir: Option<&Path>, capabilities: PromptCapabilities, +) -> (SplitSystemPrompt, ContextInfo) { + let agents_md = load_agents_md_files_from_dir(working_dir); + build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt, + available_skills, + is_selfdev, + memory_prompt, + working_dir, + capabilities, + agents_md, + ) +} + +/// Build a split prompt using an already captured AGENTS.md snapshot. +/// +/// Long-lived agents use this to keep their provider-cache prefix stable when a +/// tool edits AGENTS.md during the session. New sessions still capture the +/// latest instructions. +pub fn build_system_prompt_split_with_agents_md( + skill_prompt: Option<&str>, + available_skills: &[SkillInfo], + is_selfdev: bool, + memory_prompt: Option<&str>, + working_dir: Option<&Path>, + agents_md: (Option, ContextInfo), +) -> (SplitSystemPrompt, ContextInfo) { + build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt, + available_skills, + is_selfdev, + memory_prompt, + working_dir, + PromptCapabilities::current(), + agents_md, + ) +} + +fn build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt: Option<&str>, + available_skills: &[SkillInfo], + is_selfdev: bool, + memory_prompt: Option<&str>, + working_dir: Option<&Path>, + capabilities: PromptCapabilities, + agents_md: (Option, ContextInfo), ) -> (SplitSystemPrompt, ContextInfo) { let mut static_parts = base_system_prompt_parts(capabilities, working_dir); let mut dynamic_parts = Vec::new(); @@ -549,7 +594,7 @@ pub fn build_system_prompt_split_with_capabilities( } // Add AGENTS.md instructions (static per project) - let (md_content, md_info) = load_agents_md_files_from_dir(working_dir); + let (md_content, md_info) = agents_md; if let Some(content) = md_content { static_parts.push(content); } diff --git a/crates/jcode-base/src/prompt_tests.rs b/crates/jcode-base/src/prompt_tests.rs index 3e962a6fcc..f628a5432f 100644 --- a/crates/jcode-base/src/prompt_tests.rs +++ b/crates/jcode-base/src/prompt_tests.rs @@ -196,6 +196,58 @@ fn agents_md_distinct_project_and_global_files_are_both_loaded() { assert!(content.contains("global instructions")); } +#[test] +fn captured_agents_md_keeps_split_prompt_stable_after_file_write() { + let project_dir = tempfile::TempDir::new().unwrap(); + let agents_md = project_dir.path().join("AGENTS.md"); + std::fs::write(&agents_md, "original session instructions").unwrap(); + let snapshot = load_agents_md_files_from_dirs(project_dir.path(), None); + + let (before, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + snapshot.clone(), + ); + std::fs::write(&agents_md, "instructions written during the session").unwrap(); + let (after, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + snapshot, + ); + + assert_eq!(before.static_part, after.static_part); + assert!(after.static_part.contains("original session instructions")); + assert!( + !after + .static_part + .contains("instructions written during the session") + ); + + // A new session/workspace boundary captures a fresh snapshot rather than + // pinning the old instructions forever. + let fresh_snapshot = load_agents_md_files_from_dirs(project_dir.path(), None); + let (next_session, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + fresh_snapshot, + ); + assert!( + next_session + .static_part + .contains("instructions written during the session") + ); + assert_ne!(before.static_part, next_session.static_part); +} + #[test] fn agents_md_missing_global_file_keeps_project_instructions() { let project_dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index effad3a75d..ad5d27ea21 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -86,6 +86,40 @@ pub const AVAILABLE_MODELS: &[&str] = &[ ]; pub fn load_anthropic_api_key() -> Result { + if std::env::var("JCODE_ANTHROPIC_AUTH") + .ok() + .is_some_and(|value| value.eq_ignore_ascii_case("none")) + { + return Ok(String::new()); + } + if let Ok(env_name) = std::env::var("JCODE_ANTHROPIC_API_KEY_NAME") { + let env_name = env_name.trim(); + if !env_name.is_empty() { + if let Ok(value) = std::env::var(env_name) + && !value.trim().is_empty() + { + return Ok(value); + } + if let Ok(env_file) = std::env::var("JCODE_ANTHROPIC_ENV_FILE") + && let Some(value) = crate::provider_catalog::load_env_value_from_config_file( + env_name, + env_file.trim(), + ) + && !value.trim().is_empty() + { + return Ok(value); + } + anyhow::bail!( + "Anthropic-compatible profile credential '{}' is not configured", + env_name + ); + } + } + if let Ok(value) = std::env::var("ANTHROPIC_AUTH_TOKEN") + && !value.trim().is_empty() + { + return Ok(value); + } let key = crate::provider_catalog::load_api_key_from_env_or_config( "ANTHROPIC_API_KEY", "anthropic.env", diff --git a/crates/jcode-base/src/provider/catalog_routes.rs b/crates/jcode-base/src/provider/catalog_routes.rs index 0b769b51b5..90bb8d3841 100644 --- a/crates/jcode-base/src/provider/catalog_routes.rs +++ b/crates/jcode-base/src/provider/catalog_routes.rs @@ -222,12 +222,8 @@ pub(super) fn multiprovider_model_routes(provider: &MultiProvider) -> Vec { + let switching_from_named_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .and_then(|name| crate::config::config().providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + if switching_from_named_anthropic { + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); + crate::provider_catalog::clear_anthropic_profile_env(); + crate::env::set_var( + "JCODE_RUNTIME_PROVIDER", + match anthropic_credential_mode { + Some(mode) => match mode { + anthropic::AnthropicCredentialMode::ApiKey => "anthropic-api", + anthropic::AnthropicCredentialMode::OAuth => "claude-oauth", + anthropic::AnthropicCredentialMode::Auto => "claude", + }, + None => "claude", + }, + ); + let official = external::instantiate_expected_external_provider( + external::ANTHROPIC_RUNTIME, + ) + .ok_or_else(|| anyhow::anyhow!("Anthropic runtime is not registered"))?; + *self + .anthropic + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(official); + } + crate::provider_catalog::clear_anthropic_profile_env(); let model = model_name_for_provider(provider, model); if let Some(anthropic) = self.anthropic_provider() { if let Some(mode) = anthropic_credential_mode { @@ -1551,7 +1605,7 @@ impl MultiProvider { // Same reasoning for user-defined named provider profiles from // config: bind the named profile runtime directly instead of the // generic OpenRouter slot path. - if let selection::ConfigProviderSelection::NamedProfile(profile_name) = &selection { + if let selection::ConfigProviderSelection::NamedProfile(profile_name, _) = &selection { return self.set_model_on_named_provider_profile(profile_name, model); } diff --git a/crates/jcode-base/src/provider/selection.rs b/crates/jcode-base/src/provider/selection.rs index 1b283dc561..d9cddddebf 100644 --- a/crates/jcode-base/src/provider/selection.rs +++ b/crates/jcode-base/src/provider/selection.rs @@ -6,14 +6,15 @@ pub(super) use jcode_provider_core::{ActiveProvider, ProviderAvailability}; pub(crate) enum ConfigProviderSelection { BuiltIn(ActiveProvider), OpenAiCompatibleProfile(&'static str), - NamedProfile(String), + NamedProfile(String, ActiveProvider), } impl ConfigProviderSelection { pub(crate) fn active_provider(&self) -> ActiveProvider { match self { Self::BuiltIn(provider) => *provider, - Self::OpenAiCompatibleProfile(_) | Self::NamedProfile(_) => ActiveProvider::OpenRouter, + Self::OpenAiCompatibleProfile(_) => ActiveProvider::OpenRouter, + Self::NamedProfile(_, provider) => *provider, } } @@ -31,7 +32,7 @@ impl ConfigProviderSelection { None => format!("OpenAI-compatible profile {}", profile_id), } } - Self::NamedProfile(profile) => format!("provider profile '{}'", profile), + Self::NamedProfile(profile, _) => format!("provider profile '{}'", profile), } } } @@ -500,8 +501,19 @@ impl MultiProvider { return Some(ConfigProviderSelection::OpenAiCompatibleProfile(profile.id)); } - if cfg.providers.contains_key(trimmed) { - return Some(ConfigProviderSelection::NamedProfile(trimmed.to_string())); + if let Some(profile) = cfg.providers.get(trimmed) { + let provider = if matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) { + ActiveProvider::Claude + } else { + ActiveProvider::OpenRouter + }; + return Some(ConfigProviderSelection::NamedProfile( + trimmed.to_string(), + provider, + )); } // Accept the dual-auth `--provider` vocabulary (`anthropic-api`, diff --git a/crates/jcode-base/src/provider/startup.rs b/crates/jcode-base/src/provider/startup.rs index d40b589f3b..a8fdd3653d 100644 --- a/crates/jcode-base/src/provider/startup.rs +++ b/crates/jcode-base/src/provider/startup.rs @@ -86,8 +86,10 @@ impl MultiProvider { let provider_init_start = std::time::Instant::now(); let cfg = crate::config::config(); let provider_state = ProviderState::from_parts(cfg, &auth_status); + let initial_provider = Self::initial_provider_from_env(); let mut default_named_provider_profile: Option = None; - if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() + if initial_provider.is_none() + && std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() && std::env::var_os("JCODE_NAMED_PROVIDER_PROFILE").is_none() && let Some(pref) = provider_state.default_provider_key() { @@ -144,7 +146,21 @@ impl MultiProvider { }; let anthropic = if has_claude_creds && !use_claude_cli { - external::instantiate_expected_external_provider(external::ANTHROPIC_RUNTIME) + let provider = + external::instantiate_expected_external_provider(external::ANTHROPIC_RUNTIME); + let active_profile_is_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .and_then(|name| cfg.providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + if active_profile_is_anthropic { + crate::provider_catalog::clear_anthropic_profile_env(); + } + provider } else { None }; @@ -198,7 +214,17 @@ impl MultiProvider { None }; - let openrouter = if has_openrouter_creds { + let active_named_profile_is_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .or_else(|| default_named_provider_profile.clone()) + .and_then(|name| cfg.providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + let openrouter = if has_openrouter_creds && !active_named_profile_is_anthropic { let named_profile = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") .ok() .or_else(|| default_named_provider_profile.clone()); @@ -247,7 +273,6 @@ impl MultiProvider { ); } - let initial_provider = Self::initial_provider_from_env(); if let Some(initial) = initial_provider { active = initial; let is_configured = availability.is_configured(initial); @@ -317,7 +342,12 @@ impl MultiProvider { post_auth_refreshes_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), }; - if let Some(model) = provider_state.default_model() { + // An explicit CLI/environment provider selection owns startup routing. + // Applying the configured default model here can reactivate its configured + // provider/profile before the caller pins a dual-auth credential mode. + if result.initial_provider.is_none() + && let Some(model) = provider_state.default_model() + { if let Err(e) = result.set_config_default_model(model, provider_state.default_provider_key()) { diff --git a/crates/jcode-base/src/provider_catalog.rs b/crates/jcode-base/src/provider_catalog.rs index 785874022d..ec0efa52cc 100644 --- a/crates/jcode-base/src/provider_catalog.rs +++ b/crates/jcode-base/src/provider_catalog.rs @@ -45,6 +45,16 @@ pub fn resolve_openai_compatible_profile_with_api_key_hint( requires_api_key: profile.requires_api_key, }; + // MiniMax historically used OPENAI_API_KEY because it exposes an + // OpenAI-compatible API. Prefer the dedicated key, but keep existing + // installations working when only the legacy binding is configured. + if profile.id == MINIMAX_PROFILE.id + && load_env_value_from_env_or_config(profile.api_key_env, profile.env_file).is_none() + && load_env_value_from_env_or_config("OPENAI_API_KEY", profile.env_file).is_some() + { + resolved.api_key_env = "OPENAI_API_KEY".to_string(); + } + apply_profile_key_based_endpoint_overrides(profile, &mut resolved, api_key_hint); if profile.id != OPENAI_COMPAT_PROFILE.id { @@ -222,7 +232,7 @@ fn apply_profile_key_based_endpoint_overrides( .map(str::trim) .filter(|key| !key.is_empty()) .map(ToString::to_string) - .or_else(|| load_env_value_from_env_or_config(profile.api_key_env, profile.env_file)); + .or_else(|| load_env_value_from_env_or_config(&resolved.api_key_env, &resolved.env_file)); if key .as_deref() @@ -648,6 +658,20 @@ fn inline_key_env_name(profile_name: &str) -> String { format!("JCODE_PROVIDER_{}_API_KEY", suffix) } +pub fn clear_anthropic_profile_env() { + for key in [ + "JCODE_ANTHROPIC_API_BASE", + "JCODE_ANTHROPIC_API_KEY_NAME", + "JCODE_ANTHROPIC_ENV_FILE", + "JCODE_ANTHROPIC_AUTH", + "JCODE_ANTHROPIC_AUTH_HEADER", + "JCODE_ANTHROPIC_HEADERS", + "JCODE_ANTHROPIC_MODEL", + ] { + crate::env::remove_var(key); + } +} + pub fn apply_named_provider_profile_env(profile_name: &str) -> anyhow::Result { let config = crate::config::Config::load_strict()?; apply_named_provider_profile_env_from_config(profile_name, &config) @@ -673,6 +697,106 @@ pub fn apply_named_provider_profile_env_from_config( ) })?; + if matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) { + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", profile_name); + crate::env::set_var("JCODE_ANTHROPIC_API_BASE", &api_base); + crate::env::set_var("JCODE_RUNTIME_PROVIDER", "anthropic-api"); + if let Some(model) = profile + .default_model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + crate::env::set_var("JCODE_ANTHROPIC_MODEL", model); + } + + let key_env = profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + profile + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|key| { + let env_name = inline_key_env_name(profile_name); + crate::env::set_var(&env_name, key); + crate::logging::warn(&format!( + "Provider profile '{}' stores an inline API key in config.toml. Prefer api_key_env to avoid accidental leaks.", + profile_name + )); + env_name + }) + }); + if let Some(key_env) = key_env { + if !is_safe_env_key_name(&key_env) { + anyhow::bail!( + "Provider profile '{}' has invalid api_key_env '{}'.", + profile_name, + key_env + ); + } + crate::env::set_var("JCODE_ANTHROPIC_API_KEY_NAME", key_env); + } else { + crate::env::remove_var("JCODE_ANTHROPIC_API_KEY_NAME"); + } + if let Some(env_file) = profile + .env_file + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !is_safe_env_file_name(env_file) { + anyhow::bail!( + "Provider profile '{}' has invalid env_file '{}'.", + profile_name, + env_file + ); + } + crate::env::set_var("JCODE_ANTHROPIC_ENV_FILE", env_file); + } else { + crate::env::remove_var("JCODE_ANTHROPIC_ENV_FILE"); + } + + match profile.auth { + crate::config::NamedProviderAuth::Bearer => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "bearer"); + crate::env::remove_var("JCODE_ANTHROPIC_AUTH_HEADER"); + } + crate::config::NamedProviderAuth::Header => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "header"); + crate::env::set_var( + "JCODE_ANTHROPIC_AUTH_HEADER", + profile.auth_header.as_deref().unwrap_or("x-api-key"), + ); + } + crate::config::NamedProviderAuth::None => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "none"); + crate::env::remove_var("JCODE_ANTHROPIC_AUTH_HEADER"); + } + } + if profile.headers.is_empty() { + crate::env::remove_var("JCODE_ANTHROPIC_HEADERS"); + } else { + crate::env::set_var( + "JCODE_ANTHROPIC_HEADERS", + serde_json::to_string(&profile.headers).map_err(|err| { + anyhow::anyhow!("failed to serialize Anthropic-compatible headers: {err}") + })?, + ); + } + return Ok(profile_name.to_string()); + } + + clear_anthropic_profile_env(); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index 2874bd2751..bff97d327b 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -92,7 +92,7 @@ fn auth_issue_profile_metadata_matches_direct_provider_endpoints() { assert_eq!(DEEPSEEK_PROFILE.default_model, Some("deepseek-v4-flash")); assert_eq!(DEEPSEEK_PROFILE.setup_url, "https://api-docs.deepseek.com/"); assert_eq!(MINIMAX_PROFILE.api_base, "https://api.minimax.io/v1"); - assert_eq!(MINIMAX_PROFILE.api_key_env, "OPENAI_API_KEY"); + assert_eq!(MINIMAX_PROFILE.api_key_env, "MINIMAX_API_KEY"); assert_eq!( ALIBABA_CODING_PLAN_PROFILE.api_base, "https://coding-intl.dashscope.aliyuncs.com/v1" @@ -196,7 +196,10 @@ fn resolved_named_profile_skips_non_chat_models_when_picking_newest_default() { #[test] fn minimax_token_plan_keys_resolve_to_china_endpoint_without_changing_international_default() { let _lock = crate::storage::lock_test_env(); - let _guard = EnvGuard::save(&["OPENAI_API_KEY"]); + let _guard = EnvGuard::save(&["JCODE_HOME", "MINIMAX_API_KEY", "OPENAI_API_KEY"]); + let home = tempfile::tempdir().expect("temporary JCODE_HOME"); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::remove_var("MINIMAX_API_KEY"); crate::env::remove_var("OPENAI_API_KEY"); let international = resolve_openai_compatible_profile(MINIMAX_PROFILE); @@ -212,6 +215,11 @@ fn minimax_token_plan_keys_resolve_to_china_endpoint_without_changing_internatio ); assert_eq!(china.api_base, MINIMAX_CHINA_API_BASE); assert_eq!(china.setup_url, MINIMAX_CHINA_SETUP_URL); + + crate::env::set_var("OPENAI_API_KEY", "sk-cp-legacy-token"); + let legacy = resolve_openai_compatible_profile(MINIMAX_PROFILE); + assert_eq!(legacy.api_key_env, "OPENAI_API_KEY"); + assert_eq!(legacy.api_base, MINIMAX_CHINA_API_BASE); } #[test] @@ -536,8 +544,18 @@ fn named_provider_config_accepts_openai_compatible_spelling() { } #[test] -fn named_provider_profile_reports_malformed_config_instead_of_unknown_profile() { +fn named_anthropic_compatible_profile_maps_endpoint_auth_headers_and_model() { let _lock = crate::storage::lock_test_env(); + let _guard = EnvGuard::save(&[ + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_ANTHROPIC_API_BASE", + "JCODE_ANTHROPIC_API_KEY_NAME", + "JCODE_ANTHROPIC_AUTH", + "JCODE_ANTHROPIC_AUTH_HEADER", + "JCODE_ANTHROPIC_HEADERS", + "JCODE_ANTHROPIC_MODEL", + "JCODE_RUNTIME_PROVIDER", + ]); let previous_home = std::env::var_os("JCODE_HOME"); let temp = tempfile::TempDir::new().expect("tempdir"); crate::env::set_var("JCODE_HOME", temp.path()); @@ -549,32 +567,53 @@ fn named_provider_profile_reports_malformed_config_instead_of_unknown_profile() std::fs::write( &config_path, r#" - [providers.antigravity] + [providers.corporate-claude] type = "anthropic-compatible" - base_url = "http://192.168.1.202:8080" - api_key_env = "ANTIGRAVITY_API_KEY" - default_model = "gemini-3.1-pro-low" + base_url = "https://gateway.example.com/anthropic/v1/" + auth = "bearer" + api_key_env = "CORPORATE_CLAUDE_TOKEN" + default_model = "claude-custom" - [[providers.antigravity.models]] - id = "gemini-3.1-pro-low" + [providers.corporate-claude.headers] + x-tenant-id = "tenant-42" + + [[providers.corporate-claude.models]] + id = "claude-custom" context_window = 128000 "#, ) .expect("write config"); - let err = apply_named_provider_profile_env("antigravity").expect_err("malformed config"); - let message = err.to_string(); - assert!( - message.contains("Failed to parse config file"), - "unexpected error: {message}" + apply_named_provider_profile_env("corporate-claude").expect("apply Anthropic profile"); + assert_eq!( + std::env::var("JCODE_ANTHROPIC_API_BASE").ok().as_deref(), + Some("https://gateway.example.com/anthropic/v1") ); - assert!( - message.contains("anthropic-compatible"), - "unexpected error: {message}" + assert_eq!( + std::env::var("JCODE_ANTHROPIC_API_KEY_NAME") + .ok() + .as_deref(), + Some("CORPORATE_CLAUDE_TOKEN") ); - assert!( - !message.contains("Unknown provider profile"), - "unexpected error: {message}" + assert_eq!( + std::env::var("JCODE_ANTHROPIC_AUTH").ok().as_deref(), + Some("bearer") + ); + assert_eq!( + std::env::var("JCODE_ANTHROPIC_MODEL").ok().as_deref(), + Some("claude-custom") + ); + let headers: std::collections::BTreeMap = serde_json::from_str( + &std::env::var("JCODE_ANTHROPIC_HEADERS").expect("custom headers env"), + ) + .expect("headers JSON"); + assert_eq!( + headers.get("x-tenant-id").map(String::as_str), + Some("tenant-42") + ); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + Some("anthropic-api") ); if let Some(previous_home) = previous_home { @@ -1110,10 +1149,10 @@ fn open_weight_family_context_limits_match_published_windows() { } #[test] -fn minimax_default_provider_applies_openai_api_key_env_not_openrouter() { +fn minimax_default_provider_applies_minimax_api_key_env_not_openrouter() { // Regression for #407: `default_provider = "minimax"` (the built-in MiniMax // profile) must resolve credentials from the profile's documented - // OPENAI_API_KEY / minimax.env, not the generic OPENROUTER_API_KEY / + // MINIMAX_API_KEY / minimax.env, not the generic OPENROUTER_API_KEY / // openrouter.env. The earlier bug surfaced as // "OPENROUTER_API_KEY not found ..." when applying the configured // default_model. @@ -1150,8 +1189,8 @@ fn minimax_default_provider_applies_openai_api_key_env_not_openrouter() { std::env::var("JCODE_OPENROUTER_API_KEY_NAME") .ok() .as_deref(), - Some("OPENAI_API_KEY"), - "MiniMax profile must use OPENAI_API_KEY, not OPENROUTER_API_KEY" + Some("MINIMAX_API_KEY"), + "MiniMax profile must use MINIMAX_API_KEY, not OPENROUTER_API_KEY" ); assert_eq!( std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(), diff --git a/crates/jcode-base/src/skill.rs b/crates/jcode-base/src/skill.rs index 1e6f9321f0..bd43a345f6 100644 --- a/crates/jcode-base/src/skill.rs +++ b/crates/jcode-base/src/skill.rs @@ -477,6 +477,17 @@ impl SkillRegistry { /// Parse a SKILL.md file fn parse_skill(path: &Path) -> Result { + Self::parse_skill_inner(path).map_err(|error| { + crate::logging::warn(&format!( + "Failed to parse skill file '{}': {}", + path.display(), + error + )); + error + }) + } + + fn parse_skill_inner(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; // Parse YAML frontmatter @@ -962,6 +973,14 @@ mod tests { .expect("write skill"); } + fn write_skill_file(skills_dir: &Path, name: &str, content: &str) -> PathBuf { + let dir = skills_dir.join(name); + std::fs::create_dir_all(&dir).expect("create skill dir"); + let path = dir.join("SKILL.md"); + std::fs::write(&path, content).expect("write skill"); + path + } + #[test] fn parse_invocation_supports_a_trailing_prompt() { assert_eq!( @@ -1101,6 +1120,37 @@ mod tests { assert!(skill.path.starts_with(temp.path())); } + #[test] + fn malformed_skill_does_not_block_directory_loaders() { + let temp = tempfile::tempdir().expect("tempdir"); + let skills_dir = temp.path().join("skills"); + write_skill_file( + &skills_dir, + "valid", + "---\nname: valid\ndescription: Valid skill\n---\n\nUse valid.\n", + ); + write_skill_file( + &skills_dir, + "malformed", + "---\nname: malformed\ndescription: Triggers: invalid yaml\n---\n\nBroken.\n", + ); + + let mut registry = SkillRegistry::default(); + registry + .load_from_dir(&skills_dir) + .expect("load skills while skipping malformed file"); + assert!(registry.contains("valid")); + assert!(!registry.contains("malformed")); + + let mut counted_registry = SkillRegistry::default(); + let count = counted_registry + .load_from_dir_count(&skills_dir) + .expect("count skills while skipping malformed file"); + assert_eq!(count, 1); + assert!(counted_registry.contains("valid")); + assert!(!counted_registry.contains("malformed")); + } + #[test] fn project_overlay_is_session_scoped_and_composes_over_globals() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/jcode-base/src/sponsors/provenance.rs b/crates/jcode-base/src/sponsors/provenance.rs index 5e97b82b20..9f05795366 100644 --- a/crates/jcode-base/src/sponsors/provenance.rs +++ b/crates/jcode-base/src/sponsors/provenance.rs @@ -8,7 +8,7 @@ //! never user identity. Aggregates are flushed at most once per hour to //! `POST {sponsors.endpoint}/usage` and only while `sponsors.enabled` is //! true. The policy is disclosed at -//! and in the connect-time +//! and in the connect-time //! UI line. //! //! Everything here is process-local and best-effort: metering failures diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index fa8a29036e..9c13b51ac9 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -6,7 +6,14 @@ use std::path::PathBuf; /// Generic mid-task reassessment prompt. The elapsed-time policy that triggers /// it is intentionally private so the model reassesses from evidence rather /// than targeting a timer or evaluator boundary. -pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the original request and reconsider the current todo plan and every goal assessment using the evidence gathered during the work so far. Correct anything stale or overstated, including intent understanding, feedback-loop relevance and coverage, autonomy, difficulty, delivery, confidence, iteration maturity, and stopping evidence. Do not reply conversationally or wait for the user. Continue the work after saving an honest updated assessment."; +pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[auto] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; +const PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; +const PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the original request and reconsider the current todo plan and every goal assessment using the evidence gathered during the work so far. Correct anything stale or overstated, including intent understanding, feedback-loop relevance and coverage, autonomy, difficulty, delivery, confidence, iteration maturity, and stopping evidence. Do not reply conversationally or wait for the user. Continue the work after saving an honest updated assessment."; + +/// Static quality-gate instructions should stay short enough to be read as a +/// nudge, not a replacement system prompt. Dynamic todo/goal details are added +/// separately and have their own list-size limits. +pub const TODO_QUALITY_GATE_MAX_APPROX_TOKENS: usize = 64; /// Private policy. Do not include this duration in model-facing schemas or /// continuation text. @@ -167,12 +174,21 @@ pub fn delivery_state_passes(goal: &TodoGoal) -> bool { const LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE: &str = "Your alignment score is not high enough. Build a requirement inventory from the user's request, including outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through. Revise the plan and its stated user intention to represent every material item. Then map each item to an explicit observation or check in a feedback loop. Generic instructions to run tests, verify, or review count only for requirements those checks actually enforce; add separate checks for non-testable requirements. Reassess the weaker link before continuing the task."; /// Model-facing continuation for the private intent-understanding check. -/// Deliberately small: think more about the user's intent, do not ask the user. -pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your understanding of the user's intent is not high enough. Re-read the request and think harder about what the user actually wants and left implicit, using the conversation and codebase as evidence. Form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check the plan represents every material item. Do not ask the user; resolve the ambiguity yourself, then update the plan's user intention and understands_user_intent."; +pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "[auto] Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; +const PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; + +/// Previous verbose wording, retained so persisted sessions still classify it +/// as a hidden quality-gate message after the concise rewrite. +const PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your understanding of the user's intent is not high enough. Re-read the request and think harder about what the user actually wants and left implicit, using the conversation and codebase as evidence. Form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check the plan represents every material item. Do not ask the user; resolve the ambiguity yourself, then update the plan's user intention and understands_user_intent."; +const PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = + "Understand the user's intent better. Try to avoid asking the user."; /// Model-facing continuation for the private closed-feedback-loop check. Names /// the assessment category without disclosing the score or threshold. -pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Your feedback loop is not closed. First, improve the goal's objective and name the observation that reports back on each requirement, so progress can be measured across iterations. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate explicit checks for non-testable requirements. Then call the todo tool again with the revised goal before continuing the task. The goal is to create a strong feedback loop you can iterate against."; +pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "[auto] Your feedback loop isn't good enough. Think about what feedback loops you need. Make sure the todo is up to date."; +const PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the todo, then continue the work."; +const PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the goal, then continue the work."; +const PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Your feedback loop is not closed. First, improve the goal's objective and name the observation that reports back on each requirement, so progress can be measured across iterations. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate explicit checks for non-testable requirements. Then call the todo tool again with the revised goal before continuing the task. The goal is to create a strong feedback loop you can iterate against."; /// Pre-rename ("hill-climbability") version of the closed-feedback-loop /// continuation. Kept only so persisted transcripts still classify it as a @@ -181,7 +197,9 @@ const LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE: &str = "Your hill-clim /// Model-facing continuation for the private end-to-end ownership check. It /// asks for more work without revealing that an evaluator triggered it. -pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = + "[auto] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; /// Build an ownership continuation that directs work toward each affected goal /// without exposing fields, scores, thresholds, or pass/fail language. @@ -278,11 +296,20 @@ pub fn build_todo_ownership_continuation_message(todos: &[TodoItem], goals: &[To const LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated todo completion gate - not a user message] Your end-to-end ownership is not high enough to finish this goal."; /// Model-facing continuation for private completion-confidence checks. -pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[auto] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; -/// Model-facing continuation requesting an independent recheck without saying -/// why the private evaluator selected it. -pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Independently recheck the work below. Keep the todo up to date; do not reply or wait for the user."; +/// Model-facing continuation identifying the items whose confidence jumped and +/// asking for one explicit double-check without exposing scores or thresholds. +pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[auto] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; + +/// Final synthetic turn after every todo completion check has passed. Gate +/// continuations tell the model not to reply, so without this handoff a cycle +/// can end on a bare tool call or an internal-looking validation response. +pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[auto] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; +const PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; +const PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] All work and quality checks are complete. Give the user the final response now. Default to fewer than 5 lines unless the user's request requires more detail. Summarize the outcome clearly; do not call the todo tool or perform more work."; /// A completed todo is considered spike-finished when its final recorded /// confidence step jumps this many levels or more (e.g. speculative straight @@ -333,7 +360,9 @@ pub struct GateObservation { /// Deliberately framed as "double-check these" rather than as a refusal: by /// turn end the work is done, so the useful action is verification, not /// replanning. Names categories without disclosing scores or thresholds. -pub const TODO_GATE_DIGEST_PREFIX: &str = "[automated todo quality review - not a user message] Before you treat this turn as finished, double-check the weak points it surfaced. Do not reply conversationally or wait for the user."; +pub const TODO_GATE_DIGEST_PREFIX: &str = "[auto] Before you treat this turn as finished, double-check the weak points it surfaced. Keep the todo up to date. Do not reply or wait for the user."; +const PRE_COMPACT_TODO_GATE_DIGEST_PREFIX: &str = "Before you treat this turn as finished, double-check the weak points it surfaced. Keep the todo up to date. Do not reply or wait for the user."; +const LABELED_TODO_GATE_DIGEST_PREFIX: &str = "[automated todo quality review - not a user message] Before you treat this turn as finished, double-check the weak points it surfaced. Do not reply conversationally or wait for the user."; /// Whether the state behind this observation has since reached its bar. /// @@ -516,6 +545,9 @@ const LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "Your completion confidence is missing or not high enough."; const LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "Your completion confidence rose too sharply to count as independently validated."; +/// Wording used immediately before the evidence-backed framing. Persisted +/// sessions can still contain it and must keep treating it as a hidden gate. +const PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Independently recheck the work below. Keep the todo up to date; do not reply or wait for the user."; fn normalized_group(group: Option<&str>) -> Option { group @@ -708,11 +740,11 @@ pub fn build_todo_completion_continuation_message(todos: &[TodoItem]) -> String } /// Spike-gate continuation naming the completed todos whose confidence jumped, -/// so the recheck targets those items. +/// so the double-check targets those items. pub fn build_todo_confidence_spike_continuation_message(todos: &[TodoItem]) -> String { let spiked = spike_completed_todos(todos); let mut message = String::from(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE); - append_named_todos(&mut message, "Recheck:", &spiked); + append_named_todos(&mut message, "Confidence jumped:", &spiked); message } @@ -731,18 +763,35 @@ pub fn is_auto_poke_message(message: &str) -> bool { && trimmed.contains(" incomplete todo") && trimmed.ends_with("update the todo tool.")) || trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) || trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(PRE_COMPACT_TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(LABELED_TODO_GATE_DIGEST_PREFIX) || trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) } /// Short, user-facing stand-in for a synthetic auto-poke/gate continuation. @@ -757,31 +806,54 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { return None; } if trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) { - return Some("๐Ÿ” Double-checking a confidence jump for you..."); + return Some("๐Ÿ” Double-checking confidence jumps..."); + } + if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + { + return Some("โœ… Preparing the final response..."); } if trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) { return Some("๐Ÿ” Double-checking confidence for you..."); } - if trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) { + if trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(PRE_COMPACT_TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(LABELED_TODO_GATE_DIGEST_PREFIX) + { return Some("๐Ÿ” Reviewing the weak points of this turn for you..."); } - if trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) { + if trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) + { return Some("๐Ÿ” Rechecking the plan and assessments after extended work..."); } if trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) { return Some("๐Ÿ” Checking the delivery state of the finished work..."); } - if trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) { + if trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + { return Some("๐Ÿ” Re-checking the request was understood..."); } if trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) { @@ -1343,7 +1415,15 @@ mod tests { assert!(is_auto_poke_message( TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE )); + assert!(is_auto_poke_message( + TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE + )); + assert_eq!( + auto_poke_display_summary(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), + Some("โœ… Preparing the final response...") + ); assert!(is_auto_poke_message(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX)); + assert!(is_auto_poke_message(LABELED_TODO_GATE_DIGEST_PREFIX)); } #[test] @@ -1351,17 +1431,17 @@ mod tests { for (message, category) in [ ( TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, - "feedback loop is not closed", + "feedback loop isn't good enough", ), ( TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, - "understanding of the user's intent", + "understand the user's intent better", ), (TODO_OWNERSHIP_CONTINUATION_MESSAGE, "continue the work"), (TODO_COMPLETION_CONTINUATION_MESSAGE, "more validation"), ( TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, - "independently recheck", + "confidence jump", ), ] { let lower = message.to_ascii_lowercase(); @@ -1381,24 +1461,11 @@ mod tests { } } - assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("strong feedback loop")); - assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("First, improve")); - assert!( - TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("call the todo tool again") - ); - assert!( - TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("before continuing the task") - ); - // Deliberately terse: think harder about intent, never block on the user. - assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("think harder")); - assert!( - TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("what the user actually wants") - ); - assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Do not ask the user")); + assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("Think about")); + assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Try to avoid asking")); for message in [ TODO_OWNERSHIP_CONTINUATION_MESSAGE, TODO_COMPLETION_CONTINUATION_MESSAGE, - TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, ] { let lower = message.to_ascii_lowercase(); for evaluator_term in ["gate", "flagged", "failed", "threshold", "confidence"] { @@ -1408,6 +1475,72 @@ mod tests { ); } } + let spike = TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE.to_ascii_lowercase(); + for evaluator_term in ["gate", "flagged", "failed", "threshold", "score"] { + assert!( + !spike.contains(evaluator_term), + "disclosed {evaluator_term}" + ); + } + } + + #[test] + fn static_quality_gate_messages_stay_within_token_budget() { + for (name, message) in [ + ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), + ( + "intent understanding", + TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, + ), + ( + "closed feedback loop", + TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, + ), + ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), + ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), + ( + "confidence jump", + TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, + ), + ("final response", TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), + ("turn digest", TODO_GATE_DIGEST_PREFIX), + ] { + assert!( + message.starts_with("[auto] "), + "{name} quality gate does not use the compact automation prefix: {message}" + ); + let tokens = jcode_core::util::estimate_tokens(message); + assert!( + tokens <= TODO_QUALITY_GATE_MAX_APPROX_TOKENS, + "{name} quality-gate message is about {tokens} tokens; budget is {TODO_QUALITY_GATE_MAX_APPROX_TOKENS}: {message}" + ); + } + } + + #[test] + fn working_quality_gates_remind_the_model_to_update_todos() { + for (name, message) in [ + ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), + ( + "intent understanding", + TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, + ), + ( + "closed feedback loop", + TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, + ), + ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), + ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), + ( + "confidence jump", + TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, + ), + ] { + assert!( + message.to_ascii_lowercase().contains("todo"), + "{name} quality gate does not remind the model to update the todo: {message}" + ); + } } /// The model must be told which items it should recheck, otherwise the diff --git a/crates/jcode-config-types/src/display.rs b/crates/jcode-config-types/src/display.rs index f0dce66cf9..0440dc0013 100644 --- a/crates/jcode-config-types/src/display.rs +++ b/crates/jcode-config-types/src/display.rs @@ -79,6 +79,10 @@ pub struct DisplayConfig { /// just the one-line summary (default: false) #[serde(default)] pub show_agentgrep_output: bool, + /// Show up to the last three non-empty bash output lines beneath the tool + /// summary (default: false). + #[serde(default)] + pub show_bash_output: bool, /// Show the dimmed technical detail (command, path, args) after the /// model-provided intent on tool rows (default: false). When off, rows /// that have an intent show only the intent; rows without an intent @@ -115,6 +119,8 @@ pub struct DisplayConfig { /// sessions (issue #674). #[serde(default = "default_true")] pub external_sessions: bool, + /// Usage percentage wording: "left" (default) or "used". + pub usage_display: String, /// When to show the overscroll status line below the input /// (off/on/overscroll, default: overscroll). "overscroll" is the elastic /// reveal when scrolling past the bottom, "on" keeps it always visible. @@ -150,6 +156,7 @@ impl Default for DisplayConfig { compact_notifications: false, copy_badge_alt_label: String::new(), show_agentgrep_output: false, + show_bash_output: false, tool_call_details: false, native_scrollbars: NativeScrollbarConfig::default(), keybinding_hints: true, @@ -157,6 +164,7 @@ impl Default for DisplayConfig { colors: std::collections::BTreeMap::new(), active_sessions_manager: false, external_sessions: true, + usage_display: "left".to_string(), overscroll_status: OverscrollStatusMode::default(), } } @@ -202,6 +210,10 @@ impl DisplayConfig { pub fn reasoning_enabled(&self) -> bool { !matches!(self.reasoning_display(), ReasoningDisplayMode::Off) } + + pub fn usage_display_used(&self) -> bool { + self.usage_display.eq_ignore_ascii_case("used") + } } #[cfg(test)] @@ -219,4 +231,13 @@ mod tests { serde_json::from_str(r#"{"pin_todos":false}"#).expect("display config"); assert!(!disabled.pin_todos); } + + #[test] + fn usage_percentage_wording_defaults_to_left_and_accepts_used() { + assert_eq!(DisplayConfig::default().usage_display, "left"); + + let used: DisplayConfig = + serde_json::from_str(r#"{"usage_display":"used"}"#).expect("display config"); + assert!(used.usage_display_used()); + } } diff --git a/crates/jcode-config-types/src/keybindings.rs b/crates/jcode-config-types/src/keybindings.rs index 7141d397ad..46e3b4b927 100644 --- a/crates/jcode-config-types/src/keybindings.rs +++ b/crates/jcode-config-types/src/keybindings.rs @@ -276,6 +276,12 @@ pub const KEYBINDING_DEFAULTS: &[KeybindingDefault] = &[ macos: PlatformDefault::dev("ctrl+g"), other: PlatformDefault::dev("ctrl+g"), }, + KeybindingDefault { + id: "auto_poke_toggle", + description: "Toggle auto-poke", + macos: PlatformDefault::dev("ctrl+p"), + other: PlatformDefault::dev("ctrl+p"), + }, KeybindingDefault { id: "scroll_up_fallback", description: "Optional fallback scroll-up binding", diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 9758785f17..2073ca0590 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -405,6 +405,8 @@ pub enum NamedProviderType { #[serde(alias = "openai-compatible", alias = "openai_compatible")] #[default] OpenAiCompatible, + #[serde(alias = "anthropic-compatible", alias = "anthropic_compatible")] + AnthropicCompatible, OpenRouter, } @@ -443,6 +445,9 @@ pub struct NamedProviderConfig { pub api: Option, pub auth: NamedProviderAuth, pub auth_header: Option, + /// Extra HTTP headers sent with every request to this provider. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub headers: std::collections::BTreeMap, pub api_key_env: Option, pub api_key: Option, pub env_file: Option, @@ -485,6 +490,7 @@ impl Default for NamedProviderConfig { api: None, auth: NamedProviderAuth::Bearer, auth_header: None, + headers: std::collections::BTreeMap::new(), api_key_env: None, api_key: None, env_file: None, @@ -946,6 +952,8 @@ pub struct KeybindingsConfig { pub scroll_prompt_down: String, /// Scroll bookmark toggle key (default: "ctrl+g") pub scroll_bookmark: String, + /// Toggle auto-poke (default: "ctrl+p"). Set "" to disable. + pub auto_poke_toggle: String, /// Scroll up fallback key (default: unset; Cmd+K moves up by prompt on macOS) pub scroll_up_fallback: String, /// Scroll down fallback key (default: unset; Cmd+J moves down by prompt on macOS) @@ -1012,6 +1020,7 @@ impl Default for KeybindingsConfig { scroll_prompt_up: get("scroll_prompt_up", "ctrl+["), scroll_prompt_down: get("scroll_prompt_down", "ctrl+]"), scroll_bookmark: get("scroll_bookmark", "ctrl+g"), + auto_poke_toggle: get("auto_poke_toggle", "ctrl+p"), scroll_up_fallback: get("scroll_up_fallback", ""), scroll_down_fallback: get("scroll_down_fallback", ""), workspace_left: get("workspace_left", "alt+h"), diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index af513c8502..9e09e5fa52 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -5,6 +5,7 @@ use crate::background_progress::parse_background_notification; use jcode_harness_api::{ ApiEvent, ErrorCode, HistoryMessage, ModelRouteInfo, ServerFrame, SessionInfo, TextMatch, }; +use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Write}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -95,6 +96,8 @@ pub struct BridgeState { pending_no_reply_message_id: Option<(u64, u64)>, /// Legacy id of an in-flight `create/attach` subscribe. pending_attach_id: Option<(u64, u64)>, + /// Effective working directory requested for the in-flight attach. + pending_attach_working_dir: Option, /// Legacy id of the unsolicited model-catalog probe sent after attach. Its /// reply becomes a `model_info` event rather than a request reply, so it is /// tracked apart from `pending_simple`. @@ -121,6 +124,9 @@ pub struct BridgeState { /// a picker can mark the active entry. current_model: Option, current_provider: Option, + /// Reasoning effort last reported by the daemon, so identity events can + /// carry it without a round trip. + current_effort: Option, available_routes: Vec, } @@ -130,6 +136,35 @@ struct ArchiveState { archive_after_days: Option, } +/// The small, canonical subset of a persisted `Session` needed by list and +/// attach responses. Serde skips the heavyweight transcript fields without +/// materializing them. +#[derive(Debug, Default, Deserialize)] +struct PersistedSessionMetadata { + #[serde(default)] + working_dir: Option, + /// Generated or imported title. + #[serde(default)] + title: Option, + /// User-provided rename, which is what `Session::display_title` prefers. + #[serde(default)] + custom_title: Option, +} + +impl PersistedSessionMetadata { + fn display_title(&self) -> Option { + self.custom_title + .as_deref() + .and_then(Self::normalized_title) + .or_else(|| self.title.as_deref().and_then(Self::normalized_title)) + } + + fn normalized_title(title: &str) -> Option { + let title = title.trim(); + (!title.is_empty()).then(|| title.to_string()) + } +} + #[derive(Debug, Clone, PartialEq)] enum SimpleKind { Ping, @@ -288,6 +323,7 @@ impl BridgeState { .ok() .map(|d| d.display().to_string()) }); + self.pending_attach_working_dir = working_dir.clone(); let mut subscribe = json!({ "type": "subscribe", "id": id, @@ -388,6 +424,7 @@ impl BridgeState { ApiEvent::History { session_id: session_id.to_string(), messages: Self::stored_tail(session_id, limit), + images: Vec::new(), }, ))] } @@ -415,9 +452,21 @@ impl BridgeState { if let Some(attached) = self.session_id.clone() { ids.insert(attached); } + // Titles are deliberately not cached. A rename is persisted + // before `SessionRenamed` is broadcast, and every list call + // should reflect that newest canonical value even on another + // API connection. + let metadata: BTreeMap = ids + .iter() + .filter_map(|id| { + Self::resolve_session_metadata(id).map(|metadata| (id.clone(), metadata)) + }) + .collect(); for id in &ids { if !self.session_dirs.contains_key(id) - && let Some(dir) = Self::resolve_working_dir(id) + && let Some(dir) = metadata + .get(id) + .and_then(|metadata| metadata.working_dir.clone()) { self.session_dirs.insert(id.clone(), dir); } @@ -449,7 +498,9 @@ impl BridgeState { }) .map(|session_id| SessionInfo { working_dir: self.session_dirs.get(&session_id).cloned(), - title: None, + title: metadata + .get(&session_id) + .and_then(PersistedSessionMetadata::display_title), status: if self.session_id.as_ref() == Some(&session_id) { "attached".into() } else { @@ -496,6 +547,7 @@ impl BridgeState { session_id: self.session_id.clone().unwrap_or_default(), provider: self.current_provider.clone(), model: self.current_model.clone(), + reasoning_effort: self.current_effort.clone(), routes: self.available_routes.clone(), }, ))], @@ -743,14 +795,24 @@ impl BridgeState { && state_id == id { self.pending_attach_id = None; + let metadata = Self::resolve_session_metadata(&session_id); + let working_dir = metadata + .as_ref() + .and_then(|metadata| metadata.working_dir.clone()) + .or_else(|| self.pending_attach_working_dir.take()); + if let Some(dir) = &working_dir { + self.session_dirs.insert(session_id.clone(), dir.clone()); + } return vec![ServerFrame::reply( api_id, ApiEvent::Attached { session: SessionInfo { transcript_bytes: Self::transcript_bytes(&session_id), session_id, - working_dir: None, - title: None, + working_dir, + title: metadata + .as_ref() + .and_then(PersistedSessionMetadata::display_title), status: if event["is_processing"].as_bool().unwrap_or(false) { "processing".into() } else { @@ -802,6 +864,13 @@ impl BridgeState { output: event["output"].as_str().unwrap_or("").to_string(), error: event["error"].as_str().map(str::to_string), })], + "side_pane_images" => vec![ServerFrame::event(ApiEvent::SidePaneImages { + session_id: event["session_id"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| session(self)), + images: serde_json::from_value(event["images"].clone()).unwrap_or_default(), + })], "tokens" => vec![ServerFrame::event(ApiEvent::TokenUsage { session_id: session(self), input: event["input"].as_u64().unwrap_or(0), @@ -878,11 +947,13 @@ impl BridgeState { .collect() }) .unwrap_or_default(); + let images = serde_json::from_value(event["images"].clone()).unwrap_or_default(); vec![ServerFrame::reply( api_id, ApiEvent::History { session_id: session(self), messages, + images, }, )] } @@ -915,6 +986,7 @@ impl BridgeState { session_id: session(self), provider: event["provider_name"].as_str().map(str::to_string), model: event["model"].as_str().map(str::to_string), + reasoning_effort: self.current_effort.clone(), }; // Both a reply and a broadcast: the caller needs its request // resolved, and every other client watching the session needs @@ -929,8 +1001,28 @@ impl BridgeState { } "reasoning_effort_changed" => { let id = event["id"].as_u64().unwrap_or(0); + // Remember the new effort even when the change was requested by + // another client, so later identity events stay truthful. + let changed = event["error"].as_str().is_none() + && event["effort"].as_str().is_some_and(|effort| { + let effort = Some(effort.to_string()); + let moved = self.current_effort != effort; + self.current_effort = effort; + moved + }); + // A successful change is also broadcast as identity, mirroring + // model_changed: every attached client needs to know the + // effort moved under it, not only the one that asked. + let info = changed.then(|| { + ServerFrame::event(ApiEvent::ModelInfo { + session_id: session(self), + provider: self.current_provider.clone(), + model: self.current_model.clone(), + reasoning_effort: self.current_effort.clone(), + }) + }); let Some(api_id) = self.take_simple(id, SimpleKind::ReasoningEffort) else { - return vec![]; + return info.into_iter().collect(); }; match event["error"].as_str() { Some(error) => vec![ServerFrame::reply( @@ -940,7 +1032,9 @@ impl BridgeState { message: error.to_string(), }, )], - None => vec![ServerFrame::reply(api_id, ApiEvent::Ok)], + None => std::iter::once(ServerFrame::reply(api_id, ApiEvent::Ok)) + .chain(info) + .collect(), } } // Compaction is scheduled, not performed inline, and the daemon @@ -1100,6 +1194,9 @@ impl BridgeState { if let Some(provider) = event["provider_name"].as_str() { self.current_provider = Some(provider.to_string()); } + if let Some(effort) = event["reasoning_effort"].as_str() { + self.current_effort = Some(effort.to_string()); + } if let Some(routes) = event["available_model_routes"].as_array() { self.available_routes = routes .iter() @@ -1121,6 +1218,10 @@ impl BridgeState { session_id, provider: event["provider_name"].as_str().map(str::to_string), model: event["provider_model"].as_str().map(str::to_string), + reasoning_effort: event["reasoning_effort"] + .as_str() + .map(str::to_string) + .or_else(|| self.current_effort.clone()), } } @@ -1169,21 +1270,24 @@ impl BridgeState { Some(home.join("sessions").join(format!("{session_id}.json"))) } - /// Working directory of a session, read from its persisted record. + /// Metadata of a session, read from its persisted record. /// /// The legacy `history` event lists session *ids* only, but the strip /// groups by directory, so the bridge resolves them from the same files /// the daemon persists. Best-effort by design: an unreadable or missing /// record simply leaves the session ungrouped rather than failing the /// list, and results are cached because this is on a poll path. - fn resolve_working_dir(session_id: &str) -> Option { + fn resolve_session_metadata(session_id: &str) -> Option { let path = Self::session_record_path(session_id)?; // A missing or malformed record is expected (a session may predate the - // field, or be mid-write), and the only cost is an ungrouped bar, so - // this degrades rather than failing the whole session list. - let text = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&text).ok()?; - value["working_dir"].as_str().map(str::to_string) + // fields, or be mid-write), and the only cost is missing metadata, so + // this degrades rather than failing the whole session list or attach. + let reader = std::io::BufReader::new(std::fs::File::open(path).ok()?); + serde_json::from_reader(reader).ok() + } + + fn resolve_working_dir(session_id: &str) -> Option { + Self::resolve_session_metadata(session_id)?.working_dir } /// Size of a session's stored record, in bytes. diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index dc722b90b3..1fbc086b69 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -52,6 +52,16 @@ impl Drop for ScopedJcodeHome { } fn write_session_record(home: &Path, session_id: &str, working_dir: &Path) -> PathBuf { + write_session_record_with_titles(home, session_id, working_dir, None, None) +} + +fn write_session_record_with_titles( + home: &Path, + session_id: &str, + working_dir: &Path, + title: Option<&str>, + custom_title: Option<&str>, +) -> PathBuf { let sessions = home.join("sessions"); std::fs::create_dir_all(&sessions).expect("create sessions directory"); let path = sessions.join(format!("{session_id}.json")); @@ -59,6 +69,8 @@ fn write_session_record(home: &Path, session_id: &str, working_dir: &Path) -> Pa &path, json!({ "working_dir": working_dir, + "title": title, + "custom_title": custom_title, "messages": [{"role": "user", "content": "hello"}], }) .to_string(), @@ -114,6 +126,16 @@ fn create_session_maps_to_subscribe() { #[test] fn state_event_answers_pending_attach() { + let home = ScopedJcodeHome::new("attach-title"); + let project = home.path.join("project"); + std::fs::create_dir_all(&project).unwrap(); + write_session_record_with_titles( + &home.path, + "abc", + &project, + Some("Generated attach title"), + Some("Persisted attach rename"), + ); let mut state = BridgeState::default(); let out = state.api_request_to_legacy(&json!({"req": "create_session", "id": 5})); assert_eq!( @@ -138,12 +160,67 @@ fn state_event_answers_pending_attach() { assert_eq!(frames.len(), 1); assert_eq!(frames[0].reply_to, Some(5)); match &frames[0].event { - ApiEvent::Attached { session } => assert_eq!(session.session_id, "abc"), + ApiEvent::Attached { session } => { + assert_eq!(session.session_id, "abc"); + assert_eq!(session.title.as_deref(), Some("Persisted attach rename")); + assert_eq!(session.working_dir.as_deref(), project.to_str()); + } other => panic!("unexpected: {other:?}"), } assert_eq!(state.session_id.as_deref(), Some("abc")); } +#[test] +fn attached_session_reports_requested_working_dir() { + let mut state = BridgeState::default(); + let requested_dir = "/tmp/jcode-sdk-requested-working-dir"; + let out = state.api_request_to_legacy(&json!({ + "req": "create_session", + "id": 5, + "working_dir": requested_dir, + })); + let Outbound::Legacy(state_req) = &out[1] else { + panic!("expected legacy state request"); + }; + let state_id = state_req["id"].as_u64().unwrap(); + + let frames = state.legacy_event_to_api(&json!({ + "type": "state", "id": state_id, "session_id": "abc", + "message_count": 0, "is_processing": false, + })); + + match &frames[0].event { + ApiEvent::Attached { session } => { + assert_eq!(session.session_id, "abc"); + assert_eq!(session.working_dir.as_deref(), Some(requested_dir)); + } + other => panic!("unexpected: {other:?}"), + } +} + +#[test] +fn permission_response_is_rejected_when_bridge_has_no_permission_capability() { + let mut state = state_with_session(); + let frames = state.api_request_to_legacy(&json!({ + "req": "permission_response", + "id": 77, + "request_id": "perm-1", + "decision": "allow", + })); + + assert_eq!(frames.len(), 1); + let Outbound::Reply(reply) = &frames[0] else { + panic!("expected local reply"); + }; + assert_eq!(reply.reply_to, Some(77)); + assert!(matches!( + &reply.event, + ApiEvent::Error { code: ErrorCode::InvalidRequest, message } + if message.contains("does not issue permission prompts") + && message.contains("no `permissions` capability") + )); +} + #[test] fn send_message_then_done_becomes_turn_done() { let mut state = state_with_session(); @@ -855,6 +932,54 @@ fn reasoning_effort_reports_provider_refusal() { assert!(matches!(frames[0].event, ApiEvent::Error { .. })); } +/// An effort change is identity, like a model change: every attached client +/// needs to hear it, not only the requester. A change made by another client +/// (no pending request here) must still arrive as a `model_info` broadcast, +/// and the requester's own change gets the broadcast after its `Ok`. +#[test] +fn reasoning_effort_changes_are_broadcast_as_model_info() { + let mut state = state_with_session(); + + // Unsolicited change (another client's request id): broadcast only. + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": 999, "effort": "high", + })); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].reply_to, None); + match &frames[0].event { + ApiEvent::ModelInfo { + reasoning_effort, .. + } => assert_eq!(reasoning_effort.as_deref(), Some("high")), + other => panic!("expected model_info, got {other:?}"), + } + + // The same effort again is not news: no broadcast. + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": 999, "effort": "high", + })); + assert!(frames.is_empty(), "unchanged effort must not re-broadcast"); + + // This client's own change: Ok reply first, then the broadcast. + let out = state.api_request_to_legacy(&json!({ + "id": 7, "req": "set_reasoning_effort", "effort": "low", + })); + let legacy_id = match &out[0] { + Outbound::Legacy(value) => value["id"].as_u64().unwrap(), + _ => unreachable!(), + }; + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": legacy_id, "effort": "low", + })); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].reply_to, Some(7)); + assert!(matches!(frames[0].event, ApiEvent::Ok)); + assert!(matches!( + &frames[1].event, + ApiEvent::ModelInfo { reasoning_effort, .. } + if reasoning_effort.as_deref() == Some("low") + )); +} + /// Compaction can be refused (nothing to compact, a turn in flight) and the /// daemon says so with `success: false`, not an error frame. Telling the /// client "done" would claim work that never happened. @@ -1032,8 +1157,20 @@ fn unattached_list_sessions_discovers_all_persisted_records() { let second_root = home.path.join("second-project"); std::fs::create_dir_all(&first_root).unwrap(); std::fs::create_dir_all(&second_root).unwrap(); - write_session_record(&home.path, "persisted_one", &first_root); - write_session_record(&home.path, "persisted_two", &second_root); + write_session_record_with_titles( + &home.path, + "persisted_one", + &first_root, + Some(" Generated first title "), + None, + ); + write_session_record_with_titles( + &home.path, + "persisted_two", + &second_root, + Some("Generated second title"), + Some(" Custom second title "), + ); std::fs::write(home.path.join("sessions/not-a-session.txt"), "ignored").unwrap(); let event = only_reply_event( @@ -1051,6 +1188,8 @@ fn unattached_list_sessions_discovers_all_persisted_records() { ); assert_eq!(sessions[0].working_dir.as_deref(), first_root.to_str()); assert_eq!(sessions[1].working_dir.as_deref(), second_root.to_str()); + assert_eq!(sessions[0].title.as_deref(), Some("Generated first title")); + assert_eq!(sessions[1].title.as_deref(), Some("Custom second title")); } #[test] @@ -1060,6 +1199,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { "type": "available_models_updated", "provider_name": "anthropic", "provider_model": "claude-sonnet", + "reasoning_effort": "high", "available_models": ["claude-sonnet", "gemini-pro"], "available_model_routes": [ { @@ -1088,6 +1228,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { session_id, provider, model, + reasoning_effort, routes, } = event else { @@ -1096,6 +1237,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { assert_eq!(session_id, "s1"); assert_eq!(provider.as_deref(), Some("anthropic")); assert_eq!(model.as_deref(), Some("claude-sonnet")); + assert_eq!(reasoning_effort.as_deref(), Some("high")); assert_eq!(routes.len(), 2); assert_eq!(routes[1].provider, "gemini"); assert!(!routes[1].available); diff --git a/crates/jcode-harness-api/src/events.rs b/crates/jcode-harness-api/src/events.rs index ee9b2a9adc..5399455814 100644 --- a/crates/jcode-harness-api/src/events.rs +++ b/crates/jcode-harness-api/src/events.rs @@ -32,6 +32,9 @@ pub enum ApiEvent { History { session_id: String, messages: Vec, + /// Images anchored to user prompts or tool calls in this transcript. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + images: Vec, }, /// Reply to `Ping`. @@ -76,6 +79,13 @@ pub enum ApiEvent { error: Option, }, + /// Images the model just received from a tool result or image generator. + /// Clients should render these at their transcript anchor immediately. + SidePaneImages { + session_id: String, + images: Vec, + }, + /// Token usage update for the attached session. TokenUsage { session_id: String, @@ -154,6 +164,9 @@ pub enum ApiEvent { /// Model id, e.g. `claude-sonnet-4-20250514`. #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, + /// Reasoning effort, e.g. `high`, for providers that expose it. + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, }, /// Reply to `ListModels`: the models this session can switch to. @@ -173,6 +186,9 @@ pub enum ApiEvent { provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, + /// Reasoning effort, e.g. `high`, for providers that expose it. + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, routes: Vec, }, @@ -254,6 +270,8 @@ pub struct SessionInfo { pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub working_dir: Option, + /// The effective persisted display title. A custom rename takes precedence + /// over the generated or imported title. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, pub status: String, @@ -296,3 +314,28 @@ pub struct HistoryMessage { pub role: String, pub content: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RenderedImageSource { + UserInput, + ToolResult { tool_name: String }, + Other { role: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RenderedImageAnchor { + ToolCall { id: String }, + UserPrompt { ordinal: usize }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RenderedImage { + pub media_type: String, + pub data: String, + pub label: Option, + pub source: RenderedImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} diff --git a/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs b/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs index 43835deba4..2af22d12ef 100644 --- a/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs +++ b/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs @@ -30,6 +30,94 @@ impl Drop for EnvVarGuard { } } +#[test] +fn direct_api_url_supports_standard_and_profile_overrides() { + let _lock = jcode_base::storage::lock_test_env(); + let _standard = EnvVarGuard::set("ANTHROPIC_BASE_URL", "https://proxy.example/v1/"); + assert_eq!(direct_api_url(), "https://proxy.example/v1/messages"); + + let _profile = EnvVarGuard::set( + "JCODE_ANTHROPIC_API_BASE", + "https://gateway.example/anthropic/v1/messages", + ); + assert_eq!( + direct_api_url(), + "https://gateway.example/anthropic/v1/messages" + ); +} + +#[test] +fn configured_direct_headers_parse_and_reject_invalid_values() { + let _lock = jcode_base::storage::lock_test_env(); + let _headers = EnvVarGuard::set( + "JCODE_ANTHROPIC_HEADERS", + r#"{"x-tenant":"alpha","x-route":"claude"}"#, + ); + let parsed = configured_direct_headers().expect("valid custom headers"); + assert_eq!(parsed.get("x-tenant").unwrap(), "alpha"); + assert_eq!(parsed.get("x-route").unwrap(), "claude"); + + let _invalid = EnvVarGuard::set("JCODE_ANTHROPIC_HEADERS", r#"{"bad header":"x"}"#); + assert!(configured_direct_headers().is_err()); +} + +#[test] +fn anthropic_auth_token_selects_bearer_without_affecting_explicit_profile_auth() { + let _lock = jcode_base::storage::lock_test_env(); + let _token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", "gateway-token"); + assert_eq!(direct_auth_mode(), "bearer"); + + let _explicit = EnvVarGuard::set("JCODE_ANTHROPIC_AUTH", "header"); + assert_eq!(direct_auth_mode(), "header"); +} + +#[test] +fn named_profile_runtime_captures_transport_and_credential_immutably() { + let _lock = jcode_base::storage::lock_test_env(); + let _base = EnvVarGuard::set("JCODE_ANTHROPIC_API_BASE", "https://one.example/v1"); + let _auth = EnvVarGuard::set("JCODE_ANTHROPIC_AUTH", "bearer"); + let _key_name = EnvVarGuard::set("JCODE_ANTHROPIC_API_KEY_NAME", "PROFILE_ONE_KEY"); + let _key = EnvVarGuard::set("PROFILE_ONE_KEY", "one-secret"); + let provider = AnthropicProvider::new(); + + let _changed_base = EnvVarGuard::set("JCODE_ANTHROPIC_API_BASE", "https://two.example/v1"); + let _changed_key = EnvVarGuard::set("PROFILE_ONE_KEY", "two-secret"); + assert_eq!( + provider.direct_transport.api_url, + "https://one.example/v1/messages" + ); + assert_eq!(provider.direct_transport.auth_mode, "bearer"); + assert_eq!( + provider.profile_api_key.as_ref().unwrap().as_ref().unwrap(), + "one-secret" + ); +} + +#[test] +fn named_anthropic_profile_accepts_its_configured_custom_model() { + let _lock = jcode_base::storage::lock_test_env(); + let _home = tempfile::TempDir::new().expect("temp home"); + let _home_guard = EnvVarGuard::set("JCODE_HOME", _home.path()); + std::fs::write( + _home.path().join("config.toml"), + r#" + [providers.custom] + type = "anthropic-compatible" + base_url = "http://localhost:12345/v1" + default_model = "claude-private" + "#, + ) + .expect("write config"); + jcode_base::config::Config::invalidate_cache(); + let _profile = EnvVarGuard::set("JCODE_NAMED_PROVIDER_PROFILE", "custom"); + let models = active_anthropic_profile_models().expect("active profile models"); + assert!(models.iter().any(|model| model == "claude-private")); + assert!(!models.iter().any(|model| model == "not-configured")); + drop(_profile); + drop(_home_guard); + jcode_base::config::Config::invalidate_cache(); +} + async fn collect_live_smoke_stream( mut stream: EventStream, timeout: std::time::Duration, diff --git a/crates/jcode-provider-anthropic-runtime/src/lib.rs b/crates/jcode-provider-anthropic-runtime/src/lib.rs index b26c261ed2..fbc6207c0b 100644 --- a/crates/jcode-provider-anthropic-runtime/src/lib.rs +++ b/crates/jcode-provider-anthropic-runtime/src/lib.rs @@ -45,6 +45,7 @@ use jcode_provider_core::{ anthropic_strip_1m_suffix as strip_1m_suffix, }; use reqwest::Client; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::Serialize; use serde_json::{Value, json}; use std::sync::Arc; @@ -59,6 +60,101 @@ const API_URL: &str = "https://api.anthropic.com/v1/messages"; /// OAuth endpoint (with beta=true query param) const API_URL_OAUTH: &str = "https://api.anthropic.com/v1/messages?beta=true"; +fn direct_api_url() -> String { + let base = std::env::var("JCODE_ANTHROPIC_API_BASE") + .ok() + .or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok()) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()); + match base { + Some(base) if base.ends_with("/messages") => base, + Some(base) => format!("{base}/messages"), + None => API_URL.to_string(), + } +} + +fn configured_direct_headers() -> Result { + let Some(raw) = std::env::var("JCODE_ANTHROPIC_HEADERS") + .ok() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(HeaderMap::new()); + }; + let headers: std::collections::BTreeMap = serde_json::from_str(&raw) + .context("JCODE_ANTHROPIC_HEADERS must be a JSON object of string values")?; + let mut result = HeaderMap::new(); + for (name, value) in headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .with_context(|| format!("invalid Anthropic-compatible header name '{name}'"))?; + let value = HeaderValue::from_str(&value) + .with_context(|| format!("invalid value for Anthropic-compatible header '{name}'"))?; + result.insert(name, value); + } + Ok(result) +} + +fn direct_auth_mode() -> String { + std::env::var("JCODE_ANTHROPIC_AUTH") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + if std::env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()) + { + "bearer".to_string() + } else { + "header".to_string() + } + }) + .trim() + .to_ascii_lowercase() +} + +#[derive(Clone)] +struct DirectTransportConfig { + api_url: String, + headers: std::result::Result, + auth_mode: String, + auth_header: String, +} + +impl DirectTransportConfig { + fn from_env() -> Self { + Self { + api_url: direct_api_url(), + headers: configured_direct_headers().map_err(|err| format!("{err:#}")), + auth_mode: direct_auth_mode(), + auth_header: std::env::var("JCODE_ANTHROPIC_AUTH_HEADER") + .unwrap_or_else(|_| "x-api-key".to_string()), + } + } +} + +fn active_anthropic_profile_models() -> Option> { + let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") else { + return None; + }; + let profile = jcode_base::config::config().providers.get(&profile_name)?; + if !matches!( + profile.provider_type, + jcode_base::config::NamedProviderType::AnthropicCompatible + ) { + return None; + } + let mut models = profile + .models + .iter() + .map(|configured| configured.id.clone()) + .collect::>(); + if let Some(default) = &profile.default_model + && !models.contains(default) + { + models.push(default.clone()); + } + Some(models) +} + #[cfg(test)] pub(crate) const OAUTH_BETA_HEADERS_1M: &str = jcode_provider_core::ANTHROPIC_OAUTH_BETA_HEADERS_1M; @@ -378,6 +474,11 @@ pub struct AnthropicProvider { max_tokens_override: Option, oauth_session_id: String, oauth_preflight_done: Arc, + direct_transport: DirectTransportConfig, + /// Named profiles pin their credential at runtime construction so another + /// session/profile cannot redirect this runtime to a different process env. + profile_api_key: Option>, + profile_models: Option>, } impl AnthropicProvider { @@ -518,6 +619,11 @@ impl AnthropicProvider { .and_then(Self::normalize_reasoning_effort) .map(|effort| Self::store_effort_for_model(&model, &effort)); + let direct_transport = DirectTransportConfig::from_env(); + let profile_api_key = std::env::var_os("JCODE_ANTHROPIC_API_BASE") + .map(|_| load_anthropic_api_key().map_err(|err| format!("{err:#}"))); + let profile_models = active_anthropic_profile_models(); + Self { client: jcode_provider_core::shared_http_client(), model: Arc::new(std::sync::RwLock::new(model)), @@ -530,6 +636,17 @@ impl AnthropicProvider { max_tokens_override, oauth_session_id: Uuid::new_v4().to_string(), oauth_preflight_done: Arc::new(AtomicBool::new(false)), + direct_transport, + profile_api_key, + profile_models, + } + } + + fn direct_api_key(&self) -> Result { + match &self.profile_api_key { + Some(Ok(key)) => Ok(key.clone()), + Some(Err(err)) => anyhow::bail!(err.clone()), + None => load_anthropic_api_key(), } } @@ -809,7 +926,7 @@ impl AnthropicProvider { // Explicit API-key mode: use the direct API key and surface an error if // one is not configured (never silently fall back to OAuth). if matches!(mode, AnthropicCredentialMode::ApiKey) { - let key = load_anthropic_api_key()?; + let key = self.direct_api_key()?; return Ok((key, false)); // false = not OAuth } @@ -822,7 +939,7 @@ impl AnthropicProvider { if matches!(mode, AnthropicCredentialMode::Auto) { match self.get_oauth_access_token().await { Ok(token) => return Ok(token), - Err(oauth_err) => match load_anthropic_api_key() { + Err(oauth_err) => match self.direct_api_key() { Ok(key) => { jcode_base::logging::warn(&format!( "Claude OAuth is unusable in automatic credential mode ({oauth_err:#}); falling back to the configured Anthropic API key" @@ -1122,6 +1239,7 @@ impl Provider for AnthropicProvider { let credentials = Arc::clone(&self.credentials); let oauth_session_id = self.oauth_session_id.clone(); let model_state = Arc::clone(&self.model); + let direct_transport = self.direct_transport.clone(); // Spawn task to handle streaming with retry logic. // This includes forced OAuth refresh on auth failures. @@ -1145,6 +1263,7 @@ impl Provider for AnthropicProvider { model, oauth_session_id, model_state, + direct_transport, ) .await; }); @@ -1181,9 +1300,13 @@ impl Provider for AnthropicProvider { } else { model }; - if !jcode_base::provider::known_anthropic_model_ids() - .iter() - .any(|known| known == model) + if !self + .profile_models + .as_ref() + .is_some_and(|models| models.iter().any(|configured| configured == model)) + && !jcode_base::provider::known_anthropic_model_ids() + .iter() + .any(|known| known == model) { anyhow::bail!("Model {} not supported by Anthropic provider", model); } @@ -1328,6 +1451,12 @@ impl Provider for AnthropicProvider { } async fn prefetch_models(&self) -> Result<()> { + if self.direct_transport.api_url != API_URL { + // Named Anthropic-compatible profiles use their configured static + // model list. Never send gateway credentials to Anthropic's + // official hard-coded model-catalog endpoint. + return Ok(()); + } let (token, is_oauth) = self.get_access_token().await?; if token.trim().is_empty() { return Ok(()); @@ -1401,6 +1530,9 @@ impl Provider for AnthropicProvider { oauth_preflight_done: Arc::new(AtomicBool::new( self.oauth_preflight_done.load(Ordering::Relaxed), )), + direct_transport: self.direct_transport.clone(), + profile_api_key: self.profile_api_key.clone(), + profile_models: self.profile_models.clone(), }) } @@ -1486,6 +1618,7 @@ impl Provider for AnthropicProvider { let credentials = Arc::clone(&self.credentials); let oauth_session_id = self.oauth_session_id.clone(); let model_state = Arc::clone(&self.model); + let direct_transport = self.direct_transport.clone(); // Spawn task to handle streaming with retry logic tokio::spawn(async move { @@ -1508,6 +1641,7 @@ impl Provider for AnthropicProvider { model, oauth_session_id, model_state, + direct_transport, ) .await; }); @@ -1530,6 +1664,7 @@ async fn run_stream_with_retries( model_name: String, oauth_session_id: String, model_state: Arc>, + direct_transport: DirectTransportConfig, ) { let mut token = initial_token; let mut last_error = None; @@ -1590,6 +1725,7 @@ async fn run_stream_with_retries( attempt_tx, &model_name, &oauth_session_id, + &direct_transport, ) .await { @@ -1831,6 +1967,10 @@ async fn force_refresh_oauth_token( } /// Stream the response from Anthropic API +#[expect( + clippy::too_many_arguments, + reason = "streaming requires transport, authentication, request, event, and session context" +)] async fn stream_response( client: Client, token: String, @@ -1839,6 +1979,7 @@ async fn stream_response( tx: mpsc::Sender>, model_name: &str, oauth_session_id: &str, + direct_transport: &DirectTransportConfig, ) -> Result<()> { use jcode_message_types::ConnectionPhase; let requested_model_base = strip_1m_suffix(&request.model).to_ascii_lowercase(); @@ -1859,10 +2000,22 @@ async fn stream_response( let connect_start = std::time::Instant::now(); let stream_idle_timeout = jcode_base::provider::stream_idle_timeout(); // Build request with appropriate auth headers - let url = if is_oauth { API_URL_OAUTH } else { API_URL }; + let url = if is_oauth { + API_URL_OAUTH + } else { + direct_transport.api_url.as_str() + }; - let mut req = client - .post(url) + let mut req = client.post(url); + if !is_oauth { + req = req.headers( + direct_transport + .headers + .clone() + .map_err(anyhow::Error::msg)?, + ); + } + req = req .header("anthropic-version", API_VERSION) .header("content-type", "application/json") .header( @@ -1900,9 +2053,19 @@ async fn stream_response( }; let beta_header = anthropic_beta_header_with_thinking(beta_header, request.thinking.is_some()); - req = req - .header("x-api-key", &token) - .header("anthropic-beta", beta_header); + req = match direct_transport.auth_mode.as_str() { + "none" => req, + "bearer" => req.header("Authorization", format!("Bearer {token}")), + "header" => { + let header = HeaderName::from_bytes(direct_transport.auth_header.trim().as_bytes()) + .context("invalid JCODE_ANTHROPIC_AUTH_HEADER")?; + req.header(header, &token) + } + value => anyhow::bail!( + "invalid JCODE_ANTHROPIC_AUTH value '{value}' (expected bearer, header, or none)" + ), + }; + req = req.header("anthropic-beta", beta_header); } let response = jcode_provider_core::transport::send_with_initial_response_timeout( diff --git a/crates/jcode-provider-bedrock/src/lib.rs b/crates/jcode-provider-bedrock/src/lib.rs index 6402c75d04..82487edbc5 100644 --- a/crates/jcode-provider-bedrock/src/lib.rs +++ b/crates/jcode-provider-bedrock/src/lib.rs @@ -250,6 +250,7 @@ impl BedrockProvider { jcode_provider_env::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) } + #[cfg(any(feature = "aws-sdk", test))] fn configured_bearer_token_for_runtime() -> Option { Self::configured_profile() .is_none() diff --git a/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs index f849fb3082..2286b4828f 100644 --- a/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs +++ b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs @@ -90,6 +90,13 @@ fn main() { if std::env::var_os("JCODE_FAKE_GROK_ACP_HANG").is_some() { continue; } + if std::env::var_os("JCODE_FAKE_GROK_ACP_PAYMENT_REQUIRED").is_some() { + eprintln!( + "Error: Internal error: {{\"message\":\"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\"http_status\":402}}" + ); + response(id, json!({"stopReason":"end_turn"})); + continue; + } send(json!({ "jsonrpc":"2.0", "method":"_x.ai/settings/update", diff --git a/crates/jcode-provider-grok-build-runtime/src/lib.rs b/crates/jcode-provider-grok-build-runtime/src/lib.rs index dc5ac4bd97..a206a00944 100644 --- a/crates/jcode-provider-grok-build-runtime/src/lib.rs +++ b/crates/jcode-provider-grok-build-runtime/src/lib.rs @@ -154,10 +154,7 @@ impl Provider for GrokBuildProvider { tx.clone(), cancel_rx, ) { - let _ = tx.blocking_send(Ok(StreamEvent::Error { - message: format!("{error:#}"), - retry_after_secs: None, - })); + let _ = tx.blocking_send(Err(error)); } }) .context("Failed to start Grok Build ACP runtime thread")?; @@ -576,30 +573,59 @@ where .take() .context("Grok CLI stderr was unavailable")?; let stderr_capture = Arc::new(std::sync::Mutex::new(String::new())); - let stderr_task = tokio::task::spawn_local(capture_stderr(stderr, Arc::clone(&stderr_capture))); + let mut stderr_task = + tokio::task::spawn_local(capture_stderr(stderr, Arc::clone(&stderr_capture))); - let client = GrokAcpClient { tx: event_tx }; + let received_message = Arc::new(AtomicBool::new(false)); + let client = GrokAcpClient { + tx: event_tx, + received_message: Arc::clone(&received_message), + }; let (connection, io) = acp::ClientSideConnection::new(client, stdin.compat_write(), stdout.compat(), |future| { tokio::task::spawn_local(future); }); let io_task = tokio::task::spawn_local(io); let result = operation(connection).await; - drop(child); + let _ = child.kill().await; io_task.abort(); + let _ = tokio::time::timeout(Duration::from_millis(100), &mut stderr_task).await; stderr_task.abort(); + let stderr = stderr_capture + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .trim() + .to_string(); + if result.is_ok() + && !received_message.load(Ordering::Acquire) + && stderr_reports_provider_failure(&stderr) + { + bail!("Grok CLI provider request failed: {stderr}"); + } result.map_err(|error| { - let stderr = stderr_capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if stderr.trim().is_empty() { + if stderr.is_empty() { error } else { - error.context(format!("Grok CLI stderr: {}", stderr.trim())) + error.context(format!("Grok CLI stderr: {stderr}")) } }) } +fn stderr_reports_provider_failure(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + [ + "api error", + "payment required", + "balance exhausted", + "quota exhausted", + "too many requests", + "rate limit", + "http_status", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + async fn capture_stderr( mut stderr: tokio::process::ChildStderr, capture: Arc>, @@ -624,6 +650,7 @@ async fn capture_stderr( struct GrokAcpClient { tx: mpsc::Sender>, + received_message: Arc, } #[async_trait(?Send)] @@ -653,6 +680,7 @@ impl acp::Client for GrokAcpClient { ) -> acp::Result<()> { let event = match notification.update { acp::SessionUpdate::AgentMessageChunk(chunk) => { + self.received_message.store(true, Ordering::Release); text_from_acp_content(chunk.content).map(StreamEvent::TextDelta) } acp::SessionUpdate::AgentThoughtChunk(chunk) => { diff --git a/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs index e3a5c7b141..6d4cca990e 100644 --- a/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs +++ b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs @@ -18,6 +18,24 @@ fn fake_process(log: &Path) -> GrokBuildProcess { } } +#[tokio::test(flavor = "current_thread")] +async fn provider_surfaces_payment_failure_written_to_subprocess_stderr() { + let temp = tempfile::tempdir().unwrap(); + let mut process = fake_process(&temp.path().join("requests.jsonl")); + process + .env + .insert("JCODE_FAKE_GROK_ACP_PAYMENT_REQUIRED".into(), "1".into()); + let provider = GrokBuildProvider::with_process(process); + + let error = provider + .complete_simple("Reply exactly AUTH_TEST_OK", "") + .await + .unwrap_err(); + let detail = format!("{error:#}"); + assert!(detail.contains("402 Payment Required"), "{detail}"); + assert!(detail.contains("usage balance exhausted"), "{detail}"); +} + #[tokio::test(flavor = "current_thread")] async fn fake_subprocess_covers_handshake_models_new_prompt_and_auth_isolation() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/jcode-provider-metadata/src/catalog.rs b/crates/jcode-provider-metadata/src/catalog.rs index a361e1ed99..52cb3e711f 100644 --- a/crates/jcode-provider-metadata/src/catalog.rs +++ b/crates/jcode-provider-metadata/src/catalog.rs @@ -96,6 +96,17 @@ pub const OPENROUTER_OPENAI_COMPAT_PROFILE: OpenAiCompatibleProfile = OpenAiComp requires_api_key: true, }; +pub const ORCAROUTER_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { + id: "orcarouter", + display_name: "OrcaRouter", + api_base: "https://api.orcarouter.ai/v1", + api_key_env: "ORCAROUTER_API_KEY", + env_file: "orcarouter.env", + setup_url: "https://www.orcarouter.ai", + default_model: None, + requires_api_key: true, +}; + // Anthropic and OpenAI also expose OpenAI-compatible `/v1/chat/completions` // endpoints, so they can be driven by `provider-doctor` / // `provider-test-coverage` as OpenAI-compatible profiles. These profile ids @@ -309,7 +320,7 @@ pub const MINIMAX_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { id: "minimax", display_name: "MiniMax", api_base: "https://api.minimax.io/v1", - api_key_env: "OPENAI_API_KEY", + api_key_env: "MINIMAX_API_KEY", env_file: "minimax.env", setup_url: "https://platform.minimax.io/docs/guides/text-generation", default_model: Some("MiniMax-M2.7"), @@ -443,7 +454,7 @@ pub const OPENAI_COMPAT_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfi requires_api_key: true, }; -pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 38] = [ +pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 39] = [ OPENCODE_PROFILE, OPENCODE_GO_PROFILE, ZAI_PROFILE, @@ -455,6 +466,7 @@ pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 38] = [ BASETEN_PROFILE, CORTECS_PROFILE, OPENROUTER_OPENAI_COMPAT_PROFILE, + ORCAROUTER_PROFILE, ANTHROPIC_OPENAI_COMPAT_PROFILE, OPENAI_NATIVE_OPENAI_COMPAT_PROFILE, GEMINI_OPENAI_COMPAT_PROFILE, @@ -580,6 +592,19 @@ pub const OPENROUTER_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDesc order: LoginProviderSurfaceOrder::new(Some(4), Some(3), Some(4), Some(3), Some(3)), }; +pub const ORCAROUTER_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { + id: "orcarouter", + display_name: "OrcaRouter", + auth_kind: LoginProviderAuthKind::ApiKey, + auth_state_key: LoginProviderAuthStateKey::OpenRouterLike, + auth_status_method: "API key", + aliases: &["orca-router"], + menu_detail: "API key, OpenAI-compatible gateway", + recommended: false, + target: LoginProviderTarget::OpenAiCompatible(ORCAROUTER_PROFILE), + order: LoginProviderSurfaceOrder::new(Some(39), Some(39), Some(39), Some(39), Some(39)), +}; + pub const BEDROCK_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { id: "bedrock", display_name: "AWS Bedrock", @@ -1152,7 +1177,7 @@ pub const GOOGLE_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescript order: LoginProviderSurfaceOrder::new(Some(13), None, None, None, None), }; -pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 50] = [ +pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 51] = [ AUTO_IMPORT_LOGIN_PROVIDER, CLAUDE_LOGIN_PROVIDER, ANTHROPIC_API_LOGIN_PROVIDER, @@ -1160,6 +1185,7 @@ pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 50] = [ OPENAI_API_LOGIN_PROVIDER, JCODE_LOGIN_PROVIDER, OPENROUTER_LOGIN_PROVIDER, + ORCAROUTER_LOGIN_PROVIDER, BEDROCK_LOGIN_PROVIDER, AZURE_LOGIN_PROVIDER, OPENCODE_LOGIN_PROVIDER, diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index e03ae58925..898298ee2b 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -345,6 +345,20 @@ mod tests { assert_eq!(profile.setup_url, "https://docs.z.ai/devpack/quick-start"); } + #[test] + fn orcarouter_login_identifies_openai_compatible_endpoint() { + let provider = resolve_login_selection("orcarouter", &cli_login_providers()) + .expect("OrcaRouter CLI login provider"); + let LoginProviderTarget::OpenAiCompatible(profile) = provider.target else { + panic!("OrcaRouter should use the OpenAI-compatible runtime"); + }; + + assert_eq!(profile.id, "orcarouter"); + assert_eq!(profile.api_base, "https://api.orcarouter.ai/v1"); + assert_eq!(profile.api_key_env, "ORCAROUTER_API_KEY"); + assert!(profile.requires_api_key); + } + #[test] fn normalize_api_base_accepts_private_http_hosts() { assert_eq!( @@ -436,7 +450,7 @@ mod tests { #[test] fn minimax_profile_uses_official_openai_compatible_configuration() { assert_eq!(MINIMAX_PROFILE.api_base, "https://api.minimax.io/v1"); - assert_eq!(MINIMAX_PROFILE.api_key_env, "OPENAI_API_KEY"); + assert_eq!(MINIMAX_PROFILE.api_key_env, "MINIMAX_API_KEY"); } #[test] diff --git a/crates/jcode-provider-openrouter/src/request.rs b/crates/jcode-provider-openrouter/src/request.rs index bb1a14a980..b7c68d4469 100644 --- a/crates/jcode-provider-openrouter/src/request.rs +++ b/crates/jcode-provider-openrouter/src/request.rs @@ -2,7 +2,7 @@ use jcode_message_types::{ ContentBlock, Message, Role, TOOL_OUTPUT_MISSING_TEXT, sanitize_tool_id, }; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; /// Normalize a tool `parameters` JSON schema for whichever upstream OpenRouter /// routes the model to. @@ -479,7 +479,8 @@ pub fn build_chat_messages( } // Final pass: ensure tool outputs immediately follow assistant tool calls. - let mut tool_output_map: HashMap = HashMap::new(); + let mut tool_output_map: HashMap> = HashMap::new(); + let mut missing_tool_outputs: HashMap = HashMap::new(); for msg in &api_messages { if msg.get("role").and_then(|v| v.as_str()) == Some("tool") && let Some(id) = msg.get("tool_call_id").and_then(|v| v.as_str()) @@ -489,26 +490,20 @@ pub fn build_chat_messages( .and_then(|v| v.as_str()) .map(|v| v == missing_output) .unwrap_or(false); - match tool_output_map.get(id) { - Some(existing) => { - let existing_missing = existing - .get("content") - .and_then(|v| v.as_str()) - .map(|v| v == missing_output) - .unwrap_or(false); - if existing_missing && !is_missing { - tool_output_map.insert(id.to_string(), msg.clone()); - } - } - None => { - tool_output_map.insert(id.to_string(), msg.clone()); - } + if is_missing { + missing_tool_outputs + .entry(id.to_string()) + .or_insert_with(|| msg.clone()); + } else { + tool_output_map + .entry(id.to_string()) + .or_default() + .push_back(msg.clone()); } } } let mut reordered: Vec = Vec::with_capacity(api_messages.len()); - let mut used_outputs: HashSet = HashSet::new(); let mut injected_ordered = 0usize; let mut dropped_orphans = 0usize; @@ -524,9 +519,12 @@ pub fn build_chat_messages( reordered.push(msg); for call in tool_calls { if let Some(id) = call.get("id").and_then(|v| v.as_str()) { - if let Some(tool_msg) = tool_output_map.get(id) { - reordered.push(tool_msg.clone()); - used_outputs.insert(id.to_string()); + if let Some(tool_msg) = tool_output_map + .get_mut(id) + .and_then(VecDeque::pop_front) + .or_else(|| missing_tool_outputs.get(id).cloned()) + { + reordered.push(tool_msg); } else { injected_ordered += 1; reordered.push(serde_json::json!({ @@ -534,7 +532,6 @@ pub fn build_chat_messages( "tool_call_id": id, "content": missing_output.clone() })); - used_outputs.insert(id.to_string()); } } } @@ -543,12 +540,6 @@ pub fn build_chat_messages( } if role == "tool" { - if let Some(id) = msg.get("tool_call_id").and_then(|v| v.as_str()) - && used_outputs.contains(id) - { - dropped_orphans += 1; - continue; - } dropped_orphans += 1; continue; } @@ -577,9 +568,43 @@ pub fn build_chat_messages( #[cfg(test)] mod request_tests { use super::build_chat_messages; - use jcode_message_types::Message; + use jcode_message_types::{ContentBlock, Message, Role}; use serde_json::json; + fn tool_call(id: &str, output: &str) -> [Message; 2] { + [ + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: id.to_string(), + name: "read".to_string(), + input: json!({"path": output}), + thought_signature: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + Message::tool_result(id, output, false), + ] + } + + #[test] + fn repeated_tool_call_ids_get_their_own_outputs_in_order() { + let messages = tool_call("read:0", "first output") + .into_iter() + .chain(tool_call("read_0", "second output")) + .collect::>(); + + let api_messages = build_chat_messages(&messages, "", false, false, false); + let outputs = api_messages + .iter() + .filter(|message| message["role"] == "tool") + .map(|message| message["content"].as_str().unwrap()) + .collect::>(); + + assert_eq!(outputs, ["first output", "second output"]); + } + #[test] fn orphaned_tool_output_is_recovered_as_a_user_message() { let messages = vec![Message::tool_result("call_orphan", "orphan result", false)]; diff --git a/crates/jcode-sdk/src/client.rs b/crates/jcode-sdk/src/client.rs index e1ca5d278b..12b198d171 100644 --- a/crates/jcode-sdk/src/client.rs +++ b/crates/jcode-sdk/src/client.rs @@ -694,13 +694,23 @@ impl JcodeClient { } pub fn get_history(&self, session_id: &str) -> Result> { + self.get_history_with_images(session_id) + .map(|(messages, _)| messages) + } + + pub fn get_history_with_images( + &self, + session_id: &str, + ) -> Result<(Vec, Vec)> { match self .request_ok(ApiRequest::GetHistory { session_id: session_id.to_string(), })? .event { - ApiEvent::History { messages, .. } => Ok(messages), + ApiEvent::History { + messages, images, .. + } => Ok((messages, images)), other => Err(unexpected("history", &other)), } } @@ -780,6 +790,7 @@ impl JcodeClient { session_id, provider, model, + reasoning_effort, routes, } => { let mut providers = Vec::new(); @@ -799,6 +810,7 @@ impl JcodeClient { session_id, provider, model, + reasoning_effort, providers, routes, }) @@ -1084,6 +1096,8 @@ pub struct RuntimeInfo { pub session_id: String, pub provider: Option, pub model: Option, + /// Reasoning effort, e.g. `high`, when the provider exposes it. + pub reasoning_effort: Option, pub providers: Vec, pub routes: Vec, } diff --git a/crates/jcode-sdk/src/lib.rs b/crates/jcode-sdk/src/lib.rs index 10618a63e6..e531a08ed2 100644 --- a/crates/jcode-sdk/src/lib.rs +++ b/crates/jcode-sdk/src/lib.rs @@ -50,6 +50,6 @@ pub use structured::{ /// The protocol types, re-exported so a client needs one dependency, not two. pub use jcode_harness_api as api; pub use jcode_harness_api::{ - ApiEvent, ApiRequest, HistoryMessage, ModelRouteInfo, PermissionDecision, SessionInfo, - TextMatch, api_socket_path, + ApiEvent, ApiRequest, HistoryMessage, ModelRouteInfo, PermissionDecision, RenderedImage, + RenderedImageAnchor, RenderedImageSource, SessionInfo, TextMatch, api_socket_path, }; diff --git a/crates/jcode-sdk/src/sdk_tests/parity.rs b/crates/jcode-sdk/src/sdk_tests/parity.rs index 36e7400454..e7cb61c5ce 100644 --- a/crates/jcode-sdk/src/sdk_tests/parity.rs +++ b/crates/jcode-sdk/src/sdk_tests/parity.rs @@ -39,6 +39,7 @@ const CAPABILITIES: &[Capability] = &[ cap("cancel", "cancel"), cap("soft_interrupt", "softInterrupt"), cap("get_history", "getHistory"), + cap("get_history_with_images", "getHistoryWithImages"), cap("peek_session", "peekSession"), cap("clear", "clear"), cap("rewind", "rewind"), diff --git a/crates/jcode-sdk/tests/client_behavior.rs b/crates/jcode-sdk/tests/client_behavior.rs index 0f34509316..b88ab8229d 100644 --- a/crates/jcode-sdk/tests/client_behavior.rs +++ b/crates/jcode-sdk/tests/client_behavior.rs @@ -148,6 +148,7 @@ fn ga_runtime_and_file_methods_map_requests_and_typed_replies() { session_id: "s1".to_string(), provider: Some("anthropic".to_string()), model: Some("claude".to_string()), + reasoning_effort: Some("high".to_string()), routes: reply_routes.clone(), }, ApiRequest::SetApiKey { provider, .. } => ApiEvent::CredentialUpdated { diff --git a/crates/jcode-sdk/tests/lifecycle_events.rs b/crates/jcode-sdk/tests/lifecycle_events.rs index d4020cabe6..2b08095816 100644 --- a/crates/jcode-sdk/tests/lifecycle_events.rs +++ b/crates/jcode-sdk/tests/lifecycle_events.rs @@ -19,7 +19,7 @@ fn session(id: &str) -> SessionInfo { SessionInfo { session_id: id.to_string(), working_dir: None, - title: None, + title: Some(format!("Title for {id}")), status: "idle".to_string(), transcript_bytes: None, archived: false, @@ -27,6 +27,22 @@ fn session(id: &str) -> SessionInfo { } } +#[test] +fn public_client_exposes_titles_from_list_and_attach() { + let server = UnixHarness::start(0); + let client = server.connect(); + + let sessions = client.list_sessions().expect("list sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].title.as_deref(), Some("Title for persisted-1")); + assert_eq!(sessions[1].title.as_deref(), Some("Title for persisted-2")); + + let attached = client + .attach_session("persisted-1") + .expect("attach session"); + assert_eq!(attached.title.as_deref(), Some("Title for persisted-1")); +} + struct UnixHarness { _temp: tempfile::TempDir, socket_path: PathBuf, diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index 1e6d0e602c..cf97a37993 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -673,6 +673,9 @@ pub fn run_setup_hotkey( if _listen_macos_hotkey { return run_macos_hotkey_listener(); } + if _uninstall { + return uninstall_macos_hotkey_listener(); + } let mut state = SetupHintsState::load(); let terminal = effective_macos_terminal(); @@ -712,6 +715,10 @@ pub fn run_setup_hotkey( #[cfg(target_os = "linux")] { + if _uninstall { + return uninstall_linux_launch_hotkeys(); + } + let mut state = SetupHintsState::load(); eprintln!("\x1b[1mjcode setup-hotkey\x1b[0m"); eprintln!(); @@ -1676,6 +1683,16 @@ fn install_linux_launch_hotkeys(comp: linux_env::LinuxCompositor) -> Result Result<()> { + anyhow::bail!( + "automatic launch-hotkey removal is not supported for this Linux desktop; no changes were made" + ) +} + /// Install (or refresh) the niri launch-hotkey binds into the user's /// `config.kdl`. Writes a timestamped backup before modifying, and is a no-op /// when the managed block already matches. Returns `Ok(true)` if the config was diff --git a/crates/jcode-tui-core/src/keybind.rs b/crates/jcode-tui-core/src/keybind.rs index ca6ef45541..84cbdfc6da 100644 --- a/crates/jcode-tui-core/src/keybind.rs +++ b/crates/jcode-tui-core/src/keybind.rs @@ -74,17 +74,26 @@ pub fn macos_option_char_to_ascii_key(code: KeyCode) -> Option { 'โˆ‚' => Some('d'), 'ยด' => Some('e'), 'ฦ’' => Some('f'), + 'ยฉ' => Some('g'), 'ห™' => Some('h'), 'ห†' => Some('i'), 'โˆ†' => Some('j'), 'หš' => Some('k'), 'ยฌ' => Some('l'), 'ยต' => Some('m'), + 'หœ' => Some('n'), + 'รธ' => Some('o'), + 'ฯ€' => Some('p'), + 'ล“' => Some('q'), + 'ยฎ' => Some('r'), 'รŸ' => Some('s'), 'โ€ ' => Some('t'), 'ยจ' => Some('u'), 'โˆš' => Some('v'), + 'โˆ‘' => Some('w'), + 'โ‰ˆ' => Some('x'), 'ยฅ' => Some('y'), + 'ฮฉ' => Some('z'), _ => None, } } @@ -956,17 +965,26 @@ mod tests { ('โˆ‚', 'd'), ('ยด', 'e'), ('ฦ’', 'f'), + ('ยฉ', 'g'), ('ห™', 'h'), ('ห†', 'i'), ('โˆ†', 'j'), ('หš', 'k'), ('ยฌ', 'l'), ('ยต', 'm'), + ('หœ', 'n'), + ('รธ', 'o'), + ('ฯ€', 'p'), + ('ล“', 'q'), + ('ยฎ', 'r'), ('รŸ', 's'), ('โ€ ', 't'), ('ยจ', 'u'), ('โˆš', 'v'), + ('โˆ‘', 'w'), + ('โ‰ˆ', 'x'), ('ยฅ', 'y'), + ('ฮฉ', 'z'), ] { assert_eq!( macos_option_char_to_ascii_key(KeyCode::Char(option_char)), diff --git a/crates/jcode-tui-messages/src/cache.rs b/crates/jcode-tui-messages/src/cache.rs index 72e40da2d5..250bb2c446 100644 --- a/crates/jcode-tui-messages/src/cache.rs +++ b/crates/jcode-tui-messages/src/cache.rs @@ -16,6 +16,7 @@ struct MessageCacheKey { mermaid_epoch: u64, mermaid_aspect_bucket: Option, show_agentgrep_output: bool, + show_bash_output: bool, tool_call_details: bool, } @@ -66,6 +67,7 @@ pub struct MessageCacheContext { pub mermaid_epoch: u64, pub mermaid_aspect_bucket: Option, pub show_agentgrep_output: bool, + pub show_bash_output: bool, pub tool_call_details: bool, } @@ -116,6 +118,7 @@ where mermaid_epoch: context.mermaid_epoch, mermaid_aspect_bucket: context.mermaid_aspect_bucket, show_agentgrep_output: context.show_agentgrep_output, + show_bash_output: context.show_bash_output, tool_call_details: context.tool_call_details, }; diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 94971dbaa0..65759664a0 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -950,6 +950,9 @@ pub struct App { /// has sent. Without a budget, a model that stops updating its todos gets /// nudged on every turn forever, silently burning an API call per tick. todo_completion_gate_attempts: u8, + /// Whether the clean completion handoff has already requested a user-facing + /// final response for the current todo cycle. + todo_final_response_requested: bool, /// Exact continuation sent for the last incomplete todo state. An unchanged /// list must not trigger another automatic turn: the agent may be parked on /// a worker, wake, or human decision, and repeated pokes cannot help. @@ -1344,6 +1347,10 @@ pub struct App { /// Last time the pinned todo band re-read todos from disk (1s throttle). #[allow(dead_code)] pinned_todos_checked_at: Option, + /// User-expanded state for the pinned todo band's `+N more` row. + pinned_todos_expanded: bool, + /// Running and terminal background tasks shown beneath the pinned todo band. + background_task_rows: Vec, last_side_panel_refresh: Option, // Most recently persisted focus target for dictation routing. last_client_focus_recorded_at: Option, diff --git a/crates/jcode-tui/src/tui/app/auth.rs b/crates/jcode-tui/src/tui/app/auth.rs index 919e2980aa..b09e36c2a4 100644 --- a/crates/jcode-tui/src/tui/app/auth.rs +++ b/crates/jcode-tui/src/tui/app/auth.rs @@ -1799,67 +1799,39 @@ impl App { ))); }; - let cli = match crate::auth::grok_build::ensure_cli().await { - Ok(cli) => cli, - Err(error) => { - Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { - provider: "grok-build".to_string(), - success: false, - message: format!("Failed to prepare Grok Build: {error:#}"), - })); - return; - } - }; - publish_progress( - "Grok Build Login\n\nManaged backend ready. Requesting xAI authorization..." - .to_string(), - "Grok Build: requesting authorization", - ); - - let mut child = match tokio::process::Command::new(&cli) - .arg("login") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(child) => child, + let client = crate::provider::shared_http_client(); + let authorization = match crate::auth::grok_build::initiate_device_login(&client).await { + Ok(authorization) => authorization, Err(error) => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), success: false, - message: format!( - "Failed to start Jcode's managed Grok Build backend: {error}" - ), + message: format!("Failed to start Grok Build login: {error:#}"), })); return; } }; - - if let Some(stderr) = child.stderr.take() { - let session_id = session_id.clone(); - tokio::spawn(async move { - use tokio::io::AsyncBufReadExt; - let mut lines = tokio::io::BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim(); - if line.is_empty() { - continue; - } - Bus::global().publish(BusEvent::UiActivity( - crate::bus::UiActivity::auth( - Some(session_id.clone()), - line.to_string(), - Some("Grok Build: waiting for browser approval"), - ), - )); + let url = authorization.verification_uri_complete.as_deref() + .unwrap_or(&authorization.verification_uri); + let _ = Self::open_auth_browser(url); + publish_progress(format!( + "Grok Build Login\n\nOpen: {}\n\nConfirm code: {}\n\nWaiting for authorization...", + authorization.verification_uri, authorization.user_code + ), "Grok Build: waiting for browser approval"); + + match crate::auth::grok_build::complete_device_login(&client, &authorization).await { + Ok(()) => { + // The ACP executable is a private provider backend, not an + // authentication dependency. Provision it only after the + // native OAuth flow has completed. + if let Err(error) = crate::auth::grok_build::ensure_cli().await { + Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { + provider: "grok-build".to_string(), + success: false, + message: format!("Grok Build login succeeded, but its managed runtime could not be prepared: {error:#}"), + })); + return; } - }); - } - - match child.wait().await { - Ok(status) if status.success() => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), success: true, @@ -1867,13 +1839,6 @@ impl App { .to_string(), })); } - Ok(status) => { - Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { - provider: "grok-build".to_string(), - success: false, - message: format!("Grok Build login exited with status {status}."), - })); - } Err(error) => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker.rs b/crates/jcode-tui/src/tui/app/auth_account_picker.rs index 29ad1da7e0..8791f7741b 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker.rs @@ -1,3 +1,4 @@ +use super::auth_account_picker_saved_accounts::{account_display_name, anthropic_account_use}; use super::*; impl App { @@ -511,7 +512,7 @@ impl App { selected = idx; } models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("Claude", &account.label, claude_accounts.len()), options: vec![crate::tui::PickerOption { provider: "Claude".to_string(), api_method: if is_active { @@ -520,7 +521,13 @@ impl App { "saved".to_string() }, available: true, - detail: format!("{} - {} - plan {}", email, status, plan), + detail: format!( + "{} - {} - {} - plan {}", + email, + anthropic_account_use(account.subscription_type.as_deref()), + status, + plan + ), estimated_reference_cost_micros: None, }], action: crate::tui::PickerAction::Account( @@ -560,7 +567,7 @@ impl App { selected = idx; } models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("OpenAI", &account.label, openai_accounts.len()), options: vec![crate::tui::PickerOption { provider: "OpenAI".to_string(), api_method: if is_active { @@ -769,7 +776,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let plan = account.subscription_type.as_deref().unwrap_or("unknown"); models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("Claude", &account.label, accounts.len()), options: vec![crate::tui::PickerOption { provider: "Claude".to_string(), api_method: if is_active { @@ -778,7 +785,13 @@ impl App { "saved".to_string() }, available: true, - detail: format!("{} - {} - plan {}", email, status, plan), + detail: format!( + "{} - {} - {} - plan {}", + email, + anthropic_account_use(account.subscription_type.as_deref()), + status, + plan + ), estimated_reference_cost_micros: None, }], action: crate::tui::PickerAction::Account( @@ -919,7 +932,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let account_id = account.account_id.as_deref().unwrap_or("unknown"); models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("OpenAI", &account.label, accounts.len()), options: vec![crate::tui::PickerOption { provider: "OpenAI".to_string(), api_method: if is_active { diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs b/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs index a10171d990..059138fcdb 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs @@ -36,7 +36,7 @@ impl App { if accounts.is_empty() { return "OpenAI Accounts: none configured\n\n\ - Use /account openai add to add the next numbered account, or /login openai to refresh the active one." + Use /account openai add to add another account, or /login openai to refresh the active one." .to_string(); } @@ -57,7 +57,7 @@ impl App { let account_id = account.account_id.as_deref().unwrap_or("unknown"); let active_mark = if is_active { "active" } else { "" }; rows.push([ - account.label.clone(), + account_display_name("OpenAI", &account.label, accounts.len()), email, status.to_string(), account_id.to_string(), @@ -83,11 +83,11 @@ impl App { if accounts.is_empty() { return "Anthropic Accounts: none configured\n\n\ - Use /account claude add to add the next numbered account, or /login claude to refresh the active one." + Use /account claude add to add another account, or /login claude to refresh the active one." .to_string(); } - let headers = ["Account", "Email", "Status", "Subscription", "Active"]; + let headers = ["Account", "Email", "Status", "Use", "Subscription"]; let mut rows: Vec<[String; 5]> = Vec::new(); for account in &accounts { let is_active = active_label.as_deref() == Some(&account.label); @@ -102,13 +102,18 @@ impl App { .map(mask_email) .unwrap_or_else(|| "unknown".to_string()); let sub = account.subscription_type.as_deref().unwrap_or("unknown"); - let active_mark = if is_active { "active" } else { "" }; + let account_use = anthropic_account_use(account.subscription_type.as_deref()); + let sub = if is_active { + format!("{sub} (active)") + } else { + sub.to_string() + }; rows.push([ - account.label.clone(), + account_display_name("Claude", &account.label, accounts.len()), email, status.to_string(), - sub.to_string(), - active_mark.to_string(), + account_use.to_string(), + sub, ]); } @@ -130,7 +135,8 @@ impl App { ) { let active_label = crate::auth::claude::active_account_label(); let now_ms = chrono::Utc::now().timestamp_millis(); - for account in crate::auth::claude::list_accounts().unwrap_or_default() { + let accounts = crate::auth::claude::list_accounts().unwrap_or_default(); + for account in &accounts { let status = if account.expires > now_ms { "valid" } else { @@ -142,7 +148,9 @@ impl App { .map(mask_email) .unwrap_or_else(|| "unknown".to_string()); let plan = account.subscription_type.as_deref().unwrap_or("unknown"); + let account_use = anthropic_account_use(account.subscription_type.as_deref()); let label = account.label.clone(); + let display_name = account_display_name("Claude", &label, accounts.len()); let active_suffix = if active_label.as_deref() == Some(label.as_str()) { " - active" } else { @@ -151,8 +159,8 @@ impl App { items.push(crate::tui::account_picker::AccountPickerItem::action( provider.id, provider.display_name, - format!("Switch account `{label}`"), - format!("{email} - {status} - plan {plan}{active_suffix}"), + format!("Switch {display_name}"), + format!("{email} - {account_use} - {status} - plan {plan}{active_suffix}"), crate::tui::account_picker::AccountPickerCommand::SubmitInput(format!( "/account {} switch {}", provider.id, label @@ -188,7 +196,8 @@ impl App { ) { let active_label = crate::auth::codex::active_account_label(); let now_ms = chrono::Utc::now().timestamp_millis(); - for account in crate::auth::codex::list_accounts().unwrap_or_default() { + let accounts = crate::auth::codex::list_accounts().unwrap_or_default(); + for account in &accounts { let status = match account.expires_at { Some(expires_at) if expires_at > now_ms => "valid", Some(_) => "expired", @@ -201,6 +210,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let account_id = account.account_id.as_deref().unwrap_or("unknown"); let label = account.label.clone(); + let display_name = account_display_name("OpenAI", &label, accounts.len()); let active_suffix = if active_label.as_deref() == Some(label.as_str()) { " - active" } else { @@ -209,7 +219,7 @@ impl App { items.push(crate::tui::account_picker::AccountPickerItem::action( provider.id, provider.display_name, - format!("Switch account `{label}`"), + format!("Switch {display_name}"), format!("{email} - {status} - acct {account_id}{active_suffix}"), crate::tui::account_picker::AccountPickerCommand::SubmitInput(format!( "/account {} switch {}", @@ -240,6 +250,60 @@ impl App { } } +/// A provider name is enough when there is only one login. Animal names are +/// useful only when multiple logins of that provider need distinguishing. +pub(super) fn account_display_name(provider: &str, label: &str, account_count: usize) -> String { + if account_count <= 1 { + return provider.to_string(); + } + let animal = label + .rsplit_once('-') + .map(|(_, animal)| animal) + .unwrap_or(label); + let mut chars = animal.chars(); + let animal = chars + .next() + .map(|first| first.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_else(|| "Account".to_string()); + format!("{provider} {animal}") +} + +/// Anthropic exposes the subscription kind but not an explicit work/personal +/// flag. Team and enterprise plans are organizational; individual plans are +/// personal. Unknown values stay unknown rather than being guessed from email. +pub(super) fn anthropic_account_use(subscription_type: Option<&str>) -> &'static str { + match subscription_type.map(str::to_ascii_lowercase).as_deref() { + Some("team" | "enterprise" | "business") => "work", + Some("free" | "pro" | "max") => "personal", + _ => "unknown", + } +} + +#[cfg(test)] +mod account_display_tests { + use super::*; + + #[test] + fn animals_only_distinguish_duplicate_provider_logins() { + assert_eq!(account_display_name("Claude", "claude-otter", 1), "Claude"); + assert_eq!( + account_display_name("Claude", "claude-otter", 2), + "Claude Otter" + ); + assert_eq!( + account_display_name("Claude", "claude-fox", 2), + "Claude Fox" + ); + } + + #[test] + fn known_anthropic_plans_identify_personal_and_work_accounts() { + assert_eq!(anthropic_account_use(Some("max")), "personal"); + assert_eq!(anthropic_account_use(Some("team")), "work"); + assert_eq!(anthropic_account_use(None), "unknown"); + } +} + fn format_account_table(headers: &[&str; 5], rows: &[[String; 5]]) -> Vec { let mut widths = [0usize; 5]; for (i, h) in headers.iter().enumerate() { diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 891d9944d8..02fcc803d3 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -2186,9 +2186,12 @@ pub(super) fn build_fast_macos_release_prompt() -> String { } pub(super) fn build_remote_release_prompt() -> String { - build_release_prompt( + let jcode_release = build_release_prompt( "", "Then run scripts/quick-release.sh --remote v to push the tag immediately without any local build. Let the release workflow build, sign, checksum, and publish every platform, and leave publication gated on those remote checks.", + ); + format!( + "First identify the repository in the current working directory from its git remote, release documentation, package manifests, existing tags, and CI workflows. Only use the following Jcode-specific procedure when this is the Jcode self-development repository and scripts/quick-release.sh exists: {jcode_release} Otherwise, use the repository's own established release conventions. Inspect its release documentation, workflows, scripts, manifests, tag format, and recent releases before changing anything. Make logical commits for current work without disturbing unrelated changes and push them normally. Determine the next version from this repository's versioning scheme and user-visible changes, update only the version files and changelog formats it actually uses, validate the metadata, commit and push it, then trigger the repository's documented remote release mechanism. Prefer a tag-triggered or workflow-dispatch CI release that performs builds and publication remotely. Do not assume the project uses Cargo, changelog JSON, v-prefixed tags, or scripts/quick-release.sh. Do not build release artifacts locally unless this repository explicitly requires it and no remote release path exists. Never force-push, move an existing tag, bypass remote release gates, or invent a release process. Report the detected release convention, version, commits, tag or workflow invocation, and remote release status." ) } diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 47807ac6dc..748c0c790e 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -4,7 +4,7 @@ mod clipboard_helper; pub(crate) mod model_names; use crate::todo::TodoItem; -use crate::tui::info_widget::{AmbientWidgetData, GitInfo, MemoryInfo}; +use crate::tui::info_widget::{AmbientWidgetData, GitInfo}; use crate::tui::session_picker::ResumeTarget; use crossterm::event::{KeyCode, KeyModifiers}; use std::path::{Path, PathBuf}; @@ -75,6 +75,19 @@ pub(crate) fn invalidate_git_info_cache() { } } +/// Pin the git-status widget to a fixed value for deterministic renders. +/// +/// Full-frame artifact generators (onboarding screenshots) would otherwise +/// capture the live ahead/behind/dirty counts of whatever repo the generator +/// happens to run in. Marking the entry as `refreshing` keeps the TTL path +/// from spawning a background probe that overwrites the seed mid-render. +#[cfg(test)] +pub(crate) fn seed_git_info_cache_for_tests(info: Option) { + if let Ok(mut guard) = GIT_INFO_CACHE.lock() { + *guard = Some((std::time::Instant::now(), info, true)); + } +} + /// Force the todos widget cache to refetch the given session on its next read. /// /// Call this right after the app persists a local todo write so the info widget @@ -1103,166 +1116,6 @@ pub(super) fn gather_todos_and_goals_for_session( (Vec::new(), Vec::new()) } -pub(super) fn gather_memory_info( - memory_enabled: bool, - working_dir: Option, -) -> Option { - use std::sync::Mutex; - use std::time::Instant; - - static CACHE: Mutex, bool)>> = Mutex::new(None); - const TTL: Duration = Duration::from_secs(2); - - // When memory is disabled we still surface the stored counts (with a - // DISABLED badge) so the user can see they have memories but recall is off. - // Live activity and the sidecar model are suppressed in that case. - let activity = if memory_enabled { - crate::memory::get_activity() - } else { - None - }; - let sidecar_model = if memory_enabled && crate::memory::memory_sidecar_enabled() { - let sidecar = crate::sidecar::Sidecar::new(); - Some(format!( - "{} ยท {}", - sidecar.backend_name(), - sidecar.model_name() - )) - } else { - None - }; - - let finalize = |mut info: MemoryInfo| { - info.activity = activity.clone(); - info.sidecar_model = sidecar_model.clone(); - info.disabled = !memory_enabled; - info - }; - - if let Ok(mut guard) = CACHE.lock() { - if let Some((ts, cached, refreshing)) = guard.as_mut() { - if ts.elapsed() < TTL || *refreshing { - return match cached.clone() { - Some(info) => Some(finalize(info)), - None => fallback_memory_info(memory_enabled, &activity, &sidecar_model), - }; - } - let stale = match cached.clone() { - Some(info) => Some(finalize(info)), - None => fallback_memory_info(memory_enabled, &activity, &sidecar_model), - }; - *refreshing = true; - let working_dir = working_dir.clone(); - std::thread::spawn(move || { - let result = gather_memory_info_inner(working_dir); - if let Ok(mut guard) = CACHE.lock() { - *guard = Some((Instant::now(), result, false)); - } - }); - return stale; - } - - *guard = Some((backdated_now(TTL + Duration::from_secs(1)), None, true)); - std::thread::spawn(move || { - let result = gather_memory_info_inner(working_dir); - if let Ok(mut guard) = CACHE.lock() { - *guard = Some((Instant::now(), result, false)); - } - }); - } - - fallback_memory_info(memory_enabled, &activity, &sidecar_model) -} - -fn fallback_memory_info( - memory_enabled: bool, - activity: &Option, - sidecar_model: &Option, -) -> Option { - // No cached counts yet. Show whatever live signal we have. - if activity.is_none() && sidecar_model.is_none() && memory_enabled { - return None; - } - Some(MemoryInfo { - sidecar_available: crate::memory::memory_sidecar_enabled(), - sidecar_model: sidecar_model.clone(), - activity: activity.clone(), - disabled: !memory_enabled, - ..Default::default() - }) -} - -fn gather_memory_info_inner(working_dir: Option) -> Option { - let activity = crate::memory::get_activity(); - let sidecar_model = if crate::memory::memory_sidecar_enabled() { - let sidecar = crate::sidecar::Sidecar::new(); - Some(format!( - "{} ยท {}", - sidecar.backend_name(), - sidecar.model_name() - )) - } else { - None - }; - - use crate::memory::MemoryManager; - - // Scope the manager to the session working dir so the project count reads - // the same projects/.json store the memory tool writes (issue #491). - let manager = match working_dir.as_deref() { - Some(dir) if !dir.trim().is_empty() => MemoryManager::new().with_project_dir(dir), - _ => MemoryManager::new(), - }; - let project_graph = manager.load_project_graph().ok(); - let global_graph = manager.load_global_graph().ok(); - - let (project_count, global_count, by_category) = { - let mut by_category = std::collections::HashMap::new(); - let project_count = project_graph - .as_ref() - .map(|p| { - for entry in p.memories.values() { - *by_category.entry(entry.category.to_string()).or_insert(0) += 1; - } - p.memory_count() - }) - .unwrap_or(0); - let global_count = global_graph - .as_ref() - .map(|g| { - for entry in g.memories.values() { - *by_category.entry(entry.category.to_string()).or_insert(0) += 1; - } - g.memory_count() - }) - .unwrap_or(0); - (project_count, global_count, by_category) - }; - - let total_count = project_count + global_count; - let (graph_nodes, graph_edges) = crate::tui::info_widget::build_graph_topology( - project_graph.as_ref(), - global_graph.as_ref(), - ); - - if total_count > 0 || activity.is_some() || sidecar_model.is_some() { - Some(MemoryInfo { - total_count, - project_count, - global_count, - by_category, - sidecar_available: crate::memory::memory_sidecar_enabled(), - sidecar_model, - activity, - disabled: false, - graph_nodes, - graph_edges, - }) - } else { - None - } -} - pub(super) fn gather_ambient_info(ambient_enabled: bool) -> Option { use std::time::Instant; const TTL: Duration = Duration::from_secs(2); diff --git a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs index 1ac59310a7..e4d10fbd97 100644 --- a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs +++ b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs @@ -115,6 +115,11 @@ pub(super) fn build_registry(inputs: &RegistryInputs<'_>) -> Vec { }; // Configured pane/mode toggles (pre-control shortcuts). + push( + inputs.toggles.auto_poke.binding().cloned(), + "auto_poke_toggle", + "toggle auto-poke", + ); push( inputs.toggles.copy_selection.binding().cloned(), "copy_selection_toggle", @@ -342,11 +347,6 @@ pub(super) fn build_registry(inputs: &RegistryInputs<'_>) -> Vec { "input_stash", "stash or restore the input draft", )); - out.push(KnownHotkey::new( - ctrl('p'), - "auto_poke_toggle", - "toggle auto-poke", - )); out.push(KnownHotkey::new( ctrl('t'), "queue_mode_toggle", @@ -907,7 +907,7 @@ mod tests { } #[test] - fn lookup_finds_builtin_ctrl_p_auto_poke() { + fn lookup_finds_configured_ctrl_p_auto_poke() { let registry = test_inputs_registry(false); let info = lookup(®istry, true, KeyCode::Char('p'), KeyModifiers::CONTROL) .expect("ctrl+p known"); @@ -1041,6 +1041,7 @@ mod tests { ("effort_increase", Some(&["effort_increase"])), ("effort_decrease", Some(&["effort_decrease"])), ("centered_toggle", Some(&["centered_toggle"])), + ("auto_poke_toggle", Some(&["auto_poke_toggle"])), ("scroll_prompt_up", Some(&["prompt_jump_up"])), ("scroll_prompt_down", Some(&["prompt_jump_down"])), ("scroll_bookmark", Some(&["scroll_bookmark"])), @@ -1122,6 +1123,7 @@ mod tests { let registry = test_inputs_registry(false); let toggles = crate::tui::keybind::load_toggle_keys(); let toggle_bindings: &[(&str, Option<&KeyBinding>)] = &[ + ("auto_poke_toggle", toggles.auto_poke.binding()), ("side_panel_toggle", toggles.side_panel.binding()), ("copy_selection_toggle", toggles.copy_selection.binding()), ("diagram_pane_toggle", toggles.diagram_pane.binding()), diff --git a/crates/jcode-tui/src/tui/app/inline_interactive.rs b/crates/jcode-tui/src/tui/app/inline_interactive.rs index 310485fdef..bbff93bb51 100644 --- a/crates/jcode-tui/src/tui/app/inline_interactive.rs +++ b/crates/jcode-tui/src/tui/app/inline_interactive.rs @@ -97,13 +97,16 @@ use placeholder_routes::route_supports_reasoning_effort; /// "openai-compatible:myprofile"), or a bare openai-compatible profile id /// ("myprofile"). Matching is case/format-insensitive via the shared provider /// label normalizer. Routes for the active model are always kept so the -/// current selection never disappears from the picker, and a filter that +/// current selection never disappears from the picker, but only when their +/// provider and API method match the active route. A filter that /// matches nothing falls back to the unfiltered list instead of an empty /// picker. fn filter_routes_by_provider_allowlist( routes: Vec, allowlist: Option<&[String]>, current_model: &str, + current_provider: &str, + current_api_method: Option<&str>, ) -> Vec { use crate::provider::normalize_model_route_provider_label as normalize; @@ -137,9 +140,17 @@ fn filter_routes_by_provider_allowlist( }) }; + let route_is_current = |route: &crate::provider::ModelRoute| { + route.model == current_model + && crate::provider::model_route_provider_labels_match(&route.provider, current_provider) + && current_api_method.is_none_or(|api_method| { + crate::provider::ModelRouteApiMethod::parse(&route.api_method) + == crate::provider::ModelRouteApiMethod::parse(api_method) + }) + }; let filtered: Vec = routes .iter() - .filter(|route| route.model == current_model || route_matches(route)) + .filter(|route| route_is_current(route) || route_matches(route)) .cloned() .collect(); if filtered.is_empty() { @@ -1401,6 +1412,14 @@ impl App { } else { self.provider.model().to_string() }; + let current_provider = if self.is_remote { + self.remote_provider_name + .clone() + .unwrap_or_else(|| "remote".to_string()) + } else { + self.provider.display_name() + }; + let current_api_method = self.current_route_api_method(); let config = crate::config::config(); let config_default_model = config.provider.default_model.clone(); let config_default_provider = config.provider.default_provider.clone(); @@ -1454,6 +1473,8 @@ impl App { routes, config.provider.model_picker_providers.as_deref(), ¤t_model, + ¤t_provider, + current_api_method.as_deref(), ); if routes.is_empty() { @@ -1534,13 +1555,6 @@ impl App { } } - let current_provider = if self.is_remote { - self.remote_provider_name - .clone() - .unwrap_or_else(|| "remote".to_string()) - } else { - self.provider.name().to_string() - }; let recent_auth_provider = self .recent_authenticated_provider .as_ref() @@ -4273,6 +4287,8 @@ mod tests { routes.clone(), Some(&["Llama.CPP".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].model, "qwen3-coder"); @@ -4282,6 +4298,8 @@ mod tests { routes.clone(), Some(&["llamacpp".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].provider, "llama.cpp"); @@ -4291,6 +4309,8 @@ mod tests { routes.clone(), Some(&["claude-oauth".to_string(), "openrouter".to_string()]), "unrelated-current", + "OpenAI", + None, ); let models: Vec<&str> = filtered.iter().map(|r| r.model.as_str()).collect(); assert_eq!(models, ["claude-fable-5", "deepseek/deepseek-v4-pro"]); @@ -4308,6 +4328,8 @@ mod tests { routes.clone(), Some(&["llamacpp".to_string()]), "gpt-5.5", + "OpenAI", + Some("openai-oauth"), ); let models: Vec<&str> = filtered.iter().map(|r| r.model.as_str()).collect(); assert_eq!(models, ["gpt-5.5", "qwen3-coder"]); @@ -4317,21 +4339,90 @@ mod tests { routes.clone(), Some(&["nonexistent".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), routes.len()); // None / empty / blank-entry allowlists are no-ops. assert_eq!( - filter_routes_by_provider_allowlist(routes.clone(), None, "x").len(), + filter_routes_by_provider_allowlist(routes.clone(), None, "x", "OpenAI", None).len(), 2 ); assert_eq!( - filter_routes_by_provider_allowlist(routes.clone(), Some(&[]), "x").len(), + filter_routes_by_provider_allowlist(routes.clone(), Some(&[]), "x", "OpenAI", None,) + .len(), 2 ); assert_eq!( - filter_routes_by_provider_allowlist(routes, Some(&[" ".to_string()]), "x").len(), + filter_routes_by_provider_allowlist( + routes, + Some(&[" ".to_string()]), + "x", + "OpenAI", + None, + ) + .len(), 2 ); } + + #[test] + fn provider_allowlist_does_not_keep_disallowed_route_sharing_current_model() { + let routes = vec![ + model_route( + "moonshotai/Kimi-K3", + "my-provider", + "openai-compatible:my-provider", + ), + model_route("moonshotai/Kimi-K3", "Copilot", "copilot"), + ]; + + let filtered = filter_routes_by_provider_allowlist( + routes, + Some(&["my-provider".to_string()]), + "moonshotai/Kimi-K3", + "my-provider", + Some("openai-compatible:my-provider"), + ); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].provider, "my-provider"); + } + + #[test] + fn provider_allowlist_keeps_only_exact_active_route_when_model_and_provider_overlap() { + let routes = vec![ + model_route("gpt-5.5", "OpenAI", "openai-oauth"), + model_route("gpt-5.5", "OpenAI", "openai-api-key"), + model_route("gpt-5.5", "Copilot", "copilot"), + model_route("qwen3-coder", "llama.cpp", "openai-compatible:llamacpp"), + ]; + + let filtered = filter_routes_by_provider_allowlist( + routes, + Some(&["llamacpp".to_string()]), + "gpt-5.5", + "OpenAI", + Some("openai-oauth"), + ); + + let routes: Vec<(&str, &str, &str)> = filtered + .iter() + .map(|route| { + ( + route.model.as_str(), + route.provider.as_str(), + route.api_method.as_str(), + ) + }) + .collect(); + assert_eq!( + routes, + [ + ("gpt-5.5", "OpenAI", "openai-oauth"), + ("qwen3-coder", "llama.cpp", "openai-compatible:llamacpp"), + ] + ); + } } diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 1b62dd7a56..2e1f739592 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1637,6 +1637,7 @@ impl App { // leave incomplete todos would never be poked. Stay armed and // simply do nothing this turn. crate::logging::info("AUTO_POKE_DECISION action=idle reason=no_todos incomplete=0"); + self.todo_final_response_requested = false; return false; } // Deferred quality checks land here, once, instead of interrupting @@ -1684,7 +1685,7 @@ impl App { crate::telemetry::record_todo_gate( crate::telemetry::TodoGateKind::ConfidenceSpike, ); - "๐Ÿ” Double-checking a confidence jump for you..." + "๐Ÿ” Double-checking confidence jumps..." }; self.push_display_message(DisplayMessage::system(notice)); // User-role content: reminder-only turns read as empty user @@ -1722,20 +1723,31 @@ impl App { // it stays armed so the next batch of work is covered too; only an // explicit /poke off (or a circuit breaker above) disarms it. self.auto_poke_incomplete_todos = self.auto_poke_default_on; - self.todo_confidence_spike_challenged = false; // A finished cycle re-arms the review for whatever work comes next; // without this a session could only ever deliver one digest. self.todo_gate_digest_delivered = false; self.todo_completion_gate_attempts = 0; - self.push_display_message(DisplayMessage::system(format!( - "โœ… All todos done. Completion confidence: {}.", - confidence_label - ))); + if !self.todo_final_response_requested { + self.todo_final_response_requested = true; + self.push_display_message(DisplayMessage::system(format!( + "โœ… All todos done. Completion confidence: {}.", + confidence_label + ))); + self.queued_messages + .push(crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()); + self.pending_queued_dispatch = true; + return true; + } self.pending_queued_dispatch = false; return false; } let poke_message = super::commands::build_poke_message(&incomplete); + self.todo_final_response_requested = false; + // Open work begins a new completion cycle. Keep the prior spike check + // latched until this point so the synthetic final-response turn cannot + // retrigger the same evidence gate against unchanged completed todos. + self.todo_confidence_spike_challenged = false; let fingerprint = serde_json::to_string(&incomplete).unwrap_or_else(|_| poke_message.clone()); if self.last_auto_poke_fingerprint.as_ref() == Some(&fingerprint) { @@ -1948,10 +1960,6 @@ pub(super) fn handle_control_key(app: &mut App, code: KeyCode) -> bool { app.toggle_input_stash(); true } - KeyCode::Char('p') => { - super::commands::toggle_auto_poke_hotkey_local(app); - true - } KeyCode::Char('v') => { paste_from_clipboard(app); true @@ -2278,6 +2286,10 @@ pub(super) fn handle_pre_control_shortcuts( let macos_option_shortcut = crate::tui::keybind::shortcut_char_for_macos_option_key(code, modifiers); + if app.toggle_keys.auto_poke.matches(code, modifiers) { + super::commands::toggle_auto_poke_hotkey_local(app); + return true; + } if app.toggle_keys.copy_selection.matches(code, modifiers) { app.toggle_copy_selection_mode(); return true; @@ -2479,7 +2491,7 @@ fn handle_inline_image_toggle_shortcut(app: &mut App, key: char) -> bool { true } -fn handle_expand_edit_badge_shortcut(app: &mut App, key: char) -> bool { +pub(super) fn handle_expand_edit_badge_shortcut(app: &mut App, key: char) -> bool { if !key.eq_ignore_ascii_case(&'e') { return false; } @@ -3767,7 +3779,8 @@ impl App { // Leaving the preview should happen as soon as the user acts on it. self.onboarding_preview_mode = false; - // Add user message to display (show placeholder to user, not full paste) + // Add the expanded user message to the transcript. The composer remains compact + // while editing, but sent turns should show the actual pasted content. // Remember the typed prompt so we can restore it to the input box if this // turn fails (e.g. "token refresh needed"), instead of dropping it. self.last_submitted_input = Some(raw_input.clone()); @@ -3780,7 +3793,7 @@ impl App { self.push_display_message(DisplayMessage { role: "user".to_string(), - content: raw_input, // Show placeholder to user (condensed view) + content: input.clone(), tool_calls: vec![], duration_secs: None, title: None, diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index c8c645bc15..3a4058884f 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -5,7 +5,7 @@ use crate::bus::{ }; use crate::message::{ ContentBlock, Message, Role, background_task_status_notice, - format_background_task_notification_markdown, format_background_task_progress_markdown, + format_background_task_notification_markdown, }; use crate::session::StoredDisplayRole; use anyhow::Result; @@ -165,6 +165,10 @@ pub(super) fn handle_bus_event( handle_background_task_progress(app, progress); true } + Ok(BusEvent::BackgroundTaskStalled(task)) => { + handle_background_task_stalled(app, task); + true + } Ok(BusEvent::InputShellCompleted(shell)) => { handle_input_shell_completed(app, shell); true @@ -309,7 +313,9 @@ pub(super) fn handle_ui_activity(app: &mut App, activity: UiActivity) -> bool { match activity.kind { UiActivityKind::Background => { - app.push_display_message(DisplayMessage::background_task(activity.message.clone())) + if !app.upsert_running_background_task_started(&activity.message) { + app.push_display_message(DisplayMessage::background_task(activity.message.clone())) + } } UiActivityKind::Auth | UiActivityKind::Catalog => { if activity.message.trim().is_empty() { @@ -321,7 +327,7 @@ pub(super) fn handle_ui_activity(app: &mut App, activity: UiActivity) -> bool { ) .is_some() { - app.upsert_background_task_progress_message(activity.message.clone()); + app.upsert_running_background_task_progress(&activity.message); } else { app.push_display_message(DisplayMessage::system(activity.message.clone())) } @@ -423,12 +429,23 @@ fn apply_terminal_event( } fn handle_background_task_completed(app: &mut App, task: BackgroundTaskCompleted) { + if task.session_id == app.session.id { + let label = crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref(), + ); + let status = if task.status == crate::bus::BackgroundTaskStatus::Completed { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + app.finish_background_task(task.task_id.clone(), label, status); + } if !task.notify || task.session_id != app.session.id { return; } let notification = format_background_task_notification_markdown(&task); - app.push_display_message(DisplayMessage::background_task(notification.clone())); app.set_status_notice(background_task_status_notice(&task)); if !app.is_processing { @@ -463,12 +480,76 @@ fn handle_background_task_completed(app: &mut App, task: BackgroundTaskCompleted } } +fn handle_background_task_stalled(app: &mut App, task: crate::bus::BackgroundTaskStalled) { + if task.session_id != app.session.id { + return; + } + + let notification = crate::message::format_background_task_stalled_markdown(&task); + let percent = app + .background_task_rows_ref() + .iter() + .find(|row| row.task_id == task.task_id) + .and_then(|row| row.percent); + app.upsert_running_background_task( + task.task_id.clone(), + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref(), + ), + percent, + ); + app.set_status_notice(format!( + "Background task stalled ยท {} ยท no output for {}s", + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref() + ), + task.stall_wake_seconds + )); + + if !app.is_processing { + app.add_provider_message(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: notification.clone(), + cache_control: None, + }], + timestamp: Some(chrono::Utc::now()), + tool_duration_ms: None, + }); + app.session.add_message_with_display_role( + Role::User, + vec![ContentBlock::Text { + text: notification, + cache_control: None, + }], + Some(StoredDisplayRole::BackgroundTask), + ); + let _ = app.session.save(); + + if task.wake { + app.pending_turn = true; + app.is_processing = true; + app.status = ProcessingStatus::Sending; + if app.processing_started.is_none() { + app.processing_started = Some(std::time::Instant::now()); + } + app.visible_turn_started = Some(std::time::Instant::now()); + } + } +} + fn handle_background_task_progress(app: &mut App, event: BackgroundTaskProgressEvent) { if event.session_id != app.session.id { return; } - app.upsert_background_task_progress_message(format_background_task_progress_markdown(&event)); + let label = crate::message::background_task_display_label( + &event.tool_name, + event.display_name.as_deref(), + ); + app.upsert_running_background_task(event.task_id.clone(), label, event.progress.percent); let notice = format!( "Background task ยท {} ยท {}", diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index f4e60473f3..62425d555a 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -195,7 +195,7 @@ impl App { /// The api_method string of the route currently in use, used to exclude the /// failed route and to recognize same-model/different-method alternatives. - fn current_route_api_method(&self) -> Option { + pub(super) fn current_route_api_method(&self) -> Option { if self.is_remote { return self.session.route_api_method.clone(); } @@ -1789,11 +1789,10 @@ impl App { match completed.result { Ok(summary) => { self.invalidate_model_picker_cache(); - self.upsert_background_task_progress_message( - crate::message::format_model_refresh_progress_markdown( - "Model list refresh complete", - Some(100), - ), + self.finish_background_task( + "refresh-model-list".to_string(), + "Model list refresh".to_string(), + crate::tui::BackgroundTaskRowStatus::Completed, ); self.push_display_message(DisplayMessage::system(format_model_refresh_summary( &summary, @@ -1804,11 +1803,10 @@ impl App { )); } Err(error) => { - self.upsert_background_task_progress_message( - crate::message::format_model_refresh_progress_markdown( - "Model list refresh failed", - None, - ), + self.finish_background_task( + "refresh-model-list".to_string(), + "Model list refresh".to_string(), + crate::tui::BackgroundTaskRowStatus::Failed, ); self.push_display_message(DisplayMessage::error(format!( "Failed to refresh model list: {}", diff --git a/crates/jcode-tui/src/tui/app/navigation.rs b/crates/jcode-tui/src/tui/app/navigation.rs index d84b1a0ee3..1c71f5b743 100644 --- a/crates/jcode-tui/src/tui/app/navigation.rs +++ b/crates/jcode-tui/src/tui/app/navigation.rs @@ -1484,6 +1484,15 @@ impl App { self.set_diff_pane_focus(false); } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && crate::tui::ui::viewport::pinned_todo_more_area().is_some_and(|area| { + super::super::layout_utils::point_in_rect(mouse.column, mouse.row, area) + }) + { + self.pinned_todos_expanded = true; + finish_mouse_event!(false, "pinned_todos_expand"); + } + // A left press in the composer moves the caret first (native text-field // behavior), then falls through so the shared copy-selection machinery // can arm a drag anchor: click repositions the cursor, drag selects the @@ -1648,6 +1657,27 @@ impl App { finish_mouse_event!(false, "toggle_swarm_expand"); } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && crate::tui::ui::visible_expand_edit_badge_at(mouse.column, mouse.row) + && super::input::handle_expand_edit_badge_shortcut(self, 'e') + { + finish_mouse_event!(false, "expand_edit_badge_click"); + } + + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && let Some(target) = crate::tui::ui::visible_copy_target_at(mouse.column, mouse.row) + { + let success = super::helpers::copy_to_clipboard(&target.content); + self.record_copy_badge_key_press(target.key); + self.record_copy_badge_feedback(target.key, success); + if success { + self.set_status_notice(target.copied_notice); + } else { + self.set_status_notice(format!("Failed to copy {}", target.kind_label)); + } + finish_mouse_event!(false, "copy_badge_click"); + } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) && self.try_open_link_at(mouse.column, mouse.row) { diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index 872c026361..3500bdb2b1 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -390,6 +390,42 @@ async fn handle_remote_key_internal( return Ok(()); } + if app.toggle_keys.auto_poke.matches(code, modifiers) { + if app.auto_poke_incomplete_todos { + let cleared = app_mod::commands::disable_auto_poke(app); + app.set_status_notice("Poke: OFF"); + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_disabled_message(cleared), + )); + } else { + match app_mod::commands::activate_auto_poke(app) { + app_mod::commands::PokeActivation::EnabledNoIncomplete => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_enabled_without_incomplete_message(), + )); + } + app_mod::commands::PokeActivation::Queued => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_queued_display_message(), + )); + } + app_mod::commands::PokeActivation::SendNow { + incomplete_count, + poke_msg, + } => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_triggered_display_message(incomplete_count), + )); + + let _ = + begin_remote_send(app, remote, poke_msg, vec![], true, None, true, 0).await; + app.visible_turn_started = Some(Instant::now()); + } + } + } + return Ok(()); + } + if app.toggle_keys.side_panel.matches(code, modifiers) { app.toggle_side_panel(); return Ok(()); @@ -700,50 +736,6 @@ async fn handle_remote_key_internal( app.toggle_input_stash(); return Ok(()); } - KeyCode::Char('p') => { - if app.auto_poke_incomplete_todos { - let cleared = app_mod::commands::disable_auto_poke(app); - app.set_status_notice("Poke: OFF"); - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_disabled_message(cleared), - )); - } else { - match app_mod::commands::activate_auto_poke(app) { - app_mod::commands::PokeActivation::EnabledNoIncomplete => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_enabled_without_incomplete_message(), - )); - } - app_mod::commands::PokeActivation::Queued => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_queued_display_message(), - )); - } - app_mod::commands::PokeActivation::SendNow { - incomplete_count, - poke_msg, - } => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_triggered_display_message(incomplete_count), - )); - - let _ = begin_remote_send( - app, - remote, - poke_msg, - vec![], - true, - None, - true, - 0, - ) - .await; - app.visible_turn_started = Some(Instant::now()); - } - } - } - return Ok(()); - } KeyCode::Char('v') => { app.paste_from_clipboard(); return Ok(()); diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 7ac83b98e1..995f6af1e6 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -2438,14 +2438,31 @@ pub(in crate::tui::app) fn handle_server_event( } app.mark_soft_interrupt_injected(&content); let role = display_role.unwrap_or_else(|| "user".to_string()); - app.push_display_message(DisplayMessage { - role, - content: content.clone(), - tool_calls: vec![], - duration_secs: None, - title: None, - tool_data: None, - }); + if role == "background_task" { + if let Some(completed) = + crate::message::parse_background_task_notification_markdown(&content) + { + let status = if completed.status.contains("completed") { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + let label = crate::message::background_task_display_label( + &completed.tool_name, + completed.display_name.as_deref(), + ); + app.finish_background_task(completed.task_id, label, status); + } + } else { + app.push_display_message(DisplayMessage { + role, + content: content.clone(), + tool_calls: vec![], + duration_secs: None, + title: None, + tool_data: None, + }); + } if let Some(n) = tools_skipped { app.set_status_notice(format!("โšก {} tool(s) skipped", n)); } @@ -2526,11 +2543,23 @@ pub(in crate::tui::app) fn handle_server_event( if crate::message::parse_background_task_progress_notification_markdown(&message) .is_some() { - app.upsert_background_task_progress_message(message.clone()); + app.upsert_running_background_task_progress(&message); } else { - app.push_display_message(DisplayMessage::background_task(message.clone())); + if let Some(completed) = + crate::message::parse_background_task_notification_markdown(&message) + { + let status = if completed.status.contains("completed") { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + let label = crate::message::background_task_display_label( + &completed.tool_name, + completed.display_name.as_deref(), + ); + app.finish_background_task(completed.task_id, label, status); + } } - persist_replay_display_message(app, "background_task", None, &message); app.set_status_notice(presentation.status_notice); return false; } @@ -2559,13 +2588,14 @@ pub(in crate::tui::app) fn handle_server_event( ) { let status_notice = progress.summary.clone(); - app.upsert_background_task_progress_message(message.clone()); - persist_replay_display_message(app, "background_task", None, &message); + app.upsert_running_background_task_progress(&message); app.set_status_notice(status_notice); return false; } else if scope == "background_activity" { - app.push_display_message(DisplayMessage::background_task(message.clone())); - persist_replay_display_message(app, "background_task", None, &message); + if !app.upsert_running_background_task_started(&message) { + app.push_display_message(DisplayMessage::background_task(message.clone())); + persist_replay_display_message(app, "background_task", None, &message); + } } else { app.push_display_message(DisplayMessage::system(message.clone())); persist_replay_display_message(app, "system", None, &message); diff --git a/crates/jcode-tui/src/tui/app/state_ui_messages.rs b/crates/jcode-tui/src/tui/app/state_ui_messages.rs index 72f2877031..cf50aa3835 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_messages.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_messages.rs @@ -17,11 +17,12 @@ fn display_message_from_stored_message( if text.trim().is_empty() { return None; } + if is_background_task_lifecycle_message(&text) { + return None; + } match message.display_role { Some(crate::session::StoredDisplayRole::System) => Some(DisplayMessage::system(text)), - Some(crate::session::StoredDisplayRole::BackgroundTask) => { - Some(DisplayMessage::background_task(text)) - } + Some(crate::session::StoredDisplayRole::BackgroundTask) => None, None => match message.role { Role::User => { if crate::session::is_scheduled_task_message(message) { @@ -45,6 +46,14 @@ fn display_message_from_stored_message( } } +fn is_background_task_lifecycle_message(content: &str) -> bool { + let content = content.trim_start(); + content.starts_with("**Background task**") + || content.starts_with("**Background task started**") + || content.starts_with("**Background task progress**") + || content.starts_with("**Background task stalled**") +} + fn stored_message_visible_text(message: &crate::session::StoredMessage) -> String { let mut parts = Vec::new(); for block in &message.content { @@ -76,6 +85,9 @@ fn stored_message_visible_text(message: &crate::session::StoredMessage) -> Strin impl App { pub fn push_display_message(&mut self, mut message: DisplayMessage) { + if is_background_task_lifecycle_message(&message.content) { + return; + } compact_display_message_tool_data(&mut message); // A trailing Ctrl+L spacer only exists to keep the screen clear while // idle. The moment real content arrives, drop it so the transcript @@ -115,6 +127,7 @@ impl App { } pub(super) fn replace_display_messages(&mut self, mut messages: Vec) { + messages.retain(|message| !is_background_task_lifecycle_message(&message.content)); compact_display_messages_for_storage(&mut messages); self.display_messages = messages; self.attempt_committed_assistant_messages = 0; @@ -165,30 +178,98 @@ impl App { return false; }; + // A tool moved to the background finishes its foreground card with the + // background lifecycle notification returned by the tool. The same + // notification also drives the retained row in the pinned status band, + // so remove the transient tool card instead of turning it into a second + // transcript representation. + if is_background_task_lifecycle_message(&content) { + self.remove_display_message(idx); + return true; + } + self.replace_display_message_title_and_content(idx, title, content) } - pub(super) fn upsert_background_task_progress_message(&mut self, content: String) { + pub(super) fn background_task_rows_ref(&self) -> &[crate::tui::BackgroundTaskRow] { + &self.background_task_rows + } + + pub(super) fn upsert_running_background_task( + &mut self, + task_id: String, + label: String, + percent: Option, + ) { + if let Some(task) = self + .background_task_rows + .iter_mut() + .find(|task| task.task_id == task_id) + { + task.label = label; + task.percent = percent; + task.status = crate::tui::BackgroundTaskRowStatus::Running; + return; + } + self.background_task_rows + .push(crate::tui::BackgroundTaskRow { + task_id, + label, + percent, + status: crate::tui::BackgroundTaskRowStatus::Running, + }); + } + + pub(super) fn upsert_running_background_task_progress(&mut self, content: &str) -> bool { let Some(progress) = - crate::message::parse_background_task_progress_notification_markdown(&content) + crate::message::parse_background_task_progress_notification_markdown(content) else { - self.push_display_message(DisplayMessage::background_task(content)); - return; + return false; }; + let label = crate::message::background_task_display_label( + &progress.tool_name, + progress.display_name.as_deref(), + ); + self.upsert_running_background_task(progress.task_id, label, progress.percent); + true + } - let idx = self.display_messages.iter().rposition(|message| { - message.role == "background_task" - && crate::message::parse_background_task_progress_notification_markdown( - &message.content, - ) - .is_some_and(|existing| existing.task_id == progress.task_id) - }); + pub(super) fn upsert_running_background_task_started(&mut self, content: &str) -> bool { + let Some(started) = + crate::message::parse_background_task_started_notification_markdown(content) + else { + return false; + }; + self.upsert_running_background_task(started.task_id, started.label, None); + true + } - if let Some(idx) = idx { - self.replace_display_message_content(idx, content); - } else { - self.push_display_message(DisplayMessage::background_task(content)); + pub(super) fn finish_background_task( + &mut self, + task_id: String, + label: String, + status: crate::tui::BackgroundTaskRowStatus, + ) { + if let Some(task) = self + .background_task_rows + .iter_mut() + .find(|task| task.task_id == task_id) + { + task.label = label; + task.status = status; + if status == crate::tui::BackgroundTaskRowStatus::Completed { + task.percent = Some(100.0); + } + return; } + self.background_task_rows + .push(crate::tui::BackgroundTaskRow { + task_id, + label, + percent: (status == crate::tui::BackgroundTaskRowStatus::Completed) + .then_some(100.0), + status, + }); } pub(super) fn upsert_overnight_display_card( diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs index a929248021..8b9d7432a8 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs @@ -711,6 +711,10 @@ fn test_remote_release_command_uses_tag_only_ci_path() { assert!(prompt.contains("quick-release.sh --remote")); assert!(prompt.contains("without any local build")); assert!(prompt.contains("publication gated")); + assert!(prompt.contains("Only use the following Jcode-specific procedure")); + assert!(prompt.contains("repository's own established release conventions")); + assert!(prompt.contains("Do not assume the project uses Cargo")); + assert!(prompt.contains("tag-triggered or workflow-dispatch CI release")); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs index fb639cbed5..d743615dab 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs @@ -266,9 +266,11 @@ fn test_account_command_opens_account_picker() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "claude" && label == "claude-1" + }) if provider_id == "claude" && label == "claude-otter" ) })); + assert!(picker.entries.iter().any(|entry| entry.name == "Claude")); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI")); assert!(picker.entries.iter().any(|entry| { matches!( entry.action, @@ -334,6 +336,9 @@ fn test_account_picker_supports_arrow_and_vim_navigation() { .as_ref() .expect("inline account picker should open") .selected; + let picker = app.inline_interactive_state.as_ref().unwrap(); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI Otter")); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI Fox")); app.handle_key(KeyCode::Down, KeyModifiers::empty()) .unwrap(); @@ -451,7 +456,7 @@ fn test_account_command_combines_claude_and_openai_accounts() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "claude" && label == "claude-1" + }) if provider_id == "claude" && label == "claude-otter" ) })); assert!(picker.entries.iter().any(|entry| { @@ -460,7 +465,7 @@ fn test_account_command_combines_claude_and_openai_accounts() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "openai" && label == "openai-1" + }) if provider_id == "openai" && label == "openai-otter" ) })); assert!( diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs index 94bd73902f..d5fbfa7201 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs @@ -709,6 +709,21 @@ fn onboarding_import_happy_path_images() { // the generator has unseen changelog entries, which makes the artifact // depend on developer-local state. Force it empty for determinism. crate::tui::ui::header::set_unseen_changelog_entries_override_for_tests(Some(Vec::new())); + // The git info widget would otherwise capture the live ahead/behind and + // dirty counts of the repo the generator runs in. Pin it to a clean + // fixture branch. (The version label is compile-time build meta, which + // is deterministic for a given checkout and expected to advance.) + crate::tui::app::helpers::seed_git_info_cache_for_tests(Some( + crate::tui::info_widget::GitInfo { + branch: "main".to_string(), + modified: 0, + staged: 0, + untracked: 0, + ahead: 0, + behind: 0, + dirty_files: Vec::new(), + }, + )); let mut app = create_test_app(); // The header shows a randomly drawn session mascot ("client: Goat ๐Ÿ"), // which would make the artifact differ run to run. Pin it. diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs index f4e5bad646..54b35176ce 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs @@ -184,7 +184,7 @@ fn test_remote_auto_poke_challenges_abrupt_confidence_increase() { assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("Double-checking a confidence jump") }) + .any(|msg| { msg.content.contains("Double-checking confidence jumps") }) ); }); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs index a6797335b9..df344bb3fb 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs @@ -704,7 +704,7 @@ fn test_handle_server_event_soft_interrupt_injected_unrelated_content_keeps_pend } #[test] -fn test_handle_server_event_soft_interrupt_injected_background_task_renders_card_role() { +fn test_handle_server_event_soft_interrupt_injected_background_task_retains_row_only() { let mut app = create_test_app(); let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); @@ -720,19 +720,20 @@ fn test_handle_server_event_soft_interrupt_injected_background_task_renders_card &mut remote, ); - let last = app - .display_messages() - .last() - .expect("missing injected background task message"); - assert_eq!(last.role, "background_task"); - assert!(last.content.contains("**Background task** `abc123`")); + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Completed + ); } #[test] -fn test_handle_server_event_notification_background_task_scope_uses_card_rendering() { +fn test_handle_server_event_notification_background_task_scope_uses_failed_row() { let _render_lock = scroll_render_test_lock(); let mut app = create_test_app(); app.set_centered(true); + app.session.short_name = Some("test".to_string()); + app.push_display_message(DisplayMessage::assistant("ordinary transcript content")); let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); @@ -751,26 +752,21 @@ fn test_handle_server_event_notification_background_task_scope_uses_card_renderi &mut remote, ); - let last = app - .display_messages() - .last() - .expect("missing background task notification message"); - assert_eq!(last.role, "background_task"); - let backend = ratatui::backend::TestBackend::new(42, 12); let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); let text = render_and_snap(&app, &mut terminal); - assert!( - text.contains("โ•ญ") && text.contains("โ•ฐ"), - "expected rounded background-task card in render, got:\n{}", - text - ); - assert!( - !text.contains("โ—ฆ Background task"), - "background-task notifications should not render as generic swarm items:\n{}", - text + assert_eq!(app.display_messages().len(), 1); + assert!(!app + .display_messages() + .iter() + .any(|message| message.role == "background_task")); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Failed ); + assert!(text.contains("ร— bg bash"), "missing compact failed row:\n{text}"); + assert!(!text.contains("โ•ญ") && !text.contains("Background task failed")); } #[test] @@ -880,7 +876,7 @@ fn test_swarm_await_notification_inserts_only_compact_summary() { } #[test] -fn test_background_task_markdown_renders_card_even_if_role_was_lost() { +fn test_background_task_markdown_is_suppressed_even_if_role_was_lost() { let _render_lock = scroll_render_test_lock(); let mut app = create_test_app(); app.set_centered(true); @@ -893,21 +889,8 @@ fn test_background_task_markdown_renders_card_even_if_role_was_lost() { let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); let text = render_and_snap(&app, &mut terminal); - assert!( - text.contains("โ•ญ") && text.contains("โ•ฐ"), - "expected inferred background-task card rendering, got:\n{}", - text - ); - assert!( - text.contains("โœ— bg Run jcode library tests afte failed ยท 594967sj63"), - "expected background-task card title, got:\n{}", - text - ); - assert!( - !text.contains("**Background task**"), - "raw markdown should not be shown when the background-task role is inferred:\n{}", - text - ); + assert!(!text.contains("โ•ญ") && !text.contains("594967sj63")); + assert!(app.display_messages().is_empty()); assert_eq!(app.display_user_message_count(), 0); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs index 85021349a8..898d66a1e0 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs @@ -117,6 +117,36 @@ fn test_replace_latest_tool_display_message_updates_latest_match_and_bumps_versi assert_eq!(app.display_messages_version, after_change); } +#[test] +fn test_replace_latest_tool_display_message_removes_background_lifecycle_card() { + let mut app = create_test_app(); + app.push_display_message(DisplayMessage { + role: "tool".to_string(), + content: "running bash".to_string(), + tool_calls: vec![], + duration_secs: None, + title: Some("bash".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "tool-bg".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "cargo test"}), + intent: None, + thought_signature: None, + }), + }); + let before = app.display_messages_version; + + assert!(app.replace_latest_tool_display_message( + "tool-bg", + Some("bash".to_string()), + "**Background task started** `bg123` ยท `cargo test`\n\nJcode is running this in the background." + .to_string(), + )); + + assert!(app.display_messages().is_empty()); + assert_ne!(app.display_messages_version, before); +} + #[test] fn test_push_display_message_coalesces_repeated_single_line_system_messages() { let mut app = create_test_app(); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs index 95d64c41d7..319025e9ad 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs @@ -103,12 +103,13 @@ fn test_refresh_model_list_command_shows_summary_and_status_notice() { assert!(last.content.contains("cerebras-fast")); assert!(last.content.contains("cerebras-large")); assert!(last.content.contains("cerebras-reasoning")); - assert!(app.display_messages.iter().any(|message| { - message.role == "background_task" - && message - .content - .contains("**Background task progress** `refresh-model-list`") - && message.content.contains("Model list refresh") + assert!(!app + .display_messages + .iter() + .any(|message| message.role == "background_task")); + assert!(app.background_task_rows_ref().iter().any(|row| { + row.task_id == "refresh-model-list" + && row.status == crate::tui::BackgroundTaskRowStatus::Completed })); } @@ -333,7 +334,7 @@ fn test_remote_onboarding_catalog_activity_completes_model_setup_without_chat_no } #[test] -fn test_remote_catalog_activity_notification_upserts_progress_card() { +fn test_remote_catalog_activity_notification_upserts_compact_row() { let mut app = create_test_app(); app.auth_catalog_refresh_pending = true; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -365,15 +366,18 @@ fn test_remote_catalog_activity_notification_upserts_progress_card() { ); } - let cards: Vec<_> = app - .display_messages - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(cards.len(), 1, "progress updates should upsert one card"); + assert!(app.display_messages.is_empty()); + assert_eq!(app.background_task_rows_ref().len(), 1); assert!(app.auth_catalog_refresh_pending); - assert!(cards[0].content.contains("refresh-model-list")); - assert!(cards[0].content.contains("Waiting on provider APIs")); + assert_eq!( + app.background_task_rows_ref()[0], + crate::tui::BackgroundTaskRow { + task_id: "refresh-model-list".to_string(), + label: "Model list refresh".to_string(), + percent: Some(20.0), + status: crate::tui::BackgroundTaskRowStatus::Running, + } + ); let status = app.status_notice().expect("status notice"); assert!( status.contains("Waiting on provider APIs (2s elapsed)"), diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs index 5f5d0ffe24..92bbb7c59a 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs @@ -1,5 +1,5 @@ #[test] -fn test_handle_background_task_completed_renders_markdown_preview() { +fn test_handle_background_task_completed_retains_row_without_transcript_card() { let mut app = create_test_app(); let event = BusEvent::BackgroundTaskCompleted(BackgroundTaskCompleted { task_id: "bg123".to_string(), @@ -17,22 +17,15 @@ fn test_handle_background_task_completed_renders_markdown_preview() { super::local::handle_bus_event(&mut app, Ok(event)); - let rendered = app - .display_messages() - .last() - .expect("background task message"); - assert_eq!(rendered.role, "background_task"); - assert!( - rendered - .content - .contains("**Background task** `bg123` ยท `bash` ยท โœ“ completed ยท 7.1s ยท exit 0") - ); - assert!(rendered.content.contains("```text")); - assert!(rendered.content.contains("[stderr] one")); - assert!( - rendered - .content - .contains("_Full output:_ `bg action=\"output\" task_id=\"bg123\"`") + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bg123".to_string(), + label: "bash".to_string(), + percent: Some(100.0), + status: crate::tui::BackgroundTaskRowStatus::Completed, + }] ); assert_eq!( app.status_notice(), @@ -94,16 +87,38 @@ fn test_handle_background_task_progress_updates_status_notice() { app.status_notice(), Some("Background task ยท bash ยท 42% ยท Running tests".to_string()) ); - let progress_messages: Vec<_> = app - .display_messages() - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(progress_messages.len(), 1); - assert!( - progress_messages[0] - .content - .starts_with("**Background task progress** `bgprogress` ยท `bash`\n\n") + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bgprogress".to_string(), + label: "bash".to_string(), + percent: Some(42.0), + status: crate::tui::BackgroundTaskRowStatus::Running, + }] + ); +} + +#[test] +fn test_background_task_started_activity_creates_running_row_without_card() { + let mut app = create_test_app(); + let event = BusEvent::UiActivity(crate::bus::UiActivity::background( + Some(app.session.id.clone()), + "**Background task started** `bgstarted` ยท `cargo test`\n\nJcode is running this in the background. Progress, checkpoints, and completion will appear here.", + Some("Background task started ยท cargo test"), + )); + + super::local::handle_bus_event(&mut app, Ok(event)); + + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bgstarted".to_string(), + label: "cargo test".to_string(), + percent: None, + status: crate::tui::BackgroundTaskRowStatus::Running, + }] ); } @@ -157,7 +172,7 @@ fn test_handle_background_task_progress_debounces_identical_notice_updates() { } #[test] -fn test_handle_background_task_progress_updates_existing_card() { +fn test_handle_background_task_progress_updates_existing_compact_row() { let mut app = create_test_app(); let session_id = app.session.id.clone(); @@ -186,18 +201,13 @@ fn test_handle_background_task_progress_updates_existing_card() { ); } - let progress_messages: Vec<_> = app - .display_messages() - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(progress_messages.len(), 1); - assert!( - progress_messages[0] - .content - .contains("75% ยท Packaging artifacts") + assert!(app.display_messages().is_empty()); + assert_eq!(app.background_task_rows_ref().len(), 1); + assert_eq!(app.background_task_rows_ref()[0].percent, Some(75.0)); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Running ); - assert!(!progress_messages[0].content.contains("42% ยท Running tests")); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs index 8f0cda0804..d65990bdb8 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs @@ -426,9 +426,9 @@ fn test_paste_expansion_on_submit() { // Submit expands placeholder app.submit_input(); - // Display shows placeholder (user sees condensed view) + // Sent transcript renders the actual pasted content, while the composer above stayed compact. assert_eq!(app.display_messages().len(), 1); - assert_eq!(app.display_messages()[0].content, "A: [pasted 5 lines] B"); + assert_eq!(app.display_messages()[0].content, "A: 1\n2\n3\n4\n5 B"); // Model receives expanded content (actual pasted text). Local sessions keep the // provider message cache lazy, so inspect the materialized provider view. diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs index b81c7b51b1..9b5af9f16f 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs @@ -30,6 +30,45 @@ fn test_local_error_copy_badge_shortcut_supported() { ); } +#[test] +fn test_clicking_copy_badge_copies_its_target() { + let _render_lock = scroll_render_test_lock(); + let clipboard = CapturedClipboard::new(); + let (mut app, mut terminal) = create_error_copy_test_app(); + render_and_snap(&app, &mut terminal); + + let buf = terminal.backend().buffer(); + let area = *buf.area(); + let mut badge = None; + 'rows: for row in 0..area.height { + let line = (0..area.width) + .map(|col| buf[(col, row)].symbol()) + .collect::(); + if let Some(byte) = line.find("[S]") { + badge = Some((line[..byte].chars().count() as u16, row)); + break 'rows; + } + } + let (column, row) = badge.expect("copy badge must be visible"); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_mouse_event(MouseEvent { + kind, + column: column + 1, + row, + modifiers: KeyModifiers::empty(), + }); + } + + assert_eq!(app.status_notice(), Some("Copied error".to_string())); + assert_eq!( + clipboard.text().as_deref(), + Some("permission denied while opening ~/.jcode/config.toml") + ); +} + #[test] fn test_local_tool_error_copy_badge_shortcut_supported() { let _render_lock = scroll_render_test_lock(); @@ -1439,4 +1478,3 @@ fn test_changelog_overlay_mouse_drag_release_copies_text() { Some("Copied selection") | Some("Failed to copy selection") | Some("Selection is empty") )); } - diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs index 671031c4ec..68fd8d2456 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs @@ -370,6 +370,44 @@ fn test_expand_badge_rendered_shortcut_expands_with_alt_lowercase_event() { ); } +#[test] +fn test_clicking_expand_edit_badge_expands_to_full_diff() { + let _render_lock = scroll_render_test_lock(); + let (mut app, mut terminal) = make_edit_badge_test_app(20); + render_and_snap(&app, &mut terminal); + + let buf = terminal.backend().buffer(); + let area = *buf.area(); + let mut badge = None; + 'rows: for row in 0..area.height { + let line = (0..area.width) + .map(|col| buf[(col, row)].symbol()) + .collect::(); + if let Some(byte) = line.find("[E] expand") { + badge = Some((line[..byte].chars().count() as u16, row)); + break 'rows; + } + } + let (column, row) = badge.expect("expand edit badge must be visible"); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_mouse_event(MouseEvent { + kind, + column: column + 1, + row, + modifiers: KeyModifiers::empty(), + }); + } + + assert_eq!(app.diff_mode, crate::config::DiffDisplayMode::FullInline); + assert_eq!( + app.status_notice(), + Some("Expanded edit diffs ยท Diffs: Inline Full".to_string()) + ); +} + #[test] fn test_expand_badge_shortcut_works_while_diff_pane_focused() { use crossterm::event::{KeyCode, KeyModifiers}; diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index 4bea57ce6b..05a547c824 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2534,7 +2534,7 @@ fn test_finish_turn_auto_poke_queues_confidence_summary_when_todos_done() { assert_eq!(app.queued_messages.len(), 1); // Once the model records sufficient completion confidence through the - // todo tool, the next completion check passes and disarms auto-poke. + // todo tool, the next completion check requests one clean final answer. let mut validated = crate::todo::load_todos(&app.session.id).expect("load todos"); for todo in &mut validated { todo.completion_confidence = Some(crate::todo::ConfidenceState::from_legacy_score(100)); @@ -2559,13 +2559,25 @@ fn test_finish_turn_auto_poke_queues_confidence_summary_when_todos_done() { // Auto-poke is default-on, so a completed cycle re-arms for the next // batch of work rather than silently switching the feature off. assert_eq!(app.auto_poke_incomplete_todos, app.auto_poke_default_on); - assert!(!app.pending_queued_dispatch); - assert!(app.queued_messages.is_empty()); + assert!(app.pending_queued_dispatch); + assert_eq!( + app.queued_messages, + vec![crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()] + ); assert!(app.hidden_queued_system_messages.is_empty()); assert!(app.display_messages().iter().any(|msg| { msg.content .contains("All todos done. Completion confidence: verified.") })); + + // The final-answer turn itself must not enqueue another final-answer + // turn, otherwise a successfully completed cycle loops forever. + app.queued_messages.clear(); + app.pending_queued_dispatch = false; + app.is_processing = true; + super::local::finish_turn(&mut app); + assert!(!app.pending_queued_dispatch); + assert!(app.queued_messages.is_empty()); }); } @@ -2662,20 +2674,40 @@ fn test_finish_turn_challenges_confidence_spike_once() { assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("Double-checking a confidence jump") }) + .any(|msg| { msg.content.contains("Double-checking confidence jumps") }) ); app.queued_messages.clear(); app.pending_queued_dispatch = false; app.is_processing = true; - // Pin the default so the clean second cycle disarms; this test is - // about challenging the spike exactly once. - app.auto_poke_default_on = false; super::local::finish_turn(&mut app); - assert!(!app.auto_poke_incomplete_todos); - assert!(!app.todo_confidence_spike_challenged); + assert!(app.auto_poke_incomplete_todos); + assert!(app.todo_confidence_spike_challenged); + assert!(app.pending_queued_dispatch); + assert_eq!( + app.queued_messages, + vec![crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()] + ); + + // Finishing the synthetic final-response turn must not challenge the + // same unchanged confidence history again. + app.queued_messages.clear(); + app.pending_queued_dispatch = false; + app.is_processing = true; + super::local::finish_turn(&mut app); + + assert!(app.auto_poke_incomplete_todos); + assert!(app.todo_confidence_spike_challenged); assert!(!app.pending_queued_dispatch); + assert!(app.queued_messages.is_empty()); + assert_eq!( + app.display_messages() + .iter() + .filter(|message| message.content.contains("All todos done")) + .count(), + 1 + ); }); } diff --git a/crates/jcode-tui/src/tui/app/tests/todo_card.rs b/crates/jcode-tui/src/tui/app/tests/todo_card.rs index 5ebb68ebb5..556943ca0e 100644 --- a/crates/jcode-tui/src/tui/app/tests/todo_card.rs +++ b/crates/jcode-tui/src/tui/app/tests/todo_card.rs @@ -405,3 +405,71 @@ fn pinned_todo_band_renders_below_sticky_prompt_without_separator() { let _ = crate::todo::save_todos(&session_id, &[]); } + +#[test] +fn background_task_rows_render_without_todos_or_transcript_cards() { + let _env_lock = crate::storage::lock_test_env(); + let _render_lock = crate::tui::ui::render_state_test_lock(); + let mut app = create_test_app(); + app.session.short_name = Some("test".to_string()); + app.push_display_message(DisplayMessage::assistant("ordinary transcript content")); + app.upsert_running_background_task( + "running".to_string(), + "cargo test".to_string(), + Some(42.0), + ); + app.finish_background_task( + "done".to_string(), + "release build".to_string(), + crate::tui::BackgroundTaskRowStatus::Completed, + ); + app.finish_background_task( + "failed".to_string(), + "integration tests".to_string(), + crate::tui::BackgroundTaskRowStatus::Failed, + ); + + let backend = ratatui::backend::TestBackend::new(80, 20); + let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); + let rendered = render_and_snap(&app, &mut terminal); + + assert!( + rendered.contains("โ—Œ bg cargo test โ”โ”โ”โ•บโ”€โ”€ 42%"), + "missing running task row:\n{rendered}" + ); + assert!( + rendered.contains("โœ“ bg release build โ”โ”โ”โ”โ”โ” 100%"), + "missing completed task row:\n{rendered}" + ); + assert!( + rendered.contains("ร— bg integration tests โ”€โ”€โ”€โ”€โ”€โ”€ failed"), + "missing failed task row:\n{rendered}" + ); + assert!(!rendered.contains("Background tasks")); + assert!(!rendered.contains("Background task started")); + assert!(!rendered.contains("Background task progress")); + assert!(!rendered.contains("Background task completed")); +} + +#[test] +fn clicking_pinned_todo_more_row_expands_the_band() { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + + let mut app = create_test_app(); + app.pinned_todos_expanded = false; + crate::tui::ui::viewport::set_pinned_todo_more_area_for_test(Some(ratatui::layout::Rect { + x: 2, + y: 4, + width: 20, + height: 1, + })); + + app.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 4, + modifiers: KeyModifiers::NONE, + }); + + assert!(app.pinned_todos_expanded); +} diff --git a/crates/jcode-tui/src/tui/app/todos_view.rs b/crates/jcode-tui/src/tui/app/todos_view.rs index 55e3b40fea..2d8dacbd0f 100644 --- a/crates/jcode-tui/src/tui/app/todos_view.rs +++ b/crates/jcode-tui/src/tui/app/todos_view.rs @@ -577,14 +577,15 @@ fn format_goal_markdown(goals: &[crate::todo::TodoGoal], group: Option<&str>) -> line } -/// Plan-level intent lines, shown once for the whole todo list. +/// Plan-level assessment lines, shown once for the whole todo list. fn format_plan_markdown(plan: &crate::todo::TodoPlan) -> String { let mut markdown = String::new(); - if let Some(intention) = plan - .user_intention - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + if !crate::todo::intent_understanding_passes(plan.understands_user_intent) + && let Some(intention) = plan + .user_intention + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) { markdown.push_str(&format!("- User intention: {}\n", intention)); } @@ -918,9 +919,9 @@ mod tests { markdown.contains("## optimize rendering (1/2)"), "{markdown}" ); - // Plan-level intent renders once for the whole list. + // A clear intent keeps the intention narrative out of the todo display. assert!( - markdown.contains("- User intention: make navigation feel immediate"), + !markdown.contains("make navigation feel immediate"), "{markdown}" ); assert!( diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 9b25b1d4f3..6c9dcf1b5d 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -457,6 +457,7 @@ impl App { todo_confidence_spike_challenged: false, todo_gate_digest_delivered: false, todo_completion_gate_attempts: 0, + todo_final_response_requested: false, last_auto_poke_fingerprint: None, turn_guardrail_stopped: false, consecutive_guardrail_stops: 0, @@ -622,6 +623,8 @@ impl App { pinned_todos_payload: None, pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, + pinned_todos_expanded: false, + background_task_rows: Vec::new(), last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, @@ -899,6 +902,7 @@ impl App { todo_confidence_spike_challenged: false, todo_gate_digest_delivered: false, todo_completion_gate_attempts: 0, + todo_final_response_requested: false, last_auto_poke_fingerprint: None, turn_guardrail_stopped: false, consecutive_guardrail_stops: 0, @@ -1064,6 +1068,8 @@ impl App { pinned_todos_payload: None, pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, + pinned_todos_expanded: false, + background_task_rows: Vec::new(), last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index c5085c4f37..487c531f75 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -589,6 +589,14 @@ impl crate::tui::TuiState for App { self.pinned_todos_payload_ref() } + fn pinned_todos_expanded(&self) -> bool { + self.pinned_todos_expanded + } + + fn background_task_rows(&self) -> &[crate::tui::BackgroundTaskRow] { + self.background_task_rows_ref() + } + fn input(&self) -> &str { &self.input } @@ -1334,8 +1342,6 @@ impl crate::tui::TuiState for App { } }); - let memory_info = gather_memory_info(self.memory_enabled, self.session.working_dir.clone()); - // Gather swarm info let swarm_info = if self.swarm_enabled { let subagent_status = self.subagent_status.clone(); @@ -1589,10 +1595,13 @@ impl crate::tui::TuiState for App { session_name, working_dir: self.session.working_dir.clone(), client_count, - memory_info, + // Memory remains available through commands and tools, but no longer + // occupies a dedicated info widget. + memory_info: None, swarm_info, background_info, usage_info, + usage_display_used: crate::config::config().display.usage_display_used(), tokens_per_second, provider_name: if uses_remote_widget_metadata { self.remote_provider_name @@ -2158,6 +2167,11 @@ pub(crate) fn swarm_panel_action_for_key( // macOS Option+letter often arrives as a transformed glyph with no ALT // modifier; normalize through the shared shortcut helper. let macos_letter = crate::tui::keybind::shortcut_char_for_macos_option_key(code, modifiers); + let macos_shift_letter = + crate::tui::keybind::shortcut_char_for_macos_option_shift_key(code, modifiers); + if macos_shift_letter == Some('p') { + return Some(SwarmPanelAction::OpenPrompt); + } match code { KeyCode::Down | KeyCode::Char('j') if alt => Some(SwarmPanelAction::SelectNext), KeyCode::Up | KeyCode::Char('k') if alt => Some(SwarmPanelAction::SelectPrev), diff --git a/crates/jcode-tui/src/tui/info_widget.rs b/crates/jcode-tui/src/tui/info_widget.rs index eb0d1c4ccb..3946d24dce 100644 --- a/crates/jcode-tui/src/tui/info_widget.rs +++ b/crates/jcode-tui/src/tui/info_widget.rs @@ -623,6 +623,8 @@ pub struct InfoWidgetData { pub background_info: Option, /// Subscription usage info pub usage_info: Option, + /// Show consumed rather than remaining percentages in usage limits. + pub usage_display_used: bool, /// Streaming output tokens per second (approximate) pub tokens_per_second: Option, /// Active provider name (openrouter/openai/anthropic/...) @@ -2080,7 +2082,11 @@ fn render_sections( if let Some(info) = &data.usage_info && info.available { - lines.extend(render_usage_compact(info, inner.width)); + lines.extend(render_usage_compact( + info, + inner.width, + data.usage_display_used, + )); } if let Some(cache) = data.cache_hit_info.as_ref() { diff --git a/crates/jcode-tui/src/tui/info_widget_model.rs b/crates/jcode-tui/src/tui/info_widget_model.rs index 0d70d33eca..801e04834f 100644 --- a/crates/jcode-tui/src/tui/info_widget_model.rs +++ b/crates/jcode-tui/src/tui/info_widget_model.rs @@ -409,6 +409,7 @@ mod tests { swarm_info: None, background_info: None, usage_info: None, + usage_display_used: false, tokens_per_second: None, provider_name: None, auth_method: crate::tui::info_widget::AuthMethod::Unknown, diff --git a/crates/jcode-tui/src/tui/info_widget_tests.rs b/crates/jcode-tui/src/tui/info_widget_tests.rs index 6f82812e05..c5a9a8afc7 100644 --- a/crates/jcode-tui/src/tui/info_widget_tests.rs +++ b/crates/jcode-tui/src/tui/info_widget_tests.rs @@ -626,7 +626,7 @@ fn cost_based_usage_widgets_show_price_and_tokens() { assert!(expanded_text.contains("$0.0123")); assert!(expanded_text.contains("12.3K in + 678 out")); - let compact_text = lines_text(&render_usage_compact(&usage, 40)); + let compact_text = lines_text(&render_usage_compact(&usage, 40, false)); assert!(compact_text.contains("$0.0123")); assert!(compact_text.contains("12.3K in + 678 out")); } diff --git a/crates/jcode-tui/src/tui/info_widget_usage.rs b/crates/jcode-tui/src/tui/info_widget_usage.rs index b01abdd798..0df30b1985 100644 --- a/crates/jcode-tui/src/tui/info_widget_usage.rs +++ b/crates/jcode-tui/src/tui/info_widget_usage.rs @@ -73,6 +73,7 @@ pub(super) fn render_usage_widget(data: &InfoWidgetData, inner: Rect) -> Vec Vec Vec Vec Vec> { +pub(super) fn render_usage_compact( + info: &UsageInfo, + width: u16, + usage_display_used: bool, +) -> Vec> { if !info.available { return Vec::new(); } @@ -151,6 +158,7 @@ pub(super) fn render_usage_compact(info: &UsageInfo, width: u16) -> Vec Vec Vec, width: u16, + usage_display_used: bool, ) -> Line<'static> { let color = if left_pct <= 20 { rgb(255, 100, 100) @@ -198,25 +209,26 @@ fn render_labeled_bar( const LABEL_WIDTH: usize = 7; const MIN_BAR_WIDTH: usize = 4; + let (display_pct, display_word) = if usage_display_used { + (used_pct, "used") + } else { + (left_pct, "left") + }; + let percentage_suffix = format!(" {}% {}", display_pct, display_word); let full_suffix = match reset_time { Some(reset) if left_pct == 0 => format!(" resets {}", reset), - Some(reset) => format!(" {}% left ยท {}", left_pct, reset), - None => format!(" {}% left", left_pct), + Some(reset) => format!("{} ยท {}", percentage_suffix, reset), + None => percentage_suffix.clone(), }; - // On narrow widgets keep the reset visible and progressively shorten the - // percentage wording before sacrificing the bar. The exhausted wording is - // already compact and remains unchanged. + // On narrow widgets keep the percentage wording unambiguous, dropping the + // reset countdown before sacrificing the bar. Exhausted wording is unchanged. let suffix = match reset_time { - Some(reset) if left_pct > 0 => { - let compact = format!(" {}% ยท {}", left_pct, reset); - let reset_only = format!(" ยท {}", reset); + Some(_) if left_pct > 0 => { let budget = usize::from(width).saturating_sub(LABEL_WIDTH + MIN_BAR_WIDTH); if UnicodeWidthStr::width(full_suffix.as_str()) <= budget { full_suffix - } else if UnicodeWidthStr::width(compact.as_str()) <= budget { - compact } else { - reset_only + percentage_suffix } } _ => full_suffix, @@ -257,27 +269,54 @@ mod tests { #[test] fn usage_bar_shows_reset_countdown_before_exhaustion() { - let text = line_text(&render_labeled_bar("5-hour", 38, 62, Some("4h 5m"), 40)); + let text = line_text(&render_labeled_bar( + "5-hour", + 38, + 62, + Some("4h 5m"), + 40, + false, + )); assert!(text.contains("62% left ยท 4h 5m")); assert!(UnicodeWidthStr::width(text.as_str()) <= 40); } #[test] - fn usage_bar_keeps_countdown_within_narrow_width() { - let text = line_text(&render_labeled_bar("Weekly", 19, 81, Some("1d 4h"), 23)); + fn usage_bar_keeps_wording_unambiguous_within_narrow_width() { + let text = line_text(&render_labeled_bar( + "Weekly", + 19, + 81, + Some("1d 4h"), + 23, + true, + )); - assert!(text.contains("81% ยท 1d 4h")); + assert!(text.contains("19% used")); + assert!(!text.contains("1d 4h")); assert!(UnicodeWidthStr::width(text.as_str()) <= 23); assert!(text.contains('โ–ฐ') || text.contains('โ–ฑ')); } + #[test] + fn used_wording_does_not_change_remaining_budget_color_thresholds() { + let left = render_labeled_bar("5-hour", 85, 15, None, 24, false); + let used = render_labeled_bar("5-hour", 85, 15, None, 24, true); + + assert!(line_text(&left).contains("15% left")); + assert!(line_text(&used).contains("85% used")); + assert_eq!(left.spans[1].style.fg, Some(rgb(255, 100, 100))); + assert_eq!(used.spans[1].style.fg, left.spans[1].style.fg); + } + #[test] fn exhausted_usage_bar_preserves_resets_wording_and_width() { - let text = line_text(&render_labeled_bar("5-hour", 100, 0, Some("12m"), 24)); + let text = line_text(&render_labeled_bar("5-hour", 100, 0, Some("12m"), 24, true)); assert!(text.contains("resets 12m")); assert!(!text.contains("0% left")); + assert!(!text.contains("100% used")); assert!(UnicodeWidthStr::width(text.as_str()) <= 24); } @@ -291,7 +330,7 @@ mod tests { ..Default::default() }; - let lines = render_usage_compact(&info, 40); + let lines = render_usage_compact(&info, 40, false); let text = lines.iter().map(line_text).collect::>().join("\n"); assert!(text.contains("Monthly")); diff --git a/crates/jcode-tui/src/tui/keybind.rs b/crates/jcode-tui/src/tui/keybind.rs index 50f4e3b3b7..896ebe4015 100644 --- a/crates/jcode-tui/src/tui/keybind.rs +++ b/crates/jcode-tui/src/tui/keybind.rs @@ -376,6 +376,7 @@ impl ToggleBinding { /// All configurable pane / mode toggle keybindings. #[derive(Clone, Debug)] pub struct ToggleKeys { + pub auto_poke: ToggleBinding, pub side_panel: ToggleBinding, pub copy_selection: ToggleBinding, pub diagram_pane: ToggleBinding, @@ -389,6 +390,13 @@ pub struct ToggleKeys { pub fn load_toggle_keys() -> ToggleKeys { let cfg = config(); ToggleKeys { + auto_poke: ToggleBinding::load_with_default( + &cfg.keybindings.auto_poke_toggle, + KeyBinding { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + }, + ), side_panel: ToggleBinding::load(&cfg.keybindings.side_panel_toggle, 'm'), copy_selection: ToggleBinding::load(&cfg.keybindings.copy_selection_toggle, 'y'), diagram_pane: ToggleBinding::load(&cfg.keybindings.diagram_pane_toggle, 't'), @@ -527,17 +535,26 @@ fn macos_option_shift_char_to_ascii_key(code: KeyCode) -> Option { 'รŽ' => Some('d'), 'ยด' => Some('e'), 'ร' => Some('f'), + 'ห' => Some('g'), 'ร“' => Some('h'), 'ห†' => Some('i'), 'ร”' => Some('j'), '๏ฃฟ' => Some('k'), 'ร’' => Some('l'), 'ร‚' => Some('m'), + 'หœ' => Some('n'), + 'ร˜' => Some('o'), + 'โˆ' => Some('p'), + 'ล’' => Some('q'), + 'โ€ฐ' => Some('r'), 'ร' => Some('s'), 'ห‡' => Some('t'), 'ยจ' => Some('u'), 'โ—Š' => Some('v'), + 'โ€ž' => Some('w'), + 'ห›' => Some('x'), 'ร' => Some('y'), + 'ยธ' => Some('z'), _ => None, } } @@ -615,6 +632,21 @@ pub fn load_open_resume_key() -> OptionalBinding { mod tests { use super::*; + #[test] + fn auto_poke_toggle_can_be_remapped_or_disabled() { + let default = KeyBinding { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + }; + let remapped = ToggleBinding::load_with_default("alt+p", default.clone()); + assert!(remapped.matches(KeyCode::Char('p'), KeyModifiers::ALT)); + assert!(!remapped.matches(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + let disabled = ToggleBinding::load_with_default("", default); + assert!(disabled.binding().is_none()); + assert!(!disabled.matches(KeyCode::Char('p'), KeyModifiers::CONTROL)); + } + #[test] fn new_terminal_alt_enter_binding_parses_and_matches() { let binding = parse_keybinding("alt+enter").expect("alt+enter should parse"); diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 1152da7118..ec7d82e2af 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -8,6 +8,22 @@ pub struct ContextSnapshot { pub fresh: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackgroundTaskRowStatus { + Running, + Completed, + Failed, +} + +/// Compact presentation state for one retained background task. +#[derive(Clone, Debug, PartialEq)] +pub struct BackgroundTaskRow { + pub task_id: String, + pub label: String, + pub percent: Option, + pub status: BackgroundTaskRowStatus, +} + pub mod backend; pub(crate) mod color_support; mod core; @@ -208,6 +224,14 @@ pub trait TuiState { fn pinned_todos_payload(&self) -> Option<&str> { None } + /// Whether the pinned todo band is temporarily expanded to show every row. + fn pinned_todos_expanded(&self) -> bool { + false + } + /// Running and recently completed background tasks rendered beneath pinned todos. + fn background_task_rows(&self) -> &[BackgroundTaskRow] { + &[] + } // ---- Input ---- fn input(&self) -> &str; diff --git a/crates/jcode-tui/src/tui/session_picker/loading.rs b/crates/jcode-tui/src/tui/session_picker/loading.rs index 4b11c8fe70..2e6993ce01 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading.rs @@ -58,7 +58,7 @@ fn include_old_saved_sessions_on_initial_load() -> bool { } const SESSION_LIST_CACHE_TTL: Duration = Duration::from_secs(5); -const SESSION_LIST_DISK_CACHE_VERSION: u32 = 1; +const SESSION_LIST_DISK_CACHE_VERSION: u32 = 2; const SESSION_LIST_DISK_CACHE_MAX_AGE_SECONDS: i64 = 7 * 24 * 60 * 60; const SAVED_METADATA_TAIL_SCAN_BYTES: u64 = 64 * 1024; const INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES: usize = 64 * 1024; @@ -178,7 +178,7 @@ pub fn invalidate_session_list_cache() { } fn session_list_disk_cache_path() -> Result { - Ok(storage::jcode_dir()?.join("cache/session-picker-list-v1.json")) + Ok(storage::jcode_dir()?.join("cache/session-picker-list-v2.json")) } fn session_list_disk_cache_is_usable( @@ -259,6 +259,66 @@ fn push_with_byte_budget(dst: &mut String, src: &str, budget: &mut usize) { *budget = budget.saturating_sub(end); } +fn suffix_at_most(value: &str, max_bytes: usize) -> &str { + let mut start = value.len().saturating_sub(max_bytes); + while start < value.len() && !value.is_char_boundary(start) { + start += 1; + } + &value[start..] +} + +/// Keep a bounded sample from both ends of a growing transcript. Keeping only +/// the first 64 KiB made `/resume` search silently blind to every later turn in +/// a long session. The first half retains titles and early prompts while the +/// second half continuously follows the newest transcript content. +fn push_sampled_search_text(dst: &mut String, src: &str, limit: usize) { + if src.is_empty() || limit == 0 { + return; + } + if dst.len().saturating_add(1).saturating_add(src.len()) <= limit { + dst.push(' '); + dst.push_str(src); + return; + } + + // An oversized first message has no existing session head to preserve. Keep + // both ends of that message instead of retaining only its suffix. + if dst.is_empty() { + let head_budget = limit / 2; + let mut head_end = src.len().min(head_budget); + while head_end > 0 && !src.is_char_boundary(head_end) { + head_end -= 1; + } + dst.push_str(&src[..head_end]); + dst.push_str(suffix_at_most(src, limit.saturating_sub(head_end))); + return; + } + + let head_budget = limit / 2; + let mut head_end = dst.len().min(head_budget); + while head_end > 0 && !dst.is_char_boundary(head_end) { + head_end -= 1; + } + let head = dst[..head_end].to_string(); + + let tail_budget = limit.saturating_sub(head.len()); + let tail = if src.len().saturating_add(1) >= tail_budget { + suffix_at_most(src, tail_budget).to_string() + } else { + let old_budget = tail_budget.saturating_sub(src.len() + 1); + let old_tail = suffix_at_most(&dst[head_end..], old_budget); + let mut tail = String::with_capacity(old_tail.len() + 1 + src.len()); + tail.push_str(old_tail); + tail.push(' '); + tail.push_str(src); + tail + }; + + dst.clear(); + dst.push_str(&head); + dst.push_str(&tail); +} + pub(super) fn build_search_index( id: &str, short_name: &str, @@ -300,22 +360,14 @@ pub(super) fn build_search_index( combined.to_lowercase() } -fn push_raw_search_excerpt(dst: &mut String, raw: &str, budget: &mut usize) { - if *budget == 0 || raw.is_empty() { - return; - } - dst.push(' '); - push_with_byte_budget(dst, raw, budget); -} - fn raw_value_search_excerpt(raw: &RawValue, budget: usize) -> Option { if budget == 0 { return None; } let raw = raw.get(); - let mut budget = budget.min(MESSAGE_SEARCH_EXCERPT_BYTES); + let budget = budget.min(MESSAGE_SEARCH_EXCERPT_BYTES); let mut excerpt = String::new(); - push_with_byte_budget(&mut excerpt, raw, &mut budget); + push_sampled_search_text(&mut excerpt, raw, budget); (!excerpt.is_empty()).then_some(excerpt) } @@ -1225,10 +1277,12 @@ impl SessionMessageSummaryData { .estimated_tokens .saturating_add(usage.total_tokens() as usize); } - let mut remaining = - INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES.saturating_sub(self.search_text.len()); if let Some(raw_content) = message.content_raw.as_deref() { - push_raw_search_excerpt(&mut self.search_text, raw_content, &mut remaining); + push_sampled_search_text( + &mut self.search_text, + raw_content, + INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES, + ); } } @@ -1246,9 +1300,11 @@ impl SessionMessageSummaryData { if self.first_user_prompt.is_none() { self.first_user_prompt = other.first_user_prompt; } - let mut remaining = - INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES.saturating_sub(self.search_text.len()); - push_raw_search_excerpt(&mut self.search_text, &other.search_text, &mut remaining); + push_sampled_search_text( + &mut self.search_text, + &other.search_text, + INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES, + ); } } @@ -1276,11 +1332,10 @@ impl<'de> Visitor<'de> for SessionMessageSummaryDataVisitor { { let mut counts = SessionMessageSummaryData::default(); loop { - let remaining = INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES - .saturating_sub(counts.search_text.len()) - .min(MESSAGE_SEARCH_EXCERPT_BYTES); let Some(message) = seq.next_element_seed(SessionMessageSummarySeed { - content_excerpt_budget: remaining, + // Keep sampling each turn even after the session-wide index is + // full. `add_message` retains a bounded head + moving tail. + content_excerpt_budget: MESSAGE_SEARCH_EXCERPT_BYTES, })? else { break; diff --git a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs index 791a83d9ab..96b7a98516 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs @@ -815,6 +815,67 @@ fn session_matches_query_searches_jcode_transcript_contents() { assert!(!session_matches_query(loaded, "missing transcript phrase")); } +#[test] +fn jcode_search_index_keeps_late_turns_after_reaching_its_budget() { + let _env_lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("temp dir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + invalidate_session_list_cache(); + + let mut session = Session::create_with_id( + "session_late_transcript_search".to_string(), + Some("/tmp/transcript-search".to_string()), + Some("Late Transcript Search".to_string()), + ); + for index in 0..12 { + let text = if index == 11 { + format!("{} final-turn-platypus", "x".repeat(7_500)) + } else { + format!("turn-{index} {}", "x".repeat(7_500)) + }; + session.append_stored_message(crate::session::StoredMessage { + id: format!("msg{index}"), + role: crate::message::Role::User, + content: vec![crate::message::ContentBlock::Text { + text, + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }); + } + session.save().expect("save session"); + + let sessions = load_sessions().expect("load sessions"); + let loaded = sessions + .iter() + .find(|candidate| candidate.id == "session_late_transcript_search") + .expect("session present"); + assert!(loaded.search_index.len() <= INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES + 256); + assert!(session_matches_picker_query(loaded, "final-turn-platypus")); + invalidate_session_list_cache(); +} + +#[test] +fn raw_search_excerpt_samples_suffix_of_one_long_message_without_splitting_utf8() { + let prefix = "opening-message-needle"; + let suffix = "ๆ™šใ„-message-needle"; + let raw: Box = serde_json::from_str(&format!( + "{}", + serde_json::to_string(&format!("{prefix} {} {suffix}", "โ”€".repeat(6_000))) + .expect("serialize content") + )) + .expect("raw value"); + + let excerpt = + raw_value_search_excerpt(&raw, MESSAGE_SEARCH_EXCERPT_BYTES).expect("search excerpt"); + assert!(excerpt.contains(prefix)); + assert!(excerpt.contains(suffix)); + assert!(excerpt.len() <= MESSAGE_SEARCH_EXCERPT_BYTES); +} + #[test] fn session_matches_query_searches_external_codex_transcript_contents() { let _env_lock = crate::storage::lock_test_env(); diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index 9a18ae2164..30c483bf54 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -87,7 +87,7 @@ pub(crate) mod tools_ui; #[path = "ui_transitions.rs"] mod transitions; #[path = "ui_viewport.rs"] -mod viewport; +pub(crate) mod viewport; use crate::tui::mermaid; #[cfg(test)] pub(crate) use box_utils::truncate_line_to_width; @@ -240,6 +240,7 @@ thread_local! { static TEST_VISIBLE_COPY_TARGETS: RefCell> = RefCell::new(Vec::new()); static TEST_VISIBLE_EXPAND_EDIT_BADGE: Cell = const { Cell::new(false) }; static TEST_VISIBLE_EXPAND_EDIT_BADGE_LINE: Cell> = const { Cell::new(None) }; + static TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT: Cell> = const { Cell::new(None) }; static TEST_PROMPT_VIEWPORT_STATE: RefCell = RefCell::new(PromptViewportState::default()); static TEST_COPY_VIEWPORT: RefCell = RefCell::new(CopyViewportSnapshots::default()); } @@ -588,6 +589,8 @@ pub(crate) struct VisibleCopyTarget { pub kind_label: String, pub copied_notice: String, pub content: String, + /// Screen cells occupied by the rendered shortcut badge in the latest frame. + pub badge_rect: Option, } // Copy badges intentionally avoid h/j/k/l so they never shadow vi-style @@ -603,6 +606,9 @@ static VISIBLE_EXPAND_EDIT_BADGE: OnceLock> = OnceLock::new(); #[cfg(not(test))] static VISIBLE_EXPAND_EDIT_BADGE_LINE: OnceLock>> = OnceLock::new(); +#[cfg(not(test))] +static VISIBLE_EXPAND_EDIT_BADGE_RECT: OnceLock>> = OnceLock::new(); + #[cfg(not(test))] fn visible_copy_targets_state() -> &'static Mutex> { VISIBLE_COPY_TARGETS.get_or_init(|| Mutex::new(Vec::new())) @@ -618,6 +624,41 @@ fn visible_expand_edit_badge_line_state() -> &'static Mutex> { VISIBLE_EXPAND_EDIT_BADGE_LINE.get_or_init(|| Mutex::new(None)) } +#[cfg(not(test))] +fn visible_expand_edit_badge_rect_state() -> &'static Mutex> { + VISIBLE_EXPAND_EDIT_BADGE_RECT.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn set_visible_expand_edit_badge_rect(rect: Option) { + #[cfg(test)] + { + TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT.with(|state| state.set(rect)); + return; + } + #[cfg(not(test))] + { + let mut state = visible_expand_edit_badge_rect_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = rect; + } +} + +pub(crate) fn visible_expand_edit_badge_at(column: u16, row: u16) -> bool { + #[cfg(test)] + let rect = TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT.with(Cell::get); + #[cfg(not(test))] + let rect = *visible_expand_edit_badge_rect_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + rect.is_some_and(|rect| { + column >= rect.x + && column < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) + }) +} + pub(crate) fn set_visible_expand_edit_badge(visible: bool, line: Option) { #[cfg(test)] { @@ -713,6 +754,29 @@ pub(crate) fn visible_copy_target_for_key(key: char) -> Option Option { + let contains = |target: &&VisibleCopyTarget| { + target.badge_rect.is_some_and(|rect| { + column >= rect.x + && column < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) + }) + }; + #[cfg(test)] + { + TEST_VISIBLE_COPY_TARGETS.with(|state| state.borrow().iter().find(contains).cloned()) + } + #[cfg(not(test))] + { + let state = match visible_copy_targets_state().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + state.iter().find(contains).cloned() + } +} + #[derive(Clone, Copy)] struct PromptViewportAnimation { line_idx: usize, diff --git a/crates/jcode-tui/src/tui/ui_diff.rs b/crates/jcode-tui/src/tui/ui_diff.rs index 5a5ec80ad6..c5dd831c12 100644 --- a/crates/jcode-tui/src/tui/ui_diff.rs +++ b/crates/jcode-tui/src/tui/ui_diff.rs @@ -20,6 +20,7 @@ pub(super) struct ParsedDiffLine { pub kind: DiffLineKind, pub prefix: String, pub content: String, + pub file_path: Option, } pub(super) fn diff_change_counts(content: &str) -> (usize, usize) { @@ -235,6 +236,7 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec kind: DiffLineKind::Del, prefix: format!("{}- ", change.old_index().unwrap_or(0) + 1), content: content.to_string(), + file_path: None, }); } ChangeTag::Insert => { @@ -242,6 +244,7 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec kind: DiffLineKind::Add, prefix: format!("{}+ ", change.new_index().unwrap_or(0) + 1), content: content.to_string(), + file_path: None, }); } ChangeTag::Equal => {} @@ -252,7 +255,66 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec } pub(super) fn collect_diff_lines(content: &str) -> Vec { - content.lines().filter_map(parse_diff_line).collect() + let mut file_path = None; + let mut lines = Vec::new(); + + for raw_line in content.lines() { + if let Some(path) = diff_file_path(raw_line) { + file_path = Some(path); + continue; + } + if let Some(mut line) = parse_diff_line(raw_line) { + line.file_path = file_path.clone(); + lines.push(line); + } + } + + lines +} + +fn diff_file_path(raw_line: &str) -> Option { + let trimmed = raw_line.trim(); + if let Some(path) = trimmed + .strip_prefix("*** Add File: ") + .or_else(|| trimmed.strip_prefix("*** Update File: ")) + .or_else(|| trimmed.strip_prefix("*** Delete File: ")) + { + return non_empty_diff_path(path); + } + + if let Some(path) = trimmed.strip_prefix("+++ ") { + return unified_diff_path(path); + } + if let Some(path) = trimmed.strip_prefix("--- ") { + return unified_diff_path(path); + } + + let status = trimmed + .strip_prefix('โœ“') + .or_else(|| trimmed.strip_prefix('โœ—'))? + .trim_start(); + let (path, _) = status.split_once(": ")?; + non_empty_diff_path(path) +} + +fn unified_diff_path(raw_path: &str) -> Option { + let path = raw_path + .split('\t') + .next() + .unwrap_or(raw_path) + .split_whitespace() + .next() + .unwrap_or(""); + let path = path + .strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path); + non_empty_diff_path(path) +} + +fn non_empty_diff_path(path: &str) -> Option { + let path = path.trim(); + (!path.is_empty() && path != "/dev/null").then(|| path.to_string()) } fn parse_diff_line(raw_line: &str) -> Option { @@ -277,6 +339,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Del, prefix: prefix.to_string(), content: trim_diff_content(content), + file_path: None, }); } } @@ -287,6 +350,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Add, prefix: prefix.to_string(), content: trim_diff_content(content), + file_path: None, }); } } @@ -296,6 +360,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Add, prefix: "+".to_string(), content: trim_diff_content(rest), + file_path: None, }); } if let Some(rest) = raw_line.strip_prefix('-') { @@ -303,6 +368,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Del, prefix: "-".to_string(), content: trim_diff_content(rest), + file_path: None, }); } @@ -338,8 +404,8 @@ pub(super) fn tint_span_with_diff_color(span: Span<'static>, diff_color: Color) #[cfg(test)] mod tests { use super::{ - DiffLineKind, diff_change_counts_for_tool, diff_counts_from_apply_patch_input, - generate_diff_lines_from_strings, + DiffLineKind, collect_diff_lines, diff_change_counts_for_tool, + diff_counts_from_apply_patch_input, generate_diff_lines_from_strings, }; use crate::message::ToolCall; use serde_json::json; @@ -353,6 +419,25 @@ mod tests { assert_eq!(diff_counts_from_apply_patch_input(&input), Some((1, 1))); } + #[test] + fn collected_apply_patch_lines_retain_file_boundaries() { + let patch = "*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n*** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch\n"; + + let lines = collect_diff_lines(patch); + + assert_eq!(lines.len(), 4); + assert!( + lines[..2] + .iter() + .all(|line| line.file_path.as_deref() == Some("a.txt")) + ); + assert!( + lines[2..] + .iter() + .all(|line| line.file_path.as_deref() == Some("b.txt")) + ); + } + #[test] fn write_tool_falls_back_to_content_diff_counts() { let tool = ToolCall { diff --git a/crates/jcode-tui/src/tui/ui_frame_metrics.rs b/crates/jcode-tui/src/tui/ui_frame_metrics.rs index bb4f4a4882..55554b1db4 100644 --- a/crates/jcode-tui/src/tui/ui_frame_metrics.rs +++ b/crates/jcode-tui/src/tui/ui_frame_metrics.rs @@ -1261,6 +1261,7 @@ pub(crate) fn recent_flicker_copy_target_for_key(key: char) -> Option Vec { chunks } -/// Plan-level intent lines shown once above the todo groups. +/// Plan-level assessment lines shown once above the todo groups. fn push_todo_plan_details( lines: &mut Vec>, plan: &crate::todo::TodoPlan, @@ -1443,31 +1443,34 @@ fn push_todo_plan_details( inner_width: usize, compact_details: bool, ) { - if let Some(state) = plan.understands_user_intent { - lines.push(todo_card_line( - vec![ - Span::styled( - "Understands user intent ", - Style::default().fg(todo_label_color()), - ), - Span::styled( - state.as_str().to_string(), - Style::default().fg(todo_score_color()), - ), - ], - base_indent, - inner_width, - )); - } - if let Some(intention) = plan + let intention = plan .user_intention .as_deref() .map(str::trim) - .filter(|value| !value.is_empty()) - { + .filter(|value| !value.is_empty()); + if let Some(state) = plan.understands_user_intent { + let state_color = match state { + crate::todo::IntentUnderstanding::Uncertain => todo_failure_color(), + crate::todo::IntentUnderstanding::Partial => todo_warning_color(), + crate::todo::IntentUnderstanding::Clear + | crate::todo::IntentUnderstanding::Complete => todo_score_color(), + }; + let mut spans = vec![ + Span::styled("Intent ", Style::default().fg(todo_label_color())), + Span::styled(state.as_str().to_string(), Style::default().fg(state_color)), + Span::styled(": ", Style::default().fg(todo_label_color())), + ]; + if let Some(intention) = intention { + spans.push(Span::styled( + intention.to_string(), + Style::default().fg(todo_meta_color()), + )); + } + lines.push(todo_card_line(spans, base_indent, inner_width)); + } else if let Some(intention) = intention { push_todo_detail( lines, - "User intention", + "Intent", intention, base_indent, inner_width, @@ -1642,6 +1645,22 @@ fn render_todo_plan_update( let Some(update) = plan_update else { return Vec::new(); }; + let intent_is_unclear = !crate::todo::intent_understanding_passes( + update + .after + .as_ref() + .and_then(|plan| plan.understands_user_intent), + ); + if !update + .fields + .contains(&crate::todo::TodoPlanField::UnderstandsUserIntent) + && !(intent_is_unclear + && update + .fields + .contains(&crate::todo::TodoPlanField::UserIntention)) + { + return Vec::new(); + } let centered = markdown::center_code_blocks(); let card_width = if centered { (width.saturating_sub(4) as usize).min(120) @@ -1678,16 +1697,19 @@ fn render_todo_plan_update( base_indent, inner_width, ), - crate::todo::TodoPlanField::UserIntention => push_todo_text_update( - &mut lines, - "User intention", - update - .after - .as_ref() - .and_then(|plan| plan.user_intention.as_deref()), - base_indent, - inner_width, - ), + crate::todo::TodoPlanField::UserIntention if intent_is_unclear => { + push_todo_text_update( + &mut lines, + "User intention", + update + .after + .as_ref() + .and_then(|plan| plan.user_intention.as_deref()), + base_indent, + inner_width, + ) + } + crate::todo::TodoPlanField::UserIntention => {} } } @@ -4152,6 +4174,25 @@ pub(crate) fn render_tool_message( } } + if tools_ui::canonical_tool_name(&tc.name) == "bash" + && tools_ui::show_bash_output() + && msg.content.trim() != "Command completed successfully (no output)" + { + const MAX_COLLAPSED_OUTPUT_LINES: usize = 3; + let output_lines = msg.content.lines().filter(|line| !line.trim().is_empty()); + let total = output_lines.clone().count(); + for output in output_lines.skip(total.saturating_sub(MAX_COLLAPSED_OUTPUT_LINES)) { + let output_line = Line::from(vec![ + Span::raw(" "), + Span::styled(output.to_string(), Style::default().fg(dim_color())), + ]); + lines.push(super::truncate_line_with_ellipsis_to_width( + &output_line, + row_width, + )); + } + } + if tc.name == "batch" && let Some(calls) = tc.input.get("tool_calls").and_then(|v| v.as_array()) { @@ -4277,11 +4318,6 @@ pub(crate) fn render_tool_message( _ => None, }) }); - let file_ext = file_path_for_ext - .as_deref() - .and_then(|p| std::path::Path::new(p).extension()) - .and_then(|e| e.to_str()); - const MAX_DIFF_LINES: usize = MAX_INLINE_DIFF_LINES; let total_changes = change_lines.len(); let additions = change_lines @@ -4305,15 +4341,24 @@ pub(crate) fn render_tool_message( let pad_str = ""; + let diff_file_path = + |line: &ParsedDiffLine| line.file_path.clone().or_else(|| file_path_for_ext.clone()); + let first_file_path = display_lines.first().and_then(|line| diff_file_path(line)); + let diff_header = |prefix: &str, file_path: Option<&str>| match file_path { + Some(path) => format!("{pad_str}{prefix} diff ยท {path}"), + None => format!("{pad_str}{prefix} diff"), + }; + lines.push( Line::from(Span::styled( - format!("{}โ”Œโ”€ diff", pad_str), + diff_header("โ”Œโ”€", first_file_path.as_deref()), Style::default().fg(dim_color()), )) .alignment(ratatui::layout::Alignment::Left), ); let mut shown_truncation = false; + let mut previous_file_path = first_file_path; for (i, line) in display_lines.iter().enumerate() { if truncated && !shown_truncation && i >= half_point { @@ -4328,6 +4373,18 @@ pub(crate) fn render_tool_message( shown_truncation = true; } + let current_file_path = diff_file_path(line); + if i > 0 && current_file_path != previous_file_path { + lines.push( + Line::from(Span::styled( + diff_header("โ”œโ”€", current_file_path.as_deref()), + Style::default().fg(dim_color()), + )) + .alignment(ratatui::layout::Alignment::Left), + ); + } + previous_file_path = current_file_path.clone(); + let base_color = if line.kind == DiffLineKind::Add { diff_add_color() } else { @@ -4346,6 +4403,10 @@ pub(crate) fn render_tool_message( if !line.content.is_empty() { let content = &line.content; + let file_ext = current_file_path + .as_deref() + .and_then(|p| std::path::Path::new(p).extension()) + .and_then(|e| e.to_str()); let content_vis_width = unicode_width::UnicodeWidthStr::width(content.as_str()); if !full_inline && max_content_width > 1 && content_vis_width > max_content_width { let mut end = 0; diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index c60bf2c6d2..d4acc9f8b6 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -588,10 +588,9 @@ fn render_todos_message_shows_goal_scores_without_verbose_feedback() { } assert!(!plain.contains("Relevance representative"), "{plain}"); assert!(!plain.contains("Coverage main_paths"), "{plain}"); - // Plan-level intent renders once, above the groups. - assert!(plain.contains("Understands user intent clear"), "{plain}"); + // Only the plan-level assessment renders above the groups. assert!( - plain.contains("User intention ยท Keep the agent aligned with the user's request"), + plain.contains("Intent clear: Keep the agent aligned"), "{plain}" ); assert!(!plain.contains("Feedback ยท"), "{plain}"); @@ -601,7 +600,7 @@ fn render_todos_message_shows_goal_scores_without_verbose_feedback() { } #[test] -fn render_todos_message_compacts_long_details_at_narrow_widths() { +fn render_todos_message_shows_user_intention_when_understanding_is_unclear() { let long_text = "This deliberately long assessment detail should not consume several rows in a narrow terminal window"; let todos = vec![crate::todo::TodoItem { id: "1".to_string(), @@ -617,6 +616,7 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { }]; let plan = crate::todo::TodoPlan { user_intention: Some(long_text.to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), ..Default::default() }; let goals = vec![crate::todo::TodoGoal { @@ -635,11 +635,9 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { assert_eq!( narrow .iter() - .filter(|line| line.contains("User intention")) + .filter(|line| line.contains("Intent partial:")) .count(), - 1, - "{}", - narrow.join("\n") + 1 ); assert_eq!( narrow @@ -650,11 +648,6 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { "verbose goal feedback should stay out of inline cards: {}", narrow.join("\n") ); - assert!( - narrow.iter().any(|line| line.contains('โ€ฆ')), - "{}", - narrow.join("\n") - ); assert!( narrow .iter() @@ -669,15 +662,23 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { .collect::>(); assert!( wide.iter() - .any(|line| line.contains("narrow terminal window")), + .any(|line| line.contains("Intent partial: This deliberately long assessment detail")), "wide={wide:?}" ); + assert!( + wide.iter().any(|line| line.contains('โ€ฆ')), + "wide intent should remain on one ellipsized line: {wide:?}" + ); assert!( !narrow .iter() .any(|line| line.contains("narrow terminal window")), "narrow={narrow:?}" ); + assert!( + narrow.iter().any(|line| line.contains('โ€ฆ')), + "narrow intent should be ellipsized: {narrow:?}" + ); } #[test] @@ -718,6 +719,7 @@ fn render_todos_message_uses_readable_semantic_colors() { }; assert_eq!(color_for("todo rendering"), Some(todo_group_color())); + assert_eq!(color_for("clear"), Some(todo_score_color())); assert_eq!(color_for("Readable metadata"), Some(todo_meta_color())); assert_eq!(color_for("โ— "), Some(asap_color())); assert_eq!(color_for(" (high)"), None); @@ -727,6 +729,52 @@ fn render_todos_message_uses_readable_semantic_colors() { assert_ne!(todo_meta_color(), dim_color()); } +#[test] +fn render_todos_message_color_codes_every_intent_state() { + let cases = [ + ( + crate::todo::IntentUnderstanding::Uncertain, + todo_failure_color(), + ), + ( + crate::todo::IntentUnderstanding::Partial, + todo_warning_color(), + ), + (crate::todo::IntentUnderstanding::Clear, todo_score_color()), + ( + crate::todo::IntentUnderstanding::Complete, + todo_score_color(), + ), + ]; + + for (state, expected_color) in cases { + let state_text = state.as_str().to_string(); + let msg = DisplayMessage::todos( + serde_json::json!({ + "todos": [], + "plan": { + "user_intention": "Keep intent visible", + "understands_user_intent": state, + }, + "goals": [], + }) + .to_string(), + ); + let lines = render_todos_message(&msg, 100, crate::config::DiffDisplayMode::Off); + let rendered_color = lines + .iter() + .flat_map(|line| line.spans.iter()) + .find(|span| span.content.as_ref() == state_text) + .and_then(|span| span.style.fg); + + assert_eq!( + rendered_color, + Some(expected_color), + "intent state {state_text} should keep its semantic color in the todo renderer" + ); + } +} + #[test] fn render_todos_message_collapses_passing_quality_gates() { let todos = vec![crate::todo::TodoItem { @@ -1951,11 +1999,7 @@ fn render_tool_message_shows_intent_and_technical_preview_on_one_line() { let rendered = extract_line_text(&lines[0]); assert!(rendered.contains("bash ยท Verify compact progress card ยท $ cargo test")); - assert_eq!( - lines.len(), - 1, - "intent should not add vertical space: {rendered}" - ); + assert_eq!(lines.len(), 1, "Bash output is hidden by default"); crate::tui::ui::tools_ui::tests_tool_call_details_override::set(false); } @@ -1993,7 +2037,7 @@ fn render_tool_message_hides_technical_preview_by_default() { !rendered.contains("cargo test"), "technical detail should be hidden by default: {rendered}" ); - assert_eq!(lines.len(), 1, "no extra detail line expected: {rendered}"); + assert_eq!(lines.len(), 1, "Bash output is hidden by default"); } /// Even with details off, a failed tool row keeps its error summary so @@ -2054,6 +2098,62 @@ fn render_tool_message_shows_token_badge() { assert_eq!(badge_span.style.fg, Some(rgb(118, 118, 118))); } +#[test] +fn render_tool_message_hides_bash_output() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "\n[('p', 'b'), ('a', 'a'), ('l', 'l'), ('e', 'e')]".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: None, + tool_data: Some(crate::message::ToolCall { + id: "call_bash_output".to_string(), + name: "bash".to_string(), + input: serde_json::json!({ + "command": "python3 -c \"s='pale'; t='bale'; print(type(zip(s,t))); print(list(zip(s,t)))\"" + }), + intent: None, + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 120, crate::config::DiffDisplayMode::Off); + let rendered = lines.iter().map(extract_line_text).collect::>(); + + assert!(!rendered.iter().any(|line| line.contains(""))); + assert!(!rendered.iter().any(|line| line.contains("[('p', 'b')"))); +} + +#[test] +fn render_tool_message_shows_bash_output_when_enabled() { + crate::tui::ui::tools_ui::tests_show_bash_output_override::set(true); + let msg = DisplayMessage { + role: "tool".to_string(), + content: "one\ntwo\nthree\nfour".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: None, + tool_data: Some(crate::message::ToolCall { + id: "call_bash_output_enabled".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "printf output"}), + intent: Some("Print output".to_string()), + thought_signature: None, + }), + }; + + let rendered = render_tool_message(&msg, 120, crate::config::DiffDisplayMode::Off) + .iter() + .map(extract_line_text) + .collect::>(); + + assert_eq!(rendered.len(), 4); + assert!(!rendered.iter().any(|line| line.trim() == "one")); + assert!(rendered.iter().any(|line| line.trim() == "two")); + assert!(rendered.iter().any(|line| line.trim() == "four")); + crate::tui::ui::tools_ui::tests_show_bash_output_override::set(false); +} + fn gmail_draft_message(content: &str, input: serde_json::Value) -> DisplayMessage { DisplayMessage { role: "tool".to_string(), @@ -2625,6 +2725,80 @@ fn render_tool_message_shows_inline_diff_for_pascal_case_multiedit() { assert!(plain.contains("new line"), "plain={plain}"); } +#[test] +fn render_tool_message_labels_single_file_apply_patch_diff() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "โœ“ src/example.rs: modified (1 hunks)".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: Some("src/example.rs".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "call_apply_patch_single".to_string(), + name: "apply_patch".to_string(), + input: serde_json::json!({ + "intent": "Update example behavior", + "patch_text": "*** Begin Patch\n*** Update File: src/example.rs\n@@\n-old_value\n+new_value\n*** End Patch\n" + }), + intent: Some("Update example behavior".to_string()), + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 100, crate::config::DiffDisplayMode::Inline); + let plain = lines + .iter() + .map(extract_line_text) + .collect::>() + .join("\n"); + + assert!(plain.contains("โ”Œโ”€ diff ยท src/example.rs"), "plain={plain}"); + assert!(plain.contains("old_value"), "plain={plain}"); + assert!(plain.contains("new_value"), "plain={plain}"); +} + +#[test] +fn render_tool_message_preserves_multi_file_apply_patch_boundaries() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "โœ“ a.txt: modified (1 hunks)\n1- old a\n1+ new a\nโœ“ b.txt: modified (1 hunks)\n1- old b\n1+ new b\n".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: Some("2 files".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "call_apply_patch_multi".to_string(), + name: "apply_patch".to_string(), + input: serde_json::json!({ + "intent": "Update both examples", + "patch_text": "*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n*** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch\n" + }), + intent: Some("Update both examples".to_string()), + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 100, crate::config::DiffDisplayMode::Inline); + let plain = lines + .iter() + .map(extract_line_text) + .collect::>() + .join("\n"); + + let a_header = plain.find("โ”Œโ”€ diff ยท a.txt").expect("missing a.txt header"); + let old_a = plain.find("old a").expect("missing a.txt deletion"); + let new_a = plain.find("new a").expect("missing a.txt addition"); + let b_header = plain + .find("โ”œโ”€ diff ยท b.txt") + .expect("missing b.txt boundary"); + let old_b = plain.find("old b").expect("missing b.txt deletion"); + let new_b = plain.find("new b").expect("missing b.txt addition"); + assert!( + a_header < old_a && old_a < new_a && new_a < b_header, + "plain={plain}" + ); + assert!(b_header < old_b && old_b < new_b, "plain={plain}"); +} + #[test] fn render_tool_message_shows_numbered_write_result_diff_after_input_compaction() { let msg = DisplayMessage { diff --git a/crates/jcode-tui/src/tui/ui_messages_cache.rs b/crates/jcode-tui/src/tui/ui_messages_cache.rs index 0f9565243a..f396b2a432 100644 --- a/crates/jcode-tui/src/tui/ui_messages_cache.rs +++ b/crates/jcode-tui/src/tui/ui_messages_cache.rs @@ -21,6 +21,7 @@ where mermaid_epoch: crate::tui::mermaid::deferred_render_epoch(), mermaid_aspect_bucket: crate::tui::mermaid::current_preferred_aspect_ratio_bucket(), show_agentgrep_output: crate::config::config().display.show_agentgrep_output, + show_bash_output: crate::config::config().display.show_bash_output, tool_call_details: crate::config::config().display.tool_call_details, }, render, diff --git a/crates/jcode-tui/src/tui/ui_tests/tools.rs b/crates/jcode-tui/src/tui/ui_tests/tools.rs index 79fa729b16..a3f7cf7301 100644 --- a/crates/jcode-tui/src/tui/ui_tests/tools.rs +++ b/crates/jcode-tui/src/tui/ui_tests/tools.rs @@ -1172,10 +1172,12 @@ fn test_render_tool_message_with_intent_never_adds_second_command_line() { assert_eq!( rendered.len(), 1, - "intent rows must stay single-line: {rendered:?}" + "Bash output is hidden by default: {rendered:?}" ); assert!( - !rendered[0].trim_start().starts_with('$'), + rendered + .iter() + .all(|line| !line.trim_start().starts_with('$')), "rendered={rendered:?}" ); } diff --git a/crates/jcode-tui/src/tui/ui_tools.rs b/crates/jcode-tui/src/tui/ui_tools.rs index 868ad41e00..5239e8eb80 100644 --- a/crates/jcode-tui/src/tui/ui_tools.rs +++ b/crates/jcode-tui/src/tui/ui_tools.rs @@ -39,6 +39,33 @@ pub(crate) mod tests_tool_call_details_override { } } +#[cfg(not(test))] +pub(crate) fn show_bash_output() -> bool { + crate::config::config().display.show_bash_output +} + +#[cfg(test)] +pub(crate) fn show_bash_output() -> bool { + tests_show_bash_output_override::get() +} + +#[cfg(test)] +pub(crate) mod tests_show_bash_output_override { + use std::cell::Cell; + + thread_local! { + static SHOW_OUTPUT: Cell = const { Cell::new(false) }; + } + + pub(crate) fn get() -> bool { + SHOW_OUTPUT.with(Cell::get) + } + + pub(crate) fn set(value: bool) { + SHOW_OUTPUT.with(|cell| cell.set(value)); + } +} + fn infer_bg_action_from_intent_for_display(intent: Option<&str>) -> Option<&'static str> { let intent = intent?.trim().to_ascii_lowercase(); if intent.is_empty() { diff --git a/crates/jcode-tui/src/tui/ui_viewport.rs b/crates/jcode-tui/src/tui/ui_viewport.rs index 55c1a64bfe..805d2025b9 100644 --- a/crates/jcode-tui/src/tui/ui_viewport.rs +++ b/crates/jcode-tui/src/tui/ui_viewport.rs @@ -361,7 +361,8 @@ pub(super) fn draw_messages( let viewport_height = render_area.height as usize; // Pinned todo band (display.pin_todos): the full todo card rendered beneath // the sticky previous-prompt preview, including at the top of the transcript. - let pinned_todo_band = pinned_todo_band_lines(app, text_render_area.width, render_area.height); + let (pinned_todo_band, pinned_todo_more_line) = + pinned_todo_band_lines(app, text_render_area.width, render_area.height); let max_scroll = compute_max_scroll_with_prompt_preview( total_lines, wrapped_user_prompt_starts, @@ -413,6 +414,17 @@ pub(super) fn draw_messages( 0u16 }; let pinned_todo_lines = pinned_todo_band.len() as u16; + set_pinned_todo_more_area(pinned_todo_more_line.map(|line| { + Rect { + x: text_render_area.x, + y: render_area + .y + .saturating_add(prompt_preview_lines) + .saturating_add(line as u16), + width: text_render_area.width, + height: 1, + } + })); // Total synthetic rows reserved at the top of the viewport (previous-prompt // preview first, then the todo band, then transcript content). let top_band_lines = pinned_todo_lines + prompt_preview_lines; @@ -573,6 +585,7 @@ pub(super) fn draw_messages( kind_label: target.kind.label(), copied_notice: target.kind.copied_notice(), content: target.content.clone(), + badge_rect: None, }); // Prefer a line in the block with enough free width so the badge // doesn't cut off content (full-width blockquote lines especially). @@ -596,7 +609,6 @@ pub(super) fn draw_messages( ©_badge_ui, copy_badge_now, ); - set_visible_copy_targets(visible_copy_targets); super::note_viewport_metrics(super::ViewportMetrics { scroll, visible_end, @@ -716,6 +728,7 @@ pub(super) fn draw_messages( }) .flatten(); super::set_visible_expand_edit_badge(expand_edit_badge_visible, visible_expand_badge_line); + super::set_visible_expand_edit_badge_rect(None); let expand_badge_line = if expand_feedback_active { copy_badge_ui.expand_feedback_line.or_else(|| { @@ -774,6 +787,7 @@ pub(super) fn draw_messages( Style::default().fg(dim_color()) }; + let badge_start = line.width().saturating_add(1); line.spans.push(Span::raw(" ")); line.spans .push(Span::styled(copy_badge_alt_badge(), alt_style)); @@ -787,6 +801,23 @@ pub(super) fn draw_messages( Style::default().fg(dim_color()) }; line.spans.push(Span::styled(badge_text, badge_text_style)); + + let final_width = line.width(); + let aligned_x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Center => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16) / 2), + Alignment::Right => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16)), + Alignment::Left => content_area.x, + }; + super::set_visible_expand_edit_badge_rect(Some(Rect { + x: aligned_x.saturating_add(badge_start as u16), + y: content_area.y.saturating_add(rel_idx as u16), + width: final_width.saturating_sub(badge_start) as u16, + height: 1, + })); } } } @@ -836,6 +867,8 @@ pub(super) fn draw_messages( line.spans.push(Span::raw(" ")); } + let shortcut_start = line.width(); + line.spans .push(Span::styled(copy_badge_alt_badge(), alt_style)); line.spans.push(Span::raw(" ")); @@ -845,8 +878,31 @@ pub(super) fn draw_messages( format!("[{}]", key.to_ascii_uppercase()), key_style, )); + + let final_width = line.width(); + let aligned_x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Center => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16) / 2), + Alignment::Right => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16)), + Alignment::Left => content_area.x, + }; + if let Some(target) = visible_copy_targets + .iter_mut() + .find(|target| target.key.eq_ignore_ascii_case(&key)) + { + target.badge_rect = Some(Rect { + x: aligned_x.saturating_add(shortcut_start as u16), + y: content_area.y.saturating_add(rel_idx as u16), + width: final_width.saturating_sub(shortcut_start) as u16, + height: 1, + }); + } } } + set_visible_copy_targets(visible_copy_targets); if let Some(range) = app.copy_selection_range().filter(|range| { range.start.pane == crate::tui::CopySelectionPane::Chat @@ -1306,42 +1362,53 @@ fn windowed_min(widths: &[u16], window: usize) -> Vec { out } -/// Lines for the pinned todo band (`display.pin_todos`): the full inline todo -/// card rendered at the top of the viewport while scrolled, capped to roughly -/// a third of the viewport so the transcript stays usable. Empty when the -/// feature is off, the session has no todos, or the viewport is too small. +/// Lines for the pinned status band: optional todos followed by exactly one +/// compact row per retained background task. Todo content is capped so the +/// transcript stays usable; tasks are never folded into a summary row. fn pinned_todo_band_lines( app: &dyn TuiState, width: u16, viewport_height: u16, -) -> Vec> { - if !crate::config::config().display.pin_todos { - return Vec::new(); +) -> (Vec>, Option) { + if width < 16 || viewport_height < 3 { + return (Vec::new(), None); } - let Some(payload) = app.pinned_todos_payload() else { - return Vec::new(); + + let task_lines: Vec<_> = app + .background_task_rows() + .iter() + .map(|task| active_background_task_line(task, width)) + .collect(); + let card_lines = if crate::config::config().display.pin_todos { + app.pinned_todos_payload() + .map(|payload| { + let msg = crate::tui::DisplayMessage::todos(payload.to_string()); + super::messages::get_cached_message_lines( + &msg, + width, + app.diff_mode(), + super::messages::render_todos_message, + ) + }) + .unwrap_or_default() + } else { + Vec::new() }; - if width < 8 || viewport_height < 9 { - return Vec::new(); - } - let msg = crate::tui::DisplayMessage::todos(payload.to_string()); - let card_lines = super::messages::get_cached_message_lines( - &msg, - width, - app.diff_mode(), - super::messages::render_todos_message, - ); - if card_lines.is_empty() { - return Vec::new(); + if card_lines.is_empty() && task_lines.is_empty() { + return (Vec::new(), None); } + // Band budget: about a third of the viewport. let budget = ((viewport_height as usize) / 3).clamp(2, 12); - let content_budget = budget; + let content_budget = budget.saturating_sub(task_lines.len()).max(2); let mut lines: Vec> = Vec::new(); - if card_lines.len() > content_budget { + let has_more = card_lines.len() > content_budget && !app.pinned_todos_expanded(); + let mut more_line = None; + if has_more { let shown = content_budget.saturating_sub(1); let hidden = card_lines.len() - shown; lines.extend(card_lines.into_iter().take(shown)); + more_line = Some(lines.len()); lines.push(Line::from(Span::styled( format!(" โ€ฆ +{} more (todo)", hidden), Style::default().fg(dim_color()), @@ -1349,7 +1416,102 @@ fn pinned_todo_band_lines( } else { lines.extend(card_lines); } - lines + lines.extend(task_lines); + (lines, more_line) +} + +fn active_background_task_line(task: &crate::tui::BackgroundTaskRow, width: u16) -> Line<'static> { + const BAR_WIDTH: usize = 6; + let (icon, task_color, percent) = match task.status { + crate::tui::BackgroundTaskRowStatus::Running => ( + "โ—Œ", + accent_color(), + task.percent.unwrap_or(0.0).clamp(0.0, 100.0), + ), + crate::tui::BackgroundTaskRowStatus::Completed => ("โœ“", Color::Green, 100.0), + crate::tui::BackgroundTaskRowStatus::Failed => ( + "ร—", + Color::Red, + task.percent.unwrap_or(0.0).clamp(0.0, 100.0), + ), + }; + let rounded_percent = percent.round() as u8; + let status_label = if task.status == crate::tui::BackgroundTaskRowStatus::Failed { + "failed".to_string() + } else { + format!("{}%", rounded_percent) + }; + let filled = ((percent / 100.0) * BAR_WIDTH as f32).round() as usize; + let (active_bar, remaining_bar) = if task.status == crate::tui::BackgroundTaskRowStatus::Failed + { + ( + "โ”".repeat(filled.min(BAR_WIDTH)), + "โ”€".repeat(BAR_WIDTH.saturating_sub(filled)), + ) + } else if filled >= BAR_WIDTH { + ("โ”".repeat(BAR_WIDTH), String::new()) + } else { + ( + format!("{}โ•บ", "โ”".repeat(filled)), + "โ”€".repeat(BAR_WIDTH.saturating_sub(filled + 1)), + ) + }; + + let fixed_width = UnicodeWidthStr::width( + format!("โ—Œ bg {} {}{}", active_bar, remaining_bar, status_label).as_str(), + ); + let max_label_width = (width as usize).saturating_sub(fixed_width).max(1); + let label = truncate_background_task_label(&task.label, max_label_width); + + Line::from(vec![ + Span::styled(icon, Style::default().fg(task_color)), + Span::styled(" bg ", Style::default().fg(dim_color())), + Span::raw(label), + Span::raw(" "), + Span::styled(active_bar, Style::default().fg(task_color)), + Span::styled(remaining_bar, Style::default().fg(dim_color())), + Span::styled( + format!(" {}", status_label), + Style::default().fg(dim_color()), + ), + ]) +} + +fn truncate_background_task_label(label: &str, max_width: usize) -> String { + let label = label.replace(['\r', '\n'], " "); + if UnicodeWidthStr::width(label.as_str()) <= max_width { + return label; + } + if max_width <= 1 { + return "โ€ฆ".to_string(); + } + let mut truncated = String::new(); + for ch in label.chars() { + let candidate = format!("{}{}โ€ฆ", truncated, ch); + if UnicodeWidthStr::width(candidate.as_str()) > max_width { + break; + } + truncated.push(ch); + } + truncated.push('โ€ฆ'); + truncated +} + +static PINNED_TODO_MORE_AREA: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn set_pinned_todo_more_area(area: Option) { + if let Ok(mut current) = PINNED_TODO_MORE_AREA.lock() { + *current = area; + } +} + +#[cfg(test)] +pub(crate) fn set_pinned_todo_more_area_for_test(area: Option) { + set_pinned_todo_more_area(area); +} + +pub(crate) fn pinned_todo_more_area() -> Option { + PINNED_TODO_MORE_AREA.lock().ok().and_then(|area| *area) } fn compute_prompt_preview_line_count( diff --git a/docs/AMBIENT_MODE.md b/docs/AMBIENT_MODE.md index 92fa3303a4..5f0b24b15a 100644 --- a/docs/AMBIENT_MODE.md +++ b/docs/AMBIENT_MODE.md @@ -1,7 +1,14 @@ # Ambient Mode -> **Status:** Design -> **Updated:** 2026-02-08 +> **Status:** Implemented +> **Updated:** 2026-08-16 +> +> Ambient mode is disabled by default. Enable it with `[ambient] enabled = true` +> and configure an available subscription or API provider before use. +> +> The core ambient runner is implemented. Later sections also retain forward-looking +> design ideas; examples such as cold-start gating, warm-up bypass, and per-project +> policy fields are not all available configuration options yet. A proactive, always-on agent mode that works autonomously without user prompting. Like a brain consolidating memories during sleep, ambient mode tends to the memory graph, identifies useful work, and acts on the user's behalf โ€” all while staying within resource limits. @@ -919,48 +926,14 @@ This is a distributed systems problem that will be addressed once ambient is sta --- -## Implementation Phases - -### Phase 1: Foundation -- [ ] Ambient agent loop (spawn, run, sleep) -- [ ] Single-instance guard -- [ ] Basic scheduling (fixed interval with max ceiling) -- [ ] Provider selection chain (OpenAI OAuth โ†’ Anthropic OAuth โ†’ pay-per-token opt-in โ†’ disabled) -- [ ] Configuration (`[ambient]` section in config) -- [ ] Storage layout - -### Phase 2: Memory Consolidation โ€” Garden -- [ ] Full graph-wide dedup scan -- [ ] Fact verification against codebase -- [ ] Retroactive session extraction (crashed/missed sessions) -- [ ] Pruning dead memories (low confidence + low strength) -- [ ] Relationship discovery across sessions -- [ ] Embedding backfill -- [ ] Contradiction resolution - -### Phase 3: Scheduling -- [ ] `schedule_ambient` tool for agent self-scheduling -- [ ] Scheduled queue (persistent, with context) -- [ ] Adaptive resource calculator -- [ ] Usage history tracking -- [ ] Rate limit awareness (from provider response headers) -- [ ] Event triggers (session close, crash, git push) -- [ ] Active session detection โ†’ pause/throttle - -### Phase 4: Proactive Work -- [ ] Scout: analyze recent sessions + git history -- [ ] Infer user priorities from memories -- [ ] Identify actionable work -- [ ] Execute on separate branch -- [ ] Report results - -### Phase 5: Info Widget -- [ ] Ambient status display in TUI -- [ ] Queue preview -- [ ] Last cycle summary -- [ ] Next wake estimate -- [ ] Budget bar (user vs ambient vs remaining) +## Implementation Status + +Ambient mode is shipped. The runtime loop, persistent scheduling, memory garden, +proactive work flow, channel integration, configuration, tools, and TUI status +display are implemented. The source of truth for current behavior is +`crates/jcode-app-core/src/ambient/`; remaining enhancements are tracked as +GitHub issues rather than in the original design checklist. --- -*Last updated: 2026-02-08* +*Last updated: 2026-08-16* diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg new file mode 100644 index 0000000000..6ca461c92f --- /dev/null +++ b/docs/images/star-history.svg @@ -0,0 +1,17 @@ + +1jehuang/jcode star history +GitHub stars over time, currently 17,951 + + +GitHub stars over time +05k10k15k20k2026 + + +17,951 stars + diff --git a/score_shard.py b/score_shard.py new file mode 100644 index 0000000000..12737b4183 --- /dev/null +++ b/score_shard.py @@ -0,0 +1,30 @@ +import json,re,collections +inp='/home/jeremy/jcode-transcript-export/handpick/digests/shard_01.json'; out='/home/jeremy/jcode-transcript-export/handpick/scores/shard_01.scores.json' +data=json.load(open(inp)) +def score(x): + ts=x.get('todos') or []; n=len(ts); calls=x.get('todo_calls',0) or 0; st=[str(t.get('status','')).lower() for t in ts]; cs=[str(t.get('content','')).strip() for t in ts]; ne=[c for c in cs if c]; comp=sum(s=='completed' for s in st); pending=sum(s in ('pending','in_progress') for s in st) + if not ne or all(len(c)<=12 for c in ne): base=0 + else: + avg=sum(map(len,ne))/len(ne); spec=sum(bool(re.search(r'\b(map|trace|audit|inspect|verify|enumerate|identify|classify|test|run|search|document|review|inventory|analy[sz]|implement|fix|write|report|check|validate)\b',c,re.I)) for c in ne)/len(ne) + if n>=3 and avg>=35 and spec>=.5: + base=8 + if comp>=max(2,n*.6): base=9 + if comp==n and calls>=3: base=10 + elif n>=2 and avg>=20: + base=5 if pending else 6 + if comp>=n*.5 and calls>=2: base=7 + else: base=2 if calls<=1 or pending else 4 + if x.get('is_debug'): base-=2 + base=max(0,min(10,base)) + if not ne: reason='No meaningful todo items.' + elif base<=1: reason='Boilerplate or junk todos with little planning value.' + elif base<=4: reason='Sparse or vague plan, with limited completion evidence.' + elif base<=7: reason='Reasonable plan, but generic or incompletely updated.' + elif comp==n and calls>=3: reason='Specific decomposed plan, repeatedly updated and fully completed.' + elif comp>=2: reason='Specific multi-step plan with substantial completion evidence.' + else: reason='Specific multi-step plan, but completion remains partial.' + if x.get('is_debug'): reason='Debug session penalty applied; '+reason[0].lower()+reason[1:] + return {'file':x.get('file'),'score':base,'reason':reason[:99]} +r=[score(x) for x in data] +with open(out,'w') as f: json.dump(r,f,ensure_ascii=False,indent=2); f.write('\n') +print(len(r),dict(sorted(collections.Counter(x['score'] for x in r).items()))) diff --git a/scripts/dev_cargo.sh b/scripts/dev_cargo.sh index 0954491c2b..77af0038e5 100755 --- a/scripts/dev_cargo.sh +++ b/scripts/dev_cargo.sh @@ -1065,7 +1065,7 @@ acquire_cargo_gate() { return 0 fi - local gate_dir gate_path wait_started_ns wait_finished_ns + local gate_dir gate_path wait_started_ns wait_finished_ns waited_seconds gate_dir="${JCODE_CARGO_GATE_DIR:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}}" mkdir -p "$gate_dir" gate_path="${JCODE_CARGO_GATE_PATH:-$gate_dir/jcode-cargo-build.lock}" @@ -1073,7 +1073,13 @@ acquire_cargo_gate() { if ! flock -n "$cargo_gate_fd"; then log "waiting for the host-wide Cargo gate ($gate_path)" wait_started_ns=$(date +%s%N) - flock "$cargo_gate_fd" + waited_seconds=0 + # Avoid one silent, unbounded flock call. Periodic notes make it clear that + # the process is alive and blocked behind another compiler rather than hung. + while ! flock -w 30 "$cargo_gate_fd"; do + waited_seconds=$((waited_seconds + 30)) + log "still waiting for the host-wide Cargo gate (${waited_seconds}s elapsed)" + done wait_finished_ns=$(date +%s%N) cargo_gate_wait_ms=$(( (wait_finished_ns - wait_started_ns) / 1000000 )) fi diff --git a/scripts/test_fork_ci_workflow.py b/scripts/test_fork_ci_workflow.py new file mode 100644 index 0000000000..a9feebb5f9 --- /dev/null +++ b/scripts/test_fork_ci_workflow.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Regression tests for CI behavior in forks without optional repository features.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +ISSUE_WORKFLOW = ROOT / ".github" / "workflows" / "require-issue.yml" + + +class ForkCiWorkflowTests(unittest.TestCase): + def test_optional_deploy_key_gates_every_ssh_agent_step(self) -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + steps = re.findall( + r"(?ms)^\s+- name: Configure SSH for cargo git dependencies\n" + r"(?P(?:^\s{8,}.*\n){1,8})", + workflow, + ) + self.assertGreater(len(steps), 0) + self.assertEqual(workflow.count("DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}"), 4) + for body in steps: + self.assertIn("if: ${{ env.DEPLOY_KEY != '' }}", body) + self.assertIn("uses: webfactory/ssh-agent@", body) + self.assertIn("ssh-private-key: ${{ env.DEPLOY_KEY }}", body) + + def test_issue_policy_skips_when_repository_issues_are_disabled(self) -> None: + workflow = ISSUE_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("hasIssuesEnabled", workflow) + self.assertIn("Repository issues are disabled; skipping linked-issue requirement.", workflow) + self.assertLess( + workflow.index("hasIssuesEnabled"), + workflow.index("const candidates = new Set()"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 5f3989f2ff..a6e5f7a0e3 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -27,6 +27,7 @@ import { type ImageAttachment, type ModelRouteInfo, type PermissionDecision, + type RenderedImage, type ServerFrame, type SessionInfo, type TextMatch, @@ -544,11 +545,18 @@ export class JcodeClient extends EventEmitter { } async getHistory(sessionId: string): Promise { + const { messages } = await this.getHistoryWithImages(sessionId); + return messages; + } + + async getHistoryWithImages( + sessionId: string, + ): Promise<{ messages: HistoryMessage[]; images: RenderedImage[] }> { const frame = await this.expectReply( { req: "get_history", session_id: sessionId }, "history", ); - return frame.messages ?? []; + return { messages: frame.messages ?? [], images: frame.images ?? [] }; } async peekSession(sessionId: string, limit?: number): Promise { diff --git a/sdk/typescript/src/protocol.ts b/sdk/typescript/src/protocol.ts index 7358b72761..37c0007059 100644 --- a/sdk/typescript/src/protocol.ts +++ b/sdk/typescript/src/protocol.ts @@ -51,6 +51,23 @@ export interface HistoryMessage { content: string; } +export type RenderedImageSource = + | { kind: "user_input" } + | { kind: "tool_result"; tool_name: string } + | { kind: "other"; role: string }; + +export type RenderedImageAnchor = + | { kind: "tool_call"; id: string } + | { kind: "user_prompt"; ordinal: number }; + +export interface RenderedImage { + media_type: string; + data: string; + label?: string; + source: RenderedImageSource; + anchor?: RenderedImageAnchor; +} + /** Base64 image attachment: [mediaType, base64Data]. */ export type ImageAttachment = [string, string]; @@ -109,7 +126,7 @@ export type ApiEvent = | { ev: "error"; code: ErrorCode; message: string } | { ev: "sessions"; sessions: SessionInfo[] } | { ev: "attached"; session: SessionInfo } - | { ev: "history"; session_id: string; messages: HistoryMessage[] } + | { ev: "history"; session_id: string; messages: HistoryMessage[]; images?: RenderedImage[] } | { ev: "pong" } | { ev: "text_delta"; session_id: string; text: string } | { ev: "reasoning_delta"; session_id: string; text: string } @@ -125,6 +142,7 @@ export type ApiEvent = output: string; error?: string; } + | { ev: "side_pane_images"; session_id: string; images: RenderedImage[] } | { ev: "token_usage"; session_id: string; @@ -152,13 +170,20 @@ export type ApiEvent = } | { ev: "session_status"; session_id: string; status: string } | { ev: "connection_phase"; session_id: string; phase: string } - | { ev: "model_info"; session_id: string; provider?: string; model?: string } + | { + ev: "model_info"; + session_id: string; + provider?: string; + model?: string; + reasoning_effort?: string; + } | { ev: "models"; session_id: string; models: string[]; current?: string } | { ev: "runtime_info"; session_id: string; provider?: string; model?: string; + reasoning_effort?: string; routes: ModelRouteInfo[]; } | { ev: "credential_updated"; provider: string; configured: boolean } @@ -227,6 +252,7 @@ export const KNOWN_EVENT_KINDS = [ "sessions", "attached", "history", + "side_pane_images", "pong", "text_delta", "reasoning_delta", diff --git a/src/cli/auth_test/run.rs b/src/cli/auth_test/run.rs index 6212d359be..a70536a90b 100644 --- a/src/cli/auth_test/run.rs +++ b/src/cli/auth_test/run.rs @@ -38,7 +38,11 @@ async fn maybe_run_auth_test_smoke( }, ); } - Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")), + Err(err) => { + let detail = format!("{err:#}"); + kind.set_output(report, detail.clone()); + report.push_step(kind.step_name(), false, detail); + } } } else if !target.supports_smoke() { report.push_step(kind.step_name(), true, kind.unsupported_detail()); @@ -79,7 +83,11 @@ async fn maybe_run_auth_test_smoke_for_choice( }, ); } - Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")), + Err(err) => { + let detail = format!("{err:#}"); + kind.set_output(report, detail.clone()); + report.push_step(kind.step_name(), false, detail); + } } } Ok(AuthTestChoicePlan::Skip(detail)) => { diff --git a/src/cli/commands.rs b/src/cli/commands.rs index a423d42d72..6fe6231316 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -2450,25 +2450,49 @@ pub async fn run_single_message_command( wait_for_cold_cache_mcp_tools(®istry).await; } let mut agent = crate::agent::Agent::new(provider.clone(), registry); - restore_agent_session_if_requested(&mut agent, resume_session)?; + if let Err(error) = restore_agent_session_if_requested(&mut agent, resume_session) { + agent.mark_closed(); + return Err(error); + } - if emit_json { - let text = run_single_message_command_capture_with_auto_poke(&mut agent, message).await?; - let report = RunCommandReport { - session_id: agent.session_id().to_string(), - provider: provider.name().to_string(), - model: provider.model(), - text, - usage: agent.last_usage().clone(), - }; - println!("{}", serde_json::to_string_pretty(&report)?); - } else if emit_ndjson { - run_single_message_command_ndjson(&mut agent, provider.clone(), message).await?; - } else { - run_single_message_command_plain_with_auto_poke(&mut agent, message).await?; + run_single_message_with_agent(&mut agent, provider, message, emit_json, emit_ndjson).await +} + +async fn run_single_message_with_agent( + agent: &mut crate::agent::Agent, + provider: std::sync::Arc, + message: &str, + emit_json: bool, + emit_ndjson: bool, +) -> Result<()> { + let result: Result<()> = async { + if emit_json { + let text = run_single_message_command_capture_with_auto_poke(agent, message).await?; + let report = RunCommandReport { + session_id: agent.session_id().to_string(), + provider: provider.name().to_string(), + model: provider.model(), + text, + usage: agent.last_usage().clone(), + }; + println!("{}", serde_json::to_string_pretty(&report)?); + } else if emit_ndjson { + run_single_message_command_ndjson(agent, provider, message).await?; + } else { + run_single_message_command_plain_with_auto_poke(agent, message).await?; + } + Ok(()) } + .await; - Ok(()) + // `Agent::new` and session restore both register this process as the active + // owner. Unlike the interactive lifecycle, `jcode run` has no later quit + // path to close the session. Finalize after output has been emitted, while + // returning the original command result unchanged. This prevents a normal + // one-shot exit from looking like a stale-PID crash on the next startup + // (issue #988). + agent.mark_closed(); + result } fn run_command_auto_poke_enabled() -> bool { diff --git a/src/cli/commands/provider_setup.rs b/src/cli/commands/provider_setup.rs index a6c3b73804..9848be31cc 100644 --- a/src/cli/commands/provider_setup.rs +++ b/src/cli/commands/provider_setup.rs @@ -167,6 +167,7 @@ pub(crate) fn configure_provider_profile( .map(ToString::to_string), _ => None, }, + headers: std::collections::BTreeMap::new(), api_key_env: api_key_env.clone(), api_key: None, env_file: env_file.clone(), diff --git a/src/cli/commands_tests.rs b/src/cli/commands_tests.rs index b324417136..442e04085b 100644 --- a/src/cli/commands_tests.rs +++ b/src/cli/commands_tests.rs @@ -70,6 +70,29 @@ impl Provider for TestProvider { } } +struct FailingTestProvider; + +#[async_trait] +impl Provider for FailingTestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + Err(anyhow::anyhow!("one-shot sentinel failure")) + } + + fn name(&self) -> &str { + "failing-test" + } + + fn fork(&self) -> Arc { + Arc::new(Self) + } +} + fn spawn_single_response_http_server(status: u16, body: &str) -> String { spawn_single_response_http_server_on_host("127.0.0.1", status, body) } @@ -1300,3 +1323,160 @@ async fn restore_agent_session_if_requested_restores_resumed_session() { assert_eq!(resumed.session_id(), original_session_id); } + +#[tokio::test] +async fn one_shot_output_modes_close_sessions_and_clear_active_pid_markers() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + for (mode, emit_json, emit_ndjson) in [ + ("plain", false, false), + ("json", true, false), + ("ndjson", false, true), + ] { + let provider: Arc = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = crate::agent::Agent::new(provider.clone(), registry); + let session_id = agent.session_id().to_string(); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + assert!(marker.exists(), "{mode} session should start active"); + + run_single_message_with_agent( + &mut agent, + provider, + "Return the test response.", + emit_json, + emit_ndjson, + ) + .await + .unwrap_or_else(|error| panic!("{mode} run failed: {error:#}")); + + assert!( + !marker.exists(), + "{mode} run left an active PID marker behind" + ); + let persisted = crate::session::Session::load(&session_id) + .unwrap_or_else(|error| panic!("load {mode} session: {error:#}")); + assert!( + matches!(persisted.status, crate::session::SessionStatus::Closed), + "{mode} run persisted status {:?}", + persisted.status + ); + assert!( + !crate::session::find_recent_crashed_sessions() + .iter() + .any(|(id, _)| id == &session_id), + "{mode} run was rediscovered as a stale-PID crash" + ); + } +} + +#[tokio::test] +async fn resumed_one_shot_closes_the_restored_session() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + let provider: Arc = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let mut original = crate::agent::Agent::new(provider.clone(), registry); + original + .run_once_capture("Seed the resumed one-shot session.") + .await + .expect("seed resumed session"); + let session_id = original.session_id().to_string(); + original.mark_closed(); + + let registry = Registry::new(provider.clone()).await; + let mut resumed = crate::agent::Agent::new(provider.clone(), registry); + restore_agent_session_if_requested(&mut resumed, Some(&session_id)) + .expect("restore one-shot session"); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + assert!( + marker.exists(), + "restored session should be active while running" + ); + + run_single_message_with_agent( + &mut resumed, + provider, + "Finish the resumed one-shot session.", + true, + false, + ) + .await + .expect("run resumed one-shot session"); + + assert!(!marker.exists(), "resumed run left its active PID marker"); + let persisted = crate::session::Session::load(&session_id).expect("load resumed session"); + assert!(matches!( + persisted.status, + crate::session::SessionStatus::Closed + )); + assert!( + !crate::session::find_recent_crashed_sessions() + .iter() + .any(|(id, _)| id == &session_id), + "resumed run was rediscovered as a stale-PID crash" + ); +} + +#[tokio::test] +async fn one_shot_cleanup_preserves_the_original_command_error() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + for (mode, emit_json, emit_ndjson) in [ + ("plain", false, false), + ("json", true, false), + ("ndjson", false, true), + ] { + let provider: Arc = Arc::new(FailingTestProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = crate::agent::Agent::new(provider.clone(), registry); + let session_id = agent.session_id().to_string(); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + + let error = match run_single_message_with_agent( + &mut agent, + provider, + "Fail this run.", + emit_json, + emit_ndjson, + ) + .await + { + Ok(()) => panic!("{mode} provider failure should remain the command result"), + Err(error) => error, + }; + + assert!( + error.to_string().contains("one-shot sentinel failure"), + "{mode} changed the original error: {error:#}" + ); + assert!( + !marker.exists(), + "failed {mode} run left an active PID marker" + ); + let persisted = crate::session::Session::load(&session_id) + .unwrap_or_else(|error| panic!("load failed {mode} session: {error:#}")); + assert!(matches!( + persisted.status, + crate::session::SessionStatus::Closed + )); + } +} diff --git a/src/cli/provider_init_tests.rs b/src/cli/provider_init_tests.rs index e0bc534ea3..50c857d1f4 100644 --- a/src/cli/provider_init_tests.rs +++ b/src/cli/provider_init_tests.rs @@ -141,6 +141,76 @@ async fn explicit_anthropic_api_choice_pins_api_key_over_available_oauth() { crate::auth::AuthStatus::invalidate_cache(); } +#[tokio::test(flavor = "multi_thread")] +#[expect( + clippy::await_holding_lock, + reason = "test env locks intentionally stay held across provider init to isolate process-global auth env" +)] +async fn explicit_openai_api_choice_overrides_configured_compatible_default() { + let _guard = lock_env(); + let _env_guard = crate::storage::lock_test_env(); + let dir = TempDir::new().expect("temp dir"); + let keys = [ + "JCODE_HOME", + "OPENAI_API_KEY", + "JCODE_PROVIDER_PROFILE_ACTIVE", + "JCODE_PROVIDER_PROFILE_NAME", + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", + ]; + let saved: Vec<(&str, Option)> = keys + .iter() + .map(|key| (*key, std::env::var(key).ok())) + .collect(); + + crate::env::set_var("JCODE_HOME", dir.path()); + crate::env::set_var("OPENAI_API_KEY", "sk-openai-api-test"); + for key in keys.iter().skip(2) { + crate::env::remove_var(key); + } + std::fs::write( + dir.path().join("config.toml"), + r#" +[provider] +default_provider = "local-gateway" +default_model = "gateway-default" + +[providers.local-gateway] +type = "openai-compatible" +base_url = "http://localhost:1234/v1" +auth = "none" +default_model = "gateway-default" +requires_api_key = false +"#, + ) + .expect("write competing configured provider default"); + crate::config::invalidate_config_cache(); + crate::auth::AuthStatus::invalidate_cache(); + + let provider = init_provider_for_validation(&ProviderChoice::OpenaiApi, Some("gpt-5.6-luna")) + .await + .expect("explicit OpenAI API provider should override configured defaults"); + + assert_eq!(provider.active_auth_method_label(), Some("API key")); + assert_eq!(provider.model(), "gpt-5.6-luna"); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + Some("openai-api") + ); + + for (key, value) in saved { + if let Some(value) = value { + crate::env::set_var(key, value); + } else { + crate::env::remove_var(key); + } + } + crate::config::invalidate_config_cache(); + crate::auth::AuthStatus::invalidate_cache(); +} + #[test] fn test_server_bootstrap_login_selection_preserves_order() { let providers = provider_catalog::server_bootstrap_login_providers(); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index 11f82ca4e6..7eb2255c3d 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -190,10 +190,16 @@ through jcode, priced per model rather than with one blended rate. Setup: ```bash npm run migrate:model-prices # creates model_prices (migration 0023) npm run sync:model-prices # fills it from https://models.dev/api.json -npm run token-value # daily / per-model / summary panels +npm run token-value:fresh # refresh prices, then run the dashboard (recommended) +npm run token-value # dashboard using prices already stored in D1 npm run token-value:daily # just the per-day series, in date order ``` +`npm run token-value:fresh` is the safe default before quoting dollar values: it +refreshes the remote D1 price mappings and then runs the daily / per-model / +summary panels. Use `npm run token-value` only when the prices were refreshed +recently. + `npm run token-value:daily` is the plain time series when all you want is "dollars per day": one row per day with the tokens, sessions, and distinct users behind it. There is deliberately no per-user dollar column, because it @@ -224,6 +230,16 @@ Three things to know before quoting the number: If coverage drops, re-run the sync before trusting the dollar figure. +## Prompt-user dashboard + +`npm run prompt-users` uses the strict product definition requested for user +metrics: one distinct non-CI machine that ran at least one prompt. It reports +rolling prompt DAU and WAU from the union of `turn_end` and prompted lifecycle +rows, including in-flight or unclosed sessions. Since raw `turn_end` rows have +30-day retention, monthly growth and the all-time lower bound use durable +`session_end` / `session_crash` rows with `had_user_prompt > 0` so both monthly +windows have equivalent coverage. + ## Reading DAU without fooling yourself `npm run dau` leads with `headline_users_24h` (= `meaningful_release_24h_noci`): diff --git a/telemetry-worker/package.json b/telemetry-worker/package.json index 35eb8855fc..33d7670c51 100644 --- a/telemetry-worker/package.json +++ b/telemetry-worker/package.json @@ -23,7 +23,9 @@ "health:size": "curl -s https://telemetry.jcode.sh/v1/health", "dau": "node scripts/run-dashboard.mjs dau.sql", "users": "node scripts/run-dashboard.mjs users.sql", + "prompt-users": "node scripts/run-dashboard.mjs prompt-users.sql", "token-value": "node scripts/run-dashboard.mjs token-value.sql", + "token-value:fresh": "npm run sync:model-prices && npm run token-value", "token-value:daily": "node scripts/run-dashboard.mjs token-value-daily.sql", "sync:model-prices": "node scripts/sync-model-prices.mjs", "migrate:detail-fields": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0013_detail_table_turn_session_fields.sql", diff --git a/telemetry-worker/prompt-users.sql b/telemetry-worker/prompt-users.sql new file mode 100644 index 0000000000..ef18c69284 --- /dev/null +++ b/telemetry-worker/prompt-users.sql @@ -0,0 +1,73 @@ +-- Users who ran at least one prompt. +-- Usage: +-- npm run prompt-users +-- +-- A prompt user is a distinct non-CI telemetry_id with either: +-- * a turn_end row (fires only after a real user turn completes), or +-- * a session_end/session_crash row with had_user_prompt > 0. +-- +-- turn_end is retained in D1 for 30 days. It captures in-flight and unclosed +-- sessions, so it is used for DAU and WAU where both comparison windows have +-- equivalent coverage. MAU growth and all-time users use durable lifecycle rows +-- only, avoiding a current-period boost that the prior 30-day window cannot get. +-- The all-time number is therefore a durable lower bound. +-- +-- telemetry_id is per-machine, opt-outs are absent, and old rows written before +-- is_ci existed can be misclassified because they default to non-CI. +WITH recent AS ( + SELECT + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-1 day') + THEN telemetry_id END) AS dau_24h, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-8 days') + AND created_at < datetime('now', '-7 days') + THEN telemetry_id END) AS dau_24h_week_ago, + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', 'start of day') + THEN telemetry_id END) AS today_utc_sofar, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-1 day', 'start of day') + AND created_at < datetime('now', 'start of day') + THEN telemetry_id END) AS yesterday_utc, + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-7 days') + THEN telemetry_id END) AS wau, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-14 days') + AND created_at < datetime('now', '-7 days') + THEN telemetry_id END) AS wau_previous + FROM events + WHERE is_ci = 0 + AND created_at >= datetime('now', '-14 days') + AND ( + event = 'turn_end' + OR (event IN ('session_end', 'session_crash') AND had_user_prompt > 0) + ) +), durable AS ( + SELECT + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-30 days') + THEN telemetry_id END) AS mau_durable, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-60 days') + AND created_at < datetime('now', '-30 days') + THEN telemetry_id END) AS mau_durable_previous, + COUNT(DISTINCT telemetry_id) AS all_time_durable_lower_bound + FROM events + WHERE is_ci = 0 + AND event IN ('session_end', 'session_crash') + AND had_user_prompt > 0 +) +SELECT + dau_24h, + dau_24h_week_ago, + ROUND(100.0 * (dau_24h - dau_24h_week_ago) + / NULLIF(dau_24h_week_ago, 0), 1) AS dau_wow_pct, + today_utc_sofar, + yesterday_utc, + wau, + wau_previous, + ROUND(100.0 * (wau - wau_previous) / NULLIF(wau_previous, 0), 1) AS wau_wow_pct, + mau_durable, + mau_durable_previous, + ROUND(100.0 * (mau_durable - mau_durable_previous) + / NULLIF(mau_durable_previous, 0), 1) AS mau_mom_pct, + all_time_durable_lower_bound +FROM recent, durable; diff --git a/tests/e2e/test_support/mod.rs b/tests/e2e/test_support/mod.rs index 1124301748..1b8461de76 100644 --- a/tests/e2e/test_support/mod.rs +++ b/tests/e2e/test_support/mod.rs @@ -387,6 +387,7 @@ impl WsTestClient { content: content.to_string(), images: vec![], system_reminder: None, + active_skill: None, no_reply: false, }) .await