From 184a41643608a0c9de01e5f7d3805ef348d4f39f Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Wed, 13 May 2026 20:24:03 +0800 Subject: [PATCH 1/5] chore(skills): add codex reproduce workflows --- .agents/skills/codex-reproduce-align/SKILL.md | 77 +++++++++++ .../codex-reproduce-align/agents/openai.yaml | 4 + .../references/alignment-workflow.md | 73 ++++++++++ .../scripts/compare_traces.py | 85 ++++++++++++ .../scripts/normalize_trace.py | 128 ++++++++++++++++++ .../scripts/run_pair_capture.sh | 44 ++++++ .../skills/codex-reproduce-feature/SKILL.md | 67 +++++++++ .../agents/openai.yaml | 4 + .../references/capture-workflow.md | 103 ++++++++++++++ .../scripts/llm_dump.py | 101 ++++++++++++++ .../scripts/run_tmux_capture.sh | 36 +++++ .../scripts/run_with_mitm.sh | 69 ++++++++++ .gitignore | 3 +- 13 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/codex-reproduce-align/SKILL.md create mode 100644 .agents/skills/codex-reproduce-align/agents/openai.yaml create mode 100644 .agents/skills/codex-reproduce-align/references/alignment-workflow.md create mode 100755 .agents/skills/codex-reproduce-align/scripts/compare_traces.py create mode 100755 .agents/skills/codex-reproduce-align/scripts/normalize_trace.py create mode 100755 .agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh create mode 100644 .agents/skills/codex-reproduce-feature/SKILL.md create mode 100644 .agents/skills/codex-reproduce-feature/agents/openai.yaml create mode 100644 .agents/skills/codex-reproduce-feature/references/capture-workflow.md create mode 100644 .agents/skills/codex-reproduce-feature/scripts/llm_dump.py create mode 100755 .agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh create mode 100755 .agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh diff --git a/.agents/skills/codex-reproduce-align/SKILL.md b/.agents/skills/codex-reproduce-align/SKILL.md new file mode 100644 index 0000000000..5b5ffa51ac --- /dev/null +++ b/.agents/skills/codex-reproduce-align/SKILL.md @@ -0,0 +1,77 @@ +--- +name: codex-reproduce-align +description: Use after a Codex feature has been implemented in Qwen Code to run Codex and Qwen Code under the same prompts, capture HTTP and terminal traces, compare request bodies, tool/function schemas, outputs, and iterate until the reproduced behavior is close enough. +--- + +# Codex Reproduce Align + +## Purpose + +Use this skill when Qwen Code already has a candidate implementation and needs evidence-based parity with Codex. The goal is not byte-for-byte equality; it is matching the observable contract that matters for the feature. + +Default target repo: the current working directory. Use a user-specified path only when the user explicitly provides one. + +## Workflow + +1. Re-state the parity target: + - feature name and trigger + - one baseline Codex prompt or interaction script + - acceptable differences + - must-match fields +2. Run Codex and Qwen Code in separate capture directories with the same scenario. +3. Normalize traces with `scripts/normalize_trace.py`. +4. Compare normalized traces with `scripts/compare_traces.py`. +5. Inspect differences in this order: + - missing tool/function names + - schema shape and required fields + - model settings and response mode + - prompt role/order differences that affect behavior + - terminal-visible output and exit status +6. Patch Qwen Code, rerun the smallest failing scenario, and repeat. +7. Preserve only redacted minimal fixtures in the repo. + +Read `references/alignment-workflow.md` before the first comparison pass. + +## Common Commands + +Normalize: + +```sh +skills/codex-reproduce-align/scripts/normalize_trace.py \ + .repro-runs/codex/http.jsonl \ + > .repro-runs/codex/normalized.json +``` + +Compare: + +```sh +skills/codex-reproduce-align/scripts/compare_traces.py \ + .repro-runs/codex/normalized.json \ + .repro-runs/qwen/normalized.json +``` + +Run a paired shell scenario: + +```sh +skills/codex-reproduce-align/scripts/run_pair_capture.sh \ + .repro-runs/slash-help \ + "codex exec '/help'" \ + "npm test -- --runInBand" +``` + +Use the paired runner only when shell quoting is simple. For interactive slash commands, run the two captures manually with tmux so each side can receive the right keystrokes. + +## Comparison Rules + +- Compare contracts before wording. Exact prompt text is usually implementation detail. +- Treat absent schemas, wrong required fields, or wrong argument names as high-signal failures. +- Treat output ordering as significant only when the user-visible workflow depends on it. +- Do not chase provider-specific IDs, timestamps, token counts, or ephemeral headers. +- Stop when Qwen Code passes the user-visible scenario and the remaining trace differences are documented as intentional. + +## Done Criteria + +- Codex and Qwen Code traces for the same scenario exist locally. +- The normalized comparison has no unexplained must-match differences. +- Qwen Code tests or smoke commands cover the fixed behavior. +- Any remaining mismatch is written down in the task notes or Qwen Code docs when it affects users. diff --git a/.agents/skills/codex-reproduce-align/agents/openai.yaml b/.agents/skills/codex-reproduce-align/agents/openai.yaml new file mode 100644 index 0000000000..057072d396 --- /dev/null +++ b/.agents/skills/codex-reproduce-align/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Reproduce Align" + short_description: "Compare Codex and Qwen Code traces for parity" + default_prompt: "Use $codex-reproduce-align to compare Codex and Qwen Code traces for a reproduced feature." diff --git a/.agents/skills/codex-reproduce-align/references/alignment-workflow.md b/.agents/skills/codex-reproduce-align/references/alignment-workflow.md new file mode 100644 index 0000000000..16a3fb8559 --- /dev/null +++ b/.agents/skills/codex-reproduce-align/references/alignment-workflow.md @@ -0,0 +1,73 @@ +# Alignment Workflow Reference + +The alignment phase starts after Qwen Code has a candidate implementation. Use it to create a tight loop: run both tools, compare traces, patch the target, and rerun only the failing scenario. + +## Trace Inputs + +Expected raw capture layout: + +```text +.repro-runs// + codex/ + http.jsonl + command.stdout + command.stderr + command.exit + qwen/ + http.jsonl + command.stdout + command.stderr + command.exit +``` + +Use capture scripts from `$codex-reproduce-feature` for raw capture, or use `run_pair_capture.sh` for simple non-interactive shell scenarios. + +## Normalization + +`normalize_trace.py` reads mitm JSONL output and emits stable JSON: + +- request method and URL path +- JSON request body summary +- message role order and brief content hashes +- tool/function names +- schema required fields +- response status code + +It intentionally drops: + +- timestamps +- authorization and cookie headers +- provider request IDs +- full message text unless needed for a hash + +## Diff Triage + +High priority: + +- missing request entirely +- wrong endpoint family +- missing tool/function schema +- incompatible required fields or enum values +- slash command not routed to the same behavior class + +Medium priority: + +- prompt role ordering differences +- terminal output phrasing differences +- streaming versus non-streaming if users can observe it + +Low priority: + +- timestamps, IDs, token counts +- harmless wording differences +- extra target-side metadata ignored by the provider + +## Iteration Loop + +1. Pick the highest-priority unexplained mismatch. +2. Patch only the likely owner module in Qwen Code. +3. Run the focused test/smoke path. +4. Capture only the affected scenario again. +5. Normalize and compare again. + +Stop when the target behavior is compatible and remaining differences are either irrelevant or explicitly documented. diff --git a/.agents/skills/codex-reproduce-align/scripts/compare_traces.py b/.agents/skills/codex-reproduce-align/scripts/compare_traces.py new file mode 100755 index 0000000000..b182601014 --- /dev/null +++ b/.agents/skills/codex-reproduce-align/scripts/compare_traces.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Compare normalized reproduction traces and print actionable differences.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def tool_index(request: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + tool.get("name") or f"": tool + for idx, tool in enumerate(request.get("tools") or []) + } + + +def compare_request(idx: int, left: dict[str, Any], right: dict[str, Any]) -> list[str]: + diffs: list[str] = [] + prefix = f"request[{idx}]" + for key in ("method", "url_path", "model", "stream", "response_status"): + if left.get(key) != right.get(key): + diffs.append(f"{prefix}.{key}: {left.get(key)!r} != {right.get(key)!r}") + + left_roles = [item.get("role") for item in left.get("messages") or []] + right_roles = [item.get("role") for item in right.get("messages") or []] + if left_roles != right_roles: + diffs.append(f"{prefix}.message_roles: {left_roles!r} != {right_roles!r}") + + left_tools = tool_index(left) + right_tools = tool_index(right) + missing = sorted(set(left_tools) - set(right_tools)) + extra = sorted(set(right_tools) - set(left_tools)) + if missing: + diffs.append(f"{prefix}.tools_missing_in_right: {missing}") + if extra: + diffs.append(f"{prefix}.tools_extra_in_right: {extra}") + + for name in sorted(set(left_tools) & set(right_tools)): + for key in ("required", "properties"): + if left_tools[name].get(key) != right_tools[name].get(key): + diffs.append( + f"{prefix}.tool[{name}].{key}: " + f"{left_tools[name].get(key)!r} != {right_tools[name].get(key)!r}" + ) + return diffs + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("left", type=Path, help="Reference normalized trace, usually Codex") + parser.add_argument("right", type=Path, help="Target normalized trace, usually Qwen Code") + args = parser.parse_args() + + left = load(args.left) + right = load(args.right) + diffs: list[str] = [] + + if left.get("request_count") != right.get("request_count"): + diffs.append( + f"request_count: {left.get('request_count')!r} != {right.get('request_count')!r}" + ) + + for idx, (left_req, right_req) in enumerate( + zip(left.get("requests") or [], right.get("requests") or []) + ): + diffs.extend(compare_request(idx, left_req, right_req)) + + if not diffs: + print("No normalized trace differences found.") + return 0 + + print("Normalized trace differences:") + for diff in diffs: + print(f"- {diff}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py b/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py new file mode 100755 index 0000000000..5457c3db38 --- /dev/null +++ b/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Normalize mitm JSONL traces into a stable comparison format.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +def content_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def json_body(record: dict[str, Any]) -> Any: + body = record.get("body") or {} + if body.get("json") is not None: + return body["json"] + text = body.get("text") + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + return {"text_hash": content_hash(text), "text_len": len(text)} + + +def walk_tools(value: Any) -> list[dict[str, Any]]: + tools: list[dict[str, Any]] = [] + if isinstance(value, dict): + if "tools" in value and isinstance(value["tools"], list): + for tool in value["tools"]: + tools.append(summarize_tool(tool)) + if "functions" in value and isinstance(value["functions"], list): + for fn in value["functions"]: + tools.append(summarize_tool({"type": "function", "function": fn})) + for child in value.values(): + tools.extend(walk_tools(child)) + elif isinstance(value, list): + for child in value: + tools.extend(walk_tools(child)) + return tools + + +def summarize_tool(tool: Any) -> dict[str, Any]: + if not isinstance(tool, dict): + return {"raw_type": type(tool).__name__} + fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool + params = fn.get("parameters") if isinstance(fn, dict) else None + return { + "type": tool.get("type"), + "name": fn.get("name") if isinstance(fn, dict) else None, + "description_hash": content_hash(fn.get("description", "")) + if isinstance(fn, dict) and isinstance(fn.get("description"), str) + else None, + "required": sorted(params.get("required", [])) + if isinstance(params, dict) and isinstance(params.get("required"), list) + else [], + "properties": sorted(params.get("properties", {}).keys()) + if isinstance(params, dict) and isinstance(params.get("properties"), dict) + else [], + } + + +def summarize_messages(value: Any) -> list[dict[str, Any]]: + messages = None + if isinstance(value, dict): + if isinstance(value.get("messages"), list): + messages = value["messages"] + elif isinstance(value.get("input"), list): + messages = value["input"] + if messages is None: + return [] + summary = [] + for item in messages: + if not isinstance(item, dict): + continue + content = item.get("content", "") + if not isinstance(content, str): + content = json.dumps(content, ensure_ascii=False, sort_keys=True) + summary.append( + { + "role": item.get("role"), + "content_hash": content_hash(content), + "content_len": len(content), + } + ) + return summary + + +def normalize(path: Path) -> dict[str, Any]: + requests = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + raw = json.loads(line) + req = raw.get("request") or {} + resp = raw.get("response") or {} + parsed = urlparse(req.get("url", "")) + body = json_body(req) + requests.append( + { + "method": req.get("method"), + "url_path": parsed.path, + "body_keys": sorted(body.keys()) if isinstance(body, dict) else [], + "model": body.get("model") if isinstance(body, dict) else None, + "stream": body.get("stream") if isinstance(body, dict) else None, + "messages": summarize_messages(body), + "tools": sorted(walk_tools(body), key=lambda item: (item.get("name") or "")), + "response_status": resp.get("status_code") if isinstance(resp, dict) else None, + } + ) + return {"source": str(path), "request_count": len(requests), "requests": requests} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("trace", type=Path) + args = parser.parse_args() + print(json.dumps(normalize(args.trace), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh b/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh new file mode 100755 index 0000000000..ce3e2d8a38 --- /dev/null +++ b/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 OUT_DIR CODEX_SHELL_COMMAND QWEN_SHELL_COMMAND" >&2 + exit 2 +fi + +out_dir="$1" +codex_command="$2" +qwen_command="$3" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +feature_run="${script_dir}/../../codex-reproduce-feature/scripts/run_with_mitm.sh" + +mkdir -p "${out_dir}/codex" "${out_dir}/qwen" + +set +e +"${feature_run}" "${out_dir}/codex" -- bash -lc "${codex_command}" +codex_status=$? +"${feature_run}" "${out_dir}/qwen" -- bash -lc "${qwen_command}" +qwen_status=$? +set -e + +"${script_dir}/normalize_trace.py" "${out_dir}/codex/http.jsonl" > "${out_dir}/codex/normalized.json" +"${script_dir}/normalize_trace.py" "${out_dir}/qwen/http.jsonl" > "${out_dir}/qwen/normalized.json" + +set +e +"${script_dir}/compare_traces.py" \ + "${out_dir}/codex/normalized.json" \ + "${out_dir}/qwen/normalized.json" \ + > "${out_dir}/trace.diff" +compare_status=$? +set -e + +echo "codex_status=${codex_status}" +echo "qwen_status=${qwen_status}" +echo "compare_status=${compare_status}" +echo "diff=${out_dir}/trace.diff" + +if [[ "${codex_status}" -ne 0 || "${qwen_status}" -ne 0 || "${compare_status}" -ne 0 ]]; then + exit 1 +fi diff --git a/.agents/skills/codex-reproduce-feature/SKILL.md b/.agents/skills/codex-reproduce-feature/SKILL.md new file mode 100644 index 0000000000..8512158271 --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/SKILL.md @@ -0,0 +1,67 @@ +--- +name: codex-reproduce-feature +description: Use when reproducing an existing Codex feature in Qwen Code or another agent CLI by running Codex as the reference implementation, capturing HTTP request bodies, prompts, tool/function schemas, terminal output, and then implementing the matching behavior in the target repo. +--- + +# Codex Reproduce Feature + +## Purpose + +Use this skill to turn an observed Codex feature into an implementation task for Qwen Code. The workflow treats the current Codex session as the outer harness and runs a nested Codex process as the reference program under test. + +Default target repo: the current working directory. Use a user-specified path only when the user explicitly provides one. + +## Workflow + +1. Define the feature surface in one sentence: command, trigger, expected UI/output, and a minimal prompt that exercises it. +2. Inspect the target repo enough to identify the likely module boundaries before changing code. +3. Run nested Codex against the feature with capture enabled: + - HTTP/body capture via `scripts/run_with_mitm.sh`. + - Terminal capture via `scripts/run_tmux_capture.sh` when the feature is interactive or TUI-visible. + - Headless/non-interactive execution when the feature has a stable command-line path. +4. Extract behavioral facts from the trace: + - system/developer prompt deltas relevant to the feature + - request body shape, including `messages`, `tools`, `functions`, schemas, tool choice, model settings + - visible terminal states and command output + - file edits, exit status, and error paths +5. Implement the smallest compatible behavior in Qwen Code using its existing patterns. +6. Add focused tests or a reproducible smoke command. +7. Hand off to `$codex-reproduce-align` when implementation exists and parity needs iteration. + +Read `references/capture-workflow.md` before running capture for the first time in a session. + +## Capture Defaults + +Prefer a fresh output directory per run: + +```sh +mkdir -p .repro-runs/slash-command-baseline +skills/codex-reproduce-feature/scripts/run_with_mitm.sh \ + .repro-runs/slash-command-baseline \ + -- codex exec "exercise the Codex feature here" +``` + +For interactive slash commands or terminal rendering, use tmux: + +```sh +skills/codex-reproduce-feature/scripts/run_tmux_capture.sh \ + .repro-runs/slash-command-tui \ + codex +``` + +The mitm script sets common proxy and CA variables for Node, Python, and curl-based CLIs. If TLS fails, read the certificate notes in `references/capture-workflow.md` and fix trust before interpreting missing traffic as product behavior. + +## Implementation Rules + +- Do not copy all captured prompt text into Qwen Code. Convert it into the minimum behavior, schema, or test needed. +- Treat captured request bodies as sensitive local artifacts. Redact tokens before saving examples into docs, commits, issues, or PRs. +- Keep the first implementation narrow: one feature, one trigger path, one observable parity target. +- Prefer compatibility tests that assert behavior over brittle tests that assert exact prompt wording. +- If a captured schema reveals a stable public contract, encode that contract as a typed structure or fixture in Qwen Code. + +## Done Criteria + +- A baseline Codex trace exists under `.repro-runs/` or an equivalent ignored/local path. +- Qwen Code contains a focused implementation and at least one verification path. +- Any user-visible command behavior is documented in Qwen Code if that repo already documents similar features. +- The next parity step can be run by `$codex-reproduce-align` without re-discovering the setup. diff --git a/.agents/skills/codex-reproduce-feature/agents/openai.yaml b/.agents/skills/codex-reproduce-feature/agents/openai.yaml new file mode 100644 index 0000000000..328f5367ab --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Reproduce Feature" + short_description: "Capture Codex behavior and port it into Qwen Code" + default_prompt: "Use $codex-reproduce-feature to reproduce Codex slash command behavior in Qwen Code." diff --git a/.agents/skills/codex-reproduce-feature/references/capture-workflow.md b/.agents/skills/codex-reproduce-feature/references/capture-workflow.md new file mode 100644 index 0000000000..c26ff45903 --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/references/capture-workflow.md @@ -0,0 +1,103 @@ +# Capture Workflow Reference + +This skill follows the nested-agent pattern described in "解决问题的原始冲动": run the original tool under a harness, capture the real request bodies and tool schemas, implement the substitute, then compare traces. + +## Local Roles + +- Outer harness: the current Codex session. +- Reference program: a nested `codex` command that demonstrates the feature. +- Target program: Qwen Code in the current working directory unless the user explicitly provides another path. +- Capture layer: `mitmdump` plus terminal transcript capture. + +## Choosing Execution Mode + +Use non-interactive/headless mode when: + +- the feature has a stable CLI entrypoint +- output can be asserted from stdout/stderr/files +- request bodies are the primary evidence + +Use tmux when: + +- the feature depends on slash-command input, readline behavior, or a TUI state +- screen output matters +- you need to send multiple keystroke batches + +Use both when a feature has model calls and visible terminal state. + +## HTTP Capture + +Install mitmproxy if needed: + +```sh +python -m pip install --user mitmproxy +``` + +Run a command under capture: + +```sh +skills/codex-reproduce-feature/scripts/run_with_mitm.sh OUT_DIR -- COMMAND ARG... +``` + +Generated files: + +- `mitm.log`: mitmdump process log +- `http.jsonl`: redacted request/response records +- `command.stdout`, `command.stderr`, `command.exit`: child process result +- `env.txt`: non-secret capture metadata + +The script sets: + +- `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` +- `NODE_EXTRA_CA_CERTS` +- `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` +- `REPRO_CAPTURE_OUT` + +The default CA path is `~/.mitmproxy/mitmproxy-ca-cert.pem`. Some CLIs ignore one or more of these variables; if `http.jsonl` is empty, verify proxy support before changing product code. + +## Terminal Capture + +Run: + +```sh +skills/codex-reproduce-feature/scripts/run_tmux_capture.sh OUT_DIR COMMAND ARG... +``` + +Generated files: + +- `tmux-pane.txt`: captured pane contents +- `tmux-session.txt`: session metadata and attach instructions +- `command.txt`: the launched command + +The tmux session stays alive so the outer agent can send keys, inspect output, and capture again. Kill it after use: + +```sh +tmux kill-session -t SESSION_NAME +``` + +## What To Extract + +From HTTP records: + +- model name and model settings +- system/developer message fragments that explain the feature +- user-visible command mapping +- tool/function schema names, descriptions, and JSON schemas +- response format or streaming protocol details + +From terminal records: + +- exact slash command syntax and completion behavior +- visible state transitions +- error text and recoverable failure paths +- whether the feature is synchronous, streaming, or backgrounded + +## Redaction + +Never commit raw traces. Before moving examples into docs or tests, remove: + +- authorization headers and API keys +- user-specific paths +- unrelated prompt content +- private repository names and issue content +- full request bodies that are not needed for the feature contract diff --git a/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py b/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py new file mode 100644 index 0000000000..0a18db7cb5 --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py @@ -0,0 +1,101 @@ +"""mitmproxy addon for local agent reproduction traces. + +Writes JSONL records to REPRO_CAPTURE_OUT. Headers are redacted and bodies are +decoded when they look textual. Keep raw outputs local unless manually redacted. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from typing import Any + +from mitmproxy import http + + +OUT = os.environ.get("REPRO_CAPTURE_OUT", "http.jsonl") +MAX_BODY = int(os.environ.get("REPRO_CAPTURE_MAX_BODY", "500000")) +CAPTURE_ALL = os.environ.get("REPRO_CAPTURE_ALL", "0") == "1" +SENSITIVE_HEADERS = { + "authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "openai-organization", + "openai-project", +} +INTERESTING_PATH_HINTS = ( + "/chat/completions", + "/responses", + "/v1/messages", + "/v1beta/", + "/generate", + "/completions", +) + + +def _headers(headers: http.Headers) -> dict[str, str]: + redacted: dict[str, str] = {} + for key, value in headers.items(): + redacted[key] = "[REDACTED]" if key.lower() in SENSITIVE_HEADERS else value + return redacted + + +def _decode(content: bytes | None) -> dict[str, Any]: + if not content: + return {"kind": "empty", "text": ""} + truncated = len(content) > MAX_BODY + content = content[:MAX_BODY] + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + return { + "kind": "base64", + "base64": base64.b64encode(content).decode("ascii"), + "truncated": truncated, + } + parsed: Any = None + try: + parsed = json.loads(text) + except json.JSONDecodeError: + pass + return {"kind": "text", "text": text, "json": parsed, "truncated": truncated} + + +def _interesting(flow: http.HTTPFlow) -> bool: + if CAPTURE_ALL: + return True + url = flow.request.pretty_url.lower() + ctype = flow.request.headers.get("content-type", "").lower() + return ( + any(hint in url for hint in INTERESTING_PATH_HINTS) + or "application/json" in ctype + or "text/event-stream" in ctype + ) + + +def response(flow: http.HTTPFlow) -> None: + if not _interesting(flow): + return + record = { + "ts": time.time(), + "request": { + "method": flow.request.method, + "url": flow.request.pretty_url, + "headers": _headers(flow.request.headers), + "body": _decode(flow.request.raw_content), + }, + "response": None, + } + if flow.response is not None: + record["response"] = { + "status_code": flow.response.status_code, + "headers": _headers(flow.response.headers), + "body": _decode(flow.response.raw_content), + } + os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True) + with open(OUT, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n") diff --git a/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh b/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh new file mode 100755 index 0000000000..13bf871d20 --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 OUT_DIR COMMAND [ARG...]" >&2 + exit 2 +fi + +out_dir="$1" +shift + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not found." >&2 + exit 127 +fi + +mkdir -p "${out_dir}" +out_dir="$(cd "${out_dir}" && pwd)" + +session="repro-$(date +%Y%m%d-%H%M%S)" +printf '%q ' "$@" > "${out_dir}/command.txt" +echo >> "${out_dir}/command.txt" + +tmux new-session -d -s "${session}" "$@" +sleep "${REPRO_TMUX_SETTLE_SECONDS:-2}" +tmux capture-pane -t "${session}" -p -S - > "${out_dir}/tmux-pane.txt" + +{ + echo "session=${session}" + echo "attach=tmux attach -t ${session}" + echo "capture=tmux capture-pane -t ${session} -p -S - > ${out_dir}/tmux-pane.txt" + echo "kill=tmux kill-session -t ${session}" +} > "${out_dir}/tmux-session.txt" + +cat "${out_dir}/tmux-session.txt" diff --git a/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh b/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh new file mode 100755 index 0000000000..6ebe8a00d6 --- /dev/null +++ b/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -lt 3 || "${2:-}" != "--" ]]; then + echo "Usage: $0 OUT_DIR -- COMMAND [ARG...]" >&2 + exit 2 +fi + +out_dir="$1" +shift 2 + +mkdir -p "${out_dir}" +out_dir="$(cd "${out_dir}" && pwd)" + +port="${REPRO_PROXY_PORT:-18080}" +ca_file="${MITMPROXY_CA_FILE:-${HOME}/.mitmproxy/mitmproxy-ca-cert.pem}" +http_out="${out_dir}/http.jsonl" +mitm_log="${out_dir}/mitm.log" + +if ! command -v mitmdump >/dev/null 2>&1; then + echo "mitmdump not found. Install mitmproxy first." >&2 + exit 127 +fi + +: > "${http_out}" +: > "${mitm_log}" + +REPRO_CAPTURE_OUT="${http_out}" \ + mitmdump \ + --listen-host 127.0.0.1 \ + --listen-port "${port}" \ + --set block_global=false \ + --set ssl_insecure=true \ + -s "${script_dir}/llm_dump.py" \ + >"${mitm_log}" 2>&1 & + +mitm_pid="$!" +cleanup() { + kill "${mitm_pid}" >/dev/null 2>&1 || true + wait "${mitm_pid}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +sleep 1 + +{ + echo "out_dir=${out_dir}" + echo "proxy=http://127.0.0.1:${port}" + echo "ca_file=${ca_file}" + echo "command=$*" +} > "${out_dir}/env.txt" + +set +e +HTTP_PROXY="http://127.0.0.1:${port}" \ +HTTPS_PROXY="http://127.0.0.1:${port}" \ +ALL_PROXY="http://127.0.0.1:${port}" \ +NODE_EXTRA_CA_CERTS="${ca_file}" \ +SSL_CERT_FILE="${ca_file}" \ +REQUESTS_CA_BUNDLE="${ca_file}" \ +REPRO_CAPTURE_OUT="${http_out}" \ + "$@" >"${out_dir}/command.stdout" 2>"${out_dir}/command.stderr" +status=$? +set -e + +echo "${status}" > "${out_dir}/command.exit" +exit "${status}" diff --git a/.gitignore b/.gitignore index 6ff1d950be..f4be6695a5 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ packages/web-templates/src/generated/ packages/vscode-ide-companion/*.vsix logs/ +.repro-runs/ # GHA credentials gha-creds-*.json @@ -93,4 +94,4 @@ tmp/ # code graph skills .venv -.codegraph \ No newline at end of file +.codegraph From 0cf25f29ccc303ab501139f44197f68130bcd1f1 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Wed, 13 May 2026 20:41:58 +0800 Subject: [PATCH 2/5] feat(cli): add built-in status line presets with interactive dialog Replace the shell-command-only status line with a preset system that renders structured session info (model, context usage, git branch, token counts, etc.) without external commands. Users can configure which items to display via a new interactive dialog accessible through /statusline or the settings UI. - Add statusLinePresets module with 16 built-in item types - Add StatusLineDialog component with search, multi-select, and preview - Update /statusline command to open the preset dialog - Extend settings schema to support { type: "preset", items: [...] } - Enhance MultiSelect with separator items, active marker, and customizable checked text - Update Footer to support theme-colored preset output --- packages/cli/src/config/settingsSchema.ts | 19 +- packages/cli/src/ui/AppContainer.tsx | 18 + .../src/ui/commands/statuslineCommand.test.ts | 25 +- .../cli/src/ui/commands/statuslineCommand.ts | 21 +- packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/components/DialogManager.tsx | 13 + packages/cli/src/ui/components/Footer.tsx | 9 +- .../src/ui/components/MainContent.test.tsx | 1 + .../ui/components/StatusLineDialog.test.tsx | 136 +++++++ .../src/ui/components/StatusLineDialog.tsx | 276 +++++++++++++ .../src/ui/components/shared/MultiSelect.tsx | 35 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../ui/hooks/slashCommandProcessor.test.ts | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useDialogClose.ts | 9 + .../cli/src/ui/hooks/useStatusLine.test.ts | 2 + packages/cli/src/ui/hooks/useStatusLine.ts | 89 +++- packages/cli/src/ui/statusLinePresets.test.ts | 87 ++++ packages/cli/src/ui/statusLinePresets.ts | 384 ++++++++++++++++++ .../schemas/settings.schema.json | 2 +- 21 files changed, 1093 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/ui/components/StatusLineDialog.test.tsx create mode 100644 packages/cli/src/ui/components/StatusLineDialog.tsx create mode 100644 packages/cli/src/ui/statusLinePresets.test.ts create mode 100644 packages/cli/src/ui/statusLinePresets.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 07af4b0a77..24394a0132 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -614,14 +614,21 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: undefined as - | { - type: 'command'; - command: string; - refreshInterval?: number; - } + | ( + | { + type: 'command'; + command: string; + refreshInterval?: number; + } + | { + type: 'preset'; + items: string[]; + useThemeColors?: boolean; + } + ) | undefined, description: - 'Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.', + 'Status line display configuration. Use `type: "preset"` with built-in item ids, or `type: "command"` with a shell command. Optional command `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.', showInDialog: false, }, customThemes: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d6cbfd82ed..963101440e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -675,6 +675,15 @@ export const AppContainer = (props: AppContainerProps) => { const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); + const [isStatusLineDialogOpen, setStatusLineDialogOpen] = useState(false); + const openStatusLineDialog = useCallback( + () => setStatusLineDialogOpen(true), + [], + ); + const closeStatusLineDialog = useCallback( + () => setStatusLineDialogOpen(false), + [], + ); const { isMemoryDialogOpen, openMemoryDialog, closeMemoryDialog } = useMemoryDialog(); @@ -769,6 +778,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openMemoryDialog, openSettingsDialog, + openStatusLineDialog, openModelDialog, openManageModelsDialog, openTrustDialog, @@ -803,6 +813,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openMemoryDialog, openSettingsDialog, + openStatusLineDialog, openModelDialog, openManageModelsDialog, openArenaDialog, @@ -1846,6 +1857,7 @@ export const AppContainer = (props: AppContainerProps) => { !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || + isStatusLineDialogOpen || isMemoryDialogOpen || isModelDialogOpen || isManageModelsDialogOpen || @@ -2191,6 +2203,8 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, isSettingsDialogOpen, closeSettingsDialog, + isStatusLineDialogOpen, + closeStatusLineDialog, isMemoryDialogOpen, closeMemoryDialog, activeArenaDialog, @@ -2622,6 +2636,7 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isStatusLineDialogOpen, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2740,6 +2755,7 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isStatusLineDialogOpen, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2863,6 +2879,7 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeStatusLineDialog, closeMemoryDialog, closeModelDialog, openModelDialog, @@ -2937,6 +2954,7 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeStatusLineDialog, closeMemoryDialog, closeModelDialog, openModelDialog, diff --git a/packages/cli/src/ui/commands/statuslineCommand.test.ts b/packages/cli/src/ui/commands/statuslineCommand.test.ts index 05ac69d985..5b7bd6024f 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.test.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.test.ts @@ -21,7 +21,7 @@ describe('statuslineCommand', () => { expect(statuslineCommand.description).toBeDefined(); }); - it('should return submit_prompt with default prompt when no args', () => { + it('should open the preset dialog when no args are provided', () => { if (!statuslineCommand.action) { throw new Error('statusline command must have an action'); } @@ -29,18 +29,9 @@ describe('statuslineCommand', () => { const result = statuslineCommand.action(mockContext, ''); expect(result).toEqual({ - type: 'submit_prompt', - content: [ - { - text: expect.stringContaining('statusline-setup'), - }, - ], + type: 'dialog', + dialog: 'statusline', }); - // Default prompt should mention PS1 - expect(result).toHaveProperty( - 'content.0.text', - expect.stringContaining('PS1'), - ); }); it('should use user-provided args as the prompt', () => { @@ -63,16 +54,16 @@ describe('statuslineCommand', () => { }); }); - it('should trim whitespace-only args and use default prompt', () => { + it('should open the preset dialog when args are whitespace only', () => { if (!statuslineCommand.action) { throw new Error('statusline command must have an action'); } const result = statuslineCommand.action(mockContext, ' '); - expect(result).toHaveProperty( - 'content.0.text', - expect.stringContaining('PS1'), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'statusline', + }); }); }); diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts index 7e2a1fdeb6..dd40ca5238 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, SubmitPromptActionReturn } from './types.js'; +import type { + OpenDialogActionReturn, + SlashCommand, + SubmitPromptActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -15,9 +19,18 @@ export const statuslineCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, - action: (_context, args): SubmitPromptActionReturn => { - const prompt = - args.trim() || 'Configure my statusLine from my shell PS1 configuration'; + action: ( + _context, + args, + ): OpenDialogActionReturn | SubmitPromptActionReturn => { + const prompt = args.trim(); + if (!prompt) { + return { + type: 'dialog', + dialog: 'statusline', + }; + } + return { type: 'submit_prompt', content: [ diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 3df7dc212b..c16246c518 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -173,6 +173,7 @@ export interface OpenDialogActionReturn { | 'theme' | 'editor' | 'settings' + | 'statusline' | 'memory' | 'model' | 'fast-model' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6e7cdd641e..e26ce1cd74 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -16,6 +16,7 @@ import { SettingInputPrompt } from './SettingInputPrompt.js'; import { PluginChoicePrompt } from './PluginChoicePrompt.js'; import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; +import { StatusLineDialog } from './StatusLineDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { ExternalAuthProgress } from './ExternalAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; @@ -254,6 +255,18 @@ export const DialogManager = ({ ); } + if (uiState.isStatusLineDialogOpen) { + return ( + + ); + } if (uiState.isMemoryDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index b0539f3609..761c289122 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -28,7 +28,7 @@ export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const { lines: statusLineLines } = useStatusLine(); + const { lines: statusLineLines, useThemeColors } = useStatusLine(); const configInitMessage = useConfigInitMessage(uiState.isConfigInitialized); const { promptTokenCount, showAutoAcceptIndicator } = { @@ -141,7 +141,12 @@ export const Footer: React.FC = () => { !uiState.ctrlCPressedOnce && !uiState.ctrlDPressedOnce && statusLineLines.map((line, i) => ( - + {line} ))} diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index cfe1acd11d..73d97608c3 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -90,6 +90,7 @@ const createUIState = (overrides: Partial = {}): UIState => debugMessage: '', quittingMessages: null, isSettingsDialogOpen: false, + isStatusLineDialogOpen: false, isMemoryDialogOpen: false, isModelDialogOpen: false, isFastModelMode: false, diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx new file mode 100644 index 0000000000..5604c30f43 --- /dev/null +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { UIState } from '../contexts/UIStateContext.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { MessageType, StreamingState } from '../types.js'; +import { StatusLineDialog } from './StatusLineDialog.js'; + +function createSettings(): LoadedSettings { + const dir = mkdtempSync(path.join(tmpdir(), 'qwen-statusline-')); + return new LoadedSettings( + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'system-settings.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'system-defaults.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'user-settings.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'workspace-settings.json'), + }, + true, + new Set(), + ); +} + +const config = { + getCliVersion: () => '1.2.3', + getModel: () => 'qwen3-code-plus', + getTargetDir: () => '/repo/project', + getContentGeneratorConfig: () => ({ contextWindowSize: 1000 }), +} as Config; + +const uiState = { + currentModel: 'qwen3-code-plus', + branchName: 'feature/pr-4087-statusline', + streamingState: StreamingState.Idle, + sessionStats: { + sessionId: 'session-123', + lastPromptTokenCount: 250, + metrics: { + models: {}, + files: { totalLinesAdded: 12, totalLinesRemoved: 3 }, + }, + }, +} as UIState; + +describe('StatusLineDialog', () => { + it('renders a searchable preset picker with preview', () => { + const settings = createSettings(); + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toContain('Configure Status Line'); + expect(lastFrame()).toContain('Type to search'); + expect(lastFrame()).toContain('Preview'); + expect(lastFrame()).toContain('qwen3-code-plus'); + }); + + it('persists selected presets on enter', async () => { + const settings = createSettings(); + const addItem = vi.fn(); + const onClose = vi.fn(); + const { stdin } = render( + + + , + ); + + act(() => { + stdin.write('\r'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(settings.merged.ui?.statusLine).toEqual({ + type: 'preset', + useThemeColors: true, + items: [ + 'model-with-reasoning', + 'context-remaining', + 'current-dir', + 'context-used', + 'git-branch', + ], + }); + expect( + settings.forScope(SettingScope.User).settings.ui?.statusLine, + ).toEqual(settings.merged.ui?.statusLine); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Status line preset saved to user settings.', + }, + expect.any(Number), + ); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx new file mode 100644 index 0000000000..1cff02eb94 --- /dev/null +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { MessageType } from '../types.js'; +import type { UIState } from '../contexts/UIStateContext.js'; +import { MultiSelect, type MultiSelectItem } from './shared/MultiSelect.js'; +import { + buildStatusLinePresetData, + buildStatusLinePresetLines, + DEFAULT_STATUS_LINE_PRESET_CONFIG, + normalizeStatusLinePresetConfig, + STATUS_LINE_PRESET_ITEMS, + type StatusLinePresetConfig, + type StatusLinePresetItemId, +} from '../statusLinePresets.js'; + +type StatusLineOption = + | { kind: 'theme-colors' } + | { kind: 'separator' } + | { kind: 'item'; id: StatusLinePresetItemId }; + +interface StatusLineDialogProps { + settings: LoadedSettings; + config: Config; + uiState: UIState; + addItem: UseHistoryManagerReturn['addItem']; + onClose: () => void; + availableTerminalHeight?: number; +} + +const THEME_COLORS_KEY = 'theme-colors'; +const DESCRIPTION_COLUMN = 24; + +function buildInitialSelectedKeys(settings: LoadedSettings): string[] { + const preset = + normalizeStatusLinePresetConfig(settings.merged.ui?.statusLine) ?? + DEFAULT_STATUS_LINE_PRESET_CONFIG; + return [ + ...(preset.useThemeColors ? [THEME_COLORS_KEY] : []), + ...preset.items, + ]; +} + +function buildConfigFromKeys(keys: readonly string[]): StatusLinePresetConfig { + const selected = new Set(keys); + const validItemIds = new Set(STATUS_LINE_PRESET_ITEMS.map((item) => item.id)); + const items = keys.filter((key): key is StatusLinePresetItemId => + validItemIds.has(key as StatusLinePresetItemId), + ); + + return { + type: 'preset', + useThemeColors: selected.has(THEME_COLORS_KEY), + items, + }; +} + +function getOptionSearchText( + option: MultiSelectItem, +): string { + const value = + option.value.kind === 'theme-colors' + ? 'theme colors active theme' + : option.value.kind === 'separator' + ? '' + : option.value.id; + return `${option.label} ${value}`.toLowerCase(); +} + +function getPreviewData(config: Config, uiState: UIState) { + const stats = uiState.sessionStats; + const metrics = stats.metrics; + let totalInputTokens = 0; + let totalOutputTokens = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalInputTokens += modelMetrics.tokens.prompt; + totalOutputTokens += modelMetrics.tokens.candidates; + } + + return buildStatusLinePresetData({ + sessionId: stats.sessionId, + version: config.getCliVersion(), + modelDisplayName: uiState.currentModel || config.getModel(), + currentDir: config.getTargetDir(), + branch: uiState.branchName, + contextWindowSize: + config.getContentGeneratorConfig()?.contextWindowSize || 0, + currentUsage: stats.lastPromptTokenCount, + totalInputTokens, + totalOutputTokens, + totalLinesAdded: metrics.files.totalLinesAdded, + totalLinesRemoved: metrics.files.totalLinesRemoved, + streamingState: uiState.streamingState, + }); +} + +export function StatusLineDialog({ + settings, + config, + uiState, + addItem, + onClose, + availableTerminalHeight, +}: StatusLineDialogProps): React.JSX.Element { + const [query, setQuery] = useState(''); + const [selectedKeys, setSelectedKeys] = useState(() => + buildInitialSelectedKeys(settings), + ); + + const options = useMemo>>( + () => [ + { + key: THEME_COLORS_KEY, + value: { kind: 'theme-colors' }, + label: `${'Use theme colors'.padEnd(DESCRIPTION_COLUMN)} Apply colors from the active /theme`, + }, + { + key: 'statusline-separator', + value: { kind: 'separator' }, + label: '───────────────────────', + disabled: true, + separator: true, + }, + ...STATUS_LINE_PRESET_ITEMS.map((item) => ({ + key: item.id, + value: { kind: 'item' as const, id: item.id }, + label: `${item.label.padEnd(DESCRIPTION_COLUMN)} ${item.description}`, + })), + ], + [], + ); + + const filteredOptions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return options; + } + return options.filter((option) => + getOptionSearchText(option).includes(normalizedQuery), + ); + }, [options, query]); + + const presetConfig = useMemo( + () => buildConfigFromKeys(selectedKeys), + [selectedKeys], + ); + const previewLines = buildStatusLinePresetLines( + presetConfig, + getPreviewData(config, uiState), + ); + + const handleConfirm = useCallback(() => { + settings.setValue(SettingScope.User, 'ui.statusLine', presetConfig); + addItem( + { + type: MessageType.INFO, + text: 'Status line preset saved to user settings.', + }, + Date.now(), + ); + onClose(); + }, [addItem, onClose, presetConfig, settings]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + if (query) { + setQuery(''); + return; + } + onClose(); + return; + } + + if (key.name === 'backspace' || key.name === 'delete') { + setQuery((current) => current.slice(0, -1)); + return; + } + + if ( + !key.ctrl && + !key.meta && + key.sequence.length === 1 && + key.sequence >= '!' && + key.sequence <= '~' + ) { + setQuery((current) => `${current}${key.sequence}`); + } + }, + { isActive: true }, + ); + + const maxItemsToShow = Math.max( + 5, + Math.min(10, (availableTerminalHeight ?? 18) - 8), + ); + + return ( + + Configure Status Line + + Select which items to display in the status line. + + + + Type to search + {query ? `> ${query}` : '>'} + + + + {filteredOptions.length > 0 ? ( + + ) : ( + No preset items match. + )} + + + + Preview + {previewLines.length > 0 ? ( + previewLines.map((line, index) => ( + + {line} + + )) + ) : ( + + Select at least one item to show a status line. + + )} + + + + + Use up/down to navigate, space to select, enter to confirm, esc to + cancel + + + + ); +} diff --git a/packages/cli/src/ui/components/shared/MultiSelect.tsx b/packages/cli/src/ui/components/shared/MultiSelect.tsx index 7191d4fd63..b618953108 100644 --- a/packages/cli/src/ui/components/shared/MultiSelect.tsx +++ b/packages/cli/src/ui/components/shared/MultiSelect.tsx @@ -14,6 +14,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js'; export interface MultiSelectItem extends SelectionListItem { label: string; + separator?: boolean; } export interface MultiSelectProps { @@ -28,6 +29,8 @@ export interface MultiSelectProps { showNumbers?: boolean; showScrollArrows?: boolean; maxItemsToShow?: number; + checkedText?: string; + showActiveMarker?: boolean; } const EMPTY_SELECTED_KEYS: string[] = []; @@ -53,6 +56,8 @@ export function MultiSelect({ showNumbers = true, showScrollArrows = false, maxItemsToShow = 10, + checkedText = '[✓]', + showActiveMarker = false, }: MultiSelectProps): React.JSX.Element { const [scrollOffset, setScrollOffset] = useState(0); const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]); @@ -136,11 +141,16 @@ export function MultiSelect({ const itemIndex = scrollOffset + index; const isActive = activeIndex === itemIndex; const isChecked = selectedKeySet.has(item.key); + const activeMarker = isActive ? '›' : ' '; const itemNumberText = `${String(itemIndex + 1).padStart( numberColumnWidth, )}.`; - const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]'; + const checkboxText = item.disabled + ? '[x]' + : isChecked + ? checkedText + : '[ ]'; let textColor = theme.text.primary; if (item.disabled) { @@ -151,8 +161,31 @@ export function MultiSelect({ textColor = theme.text.accent; } + if (item.separator) { + return ( + + {showActiveMarker && ( + + {activeMarker} + + )} + + + + + {item.label} + + + ); + } + return ( + {showActiveMarker && ( + + {activeMarker} + + )} {checkboxText} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c1f1c7b8e2..60955eb46a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -37,6 +37,7 @@ export interface UIActions { ) => void; exitEditorDialog: () => void; closeSettingsDialog: () => void; + closeStatusLineDialog: () => void; closeMemoryDialog: () => void; closeModelDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 07eb1a9365..a0d46ba44c 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -51,6 +51,7 @@ export interface UIState { debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; + isStatusLineDialogOpen: boolean; isMemoryDialogOpen: boolean; isModelDialogOpen: boolean; isFastModelMode: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 461c55844d..7dc514fe66 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -140,6 +140,7 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openMemoryDialog: mockOpenMemoryDialog, openSettingsDialog: vi.fn(), + openStatusLineDialog: vi.fn(), openModelDialog: mockOpenModelDialog, openManageModelsDialog: vi.fn(), openTrustDialog: vi.fn(), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c5e30948ae..e779427687 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -91,6 +91,7 @@ export interface SlashCommandProcessorActions { openEditorDialog: () => void; openMemoryDialog: () => void; openSettingsDialog: () => void; + openStatusLineDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; openManageModelsDialog: () => void; openTrustDialog: () => void; @@ -683,6 +684,9 @@ export const useSlashCommandProcessor = ( case 'settings': actions.openSettingsDialog(); return { type: 'handled' }; + case 'statusline': + actions.openStatusLineDialog(); + return { type: 'handled' }; case 'memory': actions.openMemoryDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 11a165e93f..0be40def8e 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -43,6 +43,10 @@ export interface DialogCloseOptions { isSettingsDialogOpen: boolean; closeSettingsDialog: () => void; + // Status line dialog + isStatusLineDialogOpen: boolean; + closeStatusLineDialog: () => void; + // Memory dialog isMemoryDialogOpen: boolean; closeMemoryDialog: () => void; @@ -100,6 +104,11 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isStatusLineDialogOpen) { + options.closeStatusLineDialog(); + return true; + } + if (options.isHelpDialogOpen && options.closeHelpDialog) { options.closeHelpDialog(); return true; diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index ca2776b5c0..09f046c6a6 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import * as child_process from 'child_process'; +import { StreamingState } from '../types.js'; // --- Mock child_process (auto-mock, then override exec in beforeEach) --- vi.mock('child_process'); @@ -32,6 +33,7 @@ const mockUIState = { }, currentModel: 'test-model', branchName: 'main' as string | undefined, + streamingState: StreamingState.Idle, }; vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => mockUIState, diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 6944811824..07aa1247a2 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -12,6 +12,12 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import { + buildStatusLinePresetData, + buildStatusLinePresetLines, + normalizeStatusLinePresetConfig, + type StatusLinePresetConfig, +} from '../statusLinePresets.js'; /** * Structured JSON input passed to the status line command via stdin. @@ -66,7 +72,7 @@ export interface StatusLineCommandInput { }; } -interface StatusLineConfig { +interface StatusLineCommandConfig { type: 'command'; command: string; // Re-run the command every N seconds so external data (git branch, quota, @@ -75,6 +81,8 @@ interface StatusLineConfig { refreshInterval?: number; } +type StatusLineConfig = StatusLineCommandConfig | StatusLinePresetConfig; + const debugLog = createDebugLogger('STATUS_LINE'); // Footer's bottom row (hint/mode indicator) occupies 1 line, so the status // line gets at most 2 to keep the total footer height at 3 rows max. @@ -106,7 +114,7 @@ function getStatusLineConfig( } return config; } - return undefined; + return normalizeStatusLinePresetConfig(raw); } function buildMetricsPayload( @@ -151,6 +159,7 @@ function buildMetricsPayload( */ export function useStatusLine(): { lines: string[]; + useThemeColors: boolean; } { const settings = useSettings(); const uiState = useUIState(); @@ -158,8 +167,17 @@ export function useStatusLine(): { const { vimEnabled, vimMode } = useVimMode(); const statusLineConfig = getStatusLineConfig(settings); - const statusLineCommand = statusLineConfig?.command; - const refreshInterval = statusLineConfig?.refreshInterval; + const statusLineCommand = + statusLineConfig?.type === 'command' ? statusLineConfig.command : undefined; + const statusLinePreset = + statusLineConfig?.type === 'preset' ? statusLineConfig : undefined; + const statusLinePresetKey = statusLinePreset + ? `${statusLinePreset.useThemeColors ? 'color' : 'plain'}:${statusLinePreset.items.join(',')}` + : undefined; + const refreshInterval = + statusLineConfig?.type === 'command' + ? statusLineConfig.refreshInterval + : undefined; const [output, setOutput] = useState([]); @@ -175,6 +193,8 @@ export function useStatusLine(): { vimModeRef.current = vimMode; const statusLineCommandRef = useRef(statusLineCommand); statusLineCommandRef.current = statusLineCommand; + const statusLinePresetRef = useRef(statusLinePreset); + statusLinePresetRef.current = statusLinePreset; const debounceTimerRef = useRef | undefined>( undefined, @@ -184,7 +204,7 @@ export function useStatusLine(): { // Initialized with current values so the state-change effect // does not fire redundantly on mount. const { lastPromptTokenCount } = uiState.sessionStats; - const { currentModel, branchName } = uiState; + const { currentModel, branchName, streamingState } = uiState; const totalToolCalls = uiState.sessionStats.metrics.tools.totalCalls; const totalLinesAdded = uiState.sessionStats.metrics.files.totalLinesAdded; const totalLinesRemoved = @@ -198,6 +218,7 @@ export function useStatusLine(): { totalToolCalls: number; totalLinesAdded: number; totalLinesRemoved: number; + streamingState: string; }>({ promptTokenCount: lastPromptTokenCount, currentModel, @@ -206,6 +227,7 @@ export function useStatusLine(): { totalToolCalls, totalLinesAdded, totalLinesRemoved, + streamingState, }); // Guard: when true, the mount effect has already called doUpdate so the @@ -217,6 +239,46 @@ export function useStatusLine(): { const generationRef = useRef(0); const doUpdate = useCallback(() => { + const preset = statusLinePresetRef.current; + if (preset) { + if (activeChildRef.current) { + activeChildRef.current.kill(); + activeChildRef.current = undefined; + generationRef.current++; + } + + const ui = uiStateRef.current; + const cfg = configRef.current; + const stats = ui.sessionStats; + const m = stats.metrics; + + let totalInputTokens = 0; + let totalOutputTokens = 0; + for (const mm of Object.values(m.models)) { + totalInputTokens += mm.tokens.prompt; + totalOutputTokens += mm.tokens.candidates; + } + + const contextWindowSize = + cfg.getContentGeneratorConfig()?.contextWindowSize || 0; + const data = buildStatusLinePresetData({ + sessionId: stats.sessionId, + version: cfg.getCliVersion(), + modelDisplayName: ui.currentModel || cfg.getModel(), + currentDir: cfg.getTargetDir(), + branch: ui.branchName, + contextWindowSize, + currentUsage: stats.lastPromptTokenCount, + totalInputTokens, + totalOutputTokens, + totalLinesAdded: m.files.totalLinesAdded, + totalLinesRemoved: m.files.totalLinesRemoved, + streamingState: ui.streamingState, + }); + setOutput(buildStatusLinePresetLines(preset, data)); + return; + } + const cmd = statusLineCommandRef.current; if (!cmd) { setOutput([]); @@ -356,7 +418,7 @@ export function useStatusLine(): { // Trigger update when meaningful state changes useEffect(() => { - if (!statusLineCommand) { + if (!statusLineCommand && !statusLinePresetKey) { // Command removed — kill any in-flight process and discard callbacks. activeChildRef.current?.kill(); activeChildRef.current = undefined; @@ -377,7 +439,8 @@ export function useStatusLine(): { branchName !== prev.branchName || totalToolCalls !== prev.totalToolCalls || totalLinesAdded !== prev.totalLinesAdded || - totalLinesRemoved !== prev.totalLinesRemoved + totalLinesRemoved !== prev.totalLinesRemoved || + streamingState !== prev.streamingState ) { prev.promptTokenCount = lastPromptTokenCount; prev.currentModel = currentModel; @@ -386,10 +449,12 @@ export function useStatusLine(): { prev.totalToolCalls = totalToolCalls; prev.totalLinesAdded = totalLinesAdded; prev.totalLinesRemoved = totalLinesRemoved; + prev.streamingState = streamingState; scheduleUpdate(); } }, [ statusLineCommand, + statusLinePresetKey, lastPromptTokenCount, currentModel, effectiveVim, @@ -397,6 +462,7 @@ export function useStatusLine(): { totalToolCalls, totalLinesAdded, totalLinesRemoved, + streamingState, scheduleUpdate, ]); @@ -404,7 +470,7 @@ export function useStatusLine(): { // Skip the first run — the mount effect below already handles it. useEffect(() => { if (!hasMountedRef.current) return; - if (statusLineCommand) { + if (statusLineCommand || statusLinePresetKey) { // Clear any pending debounce so we don't get a redundant second run. if (debounceTimerRef.current !== undefined) { clearTimeout(debounceTimerRef.current); @@ -414,7 +480,7 @@ export function useStatusLine(): { } // Cleanup when command is removed is handled by the state-change effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusLineCommand]); + }, [statusLineCommand, statusLinePresetKey]); // Periodic refresh — re-run the command every `refreshInterval` seconds. // The tick yields if a previous exec is still running: unlike state-change @@ -454,5 +520,8 @@ export function useStatusLine(): { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { lines: output }; + return { + lines: output, + useThemeColors: statusLinePreset?.useThemeColors === true, + }; } diff --git a/packages/cli/src/ui/statusLinePresets.test.ts b/packages/cli/src/ui/statusLinePresets.test.ts new file mode 100644 index 0000000000..5a4aeb8954 --- /dev/null +++ b/packages/cli/src/ui/statusLinePresets.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { StreamingState } from './types.js'; +import { + buildStatusLinePresetData, + buildStatusLinePresetLines, + DEFAULT_STATUS_LINE_PRESET_CONFIG, + normalizeStatusLinePresetConfig, +} from './statusLinePresets.js'; + +describe('statusLinePresets', () => { + it('normalizes valid preset configs and drops unknown items', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + useThemeColors: false, + items: ['model', 'bogus', 'git-branch', 'model'], + }), + ).toEqual({ + type: 'preset', + useThemeColors: false, + items: ['model', 'git-branch'], + }); + }); + + it('keeps an explicit empty item list', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + items: [], + }), + ).toEqual({ + type: 'preset', + useThemeColors: true, + items: [], + }); + }); + + it('falls back to defaults when preset items are missing', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + }), + ).toEqual(DEFAULT_STATUS_LINE_PRESET_CONFIG); + }); + + it('renders available preset items and omits unavailable optional fields', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-4087-statusline', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 12, + totalLinesRemoved: 3, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: [ + 'model', + 'context-remaining', + 'current-dir', + 'pull-request-number', + 'branch-changes', + 'run-state', + ], + }, + data, + ), + ).toEqual([ + 'qwen3-code-plus | Context 75% left | /repo/project | #4087 | +12 -3 | Ready', + ]); + }); +}); diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts new file mode 100644 index 0000000000..d2167f8ce1 --- /dev/null +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import nodePath from 'node:path'; +import { StreamingState } from './types.js'; + +export const STATUS_LINE_PRESET_ITEM_IDS = [ + 'model-with-reasoning', + 'context-remaining', + 'current-dir', + 'context-used', + 'git-branch', + 'model', + 'project-name', + 'pull-request-number', + 'branch-changes', + 'run-state', + 'qwen-version', + 'context-window-size', + 'used-tokens', + 'total-input-tokens', + 'total-output-tokens', + 'session-id', +] as const; + +export type StatusLinePresetItemId = + (typeof STATUS_LINE_PRESET_ITEM_IDS)[number]; + +export interface StatusLinePresetItem { + id: StatusLinePresetItemId; + label: string; + description: string; + defaultSelected?: boolean; +} + +export interface StatusLinePresetConfig { + type: 'preset'; + items: StatusLinePresetItemId[]; + useThemeColors?: boolean; +} + +export interface StatusLinePresetData { + sessionId: string; + version: string; + modelDisplayName: string; + currentDir: string; + projectName: string | undefined; + branch: string | undefined; + contextWindowSize: number; + usedPercentage: number; + remainingPercentage: number; + currentUsage: number; + totalInputTokens: number; + totalOutputTokens: number; + totalLinesAdded: number; + totalLinesRemoved: number; + streamingState: StreamingState; +} + +export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ + { + id: 'model-with-reasoning', + label: 'model-with-reasoning', + description: 'Current model name with reasoning level when available', + defaultSelected: true, + }, + { + id: 'context-remaining', + label: 'context-remaining', + description: 'Percentage of context window remaining', + defaultSelected: true, + }, + { + id: 'current-dir', + label: 'current-dir', + description: 'Current working directory', + defaultSelected: true, + }, + { + id: 'context-used', + label: 'context-used', + description: 'Percentage of context window used', + defaultSelected: true, + }, + { + id: 'git-branch', + label: 'git-branch', + description: 'Current Git branch when available', + defaultSelected: true, + }, + { + id: 'model', + label: 'model', + description: 'Current model name', + }, + { + id: 'project-name', + label: 'project-name', + description: 'Project name when available', + }, + { + id: 'pull-request-number', + label: 'pull-request-number', + description: 'Pull request number inferred from branch name', + }, + { + id: 'branch-changes', + label: 'branch-changes', + description: 'Session file changes added and removed', + }, + { + id: 'run-state', + label: 'run-state', + description: 'Compact session run-state text', + }, + { + id: 'qwen-version', + label: 'qwen-version', + description: 'Qwen Code application version', + }, + { + id: 'context-window-size', + label: 'context-window-size', + description: 'Total context window size in tokens', + }, + { + id: 'used-tokens', + label: 'used-tokens', + description: 'Current prompt tokens used', + }, + { + id: 'total-input-tokens', + label: 'total-input-tokens', + description: 'Total input tokens used in session', + }, + { + id: 'total-output-tokens', + label: 'total-output-tokens', + description: 'Total output tokens used in session', + }, + { + id: 'session-id', + label: 'session-id', + description: 'Current session identifier', + }, +]; + +const STATUS_LINE_PRESET_ITEM_ID_SET = new Set( + STATUS_LINE_PRESET_ITEM_IDS, +); + +export const DEFAULT_STATUS_LINE_PRESET_CONFIG: StatusLinePresetConfig = { + type: 'preset', + useThemeColors: true, + items: STATUS_LINE_PRESET_ITEMS.filter((item) => item.defaultSelected).map( + (item) => item.id, + ), +}; + +export function normalizeStatusLinePresetConfig( + raw: unknown, +): StatusLinePresetConfig | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + + const candidate = raw as Record; + if (candidate['type'] !== 'preset') { + return undefined; + } + + const hasItemsArray = Array.isArray(candidate['items']); + const rawItems = hasItemsArray ? (candidate['items'] as unknown[]) : []; + const items = hasItemsArray + ? rawItems.filter( + (item): item is StatusLinePresetItemId => + typeof item === 'string' && STATUS_LINE_PRESET_ITEM_ID_SET.has(item), + ) + : []; + + return { + type: 'preset', + useThemeColors: + typeof candidate['useThemeColors'] === 'boolean' + ? candidate['useThemeColors'] + : true, + items: hasItemsArray + ? [...new Set(items)] + : [...DEFAULT_STATUS_LINE_PRESET_CONFIG.items], + }; +} + +function formatPercent(value: number): string { + if (!Number.isFinite(value)) { + return '0%'; + } + const rounded = Math.round(value * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded}%`; +} + +function formatTokenCount(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '0'; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}m`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return String(Math.round(value)); +} + +function getRunStateLabel(state: StreamingState): string { + switch (state) { + case StreamingState.Idle: + return 'Ready'; + case StreamingState.Responding: + return 'Working'; + case StreamingState.WaitingForConfirmation: + return 'Confirm'; + default: + return 'Working'; + } +} + +function inferPullRequestNumber( + branch: string | undefined, +): string | undefined { + if (!branch) { + return undefined; + } + const match = branch.match( + /(?:^|[/_-])(?:pr|pull|pull-request)[/_-]?#?(\d+)(?:$|[/_-])/i, + ); + return match?.[1]; +} + +export function buildStatusLinePresetData(params: { + sessionId: string; + version: string | undefined; + modelDisplayName: string | undefined; + currentDir: string; + branch: string | undefined; + contextWindowSize: number; + currentUsage: number; + totalInputTokens: number; + totalOutputTokens: number; + totalLinesAdded: number; + totalLinesRemoved: number; + streamingState: StreamingState; +}): StatusLinePresetData { + const usedPercentage = + params.contextWindowSize > 0 + ? Math.min( + 100, + Math.max( + 0, + Math.round( + (params.currentUsage / params.contextWindowSize) * 1000, + ) / 10, + ), + ) + : 0; + + return { + sessionId: params.sessionId, + version: params.version || 'unknown', + modelDisplayName: params.modelDisplayName || 'unknown', + currentDir: params.currentDir, + projectName: nodePath.basename(params.currentDir) || undefined, + branch: params.branch, + contextWindowSize: params.contextWindowSize, + usedPercentage, + remainingPercentage: Math.round((100 - usedPercentage) * 10) / 10, + currentUsage: params.currentUsage, + totalInputTokens: params.totalInputTokens, + totalOutputTokens: params.totalOutputTokens, + totalLinesAdded: params.totalLinesAdded, + totalLinesRemoved: params.totalLinesRemoved, + streamingState: params.streamingState, + }; +} + +export function buildStatusLinePresetParts( + config: StatusLinePresetConfig, + data: StatusLinePresetData, +): string[] { + const parts: string[] = []; + const seen = new Set(); + + for (const item of config.items) { + if (seen.has(item)) { + continue; + } + seen.add(item); + + switch (item) { + case 'model-with-reasoning': + case 'model': + parts.push(data.modelDisplayName); + break; + case 'context-remaining': + if (data.contextWindowSize > 0) { + parts.push(`Context ${formatPercent(data.remainingPercentage)} left`); + } + break; + case 'current-dir': + parts.push(data.currentDir); + break; + case 'context-used': + if (data.contextWindowSize > 0 && data.usedPercentage > 0) { + parts.push(`Context ${formatPercent(data.usedPercentage)} used`); + } + break; + case 'git-branch': + if (data.branch) { + parts.push(data.branch); + } + break; + case 'project-name': + if (data.projectName) { + parts.push(data.projectName); + } + break; + case 'pull-request-number': { + const prNumber = inferPullRequestNumber(data.branch); + if (prNumber) { + parts.push(`#${prNumber}`); + } + break; + } + case 'branch-changes': + if (data.totalLinesAdded > 0 || data.totalLinesRemoved > 0) { + parts.push(`+${data.totalLinesAdded} -${data.totalLinesRemoved}`); + } + break; + case 'run-state': + parts.push(getRunStateLabel(data.streamingState)); + break; + case 'qwen-version': + parts.push(`v${data.version}`); + break; + case 'context-window-size': + if (data.contextWindowSize > 0) { + parts.push(`${formatTokenCount(data.contextWindowSize)} window`); + } + break; + case 'used-tokens': + if (data.currentUsage > 0) { + parts.push(`${formatTokenCount(data.currentUsage)} used`); + } + break; + case 'total-input-tokens': + parts.push(`${formatTokenCount(data.totalInputTokens)} in`); + break; + case 'total-output-tokens': + parts.push(`${formatTokenCount(data.totalOutputTokens)} out`); + break; + case 'session-id': + if (data.sessionId) { + parts.push(data.sessionId); + } + break; + default: { + const exhaustive: never = item; + return exhaustive; + } + } + } + + return parts; +} + +export function buildStatusLinePresetLines( + config: StatusLinePresetConfig, + data: StatusLinePresetData, +): string[] { + const line = buildStatusLinePresetParts(config, data).join(' | '); + return line ? [line] : []; +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 7a6bb99c31..7558594730 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -182,7 +182,7 @@ "default": "Qwen Dark" }, "statusLine": { - "description": "Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.", + "description": "Status line display configuration. Use `type: \"preset\"` with built-in item ids, or `type: \"command\"` with a shell command. Optional command `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.", "type": "object", "additionalProperties": true }, From 59f6148dcdb78f20757ee4c63528bb80822d939c Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Wed, 13 May 2026 21:20:59 +0800 Subject: [PATCH 3/5] fix(cli): refresh status line preset after saving --- packages/cli/src/ui/AppContainer.tsx | 18 +++ .../cli/src/ui/components/DialogManager.tsx | 1 + .../ui/components/StatusLineDialog.test.tsx | 3 + .../src/ui/components/StatusLineDialog.tsx | 5 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 2 + .../cli/src/ui/contexts/UIStateContext.tsx | 3 + .../cli/src/ui/hooks/useStatusLine.test.ts | 77 ++++++++++++ packages/cli/src/ui/hooks/useStatusLine.ts | 119 +++++++++++++++++- packages/cli/src/ui/statusLinePresets.test.ts | 28 +++++ packages/cli/src/ui/statusLinePresets.ts | 8 +- 10 files changed, 257 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 963101440e..48ea880947 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -131,6 +131,7 @@ import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import type { StatusLinePresetConfig } from './statusLinePresets.js'; import { useExtensionUpdates, useConfirmUpdateRequests, @@ -684,6 +685,17 @@ export const AppContainer = (props: AppContainerProps) => { () => setStatusLineDialogOpen(false), [], ); + const [statusLineSettingsVersion, setStatusLineSettingsVersion] = useState(0); + const [statusLineConfigOverride, setStatusLineConfigOverride] = useState< + StatusLinePresetConfig | undefined + >(undefined); + const notifyStatusLineSettingsChanged = useCallback( + (newConfig: StatusLinePresetConfig) => { + setStatusLineConfigOverride(newConfig); + setStatusLineSettingsVersion((version) => version + 1); + }, + [], + ); const { isMemoryDialogOpen, openMemoryDialog, closeMemoryDialog } = useMemoryDialog(); @@ -2637,6 +2649,8 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isStatusLineDialogOpen, + statusLineSettingsVersion, + statusLineConfigOverride, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2756,6 +2770,8 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isStatusLineDialogOpen, + statusLineSettingsVersion, + statusLineConfigOverride, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2880,6 +2896,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeStatusLineDialog, + notifyStatusLineSettingsChanged, closeMemoryDialog, closeModelDialog, openModelDialog, @@ -2955,6 +2972,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeStatusLineDialog, + notifyStatusLineSettingsChanged, closeMemoryDialog, closeModelDialog, openModelDialog, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e26ce1cd74..3c2103853e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -262,6 +262,7 @@ export const DialogManager = ({ config={config} uiState={uiState} addItem={addItem} + onSaved={uiActions.notifyStatusLineSettingsChanged} onClose={uiActions.closeStatusLineDialog} availableTerminalHeight={terminalHeight - staticExtraHeight} /> diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx index 5604c30f43..de542f770a 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.test.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -92,6 +92,7 @@ describe('StatusLineDialog', () => { const settings = createSettings(); const addItem = vi.fn(); const onClose = vi.fn(); + const onSaved = vi.fn(); const { stdin } = render( { config={config} uiState={uiState} addItem={addItem} + onSaved={onSaved} onClose={onClose} availableTerminalHeight={18} /> @@ -131,6 +133,7 @@ describe('StatusLineDialog', () => { }, expect.any(Number), ); + expect(onSaved).toHaveBeenCalledWith(settings.merged.ui?.statusLine); expect(onClose).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx index 1cff02eb94..64791898b8 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -36,6 +36,7 @@ interface StatusLineDialogProps { config: Config; uiState: UIState; addItem: UseHistoryManagerReturn['addItem']; + onSaved?: (config: StatusLinePresetConfig) => void; onClose: () => void; availableTerminalHeight?: number; } @@ -111,6 +112,7 @@ export function StatusLineDialog({ config, uiState, addItem, + onSaved, onClose, availableTerminalHeight, }: StatusLineDialogProps): React.JSX.Element { @@ -163,6 +165,7 @@ export function StatusLineDialog({ const handleConfirm = useCallback(() => { settings.setValue(SettingScope.User, 'ui.statusLine', presetConfig); + onSaved?.(presetConfig); addItem( { type: MessageType.INFO, @@ -171,7 +174,7 @@ export function StatusLineDialog({ Date.now(), ); onClose(); - }, [addItem, onClose, presetConfig, settings]); + }, [addItem, onClose, onSaved, presetConfig, settings]); useKeypress( (key) => { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 60955eb46a..3b30fe0273 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -14,6 +14,7 @@ import { type SettingScope } from '../../config/settings.js'; import type { AuthController } from '../auth/useAuth.js'; import type { HistoryItem } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; +import type { StatusLinePresetConfig } from '../statusLinePresets.js'; export type HelpTab = 'general' | 'commands' | 'custom-commands'; @@ -38,6 +39,7 @@ export interface UIActions { exitEditorDialog: () => void; closeSettingsDialog: () => void; closeStatusLineDialog: () => void; + notifyStatusLineSettingsChanged: (config: StatusLinePresetConfig) => void; closeMemoryDialog: () => void; closeModelDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a0d46ba44c..25569a64e5 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -38,6 +38,7 @@ import { type HelpTab } from './UIActionsContext.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type ProviderUpdateRequest } from '../hooks/useProviderUpdates.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; +import type { StatusLinePresetConfig } from '../statusLinePresets.js'; export interface UIState { history: HistoryItem[]; @@ -52,6 +53,8 @@ export interface UIState { quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; isStatusLineDialogOpen: boolean; + statusLineSettingsVersion?: number; + statusLineConfigOverride?: StatusLinePresetConfig; isMemoryDialogOpen: boolean; isModelDialogOpen: boolean; isFastModelMode: boolean; diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index 09f046c6a6..49ba897627 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -34,6 +34,10 @@ const mockUIState = { currentModel: 'test-model', branchName: 'main' as string | undefined, streamingState: StreamingState.Idle, + statusLineSettingsVersion: 0, + statusLineConfigOverride: undefined as + | { type: 'preset'; items: string[]; useThemeColors?: boolean } + | undefined, }; vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => mockUIState, @@ -86,6 +90,7 @@ let mockKill: ReturnType; function setStatusLineConfig( config: | { type: string; command: string; refreshInterval?: number } + | { type: 'preset'; items: string[]; useThemeColors?: boolean } | undefined, ) { mockSettings.merged = config ? { ui: { statusLine: config } } : {}; @@ -134,6 +139,8 @@ describe('useStatusLine', () => { mockUIState.sessionStats.lastPromptTokenCount = 100; mockUIState.currentModel = 'test-model'; mockUIState.branchName = 'main'; + mockUIState.statusLineSettingsVersion = 0; + mockUIState.statusLineConfigOverride = undefined; mockUIState.sessionStats.metrics.tools.totalCalls = 0; mockUIState.sessionStats.metrics.files.totalLinesAdded = 0; mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0; @@ -180,6 +187,76 @@ describe('useStatusLine', () => { }); }); + describe('preset status line', () => { + it('looks up the current branch pull request number with gh', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['pull-request-number'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + expect(result.current.lines).toEqual([]); + + await act(async () => { + execCallback(null, '4118\n', ''); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.lines).toEqual(['#4118']); + }); + + it('does not run gh when pull request number is not selected', () => { + setStatusLineConfig({ + type: 'preset', + items: ['model'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(child_process.exec).not.toHaveBeenCalled(); + expect(result.current.lines).toEqual(['test-model']); + }); + + it('refreshes when status line settings are saved in the same process', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['model-with-reasoning'], + }); + const { result, rerender } = renderHook(() => useStatusLine()); + + expect(child_process.exec).not.toHaveBeenCalled(); + expect(result.current.lines).toEqual(['test-model']); + + setStatusLineConfig({ + type: 'preset', + items: ['model-with-reasoning', 'pull-request-number'], + }); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model-with-reasoning', 'pull-request-number'], + }; + mockUIState.statusLineSettingsVersion += 1; + rerender(); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + + await act(async () => { + execCallback(null, '4118\n', ''); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.lines).toEqual(['test-model | #4118']); + }); + }); + // --- Command execution --- describe('command execution', () => { diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 07aa1247a2..a01b1bed26 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -87,6 +87,12 @@ const debugLog = createDebugLogger('STATUS_LINE'); // Footer's bottom row (hint/mode indicator) occupies 1 line, so the status // line gets at most 2 to keep the total footer height at 3 rows max. export const MAX_STATUS_LINES = 2; +const PULL_REQUEST_LOOKUP_COMMAND = 'gh pr view --json number --jq .number'; + +function parsePullRequestNumber(stdout: string): string | undefined { + const prNumber = stdout.trim(); + return /^\d+$/.test(prNumber) ? prNumber : undefined; +} function getStatusLineConfig( settings: ReturnType, @@ -166,13 +172,15 @@ export function useStatusLine(): { const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const statusLineConfig = getStatusLineConfig(settings); + const statusLineConfig = + uiState.statusLineConfigOverride ?? getStatusLineConfig(settings); const statusLineCommand = statusLineConfig?.type === 'command' ? statusLineConfig.command : undefined; const statusLinePreset = statusLineConfig?.type === 'preset' ? statusLineConfig : undefined; + const statusLineSettingsVersion = uiState.statusLineSettingsVersion ?? 0; const statusLinePresetKey = statusLinePreset - ? `${statusLinePreset.useThemeColors ? 'color' : 'plain'}:${statusLinePreset.items.join(',')}` + ? `${statusLinePreset.useThemeColors ? 'color' : 'plain'}:${statusLinePreset.items.join(',')}:${statusLineSettingsVersion}` : undefined; const refreshInterval = statusLineConfig?.type === 'command' @@ -180,6 +188,9 @@ export function useStatusLine(): { : undefined; const [output, setOutput] = useState([]); + const [pullRequestNumber, setPullRequestNumber] = useState< + string | undefined + >(undefined); // Keep latest values in refs so the stable doUpdate callback can read them // without being recreated on every render. @@ -195,6 +206,8 @@ export function useStatusLine(): { statusLineCommandRef.current = statusLineCommand; const statusLinePresetRef = useRef(statusLinePreset); statusLinePresetRef.current = statusLinePreset; + const pullRequestNumberRef = useRef(pullRequestNumber); + pullRequestNumberRef.current = pullRequestNumber; const debounceTimerRef = useRef | undefined>( undefined, @@ -237,6 +250,82 @@ export function useStatusLine(): { // Track the active child process so we can kill it on new updates / unmount. const activeChildRef = useRef(undefined); const generationRef = useRef(0); + const pullRequestLookupChildRef = useRef(undefined); + const pullRequestLookupGenerationRef = useRef(0); + const pullRequestLookupKeyRef = useRef(undefined); + + const updatePullRequestNumber = useCallback( + (nextPullRequestNumber: string | undefined) => { + if (pullRequestNumberRef.current === nextPullRequestNumber) { + return; + } + pullRequestNumberRef.current = nextPullRequestNumber; + setPullRequestNumber(nextPullRequestNumber); + }, + [], + ); + + const clearPullRequestLookup = useCallback(() => { + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + pullRequestLookupGenerationRef.current++; + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); + }, [updatePullRequestNumber]); + + const ensurePullRequestNumber = useCallback( + ( + preset: StatusLinePresetConfig, + currentDir: string, + branch: string | undefined, + ) => { + if (!preset.items.includes('pull-request-number') || !branch) { + clearPullRequestLookup(); + return; + } + + const lookupKey = `${currentDir}\0${branch}`; + if (pullRequestLookupKeyRef.current === lookupKey) { + return; + } + + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + pullRequestLookupKeyRef.current = lookupKey; + updatePullRequestNumber(undefined); + + const generation = ++pullRequestLookupGenerationRef.current; + let child: ChildProcess; + try { + child = exec( + PULL_REQUEST_LOOKUP_COMMAND, + { cwd: currentDir, timeout: 2000, maxBuffer: 1024 }, + (error, stdout) => { + if ( + generation !== pullRequestLookupGenerationRef.current || + pullRequestLookupKeyRef.current !== lookupKey + ) { + return; + } + pullRequestLookupChildRef.current = undefined; + updatePullRequestNumber( + error ? undefined : parsePullRequestNumber(stdout), + ); + }, + ); + } catch (err) { + debugLog.error( + 'statusline pull request lookup error:', + (err as Error).message, + ); + updatePullRequestNumber(undefined); + return; + } + + pullRequestLookupChildRef.current = child; + }, + [clearPullRequestLookup, updatePullRequestNumber], + ); const doUpdate = useCallback(() => { const preset = statusLinePresetRef.current; @@ -251,6 +340,8 @@ export function useStatusLine(): { const cfg = configRef.current; const stats = ui.sessionStats; const m = stats.metrics; + const currentDir = cfg.getTargetDir(); + ensurePullRequestNumber(preset, currentDir, ui.branchName); let totalInputTokens = 0; let totalOutputTokens = 0; @@ -265,8 +356,9 @@ export function useStatusLine(): { sessionId: stats.sessionId, version: cfg.getCliVersion(), modelDisplayName: ui.currentModel || cfg.getModel(), - currentDir: cfg.getTargetDir(), + currentDir, branch: ui.branchName, + pullRequestNumber: pullRequestNumberRef.current, contextWindowSize, currentUsage: stats.lastPromptTokenCount, totalInputTokens, @@ -279,6 +371,8 @@ export function useStatusLine(): { return; } + clearPullRequestLookup(); + const cmd = statusLineCommandRef.current; if (!cmd) { setOutput([]); @@ -404,7 +498,7 @@ export function useStatusLine(): { child.stdin.write(JSON.stringify(input)); child.stdin.end(); } - }, []); // No deps — reads everything from refs + }, [clearPullRequestLookup, ensurePullRequestNumber]); const scheduleUpdate = useCallback(() => { if (debounceTimerRef.current !== undefined) { @@ -423,6 +517,11 @@ export function useStatusLine(): { activeChildRef.current?.kill(); activeChildRef.current = undefined; generationRef.current++; + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + pullRequestLookupGenerationRef.current++; + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); if (debounceTimerRef.current !== undefined) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = undefined; @@ -464,6 +563,7 @@ export function useStatusLine(): { totalLinesRemoved, streamingState, scheduleUpdate, + updatePullRequestNumber, ]); // Re-execute immediately when the command itself changes (hot reload). @@ -482,6 +582,12 @@ export function useStatusLine(): { // eslint-disable-next-line react-hooks/exhaustive-deps }, [statusLineCommand, statusLinePresetKey]); + // Re-render preset output once the async GitHub PR lookup returns. + useEffect(() => { + if (!hasMountedRef.current || !statusLinePresetKey) return; + scheduleUpdate(); + }, [pullRequestNumber, statusLinePresetKey, scheduleUpdate]); + // Periodic refresh — re-run the command every `refreshInterval` seconds. // The tick yields if a previous exec is still running: unlike state-change // triggers (which legitimately need to preempt stale data), the periodic @@ -506,12 +612,17 @@ export function useStatusLine(): { const genRef = generationRef; const debounceRef = debounceTimerRef; const childRef = activeChildRef; + const pullRequestChildRef = pullRequestLookupChildRef; + const pullRequestGenerationRef = pullRequestLookupGenerationRef; doUpdate(); return () => { // Kill active child process and invalidate callbacks childRef.current?.kill(); childRef.current = undefined; genRef.current++; + pullRequestChildRef.current?.kill(); + pullRequestChildRef.current = undefined; + pullRequestGenerationRef.current++; if (debounceRef.current !== undefined) { clearTimeout(debounceRef.current); debounceRef.current = undefined; diff --git a/packages/cli/src/ui/statusLinePresets.test.ts b/packages/cli/src/ui/statusLinePresets.test.ts index 5a4aeb8954..c85523a167 100644 --- a/packages/cli/src/ui/statusLinePresets.test.ts +++ b/packages/cli/src/ui/statusLinePresets.test.ts @@ -84,4 +84,32 @@ describe('statusLinePresets', () => { 'qwen3-code-plus | Context 75% left | /repo/project | #4087 | +12 -3 | Ready', ]); }); + + it('renders an explicit pull request number before branch-name inference', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-1', + pullRequestNumber: '4087', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 0, + totalLinesRemoved: 0, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: ['pull-request-number'], + }, + data, + ), + ).toEqual(['#4087']); + }); }); diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts index d2167f8ce1..2468cb64fa 100644 --- a/packages/cli/src/ui/statusLinePresets.ts +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -49,6 +49,7 @@ export interface StatusLinePresetData { currentDir: string; projectName: string | undefined; branch: string | undefined; + pullRequestNumber: string | undefined; contextWindowSize: number; usedPercentage: number; remainingPercentage: number; @@ -104,7 +105,7 @@ export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ { id: 'pull-request-number', label: 'pull-request-number', - description: 'Pull request number inferred from branch name', + description: 'Open pull request number for the current branch', }, { id: 'branch-changes', @@ -245,6 +246,7 @@ export function buildStatusLinePresetData(params: { modelDisplayName: string | undefined; currentDir: string; branch: string | undefined; + pullRequestNumber?: string | undefined; contextWindowSize: number; currentUsage: number; totalInputTokens: number; @@ -273,6 +275,7 @@ export function buildStatusLinePresetData(params: { currentDir: params.currentDir, projectName: nodePath.basename(params.currentDir) || undefined, branch: params.branch, + pullRequestNumber: params.pullRequestNumber, contextWindowSize: params.contextWindowSize, usedPercentage, remainingPercentage: Math.round((100 - usedPercentage) * 10) / 10, @@ -327,7 +330,8 @@ export function buildStatusLinePresetParts( } break; case 'pull-request-number': { - const prNumber = inferPullRequestNumber(data.branch); + const prNumber = + data.pullRequestNumber ?? inferPullRequestNumber(data.branch); if (prNumber) { parts.push(`#${prNumber}`); } From 09b5e6ea3e58570489895dc907db9072807a8c9d Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Thu, 14 May 2026 00:43:28 +0800 Subject: [PATCH 4/5] chore: remove codex reproduce skills --- .agents/skills/codex-reproduce-align/SKILL.md | 77 ----------- .../codex-reproduce-align/agents/openai.yaml | 4 - .../references/alignment-workflow.md | 73 ---------- .../scripts/compare_traces.py | 85 ------------ .../scripts/normalize_trace.py | 128 ------------------ .../scripts/run_pair_capture.sh | 44 ------ .../skills/codex-reproduce-feature/SKILL.md | 67 --------- .../agents/openai.yaml | 4 - .../references/capture-workflow.md | 103 -------------- .../scripts/llm_dump.py | 101 -------------- .../scripts/run_tmux_capture.sh | 36 ----- .../scripts/run_with_mitm.sh | 69 ---------- .gitignore | 3 +- 13 files changed, 1 insertion(+), 793 deletions(-) delete mode 100644 .agents/skills/codex-reproduce-align/SKILL.md delete mode 100644 .agents/skills/codex-reproduce-align/agents/openai.yaml delete mode 100644 .agents/skills/codex-reproduce-align/references/alignment-workflow.md delete mode 100755 .agents/skills/codex-reproduce-align/scripts/compare_traces.py delete mode 100755 .agents/skills/codex-reproduce-align/scripts/normalize_trace.py delete mode 100755 .agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh delete mode 100644 .agents/skills/codex-reproduce-feature/SKILL.md delete mode 100644 .agents/skills/codex-reproduce-feature/agents/openai.yaml delete mode 100644 .agents/skills/codex-reproduce-feature/references/capture-workflow.md delete mode 100644 .agents/skills/codex-reproduce-feature/scripts/llm_dump.py delete mode 100755 .agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh delete mode 100755 .agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh diff --git a/.agents/skills/codex-reproduce-align/SKILL.md b/.agents/skills/codex-reproduce-align/SKILL.md deleted file mode 100644 index 5b5ffa51ac..0000000000 --- a/.agents/skills/codex-reproduce-align/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: codex-reproduce-align -description: Use after a Codex feature has been implemented in Qwen Code to run Codex and Qwen Code under the same prompts, capture HTTP and terminal traces, compare request bodies, tool/function schemas, outputs, and iterate until the reproduced behavior is close enough. ---- - -# Codex Reproduce Align - -## Purpose - -Use this skill when Qwen Code already has a candidate implementation and needs evidence-based parity with Codex. The goal is not byte-for-byte equality; it is matching the observable contract that matters for the feature. - -Default target repo: the current working directory. Use a user-specified path only when the user explicitly provides one. - -## Workflow - -1. Re-state the parity target: - - feature name and trigger - - one baseline Codex prompt or interaction script - - acceptable differences - - must-match fields -2. Run Codex and Qwen Code in separate capture directories with the same scenario. -3. Normalize traces with `scripts/normalize_trace.py`. -4. Compare normalized traces with `scripts/compare_traces.py`. -5. Inspect differences in this order: - - missing tool/function names - - schema shape and required fields - - model settings and response mode - - prompt role/order differences that affect behavior - - terminal-visible output and exit status -6. Patch Qwen Code, rerun the smallest failing scenario, and repeat. -7. Preserve only redacted minimal fixtures in the repo. - -Read `references/alignment-workflow.md` before the first comparison pass. - -## Common Commands - -Normalize: - -```sh -skills/codex-reproduce-align/scripts/normalize_trace.py \ - .repro-runs/codex/http.jsonl \ - > .repro-runs/codex/normalized.json -``` - -Compare: - -```sh -skills/codex-reproduce-align/scripts/compare_traces.py \ - .repro-runs/codex/normalized.json \ - .repro-runs/qwen/normalized.json -``` - -Run a paired shell scenario: - -```sh -skills/codex-reproduce-align/scripts/run_pair_capture.sh \ - .repro-runs/slash-help \ - "codex exec '/help'" \ - "npm test -- --runInBand" -``` - -Use the paired runner only when shell quoting is simple. For interactive slash commands, run the two captures manually with tmux so each side can receive the right keystrokes. - -## Comparison Rules - -- Compare contracts before wording. Exact prompt text is usually implementation detail. -- Treat absent schemas, wrong required fields, or wrong argument names as high-signal failures. -- Treat output ordering as significant only when the user-visible workflow depends on it. -- Do not chase provider-specific IDs, timestamps, token counts, or ephemeral headers. -- Stop when Qwen Code passes the user-visible scenario and the remaining trace differences are documented as intentional. - -## Done Criteria - -- Codex and Qwen Code traces for the same scenario exist locally. -- The normalized comparison has no unexplained must-match differences. -- Qwen Code tests or smoke commands cover the fixed behavior. -- Any remaining mismatch is written down in the task notes or Qwen Code docs when it affects users. diff --git a/.agents/skills/codex-reproduce-align/agents/openai.yaml b/.agents/skills/codex-reproduce-align/agents/openai.yaml deleted file mode 100644 index 057072d396..0000000000 --- a/.agents/skills/codex-reproduce-align/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Codex Reproduce Align" - short_description: "Compare Codex and Qwen Code traces for parity" - default_prompt: "Use $codex-reproduce-align to compare Codex and Qwen Code traces for a reproduced feature." diff --git a/.agents/skills/codex-reproduce-align/references/alignment-workflow.md b/.agents/skills/codex-reproduce-align/references/alignment-workflow.md deleted file mode 100644 index 16a3fb8559..0000000000 --- a/.agents/skills/codex-reproduce-align/references/alignment-workflow.md +++ /dev/null @@ -1,73 +0,0 @@ -# Alignment Workflow Reference - -The alignment phase starts after Qwen Code has a candidate implementation. Use it to create a tight loop: run both tools, compare traces, patch the target, and rerun only the failing scenario. - -## Trace Inputs - -Expected raw capture layout: - -```text -.repro-runs// - codex/ - http.jsonl - command.stdout - command.stderr - command.exit - qwen/ - http.jsonl - command.stdout - command.stderr - command.exit -``` - -Use capture scripts from `$codex-reproduce-feature` for raw capture, or use `run_pair_capture.sh` for simple non-interactive shell scenarios. - -## Normalization - -`normalize_trace.py` reads mitm JSONL output and emits stable JSON: - -- request method and URL path -- JSON request body summary -- message role order and brief content hashes -- tool/function names -- schema required fields -- response status code - -It intentionally drops: - -- timestamps -- authorization and cookie headers -- provider request IDs -- full message text unless needed for a hash - -## Diff Triage - -High priority: - -- missing request entirely -- wrong endpoint family -- missing tool/function schema -- incompatible required fields or enum values -- slash command not routed to the same behavior class - -Medium priority: - -- prompt role ordering differences -- terminal output phrasing differences -- streaming versus non-streaming if users can observe it - -Low priority: - -- timestamps, IDs, token counts -- harmless wording differences -- extra target-side metadata ignored by the provider - -## Iteration Loop - -1. Pick the highest-priority unexplained mismatch. -2. Patch only the likely owner module in Qwen Code. -3. Run the focused test/smoke path. -4. Capture only the affected scenario again. -5. Normalize and compare again. - -Stop when the target behavior is compatible and remaining differences are either irrelevant or explicitly documented. diff --git a/.agents/skills/codex-reproduce-align/scripts/compare_traces.py b/.agents/skills/codex-reproduce-align/scripts/compare_traces.py deleted file mode 100755 index b182601014..0000000000 --- a/.agents/skills/codex-reproduce-align/scripts/compare_traces.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Compare normalized reproduction traces and print actionable differences.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - - -def load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def tool_index(request: dict[str, Any]) -> dict[str, dict[str, Any]]: - return { - tool.get("name") or f"": tool - for idx, tool in enumerate(request.get("tools") or []) - } - - -def compare_request(idx: int, left: dict[str, Any], right: dict[str, Any]) -> list[str]: - diffs: list[str] = [] - prefix = f"request[{idx}]" - for key in ("method", "url_path", "model", "stream", "response_status"): - if left.get(key) != right.get(key): - diffs.append(f"{prefix}.{key}: {left.get(key)!r} != {right.get(key)!r}") - - left_roles = [item.get("role") for item in left.get("messages") or []] - right_roles = [item.get("role") for item in right.get("messages") or []] - if left_roles != right_roles: - diffs.append(f"{prefix}.message_roles: {left_roles!r} != {right_roles!r}") - - left_tools = tool_index(left) - right_tools = tool_index(right) - missing = sorted(set(left_tools) - set(right_tools)) - extra = sorted(set(right_tools) - set(left_tools)) - if missing: - diffs.append(f"{prefix}.tools_missing_in_right: {missing}") - if extra: - diffs.append(f"{prefix}.tools_extra_in_right: {extra}") - - for name in sorted(set(left_tools) & set(right_tools)): - for key in ("required", "properties"): - if left_tools[name].get(key) != right_tools[name].get(key): - diffs.append( - f"{prefix}.tool[{name}].{key}: " - f"{left_tools[name].get(key)!r} != {right_tools[name].get(key)!r}" - ) - return diffs - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("left", type=Path, help="Reference normalized trace, usually Codex") - parser.add_argument("right", type=Path, help="Target normalized trace, usually Qwen Code") - args = parser.parse_args() - - left = load(args.left) - right = load(args.right) - diffs: list[str] = [] - - if left.get("request_count") != right.get("request_count"): - diffs.append( - f"request_count: {left.get('request_count')!r} != {right.get('request_count')!r}" - ) - - for idx, (left_req, right_req) in enumerate( - zip(left.get("requests") or [], right.get("requests") or []) - ): - diffs.extend(compare_request(idx, left_req, right_req)) - - if not diffs: - print("No normalized trace differences found.") - return 0 - - print("Normalized trace differences:") - for diff in diffs: - print(f"- {diff}") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py b/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py deleted file mode 100755 index 5457c3db38..0000000000 --- a/.agents/skills/codex-reproduce-align/scripts/normalize_trace.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Normalize mitm JSONL traces into a stable comparison format.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - - -def content_hash(value: str) -> str: - return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] - - -def json_body(record: dict[str, Any]) -> Any: - body = record.get("body") or {} - if body.get("json") is not None: - return body["json"] - text = body.get("text") - if not text: - return None - try: - return json.loads(text) - except json.JSONDecodeError: - return {"text_hash": content_hash(text), "text_len": len(text)} - - -def walk_tools(value: Any) -> list[dict[str, Any]]: - tools: list[dict[str, Any]] = [] - if isinstance(value, dict): - if "tools" in value and isinstance(value["tools"], list): - for tool in value["tools"]: - tools.append(summarize_tool(tool)) - if "functions" in value and isinstance(value["functions"], list): - for fn in value["functions"]: - tools.append(summarize_tool({"type": "function", "function": fn})) - for child in value.values(): - tools.extend(walk_tools(child)) - elif isinstance(value, list): - for child in value: - tools.extend(walk_tools(child)) - return tools - - -def summarize_tool(tool: Any) -> dict[str, Any]: - if not isinstance(tool, dict): - return {"raw_type": type(tool).__name__} - fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool - params = fn.get("parameters") if isinstance(fn, dict) else None - return { - "type": tool.get("type"), - "name": fn.get("name") if isinstance(fn, dict) else None, - "description_hash": content_hash(fn.get("description", "")) - if isinstance(fn, dict) and isinstance(fn.get("description"), str) - else None, - "required": sorted(params.get("required", [])) - if isinstance(params, dict) and isinstance(params.get("required"), list) - else [], - "properties": sorted(params.get("properties", {}).keys()) - if isinstance(params, dict) and isinstance(params.get("properties"), dict) - else [], - } - - -def summarize_messages(value: Any) -> list[dict[str, Any]]: - messages = None - if isinstance(value, dict): - if isinstance(value.get("messages"), list): - messages = value["messages"] - elif isinstance(value.get("input"), list): - messages = value["input"] - if messages is None: - return [] - summary = [] - for item in messages: - if not isinstance(item, dict): - continue - content = item.get("content", "") - if not isinstance(content, str): - content = json.dumps(content, ensure_ascii=False, sort_keys=True) - summary.append( - { - "role": item.get("role"), - "content_hash": content_hash(content), - "content_len": len(content), - } - ) - return summary - - -def normalize(path: Path) -> dict[str, Any]: - requests = [] - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - raw = json.loads(line) - req = raw.get("request") or {} - resp = raw.get("response") or {} - parsed = urlparse(req.get("url", "")) - body = json_body(req) - requests.append( - { - "method": req.get("method"), - "url_path": parsed.path, - "body_keys": sorted(body.keys()) if isinstance(body, dict) else [], - "model": body.get("model") if isinstance(body, dict) else None, - "stream": body.get("stream") if isinstance(body, dict) else None, - "messages": summarize_messages(body), - "tools": sorted(walk_tools(body), key=lambda item: (item.get("name") or "")), - "response_status": resp.get("status_code") if isinstance(resp, dict) else None, - } - ) - return {"source": str(path), "request_count": len(requests), "requests": requests} - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("trace", type=Path) - args = parser.parse_args() - print(json.dumps(normalize(args.trace), ensure_ascii=False, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh b/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh deleted file mode 100755 index ce3e2d8a38..0000000000 --- a/.agents/skills/codex-reproduce-align/scripts/run_pair_capture.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ $# -ne 3 ]]; then - echo "Usage: $0 OUT_DIR CODEX_SHELL_COMMAND QWEN_SHELL_COMMAND" >&2 - exit 2 -fi - -out_dir="$1" -codex_command="$2" -qwen_command="$3" - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -feature_run="${script_dir}/../../codex-reproduce-feature/scripts/run_with_mitm.sh" - -mkdir -p "${out_dir}/codex" "${out_dir}/qwen" - -set +e -"${feature_run}" "${out_dir}/codex" -- bash -lc "${codex_command}" -codex_status=$? -"${feature_run}" "${out_dir}/qwen" -- bash -lc "${qwen_command}" -qwen_status=$? -set -e - -"${script_dir}/normalize_trace.py" "${out_dir}/codex/http.jsonl" > "${out_dir}/codex/normalized.json" -"${script_dir}/normalize_trace.py" "${out_dir}/qwen/http.jsonl" > "${out_dir}/qwen/normalized.json" - -set +e -"${script_dir}/compare_traces.py" \ - "${out_dir}/codex/normalized.json" \ - "${out_dir}/qwen/normalized.json" \ - > "${out_dir}/trace.diff" -compare_status=$? -set -e - -echo "codex_status=${codex_status}" -echo "qwen_status=${qwen_status}" -echo "compare_status=${compare_status}" -echo "diff=${out_dir}/trace.diff" - -if [[ "${codex_status}" -ne 0 || "${qwen_status}" -ne 0 || "${compare_status}" -ne 0 ]]; then - exit 1 -fi diff --git a/.agents/skills/codex-reproduce-feature/SKILL.md b/.agents/skills/codex-reproduce-feature/SKILL.md deleted file mode 100644 index 8512158271..0000000000 --- a/.agents/skills/codex-reproduce-feature/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: codex-reproduce-feature -description: Use when reproducing an existing Codex feature in Qwen Code or another agent CLI by running Codex as the reference implementation, capturing HTTP request bodies, prompts, tool/function schemas, terminal output, and then implementing the matching behavior in the target repo. ---- - -# Codex Reproduce Feature - -## Purpose - -Use this skill to turn an observed Codex feature into an implementation task for Qwen Code. The workflow treats the current Codex session as the outer harness and runs a nested Codex process as the reference program under test. - -Default target repo: the current working directory. Use a user-specified path only when the user explicitly provides one. - -## Workflow - -1. Define the feature surface in one sentence: command, trigger, expected UI/output, and a minimal prompt that exercises it. -2. Inspect the target repo enough to identify the likely module boundaries before changing code. -3. Run nested Codex against the feature with capture enabled: - - HTTP/body capture via `scripts/run_with_mitm.sh`. - - Terminal capture via `scripts/run_tmux_capture.sh` when the feature is interactive or TUI-visible. - - Headless/non-interactive execution when the feature has a stable command-line path. -4. Extract behavioral facts from the trace: - - system/developer prompt deltas relevant to the feature - - request body shape, including `messages`, `tools`, `functions`, schemas, tool choice, model settings - - visible terminal states and command output - - file edits, exit status, and error paths -5. Implement the smallest compatible behavior in Qwen Code using its existing patterns. -6. Add focused tests or a reproducible smoke command. -7. Hand off to `$codex-reproduce-align` when implementation exists and parity needs iteration. - -Read `references/capture-workflow.md` before running capture for the first time in a session. - -## Capture Defaults - -Prefer a fresh output directory per run: - -```sh -mkdir -p .repro-runs/slash-command-baseline -skills/codex-reproduce-feature/scripts/run_with_mitm.sh \ - .repro-runs/slash-command-baseline \ - -- codex exec "exercise the Codex feature here" -``` - -For interactive slash commands or terminal rendering, use tmux: - -```sh -skills/codex-reproduce-feature/scripts/run_tmux_capture.sh \ - .repro-runs/slash-command-tui \ - codex -``` - -The mitm script sets common proxy and CA variables for Node, Python, and curl-based CLIs. If TLS fails, read the certificate notes in `references/capture-workflow.md` and fix trust before interpreting missing traffic as product behavior. - -## Implementation Rules - -- Do not copy all captured prompt text into Qwen Code. Convert it into the minimum behavior, schema, or test needed. -- Treat captured request bodies as sensitive local artifacts. Redact tokens before saving examples into docs, commits, issues, or PRs. -- Keep the first implementation narrow: one feature, one trigger path, one observable parity target. -- Prefer compatibility tests that assert behavior over brittle tests that assert exact prompt wording. -- If a captured schema reveals a stable public contract, encode that contract as a typed structure or fixture in Qwen Code. - -## Done Criteria - -- A baseline Codex trace exists under `.repro-runs/` or an equivalent ignored/local path. -- Qwen Code contains a focused implementation and at least one verification path. -- Any user-visible command behavior is documented in Qwen Code if that repo already documents similar features. -- The next parity step can be run by `$codex-reproduce-align` without re-discovering the setup. diff --git a/.agents/skills/codex-reproduce-feature/agents/openai.yaml b/.agents/skills/codex-reproduce-feature/agents/openai.yaml deleted file mode 100644 index 328f5367ab..0000000000 --- a/.agents/skills/codex-reproduce-feature/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Codex Reproduce Feature" - short_description: "Capture Codex behavior and port it into Qwen Code" - default_prompt: "Use $codex-reproduce-feature to reproduce Codex slash command behavior in Qwen Code." diff --git a/.agents/skills/codex-reproduce-feature/references/capture-workflow.md b/.agents/skills/codex-reproduce-feature/references/capture-workflow.md deleted file mode 100644 index c26ff45903..0000000000 --- a/.agents/skills/codex-reproduce-feature/references/capture-workflow.md +++ /dev/null @@ -1,103 +0,0 @@ -# Capture Workflow Reference - -This skill follows the nested-agent pattern described in "解决问题的原始冲动": run the original tool under a harness, capture the real request bodies and tool schemas, implement the substitute, then compare traces. - -## Local Roles - -- Outer harness: the current Codex session. -- Reference program: a nested `codex` command that demonstrates the feature. -- Target program: Qwen Code in the current working directory unless the user explicitly provides another path. -- Capture layer: `mitmdump` plus terminal transcript capture. - -## Choosing Execution Mode - -Use non-interactive/headless mode when: - -- the feature has a stable CLI entrypoint -- output can be asserted from stdout/stderr/files -- request bodies are the primary evidence - -Use tmux when: - -- the feature depends on slash-command input, readline behavior, or a TUI state -- screen output matters -- you need to send multiple keystroke batches - -Use both when a feature has model calls and visible terminal state. - -## HTTP Capture - -Install mitmproxy if needed: - -```sh -python -m pip install --user mitmproxy -``` - -Run a command under capture: - -```sh -skills/codex-reproduce-feature/scripts/run_with_mitm.sh OUT_DIR -- COMMAND ARG... -``` - -Generated files: - -- `mitm.log`: mitmdump process log -- `http.jsonl`: redacted request/response records -- `command.stdout`, `command.stderr`, `command.exit`: child process result -- `env.txt`: non-secret capture metadata - -The script sets: - -- `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` -- `NODE_EXTRA_CA_CERTS` -- `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` -- `REPRO_CAPTURE_OUT` - -The default CA path is `~/.mitmproxy/mitmproxy-ca-cert.pem`. Some CLIs ignore one or more of these variables; if `http.jsonl` is empty, verify proxy support before changing product code. - -## Terminal Capture - -Run: - -```sh -skills/codex-reproduce-feature/scripts/run_tmux_capture.sh OUT_DIR COMMAND ARG... -``` - -Generated files: - -- `tmux-pane.txt`: captured pane contents -- `tmux-session.txt`: session metadata and attach instructions -- `command.txt`: the launched command - -The tmux session stays alive so the outer agent can send keys, inspect output, and capture again. Kill it after use: - -```sh -tmux kill-session -t SESSION_NAME -``` - -## What To Extract - -From HTTP records: - -- model name and model settings -- system/developer message fragments that explain the feature -- user-visible command mapping -- tool/function schema names, descriptions, and JSON schemas -- response format or streaming protocol details - -From terminal records: - -- exact slash command syntax and completion behavior -- visible state transitions -- error text and recoverable failure paths -- whether the feature is synchronous, streaming, or backgrounded - -## Redaction - -Never commit raw traces. Before moving examples into docs or tests, remove: - -- authorization headers and API keys -- user-specific paths -- unrelated prompt content -- private repository names and issue content -- full request bodies that are not needed for the feature contract diff --git a/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py b/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py deleted file mode 100644 index 0a18db7cb5..0000000000 --- a/.agents/skills/codex-reproduce-feature/scripts/llm_dump.py +++ /dev/null @@ -1,101 +0,0 @@ -"""mitmproxy addon for local agent reproduction traces. - -Writes JSONL records to REPRO_CAPTURE_OUT. Headers are redacted and bodies are -decoded when they look textual. Keep raw outputs local unless manually redacted. -""" - -from __future__ import annotations - -import base64 -import json -import os -import time -from typing import Any - -from mitmproxy import http - - -OUT = os.environ.get("REPRO_CAPTURE_OUT", "http.jsonl") -MAX_BODY = int(os.environ.get("REPRO_CAPTURE_MAX_BODY", "500000")) -CAPTURE_ALL = os.environ.get("REPRO_CAPTURE_ALL", "0") == "1" -SENSITIVE_HEADERS = { - "authorization", - "cookie", - "set-cookie", - "x-api-key", - "api-key", - "openai-organization", - "openai-project", -} -INTERESTING_PATH_HINTS = ( - "/chat/completions", - "/responses", - "/v1/messages", - "/v1beta/", - "/generate", - "/completions", -) - - -def _headers(headers: http.Headers) -> dict[str, str]: - redacted: dict[str, str] = {} - for key, value in headers.items(): - redacted[key] = "[REDACTED]" if key.lower() in SENSITIVE_HEADERS else value - return redacted - - -def _decode(content: bytes | None) -> dict[str, Any]: - if not content: - return {"kind": "empty", "text": ""} - truncated = len(content) > MAX_BODY - content = content[:MAX_BODY] - try: - text = content.decode("utf-8") - except UnicodeDecodeError: - return { - "kind": "base64", - "base64": base64.b64encode(content).decode("ascii"), - "truncated": truncated, - } - parsed: Any = None - try: - parsed = json.loads(text) - except json.JSONDecodeError: - pass - return {"kind": "text", "text": text, "json": parsed, "truncated": truncated} - - -def _interesting(flow: http.HTTPFlow) -> bool: - if CAPTURE_ALL: - return True - url = flow.request.pretty_url.lower() - ctype = flow.request.headers.get("content-type", "").lower() - return ( - any(hint in url for hint in INTERESTING_PATH_HINTS) - or "application/json" in ctype - or "text/event-stream" in ctype - ) - - -def response(flow: http.HTTPFlow) -> None: - if not _interesting(flow): - return - record = { - "ts": time.time(), - "request": { - "method": flow.request.method, - "url": flow.request.pretty_url, - "headers": _headers(flow.request.headers), - "body": _decode(flow.request.raw_content), - }, - "response": None, - } - if flow.response is not None: - record["response"] = { - "status_code": flow.response.status_code, - "headers": _headers(flow.response.headers), - "body": _decode(flow.response.raw_content), - } - os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True) - with open(OUT, "a", encoding="utf-8") as handle: - handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n") diff --git a/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh b/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh deleted file mode 100755 index 13bf871d20..0000000000 --- a/.agents/skills/codex-reproduce-feature/scripts/run_tmux_capture.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ $# -lt 2 ]]; then - echo "Usage: $0 OUT_DIR COMMAND [ARG...]" >&2 - exit 2 -fi - -out_dir="$1" -shift - -if ! command -v tmux >/dev/null 2>&1; then - echo "tmux not found." >&2 - exit 127 -fi - -mkdir -p "${out_dir}" -out_dir="$(cd "${out_dir}" && pwd)" - -session="repro-$(date +%Y%m%d-%H%M%S)" -printf '%q ' "$@" > "${out_dir}/command.txt" -echo >> "${out_dir}/command.txt" - -tmux new-session -d -s "${session}" "$@" -sleep "${REPRO_TMUX_SETTLE_SECONDS:-2}" -tmux capture-pane -t "${session}" -p -S - > "${out_dir}/tmux-pane.txt" - -{ - echo "session=${session}" - echo "attach=tmux attach -t ${session}" - echo "capture=tmux capture-pane -t ${session} -p -S - > ${out_dir}/tmux-pane.txt" - echo "kill=tmux kill-session -t ${session}" -} > "${out_dir}/tmux-session.txt" - -cat "${out_dir}/tmux-session.txt" diff --git a/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh b/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh deleted file mode 100755 index 6ebe8a00d6..0000000000 --- a/.agents/skills/codex-reproduce-feature/scripts/run_with_mitm.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [[ $# -lt 3 || "${2:-}" != "--" ]]; then - echo "Usage: $0 OUT_DIR -- COMMAND [ARG...]" >&2 - exit 2 -fi - -out_dir="$1" -shift 2 - -mkdir -p "${out_dir}" -out_dir="$(cd "${out_dir}" && pwd)" - -port="${REPRO_PROXY_PORT:-18080}" -ca_file="${MITMPROXY_CA_FILE:-${HOME}/.mitmproxy/mitmproxy-ca-cert.pem}" -http_out="${out_dir}/http.jsonl" -mitm_log="${out_dir}/mitm.log" - -if ! command -v mitmdump >/dev/null 2>&1; then - echo "mitmdump not found. Install mitmproxy first." >&2 - exit 127 -fi - -: > "${http_out}" -: > "${mitm_log}" - -REPRO_CAPTURE_OUT="${http_out}" \ - mitmdump \ - --listen-host 127.0.0.1 \ - --listen-port "${port}" \ - --set block_global=false \ - --set ssl_insecure=true \ - -s "${script_dir}/llm_dump.py" \ - >"${mitm_log}" 2>&1 & - -mitm_pid="$!" -cleanup() { - kill "${mitm_pid}" >/dev/null 2>&1 || true - wait "${mitm_pid}" >/dev/null 2>&1 || true -} -trap cleanup EXIT - -sleep 1 - -{ - echo "out_dir=${out_dir}" - echo "proxy=http://127.0.0.1:${port}" - echo "ca_file=${ca_file}" - echo "command=$*" -} > "${out_dir}/env.txt" - -set +e -HTTP_PROXY="http://127.0.0.1:${port}" \ -HTTPS_PROXY="http://127.0.0.1:${port}" \ -ALL_PROXY="http://127.0.0.1:${port}" \ -NODE_EXTRA_CA_CERTS="${ca_file}" \ -SSL_CERT_FILE="${ca_file}" \ -REQUESTS_CA_BUNDLE="${ca_file}" \ -REPRO_CAPTURE_OUT="${http_out}" \ - "$@" >"${out_dir}/command.stdout" 2>"${out_dir}/command.stderr" -status=$? -set -e - -echo "${status}" > "${out_dir}/command.exit" -exit "${status}" diff --git a/.gitignore b/.gitignore index f4be6695a5..6ff1d950be 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,6 @@ packages/web-templates/src/generated/ packages/vscode-ide-companion/*.vsix logs/ -.repro-runs/ # GHA credentials gha-creds-*.json @@ -94,4 +93,4 @@ tmp/ # code graph skills .venv -.codegraph +.codegraph \ No newline at end of file From 8d0d11a330e78d12e78de7d852364c3d3f544305 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Fri, 15 May 2026 16:28:38 +0800 Subject: [PATCH 5/5] fix(cli): address status line preset review feedback --- .../ui/components/StatusLineDialog.test.tsx | 74 +++++++++++++++ .../src/ui/components/StatusLineDialog.tsx | 56 ++++++++--- .../cli/src/ui/hooks/useStatusLine.test.ts | 81 +++++++++++++++- packages/cli/src/ui/hooks/useStatusLine.ts | 76 +++++++++------ packages/cli/src/ui/statusLinePresets.test.ts | 94 +++++++++++++++++++ packages/cli/src/ui/statusLinePresets.ts | 24 ++++- 6 files changed, 352 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx index de542f770a..8364d1e451 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.test.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -136,4 +136,78 @@ describe('StatusLineDialog', () => { expect(onSaved).toHaveBeenCalledWith(settings.merged.ui?.statusLine); expect(onClose).toHaveBeenCalled(); }); + + it('saves back to workspace settings when workspace config is effective', async () => { + const settings = createSettings(); + settings.workspace.settings.ui = { + statusLine: { + type: 'preset', + useThemeColors: false, + items: ['model'], + }, + }; + settings.workspace.originalSettings.ui = settings.workspace.settings.ui; + settings.recomputeMerged(); + const addItem = vi.fn(); + const { stdin } = render( + + + , + ); + + act(() => { + stdin.write('\r'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(settings.forScope(SettingScope.User).settings.ui).toBeUndefined(); + expect(settings.forScope(SettingScope.Workspace).settings.ui).toEqual({ + statusLine: { + type: 'preset', + useThemeColors: false, + items: ['model'], + }, + }); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Status line preset saved to workspace settings.', + }, + expect.any(Number), + ); + }); + + it('does not append navigation keys to the search query', async () => { + const settings = createSettings(); + const { stdin, lastFrame } = render( + + + , + ); + + act(() => { + stdin.write('m'); + stdin.write('j'); + stdin.write('k'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(lastFrame()).toContain('> m'); + expect(lastFrame()).not.toContain('> mj'); + expect(lastFrame()).not.toContain('> mk'); + }); }); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx index 64791898b8..92637dffdb 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -17,6 +17,7 @@ import { MessageType } from '../types.js'; import type { UIState } from '../contexts/UIStateContext.js'; import { MultiSelect, type MultiSelectItem } from './shared/MultiSelect.js'; import { + aggregateModelTokens, buildStatusLinePresetData, buildStatusLinePresetLines, DEFAULT_STATUS_LINE_PRESET_CONFIG, @@ -57,9 +58,13 @@ function buildInitialSelectedKeys(settings: LoadedSettings): string[] { function buildConfigFromKeys(keys: readonly string[]): StatusLinePresetConfig { const selected = new Set(keys); const validItemIds = new Set(STATUS_LINE_PRESET_ITEMS.map((item) => item.id)); - const items = keys.filter((key): key is StatusLinePresetItemId => - validItemIds.has(key as StatusLinePresetItemId), - ); + const items = [ + ...new Set( + keys.filter((key): key is StatusLinePresetItemId => + validItemIds.has(key as StatusLinePresetItemId), + ), + ), + ]; return { type: 'preset', @@ -68,6 +73,19 @@ function buildConfigFromKeys(keys: readonly string[]): StatusLinePresetConfig { }; } +function getEffectiveStatusLineScope(settings: LoadedSettings): SettingScope { + if (settings.forScope(SettingScope.System).settings.ui?.statusLine) { + return SettingScope.System; + } + if ( + settings.isTrusted && + settings.forScope(SettingScope.Workspace).settings.ui?.statusLine + ) { + return SettingScope.Workspace; + } + return SettingScope.User; +} + function getOptionSearchText( option: MultiSelectItem, ): string { @@ -83,12 +101,7 @@ function getOptionSearchText( function getPreviewData(config: Config, uiState: UIState) { const stats = uiState.sessionStats; const metrics = stats.metrics; - let totalInputTokens = 0; - let totalOutputTokens = 0; - for (const modelMetrics of Object.values(metrics.models)) { - totalInputTokens += modelMetrics.tokens.prompt; - totalOutputTokens += modelMetrics.tokens.candidates; - } + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(metrics); return buildStatusLinePresetData({ sessionId: stats.sessionId, @@ -158,18 +171,23 @@ export function StatusLineDialog({ () => buildConfigFromKeys(selectedKeys), [selectedKeys], ); - const previewLines = buildStatusLinePresetLines( - presetConfig, - getPreviewData(config, uiState), + const previewData = useMemo( + () => getPreviewData(config, uiState), + [config, uiState], + ); + const previewLines = useMemo( + () => buildStatusLinePresetLines(presetConfig, previewData), + [presetConfig, previewData], ); const handleConfirm = useCallback(() => { - settings.setValue(SettingScope.User, 'ui.statusLine', presetConfig); + const effectiveScope = getEffectiveStatusLineScope(settings); + settings.setValue(effectiveScope, 'ui.statusLine', presetConfig); onSaved?.(presetConfig); addItem( { type: MessageType.INFO, - text: 'Status line preset saved to user settings.', + text: `Status line preset saved to ${effectiveScope.toLowerCase()} settings.`, }, Date.now(), ); @@ -192,6 +210,16 @@ export function StatusLineDialog({ return; } + if ( + key.name === 'j' || + key.name === 'k' || + key.name === 'up' || + key.name === 'down' || + key.name === 'return' + ) { + return; + } + if ( !key.ctrl && !key.meta && diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index 49ba897627..fafe1908cf 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -9,6 +9,12 @@ import { renderHook, act } from '@testing-library/react'; import * as child_process from 'child_process'; import { StreamingState } from '../types.js'; +const debugLogMock = vi.hoisted(() => ({ + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + // --- Mock child_process (auto-mock, then override exec in beforeEach) --- vi.mock('child_process'); @@ -66,10 +72,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await importOriginal(); return { ...original, - createDebugLogger: () => ({ - log: vi.fn(), - error: vi.fn(), - }), + createDebugLogger: () => debugLogMock, }; }); @@ -188,6 +191,18 @@ describe('useStatusLine', () => { }); describe('preset status line', () => { + it('returns the preset theme color preference', () => { + setStatusLineConfig({ + type: 'preset', + useThemeColors: true, + items: ['model'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(result.current.useThemeColors).toBe(true); + expect(result.current.lines).toEqual(['test-model']); + }); + it('looks up the current branch pull request number with gh', async () => { mockUIState.branchName = 'dragon/feat-reproduce-skill'; setStatusLineConfig({ @@ -255,6 +270,64 @@ describe('useStatusLine', () => { expect(result.current.lines).toEqual(['test-model | #4118']); }); + + it('uses command settings when a stale preset override no longer matches the settings type', () => { + setStatusLineConfig({ + type: 'command', + command: 'echo from-settings', + }); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model'], + }; + + renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('echo from-settings'); + }); + + it('ignores a stale preset override when settings no longer have status line config', () => { + setStatusLineConfig(undefined); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model'], + }; + + const { result } = renderHook(() => useStatusLine()); + + expect(result.current.lines).toEqual([]); + expect(child_process.exec).not.toHaveBeenCalled(); + }); + + it('logs and retries pull request lookup failures after state changes', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['pull-request-number'], + }); + const { rerender } = renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + + await act(async () => { + execCallback(new Error('gh not authenticated'), '', ''); + }); + + expect(debugLogMock.warn).toHaveBeenCalledWith( + 'statusline: gh pr view failed:', + 'gh not authenticated', + ); + + mockUIState.sessionStats.lastPromptTokenCount = 101; + rerender(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(child_process.exec).toHaveBeenCalledTimes(2); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + }); }); // --- Command execution --- diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index a01b1bed26..7be99e4e3a 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -13,6 +13,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; import { + aggregateModelTokens, buildStatusLinePresetData, buildStatusLinePresetLines, normalizeStatusLinePresetConfig, @@ -172,16 +173,23 @@ export function useStatusLine(): { const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); + const settingsStatusLineConfig = getStatusLineConfig(settings); + const statusLineConfigOverride = uiState.statusLineConfigOverride; const statusLineConfig = - uiState.statusLineConfigOverride ?? getStatusLineConfig(settings); + statusLineConfigOverride && + settingsStatusLineConfig && + statusLineConfigOverride.type === settingsStatusLineConfig.type + ? statusLineConfigOverride + : settingsStatusLineConfig; const statusLineCommand = statusLineConfig?.type === 'command' ? statusLineConfig.command : undefined; const statusLinePreset = statusLineConfig?.type === 'preset' ? statusLineConfig : undefined; const statusLineSettingsVersion = uiState.statusLineSettingsVersion ?? 0; - const statusLinePresetKey = statusLinePreset - ? `${statusLinePreset.useThemeColors ? 'color' : 'plain'}:${statusLinePreset.items.join(',')}:${statusLineSettingsVersion}` - : undefined; + const hasStatusLinePreset = statusLinePreset !== undefined; + const statusLinePresetUseThemeColors = + statusLinePreset?.useThemeColors ?? false; + const statusLinePresetItemsKey = statusLinePreset?.items.join('\0') ?? ''; const refreshInterval = statusLineConfig?.type === 'command' ? statusLineConfig.refreshInterval @@ -291,7 +299,6 @@ export function useStatusLine(): { pullRequestLookupChildRef.current?.kill(); pullRequestLookupChildRef.current = undefined; - pullRequestLookupKeyRef.current = lookupKey; updatePullRequestNumber(undefined); const generation = ++pullRequestLookupGenerationRef.current; @@ -308,21 +315,24 @@ export function useStatusLine(): { return; } pullRequestLookupChildRef.current = undefined; - updatePullRequestNumber( - error ? undefined : parsePullRequestNumber(stdout), - ); + if (error) { + debugLog.warn('statusline: gh pr view failed:', error.message); + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); + return; + } + updatePullRequestNumber(parsePullRequestNumber(stdout)); }, ); } catch (err) { - debugLog.error( - 'statusline pull request lookup error:', - (err as Error).message, - ); + debugLog.warn('statusline: gh pr view failed:', (err as Error).message); + pullRequestLookupKeyRef.current = undefined; updatePullRequestNumber(undefined); return; } pullRequestLookupChildRef.current = child; + pullRequestLookupKeyRef.current = lookupKey; }, [clearPullRequestLookup, updatePullRequestNumber], ); @@ -343,12 +353,7 @@ export function useStatusLine(): { const currentDir = cfg.getTargetDir(); ensurePullRequestNumber(preset, currentDir, ui.branchName); - let totalInputTokens = 0; - let totalOutputTokens = 0; - for (const mm of Object.values(m.models)) { - totalInputTokens += mm.tokens.prompt; - totalOutputTokens += mm.tokens.candidates; - } + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(m); const contextWindowSize = cfg.getContentGeneratorConfig()?.contextWindowSize || 0; @@ -399,12 +404,7 @@ export function useStatusLine(): { ) : 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - for (const mm of Object.values(m.models)) { - totalInputTokens += mm.tokens.prompt; - totalOutputTokens += mm.tokens.candidates; - } + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(m); const input: StatusLineCommandInput = { session_id: stats.sessionId, @@ -512,7 +512,7 @@ export function useStatusLine(): { // Trigger update when meaningful state changes useEffect(() => { - if (!statusLineCommand && !statusLinePresetKey) { + if (!statusLineCommand && !hasStatusLinePreset) { // Command removed — kill any in-flight process and discard callbacks. activeChildRef.current?.kill(); activeChildRef.current = undefined; @@ -553,7 +553,10 @@ export function useStatusLine(): { } }, [ statusLineCommand, - statusLinePresetKey, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, lastPromptTokenCount, currentModel, effectiveVim, @@ -570,7 +573,7 @@ export function useStatusLine(): { // Skip the first run — the mount effect below already handles it. useEffect(() => { if (!hasMountedRef.current) return; - if (statusLineCommand || statusLinePresetKey) { + if (statusLineCommand || hasStatusLinePreset) { // Clear any pending debounce so we don't get a redundant second run. if (debounceTimerRef.current !== undefined) { clearTimeout(debounceTimerRef.current); @@ -580,13 +583,26 @@ export function useStatusLine(): { } // Cleanup when command is removed is handled by the state-change effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusLineCommand, statusLinePresetKey]); + }, [ + statusLineCommand, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, + ]); // Re-render preset output once the async GitHub PR lookup returns. useEffect(() => { - if (!hasMountedRef.current || !statusLinePresetKey) return; + if (!hasMountedRef.current || !hasStatusLinePreset) return; scheduleUpdate(); - }, [pullRequestNumber, statusLinePresetKey, scheduleUpdate]); + }, [ + pullRequestNumber, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, + scheduleUpdate, + ]); // Periodic refresh — re-run the command every `refreshInterval` seconds. // The tick yields if a previous exec is still running: unlike state-change diff --git a/packages/cli/src/ui/statusLinePresets.test.ts b/packages/cli/src/ui/statusLinePresets.test.ts index c85523a167..4e6178dc69 100644 --- a/packages/cli/src/ui/statusLinePresets.test.ts +++ b/packages/cli/src/ui/statusLinePresets.test.ts @@ -7,10 +7,15 @@ import { describe, expect, it } from 'vitest'; import { StreamingState } from './types.js'; import { + aggregateModelTokens, buildStatusLinePresetData, buildStatusLinePresetLines, DEFAULT_STATUS_LINE_PRESET_CONFIG, + formatTokenCount, + getRunStateLabel, + inferPullRequestNumber, normalizeStatusLinePresetConfig, + STATUS_LINE_PRESET_ITEM_IDS, } from './statusLinePresets.js'; describe('statusLinePresets', () => { @@ -85,6 +90,62 @@ describe('statusLinePresets', () => { ]); }); + it('renders every preset item with representative data', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-4087-statusline', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 12, + totalLinesRemoved: 3, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: [...STATUS_LINE_PRESET_ITEM_IDS], + }, + data, + ), + ).toEqual([ + 'qwen3-code-plus | Context 75% left | /repo/project | Context 25% used | feature/pr-4087-statusline | project | #4087 | +12 -3 | Ready | v1.2.3 | 1.0k window | 250 used | 1.2k in | 340 out | session-123', + ]); + }); + + it('treats model and model-with-reasoning as mutually exclusive', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: undefined, + contextWindowSize: 0, + currentUsage: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: ['model-with-reasoning', 'model'], + }, + data, + ), + ).toEqual(['qwen3-code-plus']); + }); + it('renders an explicit pull request number before branch-name inference', () => { const data = buildStatusLinePresetData({ sessionId: 'session-123', @@ -112,4 +173,37 @@ describe('statusLinePresets', () => { ), ).toEqual(['#4087']); }); + + it('aggregates model token counts', () => { + expect( + aggregateModelTokens({ + models: { + qwen: { tokens: { prompt: 100, candidates: 20 } }, + coder: { tokens: { prompt: 300, candidates: 40 } }, + }, + }), + ).toEqual({ totalInputTokens: 400, totalOutputTokens: 60 }); + }); + + it('formats token counts compactly', () => { + expect(formatTokenCount(Number.NaN)).toBe('0'); + expect(formatTokenCount(999)).toBe('999'); + expect(formatTokenCount(1200)).toBe('1.2k'); + expect(formatTokenCount(2_400_000)).toBe('2.4m'); + }); + + it('labels run states', () => { + expect(getRunStateLabel(StreamingState.Idle)).toBe('Ready'); + expect(getRunStateLabel(StreamingState.Responding)).toBe('Working'); + expect(getRunStateLabel(StreamingState.WaitingForConfirmation)).toBe( + 'Confirm', + ); + }); + + it('infers pull request numbers from branch names', () => { + expect(inferPullRequestNumber('feature/pr-4087-statusline')).toBe('4087'); + expect(inferPullRequestNumber('dragon/pull-request_99')).toBe('99'); + expect(inferPullRequestNumber('main')).toBeUndefined(); + expect(inferPullRequestNumber(undefined)).toBeUndefined(); + }); }); diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts index 2468cb64fa..4b548dd7f3 100644 --- a/packages/cli/src/ui/statusLinePresets.ts +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -61,6 +61,18 @@ export interface StatusLinePresetData { streamingState: StreamingState; } +export function aggregateModelTokens(metrics: { + models: Record; +}): { totalInputTokens: number; totalOutputTokens: number } { + let totalInputTokens = 0; + let totalOutputTokens = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalInputTokens += modelMetrics.tokens.prompt; + totalOutputTokens += modelMetrics.tokens.candidates; + } + return { totalInputTokens, totalOutputTokens }; +} + export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ { id: 'model-with-reasoning', @@ -202,7 +214,7 @@ function formatPercent(value: number): string { return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded}%`; } -function formatTokenCount(value: number): string { +export function formatTokenCount(value: number): string { if (!Number.isFinite(value) || value <= 0) { return '0'; } @@ -215,7 +227,7 @@ function formatTokenCount(value: number): string { return String(Math.round(value)); } -function getRunStateLabel(state: StreamingState): string { +export function getRunStateLabel(state: StreamingState): string { switch (state) { case StreamingState.Idle: return 'Ready'; @@ -228,7 +240,7 @@ function getRunStateLabel(state: StreamingState): string { } } -function inferPullRequestNumber( +export function inferPullRequestNumber( branch: string | undefined, ): string | undefined { if (!branch) { @@ -305,6 +317,8 @@ export function buildStatusLinePresetParts( case 'model-with-reasoning': case 'model': parts.push(data.modelDisplayName); + seen.add('model'); + seen.add('model-with-reasoning'); break; case 'context-remaining': if (data.contextWindowSize > 0) { @@ -370,8 +384,8 @@ export function buildStatusLinePresetParts( } break; default: { - const exhaustive: never = item; - return exhaustive; + item satisfies never; + break; } } }