From f849803872c7e499e5dd1131b6eb232439cb6775 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 12:45:13 -0400 Subject: [PATCH 01/26] style(tui): use default foreground for diff +/- markers Diff add/remove rows are distinguished by background tint only; line numbers and +/- glyphs no longer use green/red accent foreground. Co-authored-by: Cursor --- .../ui/shell/components/diff.py | 11 +++---- .../test_tui_card_tool_renderers.py | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pythinker_code/ui/shell/components/diff.py b/src/pythinker_code/ui/shell/components/diff.py index a4e0505d..e8146f09 100644 --- a/src/pythinker_code/ui/shell/components/diff.py +++ b/src/pythinker_code/ui/shell/components/diff.py @@ -246,11 +246,12 @@ def render_diff(diff_text: str) -> Text: return Text("") colors = get_diff_colors() - # Signs/line numbers carry the green/red accent; row content stays in the - # terminal's default text color over the tinted row background, so diffs - # read as white-on-deep-green/red rather than fully recolored text. - added_sign = tui_rich_style("tool_diff_added") + colors.add_bg - removed_sign = tui_rich_style("tool_diff_removed") + colors.del_bg + # Added/removed rows are distinguished by background tint only; line numbers, + # +/- markers, and code content all use the terminal's default foreground + # so the diff reads as light text on deep green/red rather than recolored + # green/red glyphs. + added_sign = colors.add_bg + removed_sign = colors.del_bg added_body = colors.add_bg removed_body = colors.del_bg context_style = tui_rich_style("tool_diff_context") diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index d283fb02..ffb60628 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -753,6 +753,36 @@ def test_render_diff_colorizes_added_removed(): assert "world" in plain +def test_render_diff_signs_match_body_foreground(): + """+/- markers and line numbers use default fg on tinted rows, not green/red.""" + from pythinker_code.ui.theme import get_diff_colors, set_active_theme, tui_rich_style + + set_active_theme("dark") + diff = compute_edit_diff_string("old line\n", "new line\n").diff + text = render_diff(diff) + accent_fgs = { + tui_rich_style("tool_diff_added").color, + tui_rich_style("tool_diff_removed").color, + } + row_bgs = {get_diff_colors().add_bg.bgcolor, get_diff_colors().del_bg.bgcolor} + for span in text.spans: + if span.end <= span.start: + continue + style = span.style + if isinstance(style, str): + continue + if style.color in accent_fgs: + pytest.fail(f"diff sign/body used accent fg {style.color!r} on {text.plain[span.start:span.end]!r}") + + tinted = [ + text.plain[span.start : span.end] + for span in text.spans + if not isinstance(span.style, str) and span.style.bgcolor in row_bgs + ] + assert any(" -" in chunk or chunk.endswith("-") for chunk in tinted) + assert any(" +" in chunk or chunk.endswith("+") for chunk in tinted) + + # --------------------------------------------------------------------------- # Agent (subagent) # --------------------------------------------------------------------------- From d936b321a5891ef3ab366e087ed0151a44e3ed1a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 13:36:19 -0400 Subject: [PATCH 02/26] feat(lsp): add plugin-based LSP code intelligence subsystem (PLIP-10) Port the reference LSP subsystem to a first-class Python feature: a new `LSP` agent tool (definition, references, hover, document/workspace symbols, implementation, full call hierarchy), session-scoped server lifecycle over the Host stdio abstraction, passive diagnostics injected after file edits, and plugin-only server discovery/recommendation (no bundled binaries). Hardening folded in from review: - framing raises LspServerDown on EOF so the read loop fails pending requests instead of busy-spinning (C01/C10); wired LspClient.on_crash -> instance mark_crashed so the max_restarts cap is enforced against real crashes. - LSP tool distinguishes "no server for file type" from an empty server response (definition-not-found now renders guidance, not a false "no server"). - request failures return an error result instead of a false-success ok(). - file-tool edit hook (write/replace) clears delivered diagnostics for the file and wraps change/save notifications in try/except so an LSP hiccup never fails an already-successful write (C03). - no-server file types surface a one-line plugin install hint (per ext/session), gated by recommendation_disabled/never. Persisted >=5 ignore auto-disable is deferred: save_config() rewrites the whole file with no lock/atomic rename, so incremental concurrent writes are unsafe (multi-instance clobber risk). Registered on default + coder agents only; excluded from offline/fail-closed code_reviewer. Servers are plugin-only with PluginPolicy.external_exec gating. --- CHANGELOG.md | 5 + docs/.vitepress/config.ts | 1 + docs/en/customization/architecture.md | 17 +- docs/en/customization/lsp.md | 36 ++ plips/plip-10-lsp-system.md | 260 +++++++--- src/pythinker_code/agents/default/agent.yaml | 1 + src/pythinker_code/agents/default/coder.yaml | 1 + src/pythinker_code/app.py | 6 + src/pythinker_code/config.py | 23 + src/pythinker_code/lsp/__init__.py | 11 + src/pythinker_code/lsp/client.py | 317 ++++++++++++ src/pythinker_code/lsp/diagnostics.py | 309 ++++++++++++ src/pythinker_code/lsp/framing.py | 70 +++ src/pythinker_code/lsp/instance.py | 252 ++++++++++ src/pythinker_code/lsp/manager.py | 196 ++++++++ src/pythinker_code/lsp/plugin_servers.py | 224 +++++++++ src/pythinker_code/lsp/protocol.py | 156 ++++++ src/pythinker_code/lsp/recommend.py | 194 ++++++++ src/pythinker_code/lsp/service.py | 157 ++++++ src/pythinker_code/plugin/manifest.py | 7 + src/pythinker_code/soul/agent.py | 11 +- .../dynamic_injections/lsp_diagnostics.py | 53 ++ src/pythinker_code/soul/pythinkersoul.py | 3 + src/pythinker_code/tools/file/replace.py | 13 + src/pythinker_code/tools/file/write.py | 12 + src/pythinker_code/tools/lsp/__init__.py | 3 + src/pythinker_code/tools/lsp/formatters.py | 441 +++++++++++++++++ src/pythinker_code/tools/lsp/schemas.py | 26 + .../tools/lsp/symbol_context.py | 80 ++++ src/pythinker_code/tools/lsp/tool.md | 19 + src/pythinker_code/tools/lsp/tool.py | 389 +++++++++++++++ tests/tools/test_lsp_client.py | 253 ++++++++++ tests/tools/test_lsp_diagnostics.py | 342 +++++++++++++ tests/tools/test_lsp_manager.py | 389 +++++++++++++++ tests/tools/test_lsp_plugins.py | 329 +++++++++++++ tests/tools/test_lsp_tool.py | 452 ++++++++++++++++++ tests_e2e/test_wire_approvals_tools.py | 16 +- tests_e2e/test_wire_config.py | 4 +- tests_e2e/test_wire_prompt.py | 6 +- tests_e2e/test_wire_protocol.py | 4 +- tests_e2e/test_wire_sessions.py | 6 +- tests_e2e/test_wire_skills_mcp.py | 6 +- 42 files changed, 5006 insertions(+), 94 deletions(-) create mode 100644 docs/en/customization/lsp.md create mode 100644 src/pythinker_code/lsp/__init__.py create mode 100644 src/pythinker_code/lsp/client.py create mode 100644 src/pythinker_code/lsp/diagnostics.py create mode 100644 src/pythinker_code/lsp/framing.py create mode 100644 src/pythinker_code/lsp/instance.py create mode 100644 src/pythinker_code/lsp/manager.py create mode 100644 src/pythinker_code/lsp/plugin_servers.py create mode 100644 src/pythinker_code/lsp/protocol.py create mode 100644 src/pythinker_code/lsp/recommend.py create mode 100644 src/pythinker_code/lsp/service.py create mode 100644 src/pythinker_code/soul/dynamic_injections/lsp_diagnostics.py create mode 100644 src/pythinker_code/tools/lsp/__init__.py create mode 100644 src/pythinker_code/tools/lsp/formatters.py create mode 100644 src/pythinker_code/tools/lsp/schemas.py create mode 100644 src/pythinker_code/tools/lsp/symbol_context.py create mode 100644 src/pythinker_code/tools/lsp/tool.md create mode 100644 src/pythinker_code/tools/lsp/tool.py create mode 100644 tests/tools/test_lsp_client.py create mode 100644 tests/tools/test_lsp_diagnostics.py create mode 100644 tests/tools/test_lsp_manager.py create mode 100644 tests/tools/test_lsp_plugins.py create mode 100644 tests/tools/test_lsp_tool.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 209f6e8e..1653a3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **LSP code intelligence.** Plugin-provided language servers power a new `LSP` agent tool + (go-to-definition, find-references, hover, symbols, call hierarchy) with session-scoped + server lifecycle, passive diagnostics injected after file edits, and plugin-based server + discovery/recommendation — no bundled language-server binaries. + ## 0.47.0 (2026-06-16) - **Plugin marketplaces and activation policy.** `pythinker plugin marketplace` can add, diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index af19a5fb..8029c875 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -48,6 +48,7 @@ export default withMermaid(defineConfig({ text: 'Customization', items: [ { text: 'Model Context Protocol', link: '/en/customization/mcp' }, + { text: 'Language Server Protocol', link: '/en/customization/lsp' }, { text: 'Plugins (Beta)', link: '/en/customization/plugins' }, { text: 'Hooks (Beta)', link: '/en/customization/hooks' }, { text: 'Agent Skills', link: '/en/customization/skills' }, diff --git a/docs/en/customization/architecture.md b/docs/en/customization/architecture.md index 063726e7..0ab07d2c 100644 --- a/docs/en/customization/architecture.md +++ b/docs/en/customization/architecture.md @@ -100,7 +100,7 @@ canonical list lives in `src/pythinker_code/wire/types.py` (`Event` union): `Ste | `src/pythinker_code/soul/context.py` | Conversation history, checkpoints, JSONL persistence. | `Context` | | `src/pythinker_code/soul/toolset.py` | Loads built-in + MCP tools, injects deps, executes calls. | `PythinkerToolset` | | `src/pythinker_code/soul/slash.py` | Slash-command registry and dispatch. | `registry` | -| `src/pythinker_code/soul/dynamic_injection.py` (+ `dynamic_injections/`) | Injects budgeted `` content per step: plan-mode, auto-mode, model-defense. | `DynamicInjectionProvider` | +| `src/pythinker_code/soul/dynamic_injection.py` (+ `dynamic_injections/`) | Injects budgeted `` content per step: plan-mode, auto-mode, model-defense, LSP diagnostics. | `DynamicInjectionProvider` | | `src/pythinker_code/soul/permission.py` | Per-step permission profiles (`read_only`/`plan`/`ask`/`implement`/`review`/`verify`) and destructiveness classification. | `tool_destructive_reason`, `shell_command_signature` | | `src/pythinker_code/soul/denwarenji.py` | D-Mail checkpoint rewind (`BackToTheFuture`). | — | | `src/pythinker_code/soul/flow_runner.py` | Ralph Loop driver for `/flow` and iterative commands. | — | @@ -142,6 +142,7 @@ wrapped with `UntrustedData`. | `src/pythinker_code/tools/file/` | `ReadFile`, `WriteFile`, `StrReplaceFile`, `Glob`, `Grep`, `ReadMediaFile` | | `src/pythinker_code/tools/shell/` | `Shell` | | `src/pythinker_code/tools/web/` | `SearchWeb`, `FetchURL` (conditional on deps) | +| `src/pythinker_code/tools/lsp/` | `Lsp` (model name `LSP`; plugin-backed language servers) | | `src/pythinker_code/tools/agent/` | `Agent`, `RunAgents` | | `src/pythinker_code/tools/background/` | `TaskOutput`, `TaskList`, `TaskInput`, `TaskStop`, `TaskHandoff` | | `src/pythinker_code/tools/` (other) | `AskUserQuestion`, `EnterPlanMode`/`ExitPlanMode`, `Think`, `SetTodoList`, `Memory`, `Recall`, `Scratchpad`, `Suggest`, `Progress`, `ReadSkill`, `SendDMail`, `ListMcpResources`/`ReadMcpResource` | @@ -201,7 +202,19 @@ Full session lifecycle: `initialize`, `new_session`, `load_session`, `resume_ses | --- | --- | --- | | `src/pythinker_code/skill/`, `src/pythinker_code/skills/` | Skill discovery/loading across scopes (project > user > extra > built-in), local specialization, flow skills; injected via `PYTHINKER_SKILLS`. Bundled skills live in `skills/`. | `Skill`, `discover_skills_from_roots`, `index_skills`, `format_skills_for_prompt`, `Flow`, `SkillLockFile` | | `src/pythinker_code/hooks/` | Lifecycle hook engine: 13 events, server-side shell commands and client-side Wire subscriptions; fail-open (block only on explicit exit code 2 / structured deny). | `HookEngine`, `HookDef`, `HookEventType`, `HOOK_EVENT_TYPES`, `run_hook`, `events` | -| `src/pythinker_code/plugin/` | Plugin discovery, install (local/git/zip with SSRF + traversal guards, staged atomic install), and subprocess tool execution with fresh credential injection. | `parse_plugin_json`, `PluginSpec`, `install_plugin`, `list_plugins`, `load_plugin_tools`, `PluginTool` | +| `src/pythinker_code/plugin/` | Plugin discovery, install (local/git/zip with SSRF + traversal guards, staged atomic install), subprocess tool execution, MCP and LSP server configs (`plugin_lsp_servers`). | `parse_plugin_json`, `PluginSpec`, `install_plugin`, `list_plugins`, `load_plugin_tools`, `PluginTool`, `plugin_mcp_servers`, `plugin_lsp_servers` | + +## LSP subsystem + +Session-scoped language-server processes over `Host.exec` stdio (JSON-RPC Content-Length framing). +Servers are plugin-only; one `LspService` per `Runtime`, shared by subagents, torn down in +`cleanup_runtime_resources()`. + +| Path | Purpose | Key entry points | +| --- | --- | --- | +| `src/pythinker_code/lsp/` | Client, server lifecycle, routing, diagnostics registry, plugin loader, recommendation. | `LspService`, `LspServerManager`, `LspClient`, `DiagnosticRegistry`, `plugin_lsp_servers` | + +Trust boundary: LSP subprocesses run with agent privileges; output is untrusted project content. ## Memory, background, and notifications diff --git a/docs/en/customization/lsp.md b/docs/en/customization/lsp.md new file mode 100644 index 00000000..33d1c47d --- /dev/null +++ b/docs/en/customization/lsp.md @@ -0,0 +1,36 @@ +# Language Server Protocol (LSP) + +Pythinker Code can connect to [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) servers for semantic code intelligence: go-to-definition, find-references, hover, symbols, and call hierarchy. + +## Plugin-only servers + +LSP servers are **not** configured in user or project TOML. They come only from installed plugins — inline `lspServers` in `plugin.json` or a plugin-root `.lsp.json` file (same shape as MCP plugin servers). Pythinker does not bundle language-server binaries. + +Enable executable plugin artifacts (`plugins.external_exec = true` or `pythinker plugin enable `) so plugin-provided LSP subprocesses are allowed. + +## Agent tool + +The `LSP` tool is available on the default agent and the `coder` subagent (not on read-only profiles such as `code_reviewer`). It exposes nine operations with 1-based line/character positions (editor-style). + +Servers start lazily on first use per language and stay alive for the session. Subagents share the root session's LSP processes. + +## Passive diagnostics + +After `WriteFile` or `StrReplaceFile`, the session notifies open language servers and surfaces new compiler/linter diagnostics on the next turn via dynamic context injection (budget-capped). Diagnostics are labeled as LSP-reported, not agent-asserted. + +## Configuration + +Feature switches only — in `~/.pythinker/config.toml`: + +```toml +[lsp] +enabled = true +recommendation_disabled = false +recommendation_never = [] +``` + +When you edit a file whose extension matches a discoverable but not-yet-installed plugin server, Pythinker may suggest installing that plugin (respecting `recommendation_never` and auto-disabling after repeated ignores). + +## Trust boundary + +LSP servers run as subprocesses with the agent's privileges. Hover, symbol, and diagnostic text is treated as untrusted project content (same class as `ReadFile` output). diff --git a/plips/plip-10-lsp-system.md b/plips/plip-10-lsp-system.md index 70ee5a7b..0617bf5e 100644 --- a/plips/plip-10-lsp-system.md +++ b/plips/plip-10-lsp-system.md @@ -1,6 +1,6 @@ --- Author: Mohamed Elkholy -Updated: 2026-6-15 +Updated: 2026-6-16 Status: Proposed --- @@ -32,6 +32,72 @@ the reference is dropped. The only thing that does **not** port is the React/Ink (recommendation menu, init-notification toasts); its *intent* is re-expressed through the existing CLI notification + dynamic-injection systems. +## Verification status & corrections (2026-06-16) + +Fact-checked against `blackbox/pythinker-src` (reference behaviour) and the live Python tree +(integration points). Findings folded into the phases below. + +**Reference behaviour — verified exact (kept as-is):** crash cap default 3 +(`LSPServerInstance.ts:142`); transient `-32801` retry 3× at 500→1000→2000 ms +(`LSPServerInstance.ts:17,22,28,355-410`); diagnostic caps **10/file + 30/total** and a **500-file +LRU** for cross-turn dedup (`LSPDiagnosticRegistry.ts:42-46`); severity Error=1…Hint=4, sorted +before truncation; **9** tool operations (`schemas.ts:180-190`); 1-based→0-based conversion; 10 MB +file cap; UNC rejection; `git check-ignore` filtering batched ≤50; `maxResultSizeChars` 100 000; +`workspace/configuration` → `[null]` per item (`LSPServerManager.ts:133`); first-server-wins ext +routing; generation guard on reinit; recommendation auto-disable at ignore-count **≥5** +(`lspRecommendation.ts:41`). + +**Corrections (these contradicted the reference or the live tree — fixed in-plan):** + +1. **Servers are plugin-only.** `config.ts:9-11` verbatim: *"LSP servers are only supported via + plugins, not user/project settings."* So **drop `config_loader.py`, the user `lsp.servers` + registry, and the built-in pyright default** (Open Question 1 → resolved). `LspService` consumes + `plugin_lsp_servers()` directly; `LspConfig` keeps only `enabled` + recommendation flags + + limits. +2. **`rearm_injection` is a nullable callback, not a method.** `Runtime.rearm_injection: + Callable[[str], None] | None` (`soul/agent.py:255`); the method lives on `PythinkerSoul` + (`soul/pythinkersoul.py:658`, calls `provider.rearm(key)`). The file-tool hook calls the callback + **guarded by `is not None`**. +3. **`get_injections(self, history, soul)`** is the real base signature + (`soul/dynamic_injection.py`) — not `(self, budget, …)`. Budget is applied by + `collect_within_budget` / `injection_budget_from_runtime`, not passed in. +4. **Wire passive diagnostics for subagent souls, not just root.** Providers are built for both + roles (`pythinkersoul.py:552`; only a few gated `role == "root"`, `:581`). Most edits happen in + subagents, so a root-only registration silently no-ops the edit→diagnose loop where it matters. +5. **Do not register the tool on `code_reviewer`.** That profile is offline/read-only and + fail-closed (blocks network + MCP, `code_reviewer.yaml:67`; subagent profiles default to + `read_only`, `soul/permission.py:273`); LSP spawns executable plugin subprocesses — the same + risk class it refuses. Register on **default + `coder`** only (Open Question 4 → resolved). +6. **`SkipThisTool` is load-time only** (`tools/__init__.py:11`) — for `enabled=False` / no service. + Per-call unavailability (init pending, no server for the extension, server in `ERROR`) returns a + typed tool *result*, never `SkipThisTool`. +7. **No "once per session" recommendation gate exists in the reference** — `lspRecommendation.ts` + queries per file and gates on ignore-count ≥5 + a disabled flag. Mirror that; a session throttle + would be a labelled CLI adaptation, not parity. +8. **`LspClient` needs one `asyncio.Lock` around frame write+drain** — the tool is concurrency-safe + and subagents share one client per server, so concurrent `send_request` calls would otherwise + interleave frames on stdin (vscode-jsonrpc gives the reference this for free). +9. **Plugin server loading gates on `PluginPolicy.external_exec`.** `plugin_lsp_servers(policy)` + mirrors `plugin_mcp_servers(policy)` (`plugin/integration.py:127,56`); external-plugin servers + are executable artifacts and stay opt-in. + +**Integration points confirmed present** (build against these exact names): `CallableTool2` + +constructor DI (`tools/file/write.py:44`); `ToolResultBuilder.mark_untrusted()` +(`tools/utils.py`); `UntrustedData.render_for_prompt()` (`utils/trust.py`); `Host.exec`/`HostProcess` +stdio (`packages/pythinker-host/.../__init__.py:106-236`); `PluginManifest` with `Field(alias=…)` +(`plugin/manifest.py:106`); `Runtime.copy_for_subagent` (`soul/agent.py:432`); session teardown +`cleanup_runtime_resources()` (`app.py:526`); wire tool-list snapshot `tests_e2e/test_wire_config.py`. + +**Second-pass corrections (2026-06-16, folded in below):** (a) `manifest.lspServers` is a union +`str | dict | list`, not a `dict` (`lspPluginIntegration.ts:127-131`) — a plain dict drops the +path/array forms. (b) Phase 1 `LspService` *receives* an injected server map; the `plugin_servers.py` +loader is a leaf wired by `Runtime.create()` (resolves a Phase-1→Phase-4 forward dependency). (c) No +`/doctor` command or `plugins.errors` channel exists in the CLI agent — init errors use the existing +notification/log path. (d) No runtime plugin-refresh hook exists — reinit triggers on session reload. +(e) `recommendation_ignored_count` is mutable user/global state persisted per ignore +(`getGlobalConfig`/`saveGlobalConfig`, `lspRecommendation.ts:15`), not a frozen field. (f) Tool name is +byte-exact `LSP` (`prompt.ts:1`); the Python class stays `Lsp`. + ## Motivation * The agent currently navigates code with `Grep`/`Glob`/`SmartSearch` — textual, not semantic. It @@ -180,20 +246,23 @@ Every claim below was checked against the live tree. building on it gives Local/SSH/ACP backends for free instead of raw `asyncio.create_subprocess_exec`. * **No reusable JSON-RPC framing** exists in target (see dependency decision). We add it. -* **Config**: `Config` is a Pydantic `BaseModel` with nested sections (`src/pythinker_code/config.py`). - Add an `lsp: LspConfig` section the same way `web`/`services` are modelled. Secret-bearing - sections are scope-locked via `SCOPE_LOCKED_PATHS`; LSP commands are not secrets, so they stay - project-overridable. +* **Config**: `Config` is a Pydantic `BaseModel` with nested sections (`src/pythinker_code/config.py`; + e.g. `GoalConfig`, `BackgroundConfig`). Add an `lsp: LspConfig` section the same way. `LspConfig` + holds **only** `enabled` + recommendation flags + limits — **no server registry**: servers are + plugin-only (`config.ts:9-11`), so there is nothing user-overridable to scope-lock. * **Approval / read-only**: a tool is read-only simply by never calling `self._runtime.approval.request(...)`; gating happens at the execution-policy layer (`resolve_execution_policy`, see `tools/web/search.py:62-75`). LSP queries are read-only. * **Passive-context injection point**: `src/pythinker_code/soul/dynamic_injection.py` defines - `DynamicInjectionProvider(ABC)` with `async def get_injections(...)`, `DynamicInjection`, - `ContextBudget`/`injection_budget_from_runtime`, and `collect_within_budget`. Providers are - registered in `PythinkerSoul` (`soul/pythinkersoul.py:82-96` — `git_status`, `goal_mode`, - `agent_list`, … under `soul/dynamic_injections/`). **Passive diagnostics become one provider - here**, budget-aware by construction. `Runtime.rearm_injection` (`soul/agent.py:255-256`) lets a - tool refresh injections after an edit. + `DynamicInjectionProvider(ABC)` with `async def get_injections(self, history, soul)`, + `DynamicInjection`, `ContextBudget`/`injection_budget_from_runtime`, and `collect_within_budget`. + Providers are built in `PythinkerSoul.__init__` (`soul/pythinkersoul.py:552` — `git_status`, + `goal_mode`, `agent_list`, … under `soul/dynamic_injections/`) for **both root and subagent** + souls (a few are gated `role == "root"`, `:581`). **Passive diagnostics become one provider + here**, budget-aware by construction. `Runtime.rearm_injection` is a **nullable callback** + (`Callable[[str], None] | None`, `soul/agent.py:255`) wired per-soul to + `PythinkerSoul.rearm_injection` (`:658`); a tool calls it (guarded) to refresh injections after an + edit. * **Plugin system**: discovery/loading under `src/pythinker_code/plugin/`; manifests are `plugin.json`. The reference's `.lsp.json` / `manifest.lspServers` recommendation flow maps onto this loader. @@ -211,7 +280,7 @@ Every claim below was checked against the live tree. | `services/lsp/LSPServerInstance.ts` | `lsp/instance.py` | state machine, retry, restart | | `services/lsp/LSPServerManager.ts` | `lsp/manager.py` | routing + file sync | | `services/lsp/manager.ts` | `lsp/service.py` | session-scoped service (not a global singleton) | -| `services/lsp/config.ts` | `lsp/config_loader.py` | merge built-in + plugin server configs | +| `services/lsp/config.ts` | `lsp/plugin_servers.py` (aggregate) | plugin-only; no merge, no built-in default | | `services/lsp/LSPDiagnosticRegistry.ts` | `lsp/diagnostics.py` | store/dedup/volume-limit | | `services/lsp/passiveFeedback.ts` | `lsp/diagnostics.py` (handler) + injection provider | split: capture vs surface | | `tools/LSPTool/{LSPTool,schemas,formatters,symbolContext,prompt}.ts` | `src/pythinker_code/tools/lsp/` | the agent tool | @@ -221,9 +290,11 @@ Every claim below was checked against the live tree. **Singleton → session-scoped.** The reference uses a *global* singleton (`manager.ts`) because the TUI is one process serving one workspace. Pythinker is multi-instance and session-oriented -(`pythinker-multi-instance-invariants`), so the port owns the LSP service on the **`Runtime`** -(one service per session), constructed in `Runtime.create()` and torn down in session cleanup. No -module-global mutable state. +(`pythinker-multi-instance-invariants`), so the port owns the LSP service on the **`Runtime`** (one +service per session), constructed for the root runtime and **shared by subagents** +(`copy_for_subagent` passes the same `LspService` reference — no duplicate language-server +processes), and shut down in `cleanup_runtime_resources()` (`app.py:526`) on reload and final +teardown so server subprocesses never leak (C08). No module-global mutable state. ## Target module layout @@ -236,7 +307,6 @@ src/pythinker_code/lsp/ instance.py LspServerInstance: lifecycle + state machine + retry + restart manager.py LspServerManager: ext routing + open/change/save/close file sync service.py LspService: session-scoped facade, lazy init, status, shutdown - config_loader.py merge built-in defaults + user config + plugin servers diagnostics.py DiagnosticRegistry (store/dedup/limit) + publishDiagnostics handler plugin_servers.py load LSP server configs from installed plugins recommend.py file-ext → recommendable plugin server, install gating @@ -322,6 +392,10 @@ class LspClient: pending request as success after the process died) and mark the client stopped. * **`stop()`**: `send_request("shutdown")` → `send_notification("exit")` → cancel read loop → `proc.kill()` with a short grace; idempotent; suppress errors during teardown. +* **Concurrent sends**: the `Lsp` tool is concurrency-safe and subagents share one client per + server, so guard `write_message` + `drain` with a single `asyncio.Lock` — otherwise parallel + `send_request` calls interleave frames on one stdin. (vscode-jsonrpc gives the reference this for + free; the hand-rolled client must add it.) ### Verification (Phase 0) @@ -413,12 +487,20 @@ class LspService: * Held on `Runtime` (new field `lsp: LspService | None`), constructed in `Runtime.create()`, shut down in session teardown alongside other session resources. -### Step 1.4 — `lsp/config_loader.py` + config model +### Step 1.4 — config model (no `config_loader.py`) + +Servers are **plugin-only** (`config.ts:9-11`: *"LSP servers are only supported via plugins, not +user/project settings"*) — no user/project registry, no built-in default, no merge layer, so +`config_loader.py` is dropped. `LspService` **receives** its server map (injected, exactly as +`LspServerManager(host, servers=…)` takes it at Step 1.2): in Phase 1 the service is built and tested +against a fake/empty map (see Phase 1 verification), and `Runtime.create()` feeds the real map from +`plugin_lsp_servers(policy)` once the loader (`plugin_servers.py`, Step 4.1) lands. Nothing in Phase 1 +calls the loader directly, so Phase 1 needs no placeholder and the loader can land earlier if convenient. -`src/pythinker_code/config.py`: +`src/pythinker_code/config.py` adds only feature switches + limits: ```python -class LspServerConfig(BaseModel): +class LspServerConfig(BaseModel): # parsed shape plugin_servers.py emits — NOT user TOML command: str args: list[str] = Field(default_factory=list) extension_to_language: dict[str, str] # ".py": "python" @@ -429,27 +511,26 @@ class LspServerConfig(BaseModel): class LspConfig(BaseModel): enabled: bool = True - servers: dict[str, LspServerConfig] = Field(default_factory=dict) + recommendation_disabled: bool = False + recommendation_never: list[str] = Field(default_factory=list) + recommendation_ignored_count: int = 0 # persisted user/global state, auto-disable at >= 5 — see Step 4.2 (lspRecommendation.ts:15,41) class Config(BaseModel): ... lsp: LspConfig = Field(default_factory=LspConfig) ``` -* `config_loader.py` merges: built-in defaults (e.g. `pyright --stdio` for `.py` if present on - PATH) ← user `config.lsp.servers` ← plugin-provided servers (Phase 4). Later wins. -* Ship a sane built-in default for Python only (pyright if discoverable); everything else is - user/plugin-supplied. Do **not** bundle server binaries. +* **No `lsp.servers` and no built-in default server.** Servers come only from installed plugins + (inline `manifest.lspServers` or `/.lsp.json`, Phase 4), mirroring `config.ts`. Do + **not** bundle server binaries. -TOML shape: +TOML shape (feature switches only — there is no `[lsp.servers.*]`): ```toml [lsp] enabled = true -[lsp.servers.python] -command = "pyright-langserver" -args = ["--stdio"] -extension_to_language = { ".py" = "python", ".pyi" = "python" } +recommendation_disabled = false +recommendation_never = [] ``` ### Verification (Phase 1) @@ -485,7 +566,7 @@ regular file; reject UNC (`\\`/`//` prefix); reject >10 MB. Convert to 0-based L ```python class Lsp(CallableTool2[Params]): - name = "Lsp" + name = "LSP" # model-facing contract: byte-exact from prompt.ts (LSP_TOOL_NAME = 'LSP'); the Python class stays Lsp description = load_desc(Path(__file__).parent / "tool.md", {}) params = Params supports_parallel = True # isConcurrencySafe @@ -527,6 +608,9 @@ Dispatch table (operation → LSP method(s)): profile forbids subprocess (LSP spawns a process) — mirror `search.py`'s policy check. * **Untrusted output**: hover/symbol text comes from project files; pass through `ToolResultBuilder.mark_untrusted()` so it is wrapped, consistent with `utils/trust.py`. +* **`SkipThisTool` is load-time** (`tools/__init__.py:11`) — raise it only for `enabled=False` / no + service. Per-call unavailability (init still pending, no server for the extension, server in + `ERROR`) returns a typed tool *result* with guidance, never `SkipThisTool`. ### Step 2.3 — `tools/lsp/formatters.py` + `symbol_context.py` @@ -546,10 +630,11 @@ caveat). Register in `src/pythinker_code/agents/default/agent.yaml` under `tools - "pythinker_code.tools.lsp:Lsp" ``` -Add the same line to any subagent spec that should have code-intelligence (e.g. `coder.yaml`, -`code_reviewer.yaml`) — decision: include in `coder`/`code-reviewer`, exclude from `explore`/`scout` -(they are textual-recon by design). Wire `Runtime` into the tool deps if not already present -(it is — `search.py` receives it). +Add the same line to **`coder.yaml` only**. **Do not register on `code_reviewer.yaml`** — that +profile runs offline/read-only and fail-closed (blocks network + MCP, `code_reviewer.yaml:67`; +subagent profiles default to `read_only`, `soul/permission.py:273`), and LSP spawns executable +plugin subprocesses, the same risk class the reviewer profile refuses. Exclude `explore`/`scout` too +(textual-recon by design). `Runtime` is already a tool dep (`search.py`/`write.py` receive it). ### Verification (Phase 2) @@ -593,22 +678,29 @@ The *surface* half — a provider mirroring `git_status.py`: ```python class LspDiagnosticsInjectionProvider(DynamicInjectionProvider): def __init__(self, runtime: Runtime): self._runtime = runtime - async def get_injections(self, budget: ContextBudget, ...) -> list[DynamicInjection]: + async def get_injections( # real base signature (dynamic_injection.py) + self, history: Sequence[Message], soul: PythinkerSoul, + ) -> list[DynamicInjection]: if self._runtime.lsp is None or not self._runtime.lsp.is_connected(): return [] groups = self._runtime.lsp.diagnostics.check_for_diagnostics() if not groups: return [] - text = render_diagnostics_block(groups) # " LSP diagnostics …" - return [DynamicInjection(text=text, ...)] # collect_within_budget truncates if needed + text = render_diagnostics_block(groups) # " LSP diagnostics …" + return [DynamicInjection(text=text, ...)] # collect_within_budget applies the budget ``` -* Register it in `PythinkerSoul` alongside the other providers (`soul/pythinkersoul.py:82-96`). +* Register it in `PythinkerSoul.__init__`'s provider list (`soul/pythinkersoul.py:552`) — **for both + root and subagent souls**. Providers are built for both roles (only a few gated `role == "root"`, + `:581`); most file edits happen inside subagents, so a root-only registration would silently no-op + the edit→diagnose loop where it matters most. * The provider is **budget-governed** automatically by `collect_within_budget` / - `injection_budget_from_runtime` — no separate volume logic needed beyond the registry's own caps. -* **Edit→save→diagnose loop**: after `WriteFile`/`StrReplaceFile` mutate a file, call - `runtime.lsp.manager.save_file(path)` (and `change_file` with new content) so the server - re-diagnoses, then `runtime.rearm_injection(...)` so the next turn picks up fresh diagnostics. - Wire this in the file tools' success path (a 3-line hook guarded by `runtime.lsp is not None`). - This is the single cross-cutting touch into existing tools — keep it surgical. + `injection_budget_from_runtime` — no separate volume logic beyond the registry's own caps. +* **Edit→save→diagnose loop**: after `WriteFile`/`StrReplaceFile` mutate a file, go through the + service (`runtime.lsp.change_file(path, content)` + `save_file(path)`) so the server re-diagnoses, + then re-arm via the **callback** `runtime.rearm_injection` — `Callable[[str], None] | None` + (`soul/agent.py:255`), wired per-soul to `PythinkerSoul.rearm_injection` (`:658`). Guard it: + `if runtime.lsp and runtime.rearm_injection: runtime.rearm_injection("lsp_diagnostics")`. Confirm + the callback is wired on **subagent** runtimes too, or the loop no-ops there. Keep the hook + surgical (a few lines in each file tool's success path). ### Verification (Phase 3) @@ -624,12 +716,24 @@ make test-pythinker-code`. ### Step 4.1 — `lsp/plugin_servers.py` -Port `lspPluginIntegration.ts`: read LSP server configs from installed plugins via two sources — -inline `manifest.lspServers` and an external `/.lsp.json` (same schema). Resolve env -placeholders: `${PYTHINKER_PLUGIN_ROOT}`, `${PYTHINKER_PLUGIN_DATA}`, `${user_config.KEY}`, and -standard `${VAR}`. Scope names as `plugin::` to avoid collisions. Feed the result -into `config_loader.py`'s merge (highest precedence after explicit user config — decision: user -config wins over plugin, so a user can override a plugin's command). +Port `lspPluginIntegration.ts` as `plugin_lsp_servers(policy)`, mirroring `plugin_mcp_servers(policy)` +(`plugin/integration.py:127`): read LSP server configs from installed plugins via two sources — +inline `manifest.lspServers` (add `lsp_servers: str | dict | list | None = Field(default=None, +alias="lspServers")` to `PluginManifest`, which already uses aliases, `plugin/manifest.py:106`) and an +external `/.lsp.json` (same schema). **The manifest field is a union, not a `dict`:** the +reference accepts `string | Record | Array` (`lspPluginIntegration.ts:127-131`) — a +string is a relative path to a `.lsp.json`-style file (validated within the plugin dir), a record is an +inline server map, and an array mixes both. A plain `dict` silently drops the string-path and array +forms, so normalise/validate the three shapes in `plugin_servers.py`. Resolve env placeholders: `${PYTHINKER_PLUGIN_ROOT}`, +`${PYTHINKER_PLUGIN_DATA}`, `${user_config.KEY}`, and standard `${VAR}`. Reject manifest path +traversal; isolate per-plugin errors so one bad plugin never drops the others (`config.ts:33-41`). +Scope names as `plugin::`. **Gate external-plugin servers on +`PluginPolicy.external_exec`** (executable artifacts are opt-in, like MCP servers, +`plugin/integration.py:56`). `Runtime.create()` passes the returned map into `LspService` — no merge +layer, no user-config override (servers are plugin-only). **Build-order:** this loader is a +dependency-free leaf and a prerequisite for the Phase 1 service's *production* wiring; it sits under +Phase 4 only because recommendation (4.2-4.3) builds on it, so implement it as soon as Phase 1 needs +real servers (Phase 1 itself runs against injected fake maps). ### Step 4.2 — `lsp/recommend.py` @@ -637,7 +741,16 @@ Port `lspRecommendation.ts`: on a file edit, match the extension against discove servers (inline manifests only — `.lsp.json` is post-install, not pre-install readable); filter by: server supports ext ∧ binary on PATH ∧ plugin not installed ∧ not in `config.lsp.recommendation_never` ∧ recommendations not disabled. Sort official-marketplace plugins -first. Surface **once per session**. +first. **Gating matches the reference**: queried per file, auto-disabled once +`recommendation_ignored_count >= 5` (`lspRecommendation.ts:41`) or the disabled flag is set — there +is **no "once per session" flag** in the reference. A session-level throttle, if wanted, is a +clearly-labelled CLI adaptation, not parity. + +**Persistence:** `recommendation_ignored_count` is **mutable user/global config** — incremented and +written back on each ignore (the reference reads/writes it via `getGlobalConfig`/`saveGlobalConfig`, +`lspRecommendation.ts:15`). It is **not** a frozen field loaded from project TOML: model it on the +user/global config store and persist on update, or the counter never advances and the ≥5 auto-disable +never fires. ### Step 4.3 — Recommendation surface (UI intent, not React) @@ -650,14 +763,19 @@ Re-express the intent on the CLI: No interactive blocking menu; the agent/user acts via existing plugin commands. Track never/disable in config (`lsp.recommendation_never: list[str]`, `lsp.recommendation_disabled: bool`), and auto-disable after N ignores. -* **Init errors**: when `LspService.status()` is `failed` or a server is in `ERROR`, log to the - plugins-error channel surfaced by `/doctor` (dedup by `source:message`), exactly as the reference - feeds `appState.plugins.errors`. +* **Init errors**: when `LspService.status()` is `failed` or a server is in `ERROR`, surface through + the existing notification/log path (dedup by `source:message`) — the plugin loader already records + per-plugin load errors (`plugin/loader.py`), so reuse that. **There is no `/doctor` command or + `plugins.errors` channel in the CLI agent** (only the reference's React `appState.plugins.errors`); + do not invent one — a dedicated error surface would be separate, clearly-labelled CLI work. -### Step 4.4 — Reinit on plugin refresh +### Step 4.4 — Reinit on plugin change -When plugins are added/removed/refreshed, call `runtime.lsp.reinitialize()` (the generation guard -makes this safe). Hook into the existing plugin-refresh path in `src/pythinker_code/plugin/`. +When the installed plugin set changes, call `runtime.lsp.reinitialize()` (the generation guard makes +this safe). **There is no runtime plugin-refresh hook in `src/pythinker_code/plugin/` today** (only +install/uninstall), so the realistic trigger is **session reload** — the same +`cleanup_runtime_resources()` teardown + `Runtime` reconstruction already used for config reload. +A dedicated runtime refresh hook is optional follow-up, not a prerequisite for this PLIP. ### Verification (Phase 4) @@ -685,31 +803,34 @@ make test-pythinker-code`. * **Performance**: servers are lazy (spawned on first file touch per language) and long-lived (reused across tool calls). `documentSymbol`/`workspace/symbol` can be large → the 100 000-char cap + spill. Diagnostics are budget-capped by the injection system. -* **Compatibility**: new config keys (`config.lsp.*`), a new tool name (`Lsp`), and a new dynamic +* **Compatibility**: new config keys (`config.lsp.*`), a new tool name (`LSP`, byte-exact; Python class `Lsp`), and a new dynamic injection. The tool addition changes the agent's advertised tool list → update the wire-handshake inline snapshot in `tests_e2e/` (`full-test-scope-includes-tests-e2e`). Add a `## Unreleased` CHANGELOG bullet. No persisted-session schema change (LSP state is ephemeral per session). -* **Docs**: add an LSP page under `docs/en/customization/` (config shape, built-in Python default, - how to add a server, plugin recommendation), and update the architecture repo-map +* **Docs**: add an LSP page under `docs/en/customization/` (plugin-only config shape, no + bundled/default servers, how to add a plugin-provided server, plugin recommendation), and update the + architecture repo-map (`docs/en/customization/architecture.md`) with the `lsp/` subsystem and its trust boundary. ## What does NOT port * React/Ink components and hooks (`components/LspRecommendation`, `hooks/useLsp*`) — their *intent* - is re-expressed via the CLI notification/suggest path and `/doctor` error surface (Phase 4.3). + is re-expressed via the CLI notification/suggest + log path (Phase 4.3); the reference's + `appState.plugins.errors` / `/doctor` surface has no CLI-agent equivalent and is not recreated. * The global-singleton lifetime model — replaced by session-scoped ownership on `Runtime`. ## Open questions (resolve before/while implementing) -1. **Built-in default servers**: ship only a Python default (pyright if on PATH), or none at all - (everything user/plugin-supplied)? Recommendation: Python-only default, gated on binary presence. +1. ~~**Built-in default servers**~~ **RESOLVED (verified):** none. `config.ts:9-11` is plugin-only + with no built-in/default server, so the port ships no bundled pyright and no user server registry. 2. **`lsprotocol` vs hand-rolled**: this plan recommends hand-rolled (no new dep). Confirm with maintainers; if they prefer `lsprotocol`, it replaces `protocol.py` + framing only. 3. **Server output trust level**: confirm `mark_untrusted` is the right wrapper for hover/symbol text (it is project source, same trust class as `ReadFile` output). -4. **Execution-profile gating**: should LSP be disabled under read-only/review profiles (they - shouldn't spawn processes), or allowed because it's read-only? Recommendation: allow in - `coder`/default, disable where the profile forbids subprocess. +4. ~~**Execution-profile gating**~~ **RESOLVED:** register on default + `coder`; **exclude + `code_reviewer`** and other offline/fail-closed profiles (`code_reviewer.yaml:67` blocks + network/MCP; `soul/permission.py:273`) — LSP spawns executable plugin subprocesses. Gate + plugin-sourced servers on `PluginPolicy.external_exec`. ## Verification matrix (per AGENTS.md) @@ -727,9 +848,8 @@ make test-pythinker-code`. - [ ] `tests/tools/test_lsp_client.py` - [ ] `src/pythinker_code/lsp/instance.py` — `LspServerInstance` (state machine, retry, restart) - [ ] `src/pythinker_code/lsp/manager.py` — `LspServerManager` (routing, file sync) -- [ ] `src/pythinker_code/lsp/service.py` — `LspService` (session-scoped, lazy init, reinit) -- [ ] `src/pythinker_code/lsp/config_loader.py` — merge defaults/user/plugin -- [ ] `src/pythinker_code/config.py` — add `LspConfig`/`LspServerConfig` + `Config.lsp` +- [ ] `src/pythinker_code/lsp/service.py` — `LspService` (session-scoped, lazy init, reinit; consumes `plugin_lsp_servers()`) +- [ ] `src/pythinker_code/config.py` — add `LspConfig` (enabled + recommendation flags + limits) + `LspServerConfig` (plugin-emitted shape) + `Config.lsp` - [ ] `src/pythinker_code/soul/agent.py` — `Runtime.lsp` field + construct in `Runtime.create` + teardown - [ ] `tests/tools/test_lsp_manager.py` - [ ] `src/pythinker_code/tools/lsp/schemas.py` @@ -737,7 +857,7 @@ make test-pythinker-code`. - [ ] `src/pythinker_code/tools/lsp/symbol_context.py` - [ ] `src/pythinker_code/tools/lsp/tool.py` — `Lsp(CallableTool2)` - [ ] `src/pythinker_code/tools/lsp/tool.md` -- [ ] `src/pythinker_code/agents/default/agent.yaml` (+ `coder.yaml`, `code_reviewer.yaml`) +- [ ] `src/pythinker_code/agents/default/agent.yaml` (+ `coder.yaml`; NOT `code_reviewer.yaml`) - [ ] `tests/tools/test_lsp_tool.py` - [ ] `src/pythinker_code/lsp/diagnostics.py` — registry + publishDiagnostics handler - [ ] `src/pythinker_code/soul/dynamic_injections/lsp_diagnostics.py` — injection provider diff --git a/src/pythinker_code/agents/default/agent.yaml b/src/pythinker_code/agents/default/agent.yaml index 19aa7b9c..05ad7182 100644 --- a/src/pythinker_code/agents/default/agent.yaml +++ b/src/pythinker_code/agents/default/agent.yaml @@ -32,6 +32,7 @@ agent: - "pythinker_code.tools.file:Glob" - "pythinker_code.tools.file:Grep" - "pythinker_code.tools.file:SmartSearch" + - "pythinker_code.tools.lsp:Lsp" - "pythinker_code.tools.file:WriteFile" - "pythinker_code.tools.file:StrReplaceFile" - "pythinker_code.tools.web:SearchWeb" diff --git a/src/pythinker_code/agents/default/coder.yaml b/src/pythinker_code/agents/default/coder.yaml index dafc308f..ecdd63cd 100644 --- a/src/pythinker_code/agents/default/coder.yaml +++ b/src/pythinker_code/agents/default/coder.yaml @@ -97,6 +97,7 @@ agent: - "pythinker_code.tools.file:Glob" - "pythinker_code.tools.file:Grep" - "pythinker_code.tools.file:SmartSearch" + - "pythinker_code.tools.lsp:Lsp" - "pythinker_code.tools.file:WriteFile" - "pythinker_code.tools.file:StrReplaceFile" - "pythinker_code.tools.skill:ReadSkill" diff --git a/src/pythinker_code/app.py b/src/pythinker_code/app.py index 78a1c285..73a01f90 100644 --- a/src/pythinker_code/app.py +++ b/src/pythinker_code/app.py @@ -541,6 +541,12 @@ async def cleanup_runtime_resources(self) -> None: except Exception: logger.exception("Failed to cleanup MCP toolset during reload") + if self._runtime.lsp is not None: + try: + await self._runtime.lsp.shutdown() + except Exception: + logger.exception("Failed to shutdown LSP service during reload") + async def shutdown_background_tasks(self) -> None: """Kill active background tasks on exit, unless keep_alive_on_exit is configured. diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index aa9ddd2d..aa2bc851 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -11,6 +11,7 @@ from pydantic import ( AliasChoices, BaseModel, + ConfigDict, Field, SecretStr, ValidationError, @@ -1025,6 +1026,27 @@ class MCPConfig(BaseModel): ) +class LspServerConfig(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + command: str + args: list[str] = Field(default_factory=list) + extension_to_language: dict[str, str] = Field(alias="extensionToLanguage") + env: dict[str, str] = Field(default_factory=dict) + initialization_options: dict[str, Any] | None = Field( + default=None, alias="initializationOptions" + ) + startup_timeout: float = Field(default=30.0, alias="startupTimeout") + max_restarts: int = Field(default=3, alias="maxRestarts") + + +class LspConfig(BaseModel): + enabled: bool = True + recommendation_disabled: bool = False + recommendation_never: list[str] = Field(default_factory=list) + recommendation_ignored_count: int = 0 + + class PluginsConfig(BaseModel): """Plugin/marketplace activation policy. @@ -1226,6 +1248,7 @@ class Config(BaseModel): plugins: PluginsConfig = Field( default_factory=PluginsConfig, description="Plugin/marketplace activation policy" ) + lsp: LspConfig = Field(default_factory=LspConfig, description="LSP feature configuration") tui: TUIConfig = Field(default_factory=TUIConfig, description="TUI rendering configuration") hooks: list[HookDef] = Field(default_factory=list, description="Hook definitions") # pyright: ignore[reportUnknownVariableType] disabled_project_hooks: list[str] = Field( diff --git a/src/pythinker_code/lsp/__init__.py b/src/pythinker_code/lsp/__init__.py new file mode 100644 index 00000000..57f3804c --- /dev/null +++ b/src/pythinker_code/lsp/__init__.py @@ -0,0 +1,11 @@ +"""LSP client transport and protocol types.""" + +from pythinker_code.lsp.client import LspClient +from pythinker_code.lsp.framing import LspProtocolError, LspServerDown, LspStartError + +__all__ = [ + "LspClient", + "LspProtocolError", + "LspServerDown", + "LspStartError", +] diff --git a/src/pythinker_code/lsp/client.py b/src/pythinker_code/lsp/client.py new file mode 100644 index 00000000..0937a64d --- /dev/null +++ b/src/pythinker_code/lsp/client.py @@ -0,0 +1,317 @@ +"""JSON-RPC LSP client over Host stdio.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable, Mapping +from contextlib import suppress +from inspect import iscoroutine +from typing import Any + +from pythinker_host import Host, HostProcess + +from pythinker_code.lsp.framing import ( + LspProtocolError, + LspServerDown, + LspStartError, + read_message, + write_message, +) +from pythinker_code.lsp.protocol import InitializeParams, InitializeResult, ServerCapabilities + +NotificationHandler = Callable[[Any], None] | Callable[[Any], Awaitable[None]] +RequestHandler = Callable[[Any], Any] | Callable[[Any], Awaitable[Any]] + + +class LspClient: + """Minimal LSP client: spawn via Host.exec, JSON-RPC over Content-Length framing.""" + + def __init__(self, host: Host, *, logger: logging.Logger | None = None) -> None: + self._host = host + self._logger = logger or logging.getLogger(__name__) + self._proc: HostProcess | None = None + self._next_id = 1 + self._pending: dict[int, asyncio.Future[Any]] = {} + self._read_task: asyncio.Task[None] | None = None + self._stderr_task: asyncio.Task[None] | None = None + self._write_lock = asyncio.Lock() + self._stopped = False + self._capabilities: ServerCapabilities | None = None + self._initialized = False + self._notification_handlers: dict[str, list[NotificationHandler]] = {} + self._request_handlers: dict[str, RequestHandler] = {} + self._on_crash: Callable[[Exception], None] | None = None + + @property + def capabilities(self) -> ServerCapabilities | None: + return self._capabilities + + @property + def is_initialized(self) -> bool: + return self._initialized + + async def start( + self, + command: str, + args: list[str], + *, + env: Mapping[str, str] | None = None, + cwd: str | None = None, + ) -> None: + if self._proc is not None: + raise LspStartError("LSP client already started") + try: + exec_env: Mapping[str, str] | None = None + if env is not None: + exec_env = {**os.environ, **env} + self._proc = await self._host.exec(command, *args, env=exec_env, cwd=cwd) + except FileNotFoundError as exc: + raise LspStartError(f"command not found: {command}") from exc + except OSError as exc: + raise LspStartError(str(exc)) from exc + + self._stopped = False + self._stderr_task = asyncio.create_task(self._drain_stderr()) + self._read_task = asyncio.create_task(self._read_loop()) + + async def initialize(self, params: InitializeParams) -> InitializeResult: + result = await self.send_request( + "initialize", + params.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + init_result = InitializeResult.model_validate(result) + self._capabilities = init_result.capabilities + await self.send_notification("initialized", {}) + self._initialized = True + return init_result + + async def send_request(self, method: str, params: Any) -> Any: + proc = self._require_running() + request_id = self._next_id + self._next_id += 1 + loop = asyncio.get_running_loop() + future: asyncio.Future[Any] = loop.create_future() + self._pending[request_id] = future + message = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + try: + async with self._write_lock: + await write_message(proc.stdin, message) + except Exception as exc: + self._pending.pop(request_id, None) + if not future.done(): + future.set_exception(LspServerDown(str(exc))) + raise + + try: + return await future + except asyncio.CancelledError: + self._pending.pop(request_id, None) + raise + + async def send_notification(self, method: str, params: Any) -> None: + proc = self._require_running() + message = {"jsonrpc": "2.0", "method": method, "params": params} + async with self._write_lock: + await write_message(proc.stdin, message) + + def on_notification(self, method: str, handler: NotificationHandler) -> None: + self._notification_handlers.setdefault(method, []).append(handler) + + def on_request(self, method: str, handler: RequestHandler) -> None: + self._request_handlers[method] = handler + + def on_crash(self, handler: Callable[[Exception], None]) -> None: + """Register a callback fired when the server exits unexpectedly (not on stop()).""" + self._on_crash = handler + + async def stop(self) -> None: + if self._stopped: + return + + proc = self._proc + if proc is not None and proc.returncode is None: + with suppress(Exception): + await self.send_request("shutdown", None) + with suppress(Exception): + await self.send_notification("exit", None) + + self._mark_stopped() + + if self._read_task is not None: + self._read_task.cancel() + with suppress(asyncio.CancelledError): + await self._read_task + self._read_task = None + + if self._stderr_task is not None: + self._stderr_task.cancel() + with suppress(asyncio.CancelledError): + await self._stderr_task + self._stderr_task = None + + if proc is not None and proc.returncode is None: + with suppress(asyncio.TimeoutError, Exception): + await asyncio.wait_for(proc.wait(), timeout=0.5) + if proc.returncode is None: + with suppress(Exception): + await proc.kill() + + self._fail_all_pending(LspServerDown("LSP client stopped")) + self._proc = None + + def _require_running(self) -> HostProcess: + if self._proc is None or self._stopped: + raise LspServerDown("LSP client is not running") + if self._proc.returncode is not None: + raise LspServerDown(f"LSP server exited with code {self._proc.returncode}") + return self._proc + + async def _read_loop(self) -> None: + try: + while not self._stopped: + proc = self._proc + if proc is None: + break + if proc.returncode is not None: + self._handle_server_down( + LspServerDown(f"LSP server exited with code {proc.returncode}") + ) + break + try: + message = await read_message(proc.stdout) + except LspServerDown as exc: + self._handle_server_down(exc) + break + except LspProtocolError as exc: + self._logger.warning("LSP protocol error: %s", exc) + continue + await self._dispatch(message) + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.debug("LSP read loop failed", exc_info=True) + self._handle_server_down(LspServerDown(str(exc))) + finally: + proc = self._proc + if proc is not None and proc.returncode is not None: + self._handle_server_down( + LspServerDown(f"LSP server exited with code {proc.returncode}") + ) + + def _handle_server_down(self, exc: LspServerDown) -> None: + # Distinguishes an unexpected server exit from an intentional stop(): + # stop() sets ``_stopped`` before cancelling the read loop, so a second + # call here (e.g. from the finally block) is a no-op and on_crash never + # fires for a clean shutdown. + if self._stopped: + return + if self._on_crash is not None: + with suppress(Exception): + self._on_crash(exc) + self._fail_all_pending(exc) + self._mark_stopped() + + async def _dispatch(self, message: dict[str, Any]) -> None: + if "method" in message: + if "id" in message: + await self._handle_server_request(message) + else: + await self._handle_server_notification(message) + return + + request_id = message.get("id") + if request_id is None: + return + + future = self._pending.pop(request_id, None) + if future is None: + self._logger.debug("unexpected LSP response id=%s", request_id) + return + + if "error" in message: + error = message["error"] + message_text = error.get("message", "LSP request failed") + raw_code = error.get("code") + code = raw_code if isinstance(raw_code, int) else None + future.set_exception(LspProtocolError(message_text, code=code)) + return + + future.set_result(message.get("result")) + + async def _handle_server_notification(self, message: dict[str, Any]) -> None: + method = message["method"] + params = message.get("params") + for handler in self._notification_handlers.get(method, ()): + try: + result = handler(params) + if iscoroutine(result): + await result + except Exception: + self._logger.debug("LSP notification handler failed for %s", method, exc_info=True) + + async def _handle_server_request(self, message: dict[str, Any]) -> None: + proc = self._proc + if proc is None: + return + + method = message["method"] + request_id = message["id"] + params = message.get("params") + handler = self._request_handlers.get(method) + if handler is None: + response: dict[str, Any] = { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + else: + try: + result = handler(params) + if iscoroutine(result): + result = await result + response = {"jsonrpc": "2.0", "id": request_id, "result": result} + except Exception as exc: + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32603, "message": str(exc)}, + } + + async with self._write_lock: + await write_message(proc.stdin, response) + + async def _drain_stderr(self) -> None: + proc = self._proc + if proc is None: + return + try: + while proc.returncode is None: + line = await proc.stderr.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").rstrip() + if text: + self._logger.debug("lsp stderr: %s", text) + except asyncio.CancelledError: + raise + except Exception: + self._logger.debug("LSP stderr drain failed", exc_info=True) + + def _mark_stopped(self) -> None: + self._stopped = True + self._initialized = False + self._capabilities = None + + def _fail_all_pending(self, exc: BaseException) -> None: + pending = self._pending + self._pending = {} + for future in pending.values(): + if not future.done(): + future.set_exception(exc) diff --git a/src/pythinker_code/lsp/diagnostics.py b/src/pythinker_code/lsp/diagnostics.py new file mode 100644 index 00000000..b95968b2 --- /dev/null +++ b/src/pythinker_code/lsp/diagnostics.py @@ -0,0 +1,309 @@ +"""Diagnostic aggregation for passive LSP feedback.""" + +from __future__ import annotations + +import json +from collections import OrderedDict, defaultdict +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from urllib.parse import unquote, urlparse + +from pythinker_code.lsp.protocol import ( + Diagnostic, + DiagnosticSeverity, + PublishDiagnosticsParams, + Range, +) +from pythinker_code.utils.logging import logger + +MAX_DIAGNOSTICS_PER_FILE = 10 +MAX_DIAGNOSTICS_TOTAL = 30 +SENT_FILE_LRU_CAP = 500 +_HANDLER_FAILURE_WARN_THRESHOLD = 3 + +_SEVERITY_LABELS: Mapping[int, str] = { + DiagnosticSeverity.ERROR: "Error", + DiagnosticSeverity.WARNING: "Warning", + DiagnosticSeverity.INFORMATION: "Information", + DiagnosticSeverity.HINT: "Hint", +} + + +@dataclass(frozen=True, slots=True) +class DiagnosticEntry: + message: str + severity: int + range: Range + source: str | None = None + code: str | int | None = None + + +@dataclass(frozen=True, slots=True) +class DiagnosticFile: + uri: str + path: str + diagnostics: list[DiagnosticEntry] + + +@dataclass(frozen=True, slots=True) +class ServerDiagnostics: + server_name: str + files: list[DiagnosticFile] + + +def uri_to_path(uri: str) -> str | None: + parsed = urlparse(uri) + if parsed.scheme != "file": + return None + return unquote(parsed.path) + + +def diagnostic_entry_from_lsp(diagnostic: Diagnostic) -> DiagnosticEntry: + severity = int(diagnostic.severity or DiagnosticSeverity.HINT) + return DiagnosticEntry( + message=diagnostic.message, + severity=severity, + range=diagnostic.range, + source=diagnostic.source, + code=diagnostic.code, + ) + + +def diagnostic_key(entry: DiagnosticEntry) -> str: + return json.dumps( + { + "message": entry.message, + "severity": entry.severity, + "range": { + "start": { + "line": entry.range.start.line, + "character": entry.range.start.character, + }, + "end": { + "line": entry.range.end.line, + "character": entry.range.end.character, + }, + }, + "source": entry.source or None, + "code": entry.code or None, + }, + sort_keys=True, + separators=(",", ":"), + ) + + +def render_diagnostics_block(groups: list[ServerDiagnostics]) -> str: + lines = [ + "LSP diagnostics from connected language servers " + "(point-in-time; may be stale by the time you act):\n" + ] + for group in groups: + lines.append(f"[{group.server_name}]") + for file in group.files: + lines.append(f"{file.path}:") + for diag in file.diagnostics: + label = _SEVERITY_LABELS.get(diag.severity, "Diagnostic") + start = diag.range.start + location = f"{start.line + 1}:{start.character + 1}" + code_part = f" [{diag.code}]" if diag.code is not None else "" + source_part = f" ({diag.source})" if diag.source else "" + lines.append(f" {label} ({location}){code_part}{source_part}: {diag.message}") + lines.append("") + return "\n".join(lines).rstrip() + + +class DiagnosticRegistry: + """Stores, deduplicates, and volume-limits LSP diagnostics for passive injection.""" + + def __init__(self) -> None: + self._pending: dict[str, dict[str, list[DiagnosticEntry]]] = {} + self._pending_paths: dict[str, dict[str, str]] = {} + self._sent_keys: OrderedDict[str, set[str]] = OrderedDict() + + def register_pending(self, server_name: str, files: list[DiagnosticFile]) -> None: + if not files: + return + server_pending = self._pending.setdefault(server_name, {}) + server_paths = self._pending_paths.setdefault(server_name, {}) + for file in files: + if not file.diagnostics: + continue + server_paths[file.uri] = file.path + entries = server_pending.setdefault(file.uri, []) + seen_in_batch: set[str] = set() + for diagnostic in file.diagnostics: + key = diagnostic_key(diagnostic) + if key in seen_in_batch: + continue + seen_in_batch.add(key) + entries.append(diagnostic) + + def check_for_diagnostics(self) -> list[ServerDiagnostics]: + candidates: list[tuple[str, str, str, DiagnosticEntry, str]] = [] + for server_name, file_map in self._pending.items(): + paths = self._pending_paths.get(server_name, {}) + for file_uri, diagnostics in file_map.items(): + path = paths.get(file_uri) or uri_to_path(file_uri) or file_uri + deduped: list[tuple[DiagnosticEntry, str]] = [] + for diagnostic in diagnostics: + key = diagnostic_key(diagnostic) + if self._is_sent(file_uri, key): + continue + deduped.append((diagnostic, key)) + if not deduped: + continue + deduped.sort( + key=lambda item: ( + item[0].severity, + item[0].range.start.line, + item[0].range.start.character, + ) + ) + for diagnostic, key in deduped[:MAX_DIAGNOSTICS_PER_FILE]: + candidates.append((server_name, file_uri, path, diagnostic, key)) + + candidates.sort( + key=lambda item: ( + item[3].severity, + item[2], + item[3].range.start.line, + item[3].range.start.character, + ) + ) + selected = candidates[:MAX_DIAGNOSTICS_TOTAL] + if not selected: + return [] + + selected_keys: dict[str, set[str]] = defaultdict(set) + for _, file_uri, _, _, key in selected: + self._mark_sent(file_uri, key) + selected_keys[file_uri].add(key) + + self._remove_selected_from_pending(selected_keys) + return _group_selected(selected) + + def clear_all(self) -> None: + self._pending.clear() + self._pending_paths.clear() + self._sent_keys.clear() + + def clear_for_file(self, file_uri: str) -> None: + for server_name in list(self._pending.keys()): + file_map = self._pending.get(server_name) + if file_map is not None: + file_map.pop(file_uri, None) + if not file_map: + self._pending.pop(server_name, None) + paths = self._pending_paths.get(server_name) + if paths is not None: + paths.pop(file_uri, None) + if not paths: + self._pending_paths.pop(server_name, None) + self._sent_keys.pop(file_uri, None) + + @property + def pending_count(self) -> int: + return sum( + len(entries) for file_map in self._pending.values() for entries in file_map.values() + ) + + def _is_sent(self, file_uri: str, key: str) -> bool: + sent = self._sent_keys.get(file_uri) + return sent is not None and key in sent + + def _mark_sent(self, file_uri: str, key: str) -> None: + if file_uri in self._sent_keys: + self._sent_keys.move_to_end(file_uri) + self._sent_keys[file_uri].add(key) + else: + self._sent_keys[file_uri] = {key} + while len(self._sent_keys) > SENT_FILE_LRU_CAP: + self._sent_keys.popitem(last=False) + + def _remove_selected_from_pending(self, selected_keys: dict[str, set[str]]) -> None: + for server_name, file_map in list(self._pending.items()): + for file_uri, keys in selected_keys.items(): + diagnostics = file_map.get(file_uri) + if diagnostics is None: + continue + remaining = [ + diagnostic + for diagnostic in diagnostics + if diagnostic_key(diagnostic) not in keys + ] + if remaining: + file_map[file_uri] = remaining + else: + file_map.pop(file_uri, None) + paths = self._pending_paths.get(server_name) + if paths is not None: + paths.pop(file_uri, None) + if not file_map: + self._pending.pop(server_name, None) + self._pending_paths.pop(server_name, None) + + +def _group_selected( + selected: list[tuple[str, str, str, DiagnosticEntry, str]], +) -> list[ServerDiagnostics]: + grouped: dict[str, dict[str, tuple[str, list[DiagnosticEntry]]]] = defaultdict(dict) + for server_name, file_uri, path, diagnostic, _ in selected: + file_bucket = grouped[server_name].get(file_uri) + if file_bucket is None: + grouped[server_name][file_uri] = (path, [diagnostic]) + else: + _, diagnostics = file_bucket + diagnostics.append(diagnostic) + + result: list[ServerDiagnostics] = [] + for server_name in sorted(grouped.keys()): + files = [ + DiagnosticFile(uri=file_uri, path=path, diagnostics=diagnostics) + for file_uri, (path, diagnostics) in sorted(grouped[server_name].items()) + ] + result.append(ServerDiagnostics(server_name=server_name, files=files)) + return result + + +def register_publish_diagnostics_handler( + registry: DiagnosticRegistry, + server_name: str, + instance: Any, +) -> None: + """Wire ``textDocument/publishDiagnostics`` for one server instance.""" + failure_count = 0 + warned = False + + async def handler(params: Any) -> None: + nonlocal failure_count, warned + try: + parsed = PublishDiagnosticsParams.model_validate(params) + path = uri_to_path(parsed.uri) or parsed.uri + entries = [diagnostic_entry_from_lsp(item) for item in parsed.diagnostics] + registry.register_pending( + server_name, + [DiagnosticFile(uri=parsed.uri, path=path, diagnostics=entries)], + ) + failure_count = 0 + except Exception as exc: + failure_count += 1 + if failure_count >= _HANDLER_FAILURE_WARN_THRESHOLD and not warned: + logger.warning( + "LSP publishDiagnostics handler for {server} failed {count} times: {err}", + server=server_name, + count=failure_count, + err=exc, + ) + warned = True + + instance.on_notification("textDocument/publishDiagnostics", handler) + + +def wire_publish_diagnostics_handlers( + registry: DiagnosticRegistry, + servers: Mapping[str, Any], +) -> None: + """Register publishDiagnostics handlers for all running server instances.""" + for server_name, instance in servers.items(): + register_publish_diagnostics_handler(registry, server_name, instance) diff --git a/src/pythinker_code/lsp/framing.py b/src/pythinker_code/lsp/framing.py new file mode 100644 index 00000000..3c63fc45 --- /dev/null +++ b/src/pythinker_code/lsp/framing.py @@ -0,0 +1,70 @@ +"""Content-Length JSON-RPC framing for LSP over stdio.""" + +from __future__ import annotations + +import json +from asyncio import IncompleteReadError +from typing import Any, cast + +from pythinker_host import AsyncReadable, AsyncWritable + + +class LspProtocolError(Exception): + """Malformed or invalid LSP frame or JSON-RPC error response.""" + + def __init__(self, message: str, *, code: int | None = None) -> None: + super().__init__(message) + self.code = code + + +class LspStartError(Exception): + """Failed to start the language server process.""" + + +class LspServerDown(Exception): + """Language server process exited or the read loop failed.""" + + +async def read_message(stdout: AsyncReadable) -> dict[str, Any]: + """Read one LSP message framed with Content-Length headers.""" + content_length: int | None = None + while True: + line = await stdout.readline() + if not line: + raise LspServerDown("unexpected EOF while reading header") + line_str = line.decode("ascii", errors="strict").rstrip("\r\n") + if line_str == "": + break + key, _, value = line_str.partition(":") + if key.strip().lower() == "content-length": + try: + content_length = int(value.strip()) + except ValueError as exc: + raise LspProtocolError(f"invalid Content-Length: {value.strip()!r}") from exc + + if content_length is None: + raise LspProtocolError("missing Content-Length header") + if content_length < 0: + raise LspProtocolError(f"invalid Content-Length: {content_length}") + + try: + body = await stdout.readexactly(content_length) + except IncompleteReadError as exc: + raise LspServerDown("unexpected EOF while reading message body") from exc + + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LspProtocolError("invalid JSON body") from exc + + if not isinstance(payload, dict): + raise LspProtocolError("LSP message must be a JSON object") + return cast(dict[str, Any], payload) + + +async def write_message(stdin: AsyncWritable, message: dict[str, Any]) -> None: + """Write one LSP message with Content-Length framing.""" + body = json.dumps(message, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + stdin.write(header + body) + await stdin.drain() diff --git a/src/pythinker_code/lsp/instance.py b/src/pythinker_code/lsp/instance.py new file mode 100644 index 00000000..473a6c3a --- /dev/null +++ b/src/pythinker_code/lsp/instance.py @@ -0,0 +1,252 @@ +"""Single LSP server lifecycle wrapper.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from enum import StrEnum +from pathlib import Path +from typing import Any + +from pythinker_host import Host + +from pythinker_code.config import LspServerConfig +from pythinker_code.lsp.client import LspClient +from pythinker_code.lsp.framing import LspProtocolError +from pythinker_code.lsp.protocol import InitializeParams + +LSP_ERROR_CONTENT_MODIFIED = -32801 +MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 +RETRY_BASE_DELAY_S = 0.5 + +NotificationHandler = Callable[[Any], None] | Callable[[Any], Awaitable[None]] +RequestHandler = Callable[[Any], Any] | Callable[[Any], Awaitable[Any]] + + +class LspState(StrEnum): + STOPPED = "stopped" + STARTING = "starting" + RUNNING = "running" + ERROR = "error" + + +class LspServerInstance: + """Manages one language-server process and its initialize handshake.""" + + def __init__( + self, + name: str, + config: LspServerConfig, + host: Host, + *, + workspace_folder: str, + logger: logging.Logger | None = None, + ) -> None: + self.name = name + self.config = config + self._host = host + self._workspace_folder = workspace_folder + self._logger = logger or logging.getLogger(__name__) + self._client = LspClient(host, logger=self._logger) + self._client.on_crash(self._handle_client_crash) + self._state = LspState.STOPPED + self.start_time: datetime | None = None + self.last_error: Exception | None = None + self.restart_count = 0 + self._crash_recovery_count = 0 + + @property + def state(self) -> LspState: + return self._state + + def is_healthy(self) -> bool: + return self._state == LspState.RUNNING and self._client.is_initialized + + async def start(self) -> None: + if self._state in (LspState.RUNNING, LspState.STARTING): + return + + max_restarts = self.config.max_restarts + if self._state == LspState.ERROR and self._crash_recovery_count > max_restarts: + error = RuntimeError( + f"LSP server '{self.name}' exceeded max crash recovery attempts ({max_restarts})" + ) + self.last_error = error + raise error + + try: + self._state = LspState.STARTING + await self._client.start( + self.config.command, + self.config.args, + env=self.config.env or None, + cwd=self._workspace_folder, + ) + init_params = _build_initialize_params(self.config, self._workspace_folder) + init_coro = self._client.initialize(init_params) + await asyncio.wait_for(init_coro, timeout=self.config.startup_timeout) + self._state = LspState.RUNNING + self.start_time = datetime.now(tz=UTC) + self._crash_recovery_count = 0 + except Exception as exc: + await self._client.stop() + self._state = LspState.ERROR + self.last_error = exc + if isinstance(exc, asyncio.TimeoutError): + raise TimeoutError( + f"LSP server '{self.name}' timed out after {self.config.startup_timeout}s " + "during initialization" + ) from exc + raise + + async def stop(self) -> None: + if self._state == LspState.STOPPED: + return + try: + await self._client.stop() + self._state = LspState.STOPPED + except Exception as exc: + self._state = LspState.ERROR + self.last_error = exc + raise + + async def restart(self) -> None: + try: + await self.stop() + except Exception as exc: + raise RuntimeError( + f"Failed to stop LSP server '{self.name}' during restart: {exc}" + ) from exc + + self.restart_count += 1 + max_restarts = self.config.max_restarts + if self.restart_count > max_restarts: + error = RuntimeError( + f"Max restart attempts ({max_restarts}) exceeded for server '{self.name}'" + ) + self.last_error = error + self._state = LspState.ERROR + raise error + + await self.start() + + async def send_request(self, method: str, params: Any) -> Any: + if not self.is_healthy(): + raise RuntimeError( + f"Cannot send request to LSP server '{self.name}': server is {self._state}" + + (f", last error: {self.last_error}" if self.last_error else "") + ) + + last_error: Exception | None = None + for attempt in range(MAX_RETRIES_FOR_TRANSIENT_ERRORS + 1): + try: + return await self._client.send_request(method, params) + except LspProtocolError as exc: + last_error = exc + if ( + exc.code == LSP_ERROR_CONTENT_MODIFIED + and attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS + ): + delay = RETRY_BASE_DELAY_S * (2**attempt) + self._logger.debug( + "LSP request %r to %r got ContentModified, retrying in %.1fs", + method, + self.name, + delay, + ) + await asyncio.sleep(delay) + continue + break + + raise RuntimeError( + f"LSP request '{method}' failed for server '{self.name}': {last_error}" + ) from last_error + + async def send_notification(self, method: str, params: Any) -> None: + if not self.is_healthy(): + raise RuntimeError( + f"Cannot send notification to LSP server '{self.name}': server is {self._state}" + ) + try: + await self._client.send_notification(method, params) + except Exception as exc: + raise RuntimeError( + f"LSP notification '{method}' failed for server '{self.name}': {exc}" + ) from exc + + def on_notification(self, method: str, handler: NotificationHandler) -> None: + self._client.on_notification(method, handler) + + def on_request(self, method: str, handler: RequestHandler) -> None: + self._client.on_request(method, handler) + + def mark_crashed(self, error: Exception | None = None) -> None: + self._state = LspState.ERROR + self._crash_recovery_count += 1 + if error is not None: + self.last_error = error + + def _handle_client_crash(self, error: Exception) -> None: + # Fired from the client read loop when the process exits unexpectedly. + # Parks the instance in ERROR so the crash cap is enforced on the next + # ensure_started()/start() and is_healthy() stops reporting RUNNING. + self.mark_crashed(error) + self._logger.warning("LSP server %r crashed: %s", self.name, error) + + +def _build_initialize_params(config: LspServerConfig, workspace_folder: str) -> InitializeParams: + workspace_path = Path(workspace_folder).resolve() + workspace_uri = workspace_path.as_uri() + return InitializeParams( + processId=os.getpid(), + initializationOptions=config.initialization_options + if config.initialization_options is not None + else {}, + workspaceFolders=[ + { + "uri": workspace_uri, + "name": workspace_path.name, + } + ], + rootPath=str(workspace_path), + rootUri=workspace_uri, + capabilities={ + "workspace": { + "configuration": False, + "workspaceFolders": False, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": False, + "willSave": False, + "willSaveWaitUntil": False, + "didSave": True, + }, + "publishDiagnostics": { + "relatedInformation": True, + "tagSupport": {"valueSet": [1, 2]}, + "versionSupport": False, + "codeDescriptionSupport": True, + "dataSupport": False, + }, + "hover": { + "dynamicRegistration": False, + "contentFormat": ["markdown", "plaintext"], + }, + "definition": { + "dynamicRegistration": False, + "linkSupport": True, + }, + "references": {"dynamicRegistration": False}, + "documentSymbol": { + "dynamicRegistration": False, + "hierarchicalDocumentSymbolSupport": True, + }, + "callHierarchy": {"dynamicRegistration": False}, + }, + "general": {"positionEncodings": ["utf-16"]}, + }, + ) diff --git a/src/pythinker_code/lsp/manager.py b/src/pythinker_code/lsp/manager.py new file mode 100644 index 00000000..e5e9f8a3 --- /dev/null +++ b/src/pythinker_code/lsp/manager.py @@ -0,0 +1,196 @@ +"""Multi-server LSP routing and text-document synchronization.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, cast + +from pythinker_host import Host + +from pythinker_code.config import LspServerConfig +from pythinker_code.lsp.instance import LspServerInstance, LspState +from pythinker_code.utils.logging import logger as default_logger + + +class LspServerManager: + """Routes file operations to configured language servers.""" + + def __init__( + self, + host: Host, + servers: dict[str, LspServerConfig], + *, + workspace_folder: str, + logger: Any = None, + ) -> None: + self._host = host + self._workspace_folder = workspace_folder + self._logger = logger or default_logger + self._server_configs = servers + self._instances: dict[str, LspServerInstance] = {} + self._ext_map: dict[str, list[str]] = {} + self._opened_files: dict[str, str] = {} + + async def initialize(self) -> None: + errors: list[str] = [] + for server_name, config in self._server_configs.items(): + try: + if not config.command: + raise ValueError(f"Server {server_name} missing required 'command' field") + if not config.extension_to_language: + raise ValueError( + f"Server {server_name} missing required 'extension_to_language' field" + ) + + for ext in config.extension_to_language: + normalized = ext.lower() + self._ext_map.setdefault(normalized, []).append(server_name) + + instance = LspServerInstance( + server_name, + config, + self._host, + workspace_folder=self._workspace_folder, + logger=self._logger, + ) + instance.on_request("workspace/configuration", _workspace_configuration_handler) + self._instances[server_name] = instance + except Exception as exc: + message = f"Failed to initialize LSP server {server_name}: {exc}" + self._logger.error(message) + errors.append(message) + + if errors: + ok = len(self._instances) + total = len(self._server_configs) + self._logger.error( + f"LSP manager initialized with {ok}/{total} servers; failures: {'; '.join(errors)}" + ) + + async def shutdown(self) -> None: + to_stop = [ + (name, instance) + for name, instance in self._instances.items() + if instance.state in (LspState.RUNNING, LspState.ERROR) + ] + results = await asyncio.gather( + *(instance.stop() for _, instance in to_stop), + return_exceptions=True, + ) + + self._instances.clear() + self._ext_map.clear() + self._opened_files.clear() + + stop_errors = [ + f"{to_stop[i][0]}: {result}" + for i, result in enumerate(results) + if isinstance(result, Exception) + ] + if stop_errors: + raise RuntimeError( + f"Failed to stop {len(stop_errors)} LSP server(s): {'; '.join(stop_errors)}" + ) + + def server_for_file(self, path: str) -> LspServerInstance | None: + ext = Path(path).suffix.lower() + server_names = self._ext_map.get(ext) + if not server_names: + return None + return self._instances.get(server_names[0]) + + async def ensure_started(self, path: str) -> LspServerInstance | None: + server = self.server_for_file(path) + if server is None: + return None + if server.state in (LspState.STOPPED, LspState.ERROR): + await server.start() + return server + + async def send_request(self, path: str, method: str, params: Any) -> Any | None: + server = await self.ensure_started(path) + if server is None: + return None + return await server.send_request(method, params) + + def all_servers(self) -> dict[str, LspServerInstance]: + return dict(self._instances) + + def is_file_open(self, path: str) -> bool: + return _file_uri(path) in self._opened_files + + async def open_file(self, path: str, content: str) -> None: + server = await self.ensure_started(path) + if server is None: + return + + file_uri = _file_uri(path) + if self._opened_files.get(file_uri) == server.name: + return + + ext = Path(path).suffix.lower() + language_id = server.config.extension_to_language.get(ext, "plaintext") + await server.send_notification( + "textDocument/didOpen", + { + "textDocument": { + "uri": file_uri, + "languageId": language_id, + "version": 1, + "text": content, + } + }, + ) + self._opened_files[file_uri] = server.name + + async def change_file(self, path: str, content: str) -> None: + server = self.server_for_file(path) + if server is None or server.state != LspState.RUNNING: + await self.open_file(path, content) + return + + file_uri = _file_uri(path) + if self._opened_files.get(file_uri) != server.name: + await self.open_file(path, content) + return + + await server.send_notification( + "textDocument/didChange", + { + "textDocument": {"uri": file_uri, "version": 1}, + "contentChanges": [{"text": content}], + }, + ) + + async def save_file(self, path: str) -> None: + server = self.server_for_file(path) + if server is None or server.state != LspState.RUNNING: + return + await server.send_notification( + "textDocument/didSave", + {"textDocument": {"uri": _file_uri(path)}}, + ) + + async def close_file(self, path: str) -> None: + server = self.server_for_file(path) + if server is None or server.state != LspState.RUNNING: + return + + file_uri = _file_uri(path) + await server.send_notification( + "textDocument/didClose", + {"textDocument": {"uri": file_uri}}, + ) + self._opened_files.pop(file_uri, None) + + +def _file_uri(path: str) -> str: + return Path(path).resolve().as_uri() + + +def _workspace_configuration_handler(params: dict[str, Any]) -> list[None]: + items: Any = params.get("items", []) + if not isinstance(items, list): + return [] + return [None] * len(cast(list[Any], items)) diff --git a/src/pythinker_code/lsp/plugin_servers.py b/src/pythinker_code/lsp/plugin_servers.py new file mode 100644 index 00000000..9e66b5d2 --- /dev/null +++ b/src/pythinker_code/lsp/plugin_servers.py @@ -0,0 +1,224 @@ +"""Load LSP server configs from installed plugins.""" + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import cast + +from pydantic import ValidationError + +from pythinker_code.config import LspServerConfig +from pythinker_code.plugin.directories import plugin_data_dir +from pythinker_code.plugin.integration import ( # pyright: ignore[reportPrivateUsage] + _enabled_plugins, + _exec_external, + _expand_plugin_vars, + _plugin_options, +) +from pythinker_code.plugin.loader import LoadedPlugin +from pythinker_code.plugin.options import UserConfigError, substitute_user_config_vars +from pythinker_code.plugin.policy import PluginPolicy, current_plugin_policy +from pythinker_code.utils.logging import logger + +_LSP_FILE = ".lsp.json" +_ENV_VAR = re.compile(r"\$\{([^}]+)\}") + + +def _safe_join(root: Path, relative: str) -> Path | None: + root_resolved = root.resolve() + candidate = (root_resolved / relative).resolve() + if candidate == root_resolved or root_resolved in candidate.parents: + return candidate + return None + + +def _expand_env_vars(text: str) -> str: + """Expand ``${VAR}`` and ``${VAR:-default}`` using the process environment.""" + + def _replace(match: re.Match[str]) -> str: + var_content = match.group(1) + var_name, _, default = var_content.partition(":-") + env_value = os.environ.get(var_name) + if env_value is not None: + return env_value + if default: + return default + return match.group(0) + + return _ENV_VAR.sub(_replace, text) + + +def _parse_server_configs(raw: object, *, plugin: str, source: str) -> dict[str, LspServerConfig]: + """Parse a name -> config map from JSON data.""" + if not isinstance(raw, dict): + logger.warning( + "Skipping LSP config {source} from {plugin}: expected object", + source=source, + plugin=plugin, + ) + return {} + servers: dict[str, LspServerConfig] = {} + for name, config in cast("dict[str, object]", raw).items(): + if not isinstance(config, dict): + logger.warning( + "Skipping LSP server {name} from {plugin}: invalid config", + name=name, + plugin=plugin, + ) + continue + try: + servers[name] = LspServerConfig.model_validate(config) + except ValidationError as exc: + logger.warning( + "Skipping LSP server {name} from {plugin}: {error}", + name=name, + plugin=plugin, + error=exc, + ) + return servers + + +def _load_lsp_json_file(path: Path, *, plugin: str, source: str) -> dict[str, LspServerConfig]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Skipping unreadable LSP config {source} from {plugin}: {error}", + source=source, + plugin=plugin, + error=exc, + ) + return {} + return _parse_server_configs(raw, plugin=plugin, source=source) + + +def _load_from_manifest_declaration( + declaration: str | dict[str, object] | list[str | dict[str, object]], + plugin: LoadedPlugin, +) -> dict[str, LspServerConfig]: + servers: dict[str, LspServerConfig] = {} + declarations = declaration if isinstance(declaration, list) else [declaration] + for decl in declarations: + if isinstance(decl, str): + validated = _safe_join(plugin.root, decl) + if validated is None: + logger.warning( + "Skipping LSP path traversal in {plugin}: {path}", + plugin=plugin.name, + path=decl, + ) + continue + if not validated.is_file(): + logger.warning( + "Skipping missing LSP config file in {plugin}: {path}", + plugin=plugin.name, + path=decl, + ) + continue + servers.update( + _load_lsp_json_file(validated, plugin=plugin.name, source=decl), + ) + else: + for name, config in decl.items(): + if not isinstance(config, dict): + logger.warning( + "Skipping inline LSP server {name} from {plugin}: invalid config", + name=name, + plugin=plugin.name, + ) + continue + try: + servers[name] = LspServerConfig.model_validate(config) + except ValidationError as exc: + logger.warning( + "Skipping inline LSP server {name} from {plugin}: {error}", + name=name, + plugin=plugin.name, + error=exc, + ) + return servers + + +def _load_plugin_lsp_servers(plugin: LoadedPlugin) -> dict[str, LspServerConfig]: + """Load raw LSP server configs from ``.lsp.json`` and ``manifest.lspServers``.""" + servers: dict[str, LspServerConfig] = {} + lsp_path = plugin.root / _LSP_FILE + if lsp_path.is_file(): + servers.update(_load_lsp_json_file(lsp_path, plugin=plugin.name, source=_LSP_FILE)) + declaration = plugin.manifest.lsp_servers + if declaration is not None: + servers.update(_load_from_manifest_declaration(declaration, plugin)) + return servers + + +def _resolve_server_config( + config: LspServerConfig, + plugin: LoadedPlugin, + *, + options: dict[str, object] | None, +) -> LspServerConfig | None: + def resolve_value(text: str) -> str: + resolved = _expand_plugin_vars(text, plugin) + if options is not None: + resolved = substitute_user_config_vars(resolved, options) + return _expand_env_vars(resolved) + + try: + command = resolve_value(config.command) + args = [resolve_value(arg) for arg in config.args] + env: dict[str, str] = { + "PYTHINKER_PLUGIN_ROOT": str(plugin.root), + "PYTHINKER_PLUGIN_DATA": str(plugin_data_dir(plugin.name)), + } + for key, value in config.env.items(): + if key in ("PYTHINKER_PLUGIN_ROOT", "PYTHINKER_PLUGIN_DATA"): + env[key] = value + else: + env[key] = resolve_value(value) + except UserConfigError as exc: + logger.warning( + "Skipping LSP server from {plugin}: unconfigured user_config {error}", + plugin=plugin.name, + error=exc, + ) + return None + + return config.model_copy(update={"command": command, "args": args, "env": env}) + + +def _scope_server_name(plugin_name: str, server_name: str) -> str: + return f"plugin:{plugin_name}:{server_name}" + + +def plugin_lsp_servers(policy: PluginPolicy | None = None) -> dict[str, LspServerConfig]: + """LSP server configs contributed by enabled plugins (earlier plugins win). + + Executable artifact: external plugins contribute only when ``external_exec``. + Server names are scoped as ``plugin::`` to avoid collisions. + """ + pol = policy if policy is not None else current_plugin_policy() + servers: dict[str, LspServerConfig] = {} + for plugin in _enabled_plugins(pol, include_external=_exec_external(pol)): + try: + raw_servers = _load_plugin_lsp_servers(plugin) + if not raw_servers: + continue + options = _plugin_options(plugin, pol) if plugin.manifest.user_config else None + for name, config in raw_servers.items(): + resolved = _resolve_server_config(config, plugin, options=options) + if resolved is None: + continue + scoped = _scope_server_name(plugin.name, name) + servers.setdefault(scoped, resolved) + except Exception as exc: + logger.warning( + "Skipping LSP servers from plugin {plugin}: {error}", + plugin=plugin.name, + error=exc, + ) + return servers diff --git a/src/pythinker_code/lsp/protocol.py b/src/pythinker_code/lsp/protocol.py new file mode 100644 index 00000000..073e7035 --- /dev/null +++ b/src/pythinker_code/lsp/protocol.py @@ -0,0 +1,156 @@ +"""Hand-defined LSP types used by the Pythinker CLI LSP subsystem.""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class _LspModel(BaseModel): + model_config = ConfigDict(extra="ignore") + + +class DiagnosticSeverity(IntEnum): + ERROR = 1 + WARNING = 2 + INFORMATION = 3 + HINT = 4 + + +class SymbolKind(IntEnum): + FILE = 1 + MODULE = 2 + NAMESPACE = 3 + PACKAGE = 4 + CLASS = 5 + METHOD = 6 + PROPERTY = 7 + FIELD = 8 + CONSTRUCTOR = 9 + ENUM = 10 + INTERFACE = 11 + FUNCTION = 12 + VARIABLE = 13 + CONSTANT = 14 + STRING = 15 + NUMBER = 16 + BOOLEAN = 17 + ARRAY = 18 + OBJECT = 19 + KEY = 20 + NULL = 21 + ENUM_MEMBER = 22 + STRUCT = 23 + EVENT = 24 + OPERATOR = 25 + TYPE_PARAMETER = 26 + + +class Position(_LspModel): + line: int + character: int + + +class Range(_LspModel): + start: Position + end: Position + + +class Location(_LspModel): + uri: str + range: Range + + +class LocationLink(_LspModel): + targetUri: str + targetRange: Range + targetSelectionRange: Range | None = None + originSelectionRange: Range | None = None + + +class Diagnostic(_LspModel): + range: Range + message: str + severity: DiagnosticSeverity | None = None + code: str | int | None = None + source: str | None = None + + +class DocumentSymbol(_LspModel): + name: str + kind: SymbolKind + range: Range + selectionRange: Range + children: list[DocumentSymbol] | None = None + detail: str | None = None + + +class SymbolInformation(_LspModel): + name: str + kind: SymbolKind + location: Location + containerName: str | None = None + + +class MarkupContent(_LspModel): + kind: str + value: str + + +class Hover(_LspModel): + contents: MarkupContent | str | list[str | MarkupContent] + range: Range | None = None + + +class CallHierarchyItem(_LspModel): + name: str + kind: SymbolKind + uri: str + range: Range + selectionRange: Range + detail: str | None = None + data: Any | None = None + + +class CallHierarchyIncomingCall(_LspModel): + from_: CallHierarchyItem = Field(alias="from") + fromRanges: list[Range] + + +class CallHierarchyOutgoingCall(_LspModel): + to: CallHierarchyItem + fromRanges: list[Range] + + +class InitializeParams(_LspModel): + processId: int | None = None + rootUri: str | None = None + rootPath: str | None = None + workspaceFolders: list[dict[str, Any]] | None = None + capabilities: dict[str, Any] | None = None + initializationOptions: Any | None = None + trace: str | None = None + locale: str | None = None + + +class ServerCapabilities(_LspModel): + hoverProvider: bool | dict[str, Any] | None = None + definitionProvider: bool | dict[str, Any] | None = None + referencesProvider: bool | dict[str, Any] | None = None + documentSymbolProvider: bool | dict[str, Any] | None = None + workspaceSymbolProvider: bool | dict[str, Any] | None = None + implementationProvider: bool | dict[str, Any] | None = None + callHierarchyProvider: bool | dict[str, Any] | None = None + + +class InitializeResult(_LspModel): + capabilities: ServerCapabilities + serverInfo: dict[str, Any] | None = None + + +class PublishDiagnosticsParams(_LspModel): + uri: str + diagnostics: list[Diagnostic] + version: int | None = None diff --git a/src/pythinker_code/lsp/recommend.py b/src/pythinker_code/lsp/recommend.py new file mode 100644 index 00000000..856f1960 --- /dev/null +++ b/src/pythinker_code/lsp/recommend.py @@ -0,0 +1,194 @@ +"""Recommend marketplace LSP plugins when a file extension has no installed server.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from pythinker_code.config import Config, LspConfig +from pythinker_code.plugin.installed import load_installed_plugins, plugin_identifier +from pythinker_code.plugin.manifest import MarketplaceEntry +from pythinker_code.plugin.marketplace import ( + MarketplaceError, + load_known_marketplaces, + resolve_local_marketplace, +) +from pythinker_code.utils.logging import logger + +MAX_IGNORED_COUNT = 5 + +OFFICIAL_MARKETPLACE_NAMES = frozenset( + { + "pythinker-code-marketplace", + "pythinker-code-plugins", + "pythinker-plugins-official", + "pythoughts-marketplace", + "pythoughts-plugins", + "agent-skills", + "life-sciences", + "knowledge-work-plugins", + } +) + + +@dataclass(frozen=True) +class LspPluginRecommendation: + plugin_id: str + plugin_name: str + marketplace_name: str + description: str | None + is_official: bool + extensions: list[str] + command: str + + +@dataclass(frozen=True) +class _LspInfo: + extensions: frozenset[str] + command: str + + +def is_official_marketplace(name: str) -> bool: + return name.lower() in OFFICIAL_MARKETPLACE_NAMES + + +def is_lsp_recommendations_disabled(config: LspConfig) -> bool: + return ( + config.recommendation_disabled or config.recommendation_ignored_count >= MAX_IGNORED_COUNT + ) + + +def is_plugin_installed(plugin_id: str) -> bool: + return plugin_id in load_installed_plugins() + + +def is_binary_installed(command: str) -> bool: + binary = command.split()[0] if " " in command and not command.startswith("/") else command + return shutil.which(binary) is not None + + +def _extract_from_server_config_record(server_configs: dict[str, object]) -> _LspInfo | None: + extensions: set[str] = set() + command: str | None = None + for config in server_configs.values(): + if not isinstance(config, dict): + continue + config_d = cast("dict[str, object]", config) + if command is None: + cmd_raw = config_d.get("command") + if isinstance(cmd_raw, str): + command = cmd_raw + ext_mapping = config_d.get("extensionToLanguage") + if isinstance(ext_mapping, dict): + for ext in cast("dict[str, object]", ext_mapping): + extensions.add(ext.lower()) + if not command or not extensions: + return None + return _LspInfo(extensions=frozenset(extensions), command=command) + + +def _extract_lsp_info_from_manifest( + lsp_servers: str | dict[str, object] | list[str | dict[str, object]] | None, +) -> _LspInfo | None: + if lsp_servers is None: + return None + if isinstance(lsp_servers, str): + return None + if isinstance(lsp_servers, list): + for item in lsp_servers: + if isinstance(item, str): + continue + info = _extract_from_server_config_record(item) + if info is not None: + return info + return None + return _extract_from_server_config_record(lsp_servers) + + +def _lsp_plugins_from_marketplaces() -> dict[str, tuple[MarketplaceEntry, str, _LspInfo, bool]]: + result: dict[str, tuple[MarketplaceEntry, str, _LspInfo, bool]] = {} + for marketplace_name, entry in load_known_marketplaces().items(): + try: + manifest = resolve_local_marketplace(entry.source) + except MarketplaceError as exc: + logger.debug( + "Skipping marketplace {name} for LSP recommendation: {error}", + name=marketplace_name, + error=exc, + ) + continue + is_official = is_official_marketplace(marketplace_name) + for plugin_entry in manifest.plugins: + if plugin_entry.lsp_servers is None: + continue + lsp_info = _extract_lsp_info_from_manifest(plugin_entry.lsp_servers) + if lsp_info is None: + continue + plugin_id = plugin_identifier(plugin_entry.name, marketplace_name) + result[plugin_id] = (plugin_entry, marketplace_name, lsp_info, is_official) + return result + + +def get_matching_lsp_plugins( + file_path: str | Path, + config: Config | LspConfig, +) -> list[LspPluginRecommendation]: + """Return installable LSP plugin recommendations for *file_path*.""" + lsp_config = config.lsp if isinstance(config, Config) else config + if is_lsp_recommendations_disabled(lsp_config): + return [] + + ext = Path(file_path).suffix.lower() + if not ext: + return [] + + never_plugins = set(lsp_config.recommendation_never) + all_lsp_plugins = _lsp_plugins_from_marketplaces() + matching: list[tuple[MarketplaceEntry, str, _LspInfo, bool, str]] = [] + + for plugin_id, (entry, marketplace_name, lsp_info, is_official) in all_lsp_plugins.items(): + if ext not in lsp_info.extensions: + continue + if plugin_id in never_plugins: + continue + if is_plugin_installed(plugin_id): + continue + matching.append((entry, marketplace_name, lsp_info, is_official, plugin_id)) + + with_binary = [item for item in matching if is_binary_installed(item[2].command)] + with_binary.sort(key=lambda item: (not item[3], item[4])) + + return [ + LspPluginRecommendation( + plugin_id=plugin_id, + plugin_name=entry.name, + marketplace_name=marketplace_name, + description=entry.description or None, + is_official=is_official, + extensions=sorted(lsp_info.extensions), + command=lsp_info.command, + ) + for entry, marketplace_name, lsp_info, is_official, plugin_id in with_binary + ] + + +def add_to_never_suggest(config: LspConfig, plugin_id: str) -> LspConfig: + if plugin_id in config.recommendation_never: + return config + return config.model_copy( + update={"recommendation_never": [*config.recommendation_never, plugin_id]} + ) + + +def increment_ignored_count(config: LspConfig) -> LspConfig: + return config.model_copy( + update={"recommendation_ignored_count": config.recommendation_ignored_count + 1} + ) + + +def reset_ignored_count(config: LspConfig) -> LspConfig: + if config.recommendation_ignored_count == 0: + return config + return config.model_copy(update={"recommendation_ignored_count": 0}) diff --git a/src/pythinker_code/lsp/service.py b/src/pythinker_code/lsp/service.py new file mode 100644 index 00000000..72131d70 --- /dev/null +++ b/src/pythinker_code/lsp/service.py @@ -0,0 +1,157 @@ +"""Session-scoped LSP facade with lazy async initialization.""" + +from __future__ import annotations + +import asyncio +import contextlib +from enum import StrEnum +from typing import TYPE_CHECKING + +from pythinker_host import get_current_host + +from pythinker_code.lsp.diagnostics import DiagnosticRegistry, wire_publish_diagnostics_handlers +from pythinker_code.lsp.instance import LspState +from pythinker_code.lsp.manager import LspServerManager +from pythinker_code.utils.logging import logger + +if TYPE_CHECKING: + from pythinker_code.config import LspServerConfig + from pythinker_code.soul.agent import Runtime + + +class LspInitStatus(StrEnum): + NOT_STARTED = "not_started" + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + + +class LspService: + """Owns one LspServerManager per agent session.""" + + def __init__( + self, + runtime: Runtime, + *, + servers: dict[str, LspServerConfig] | None = None, + ) -> None: + self._runtime = runtime + self._servers = servers or {} + self._manager: LspServerManager | None = None + self._diagnostics = DiagnosticRegistry() + self._status = LspInitStatus.NOT_STARTED + self._init_error: Exception | None = None + self._init_task: asyncio.Task[None] | None = None + self._init_event = asyncio.Event() + self._generation = 0 + + @classmethod + def create( + cls, + runtime: Runtime, + *, + servers: dict[str, LspServerConfig] | None = None, + ) -> LspService: + service = cls(runtime, servers=servers) + service._kickoff_init() + return service + + @property + def manager(self) -> LspServerManager | None: + if self._status == LspInitStatus.FAILED: + return None + return self._manager + + @property + def diagnostics(self) -> DiagnosticRegistry: + return self._diagnostics + + def status(self) -> LspInitStatus: + return self._status + + def is_connected(self) -> bool: + if self._status == LspInitStatus.FAILED or self._manager is None: + return False + servers = self._manager.all_servers() + if not servers: + return False + return any(instance.state != LspState.ERROR for instance in servers.values()) + + async def wait_for_init(self) -> None: + if self._status in (LspInitStatus.SUCCESS, LspInitStatus.FAILED): + return + if self._status == LspInitStatus.NOT_STARTED: + return + await self._init_event.wait() + + async def change_file(self, path: str, content: str) -> None: + manager = self.manager + if manager is None: + return + await manager.change_file(path, content) + + async def save_file(self, path: str) -> None: + manager = self.manager + if manager is None: + return + await manager.save_file(path) + + async def reinitialize(self, *, servers: dict[str, LspServerConfig] | None = None) -> None: + if servers is not None: + self._servers = servers + if self._manager is not None: + await self._manager.shutdown() + self._manager = None + self._status = LspInitStatus.NOT_STARTED + self._init_error = None + self._init_event = asyncio.Event() + self._kickoff_init() + + async def shutdown(self) -> None: + if self._init_task is not None and not self._init_task.done(): + self._init_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._init_task + if self._manager is not None: + await self._manager.shutdown() + self._manager = None + self._diagnostics.clear_all() + self._status = LspInitStatus.NOT_STARTED + self._init_error = None + self._init_event.set() + self._generation += 1 + + def _kickoff_init(self) -> None: + if self._status in (LspInitStatus.PENDING, LspInitStatus.SUCCESS): + return + self._status = LspInitStatus.PENDING + self._init_event.clear() + generation = self._generation + 1 + self._generation = generation + self._init_task = asyncio.create_task(self._run_init(generation)) + + async def _run_init(self, generation: int) -> None: + try: + workspace = str(self._runtime.work_dir) + manager = LspServerManager( + get_current_host(), + self._servers, + workspace_folder=workspace, + ) + await manager.initialize() + if generation != self._generation: + await manager.shutdown() + return + self._manager = manager + wire_publish_diagnostics_handlers(self._diagnostics, manager.all_servers()) + self._status = LspInitStatus.SUCCESS + except Exception as exc: + if generation != self._generation: + return + self._status = LspInitStatus.FAILED + self._init_error = exc + self._manager = None + logger.error("Failed to initialize LSP service: {err}", err=exc) + finally: + if generation == self._generation: + self._init_event.set() diff --git a/src/pythinker_code/plugin/manifest.py b/src/pythinker_code/plugin/manifest.py index 345c2103..f790e54c 100644 --- a/src/pythinker_code/plugin/manifest.py +++ b/src/pythinker_code/plugin/manifest.py @@ -129,6 +129,10 @@ class PluginManifest(BaseModel): hooks: object = None # MCP servers: name -> server config (or a path/ref resolved later). mcp_servers: dict[str, object] = Field(default_factory=dict, alias="mcpServers") + # LSP servers: inline map, path to .lsp.json, or a mixed array of both. + lsp_servers: str | dict[str, object] | list[str | dict[str, object]] | None = Field( + default=None, alias="lspServers" + ) # Plugin dependencies: "name" or "name@marketplace". dependencies: list[str] = Field(default_factory=list) @@ -168,6 +172,9 @@ class MarketplaceEntry(BaseModel): category: str | None = None tags: list[str] = Field(default_factory=list) strict: bool = True + lsp_servers: str | dict[str, object] | list[str | dict[str, object]] | None = Field( + default=None, alias="lspServers" + ) @field_validator("author", mode="before") @classmethod diff --git a/src/pythinker_code/soul/agent.py b/src/pythinker_code/soul/agent.py index da029c35..6c1b8e3c 100644 --- a/src/pythinker_code/soul/agent.py +++ b/src/pythinker_code/soul/agent.py @@ -21,6 +21,7 @@ from pythinker_code.config import Config from pythinker_code.exception import MCPConfigError, SystemPromptTemplateError from pythinker_code.llm import LLM +from pythinker_code.lsp.service import LspService from pythinker_code.notifications import NotificationManager from pythinker_code.prompt_templates import PromptTemplate, discover_prompt_templates from pythinker_code.scratchpad import DEFAULT_SCRATCHPAD_SECTION @@ -254,6 +255,8 @@ class Runtime: """HookEngine instance, set by PythinkerCLI after soul creation.""" rearm_injection: Callable[[str], None] | None = None """Callback set by PythinkerSoul so tools can refresh dynamic injections.""" + lsp: LspService | None = None + """Session-scoped LSP service; shared with subagents.""" work_dir_override: HostPath | None = None """Operational working directory override (e.g. a per-child git worktree). @@ -388,7 +391,7 @@ def _on_approval_change() -> None: config.notifications, ) - return Runtime( + runtime = Runtime( config=config, oauth=oauth, llm=llm, @@ -428,6 +431,11 @@ def _on_approval_change() -> None: root_wire_hub=RootWireHub(), role="root", ) + if config.lsp.enabled: + from pythinker_code.lsp.plugin_servers import plugin_lsp_servers + + runtime.lsp = LspService.create(runtime, servers=plugin_lsp_servers()) + return runtime def copy_for_subagent( self, @@ -488,6 +496,7 @@ def copy_for_subagent( subagent_type=subagent_type, role="subagent", work_dir_override=work_dir_override or self.work_dir_override, + lsp=self.lsp, ) diff --git a/src/pythinker_code/soul/dynamic_injections/lsp_diagnostics.py b/src/pythinker_code/soul/dynamic_injections/lsp_diagnostics.py new file mode 100644 index 00000000..f6a50ed3 --- /dev/null +++ b/src/pythinker_code/soul/dynamic_injections/lsp_diagnostics.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from pythinker_core.message import Message + +from pythinker_code.lsp.diagnostics import render_diagnostics_block +from pythinker_code.soul.dynamic_injection import DynamicInjection, DynamicInjectionProvider + +if TYPE_CHECKING: + from pythinker_code.soul.agent import Runtime + from pythinker_code.soul.pythinkersoul import PythinkerSoul + +_INJECTION_TYPE = "lsp_diagnostics" +_REARM_KEY = "lsp_diagnostics" + + +class LspDiagnosticsInjectionProvider(DynamicInjectionProvider): + """Inject bounded LSP diagnostics when language servers report issues.""" + + def __init__(self, runtime: Runtime) -> None: + self._runtime = runtime + self._armed = True + + async def get_injections( + self, + history: Sequence[Message], + soul: PythinkerSoul, + ) -> list[DynamicInjection]: + del history, soul + lsp = self._runtime.lsp + if lsp is None or not lsp.is_connected() or not self._armed: + return [] + groups = lsp.diagnostics.check_for_diagnostics() + if not groups: + return [] + self._armed = False + return [ + DynamicInjection( + type=_INJECTION_TYPE, + content=render_diagnostics_block(groups), + ) + ] + + async def on_context_compacted(self) -> None: + self._armed = True + + def rearm(self, key: str) -> bool: + if key != _REARM_KEY: + return False + self._armed = True + return True diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index f2bae3f7..452dfb60 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -92,6 +92,7 @@ from pythinker_code.soul.dynamic_injections.git_status import GitStatusInjectionProvider from pythinker_code.soul.dynamic_injections.goal_mode import GoalModeInjectionProvider from pythinker_code.soul.dynamic_injections.inline_commands import InlineCommandReminderProvider +from pythinker_code.soul.dynamic_injections.lsp_diagnostics import LspDiagnosticsInjectionProvider from pythinker_code.soul.dynamic_injections.model_defense import ModelDefenseInjectionProvider from pythinker_code.soul.dynamic_injections.orchestration import OrchestrationInjectionProvider from pythinker_code.soul.dynamic_injections.permissions_state import PermissionsInjectionProvider @@ -567,6 +568,8 @@ def __init__( PermissionsInjectionProvider(), # Self-filtering: root-only; bounded git snapshot for working-tree orientation. GitStatusInjectionProvider(), + # Self-filtering: injects when LSP reports pending diagnostics after edits. + LspDiagnosticsInjectionProvider(self._runtime), # Self-filtering: root-only; keeps the model's subagent list current # without tying it to the static tool description cache. AgentListInjectionProvider(), diff --git a/src/pythinker_code/tools/file/replace.py b/src/pythinker_code/tools/file/replace.py index 01af08a6..13812161 100644 --- a/src/pythinker_code/tools/file/replace.py +++ b/src/pythinker_code/tools/file/replace.py @@ -523,6 +523,19 @@ async def __call__(self, params: Params) -> ToolReturnValue: st = await p.stat() self._runtime.file_read_cache.record(real_p, st.st_mtime, st.st_size) + if self._runtime.lsp and self._runtime.rearm_injection: + file_uri = Path(str(p)).resolve().as_uri() + self._runtime.lsp.diagnostics.clear_for_file(file_uri) + try: + await self._runtime.lsp.change_file(str(p), content) + await self._runtime.lsp.save_file(str(p)) + self._runtime.rearm_injection("lsp_diagnostics") + except Exception: + logger.warning( + "LSP notification failed for {path}; skipping diagnostic rearm", + path=p, + ) + # Count changes for success message (tallied per-edit during application). total_replacements = sum(per_edit_counts) diff --git a/src/pythinker_code/tools/file/write.py b/src/pythinker_code/tools/file/write.py index 30810113..56eecd22 100644 --- a/src/pythinker_code/tools/file/write.py +++ b/src/pythinker_code/tools/file/write.py @@ -238,6 +238,18 @@ async def __call__(self, params: Params) -> ToolReturnValue: stat_after = await p.stat() file_size = stat_after.st_size self._runtime.file_read_cache.record(real_p, stat_after.st_mtime, file_size) + if self._runtime.lsp and self._runtime.rearm_injection: + file_uri = Path(str(p)).resolve().as_uri() + self._runtime.lsp.diagnostics.clear_for_file(file_uri) + try: + await self._runtime.lsp.change_file(str(p), new_text) + await self._runtime.lsp.save_file(str(p)) + self._runtime.rearm_injection("lsp_diagnostics") + except Exception: + logger.warning( + "LSP notification failed for {path}; skipping diagnostic rearm", + path=p, + ) action = "overwritten" if params.mode == "overwrite" else "appended to" size_note = f" Current size: {file_size} bytes." if file_size is not None else "" return ToolReturnValue( diff --git a/src/pythinker_code/tools/lsp/__init__.py b/src/pythinker_code/tools/lsp/__init__.py new file mode 100644 index 00000000..ec665ad5 --- /dev/null +++ b/src/pythinker_code/tools/lsp/__init__.py @@ -0,0 +1,3 @@ +from pythinker_code.tools.lsp.tool import Lsp + +__all__ = ["Lsp"] diff --git a/src/pythinker_code/tools/lsp/formatters.py b/src/pythinker_code/tools/lsp/formatters.py new file mode 100644 index 00000000..7840238b --- /dev/null +++ b/src/pythinker_code/tools/lsp/formatters.py @@ -0,0 +1,441 @@ +"""Format LSP tool results as human-readable text.""" + +# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false + +from __future__ import annotations + +import contextlib +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +from pythinker_code.lsp.protocol import SymbolKind + +MAX_RESULT_SIZE_CHARS = 100_000 + +_SYMBOL_KIND_LABELS: dict[int, str] = { + SymbolKind.FILE: "File", + SymbolKind.MODULE: "Module", + SymbolKind.NAMESPACE: "Namespace", + SymbolKind.PACKAGE: "Package", + SymbolKind.CLASS: "Class", + SymbolKind.METHOD: "Method", + SymbolKind.PROPERTY: "Property", + SymbolKind.FIELD: "Field", + SymbolKind.CONSTRUCTOR: "Constructor", + SymbolKind.ENUM: "Enum", + SymbolKind.INTERFACE: "Interface", + SymbolKind.FUNCTION: "Function", + SymbolKind.VARIABLE: "Variable", + SymbolKind.CONSTANT: "Constant", + SymbolKind.STRING: "String", + SymbolKind.NUMBER: "Number", + SymbolKind.BOOLEAN: "Boolean", + SymbolKind.ARRAY: "Array", + SymbolKind.OBJECT: "Object", + SymbolKind.KEY: "Key", + SymbolKind.NULL: "Null", + SymbolKind.ENUM_MEMBER: "EnumMember", + SymbolKind.STRUCT: "Struct", + SymbolKind.EVENT: "Event", + SymbolKind.OPERATOR: "Operator", + SymbolKind.TYPE_PARAMETER: "TypeParameter", +} + + +def _plural(count: int, word: str) -> str: + return word if count == 1 else f"{word}s" + + +def _symbol_kind_label(kind: int | SymbolKind) -> str: + value = int(kind) + return _SYMBOL_KIND_LABELS.get(value, "Unknown") + + +def format_uri(uri: str | None, cwd: str | None = None) -> str: + if not uri: + return "" + + file_path = uri.removeprefix("file://") + if len(file_path) >= 3 and file_path[0] == "/" and file_path[2] == ":": + file_path = file_path[1:] + + with contextlib.suppress(Exception): + file_path = unquote(file_path) + + file_path = file_path.replace("\\", "/") + + if cwd: + try: + relative = Path(file_path).relative_to(cwd).as_posix() + if len(relative) < len(file_path) and not relative.startswith("../.."): + return relative + except ValueError: + pass + + return file_path + + +def _group_by_file( + items: list[dict[str, Any]], *, cwd: str | None +) -> dict[str, list[dict[str, Any]]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for item in items: + if "uri" in item: + uri = item["uri"] + else: + location = item.get("location") or {} + uri = location.get("uri") + file_path = format_uri(uri, cwd) + grouped.setdefault(file_path, []).append(item) + return grouped + + +def _format_location(location: dict[str, Any], cwd: str | None) -> str: + file_path = format_uri(location.get("uri"), cwd) + start = (location.get("range") or {}).get("start") or {} + line = int(start.get("line", 0)) + 1 + character = int(start.get("character", 0)) + 1 + return f"{file_path}:{line}:{character}" + + +def _is_location_link(item: dict[str, Any]) -> bool: + return "targetUri" in item + + +def _to_location(item: dict[str, Any]) -> dict[str, Any]: + if _is_location_link(item): + return { + "uri": item.get("targetUri"), + "range": item.get("targetSelectionRange") or item.get("targetRange") or {}, + } + return item + + +def format_go_to_definition_result( + result: dict[str, Any] | list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return ( + "No definition found. This may occur if the cursor is not on a symbol, or if the " + "definition is in an external library not indexed by the LSP server." + ) + + raw_results = result if isinstance(result, list) else [result] + locations = [_to_location(item) for item in raw_results] + valid = [loc for loc in locations if loc.get("uri")] + + if not valid: + return ( + "No definition found. This may occur if the cursor is not on a symbol, or if the " + "definition is in an external library not indexed by the LSP server." + ) + if len(valid) == 1: + return f"Defined in {_format_location(valid[0], cwd)}" + + lines = [f"Found {len(valid)} definitions:"] + lines.extend(f" {_format_location(loc, cwd)}" for loc in valid) + return "\n".join(lines) + + +def format_find_references_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return ( + "No references found. This may occur if the symbol has no usages, or if the LSP " + "server has not fully indexed the workspace." + ) + + valid = [loc for loc in result if loc and loc.get("uri")] + if not valid: + return ( + "No references found. This may occur if the symbol has no usages, or if the LSP " + "server has not fully indexed the workspace." + ) + if len(valid) == 1: + return f"Found 1 reference:\n {_format_location(valid[0], cwd)}" + + by_file = _group_by_file(valid, cwd=cwd) + lines = [f"Found {len(valid)} references across {len(by_file)} files:"] + for file_path, locations in by_file.items(): + lines.append(f"\n{file_path}:") + for loc in locations: + start = (loc.get("range") or {}).get("start") or {} + line = int(start.get("line", 0)) + 1 + character = int(start.get("character", 0)) + 1 + lines.append(f" Line {line}:{character}") + return "\n".join(lines) + + +def _extract_markup_text(contents: Any) -> str: + if isinstance(contents, list): + parts: list[str] = [] + for item in contents: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + parts.append(str(item.get("value", ""))) + return "\n\n".join(parts) + if isinstance(contents, str): + return contents + if isinstance(contents, dict): + return str(contents.get("value", "")) + return str(contents) + + +def format_hover_result(result: dict[str, Any] | None, _cwd: str | None = None) -> str: + if not result: + return ( + "No hover information available. This may occur if the cursor is not on a symbol, " + "or if the LSP server has not fully indexed the file." + ) + + content = _extract_markup_text(result.get("contents")) + range_obj = result.get("range") + if range_obj: + start = range_obj.get("start") or {} + line = int(start.get("line", 0)) + 1 + character = int(start.get("character", 0)) + 1 + return f"Hover info at {line}:{character}:\n\n{content}" + return content + + +def _format_document_symbol_node(symbol: dict[str, Any], indent: int = 0) -> list[str]: + lines: list[str] = [] + prefix = " " * indent + kind = _symbol_kind_label(symbol.get("kind", 0)) + line = f"{prefix}{symbol.get('name', '')} ({kind})" + if symbol.get("detail"): + line += f" {symbol['detail']}" + symbol_line = int((symbol.get("range") or {}).get("start", {}).get("line", 0)) + 1 + line += f" - Line {symbol_line}" + lines.append(line) + for child in symbol.get("children") or []: + lines.extend(_format_document_symbol_node(child, indent + 1)) + return lines + + +def format_document_symbol_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return ( + "No symbols found in document. This may occur if the file is empty, not supported " + "by the LSP server, or if the server has not fully indexed the file." + ) + + first = result[0] + if first and "location" in first: + return format_workspace_symbol_result(result, cwd) + + lines = ["Document symbols:"] + for symbol in result: + lines.extend(_format_document_symbol_node(symbol)) + return "\n".join(lines) + + +def format_workspace_symbol_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return ( + "No symbols found in workspace. This may occur if the workspace is empty, or if the " + "LSP server has not finished indexing the project." + ) + + valid = [sym for sym in result if sym and (sym.get("location") or {}).get("uri")] + if not valid: + return ( + "No symbols found in workspace. This may occur if the workspace is empty, or if the " + "LSP server has not finished indexing the project." + ) + + lines = [f"Found {len(valid)} {_plural(len(valid), 'symbol')} in workspace:"] + by_file = _group_by_file(valid, cwd=cwd) + for file_path, symbols in by_file.items(): + lines.append(f"\n{file_path}:") + for symbol in symbols: + kind = _symbol_kind_label(symbol.get("kind", 0)) + location = symbol.get("location") or {} + sym_line = int((location.get("range") or {}).get("start", {}).get("line", 0)) + 1 + symbol_line = f" {symbol.get('name', '')} ({kind}) - Line {sym_line}" + if symbol.get("containerName"): + symbol_line += f" in {symbol['containerName']}" + lines.append(symbol_line) + return "\n".join(lines) + + +def _format_call_hierarchy_item(item: dict[str, Any], cwd: str | None) -> str: + if not item.get("uri"): + kind = _symbol_kind_label(item.get("kind", 0)) + return f"{item.get('name', '')} ({kind}) - " + + file_path = format_uri(item.get("uri"), cwd) + line = int((item.get("range") or {}).get("start", {}).get("line", 0)) + 1 + kind = _symbol_kind_label(item.get("kind", 0)) + text = f"{item.get('name', '')} ({kind}) - {file_path}:{line}" + if item.get("detail"): + text += f" [{item['detail']}]" + return text + + +def format_prepare_call_hierarchy_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return "No call hierarchy item found at this position" + + if len(result) == 1: + return f"Call hierarchy item: {_format_call_hierarchy_item(result[0], cwd)}" + + lines = [f"Found {len(result)} call hierarchy items:"] + for item in result: + lines.append(f" {_format_call_hierarchy_item(item, cwd)}") + return "\n".join(lines) + + +def format_incoming_calls_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return "No incoming calls found (nothing calls this function)" + + lines = [f"Found {len(result)} incoming {_plural(len(result), 'call')}:"] + by_file: dict[str, list[dict[str, Any]]] = {} + for call in result: + source = call.get("from") + if not source: + continue + file_path = format_uri(source.get("uri"), cwd) + by_file.setdefault(file_path, []).append(call) + + for file_path, calls in by_file.items(): + lines.append(f"\n{file_path}:") + for call in calls: + source = call.get("from") or {} + kind = _symbol_kind_label(source.get("kind", 0)) + line = int((source.get("range") or {}).get("start", {}).get("line", 0)) + 1 + call_line = f" {source.get('name', '')} ({kind}) - Line {line}" + from_ranges = call.get("fromRanges") or [] + if from_ranges: + call_sites = ", ".join( + f"{int(r.get('start', {}).get('line', 0)) + 1}:" + f"{int(r.get('start', {}).get('character', 0)) + 1}" + for r in from_ranges + ) + call_line += f" [calls at: {call_sites}]" + lines.append(call_line) + return "\n".join(lines) + + +def format_outgoing_calls_result( + result: list[dict[str, Any]] | None, + cwd: str | None = None, +) -> str: + if not result: + return "No outgoing calls found (this function calls nothing)" + + lines = [f"Found {len(result)} outgoing {_plural(len(result), 'call')}:"] + by_file: dict[str, list[dict[str, Any]]] = {} + for call in result: + target = call.get("to") + if not target: + continue + file_path = format_uri(target.get("uri"), cwd) + by_file.setdefault(file_path, []).append(call) + + for file_path, calls in by_file.items(): + lines.append(f"\n{file_path}:") + for call in calls: + target = call.get("to") or {} + kind = _symbol_kind_label(target.get("kind", 0)) + line = int((target.get("range") or {}).get("start", {}).get("line", 0)) + 1 + call_line = f" {target.get('name', '')} ({kind}) - Line {line}" + from_ranges = call.get("fromRanges") or [] + if from_ranges: + call_sites = ", ".join( + f"{int(r.get('start', {}).get('line', 0)) + 1}:" + f"{int(r.get('start', {}).get('character', 0)) + 1}" + for r in from_ranges + ) + call_line += f" [called from: {call_sites}]" + lines.append(call_line) + return "\n".join(lines) + + +def count_symbols(symbols: list[dict[str, Any]]) -> int: + total = len(symbols) + for symbol in symbols: + children = symbol.get("children") or [] + if children: + total += count_symbols(children) + return total + + +def count_unique_files_from_locations(locations: list[dict[str, Any]]) -> int: + return len({loc.get("uri") for loc in locations if loc.get("uri")}) + + +def format_result( + operation: str, + result: Any, + cwd: str | None, +) -> tuple[str, int, int]: + match operation: + case "goToDefinition" | "goToImplementation": + raw_results = result if isinstance(result, list) else ([result] if result else []) + locations = [_to_location(item) for item in raw_results] + valid = [loc for loc in locations if loc.get("uri")] + formatted = format_go_to_definition_result(result, cwd) + return formatted, len(valid), count_unique_files_from_locations(valid) + case "findReferences": + locations = result or [] + valid = [loc for loc in locations if loc and loc.get("uri")] + formatted = format_find_references_result(result, cwd) + return formatted, len(valid), count_unique_files_from_locations(valid) + case "hover": + formatted = format_hover_result(result, cwd) + count = 1 if result else 0 + return formatted, count, count + case "documentSymbol": + symbols = result or [] + is_document_symbol = bool(symbols and "range" in symbols[0]) + count = count_symbols(symbols) if is_document_symbol else len(symbols) + formatted = format_document_symbol_result(result, cwd) + file_count = 1 if symbols else 0 + return formatted, count, file_count + case "workspaceSymbol": + symbols = result or [] + valid = [sym for sym in symbols if sym and (sym.get("location") or {}).get("uri")] + locations = [sym.get("location") for sym in valid] + formatted = format_workspace_symbol_result(result, cwd) + return formatted, len(valid), count_unique_files_from_locations(locations) + case "prepareCallHierarchy": + items = result or [] + formatted = format_prepare_call_hierarchy_result(result, cwd) + uris = [item.get("uri") for item in items if item.get("uri")] + file_count = len(set(uris)) if uris else 0 + return formatted, len(items), file_count + case "incomingCalls": + calls = result or [] + formatted = format_incoming_calls_result(result, cwd) + uris = [(call.get("from") or {}).get("uri") for call in calls] + uris = [uri for uri in uris if uri] + file_count = len(set(uris)) if uris else 0 + return formatted, len(calls), file_count + case "outgoingCalls": + calls = result or [] + formatted = format_outgoing_calls_result(result, cwd) + uris = [(call.get("to") or {}).get("uri") for call in calls] + uris = [uri for uri in uris if uri] + file_count = len(set(uris)) if uris else 0 + return formatted, len(calls), file_count + case _: + return str(result), 0, 0 diff --git a/src/pythinker_code/tools/lsp/schemas.py b/src/pythinker_code/tools/lsp/schemas.py new file mode 100644 index 00000000..49a8524b --- /dev/null +++ b/src/pythinker_code/tools/lsp/schemas.py @@ -0,0 +1,26 @@ +"""LSP tool input schemas.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class Operation(StrEnum): + GO_TO_DEFINITION = "goToDefinition" + FIND_REFERENCES = "findReferences" + HOVER = "hover" + DOCUMENT_SYMBOL = "documentSymbol" + WORKSPACE_SYMBOL = "workspaceSymbol" + GO_TO_IMPLEMENTATION = "goToImplementation" + PREPARE_CALL_HIERARCHY = "prepareCallHierarchy" + INCOMING_CALLS = "incomingCalls" + OUTGOING_CALLS = "outgoingCalls" + + +class Params(BaseModel): + operation: Operation = Field(description="The LSP operation to perform") + file_path: str = Field(description="The absolute or relative path to the file") + line: int = Field(ge=1, description="The line number (1-based, as shown in editors)") + character: int = Field(ge=1, description="The character offset (1-based, as shown in editors)") diff --git a/src/pythinker_code/tools/lsp/symbol_context.py b/src/pythinker_code/tools/lsp/symbol_context.py new file mode 100644 index 00000000..97a348bb --- /dev/null +++ b/src/pythinker_code/tools/lsp/symbol_context.py @@ -0,0 +1,80 @@ +"""Extract symbol context around a file position.""" + +from __future__ import annotations + +import re +from pathlib import Path + +MAX_READ_BYTES = 64 * 1024 +MAX_SYMBOL_LEN = 30 +_SYMBOL_PATTERN = re.compile(r"[\w$'!]+|[+\-*/%&|^~<>=]+") + + +def get_symbol_at_position(file_path: str | Path, line: int, character: int) -> str | None: + """Return the symbol at a 1-based line/character, or None on failure.""" + context = get_symbol_context(file_path, line, character, context_lines=0) + if context is None: + return None + first_line = context.splitlines()[0] + prefix = "Symbol: " + if first_line.startswith(prefix): + symbol = first_line[len(prefix) :] + return symbol[:MAX_SYMBOL_LEN] if symbol else None + return None + + +def get_symbol_context( + file_path: str | Path, + line: int, + character: int, + *, + context_lines: int = 2, +) -> str | None: + """Return the symbol and surrounding lines for a 1-based position.""" + if line < 1 or character < 1: + return None + + path = Path(file_path) + try: + with path.open("rb") as handle: + chunk = handle.read(MAX_READ_BYTES) + except OSError: + return None + + try: + content = chunk.decode("utf-8") + except UnicodeDecodeError: + return None + + lines = content.splitlines() + zero_line = line - 1 + zero_char = character - 1 + + if zero_line < 0 or zero_line >= len(lines): + return None + if len(chunk) == MAX_READ_BYTES and zero_line == len(lines) - 1: + return None + + line_content = lines[zero_line] + if zero_char < 0 or zero_char >= len(line_content): + return None + + symbol: str | None = None + for match in _SYMBOL_PATTERN.finditer(line_content): + start = match.start() + end = start + len(match.group(0)) + if zero_char >= start and zero_char < end: + symbol = match.group(0)[:MAX_SYMBOL_LEN] + break + + parts: list[str] = [] + if symbol: + parts.append(f"Symbol: {symbol}") + + if context_lines > 0: + start = max(0, zero_line - context_lines) + end = min(len(lines), zero_line + context_lines + 1) + snippet = "\n".join(f"{start + idx + 1:>4}| {lines[idx]}" for idx in range(start, end)) + parts.append(snippet) + + return "\n".join(parts) if parts else None diff --git a/src/pythinker_code/tools/lsp/tool.md b/src/pythinker_code/tools/lsp/tool.md new file mode 100644 index 00000000..50c9a05b --- /dev/null +++ b/src/pythinker_code/tools/lsp/tool.md @@ -0,0 +1,19 @@ +Interact with Language Server Protocol (LSP) servers to get code intelligence features. + +Supported operations: +- goToDefinition: Find where a symbol is defined +- findReferences: Find all references to a symbol +- hover: Get hover information (documentation, type info) for a symbol +- documentSymbol: Get all symbols (functions, classes, variables) in a document +- workspaceSymbol: Search for symbols across the entire workspace +- goToImplementation: Find implementations of an interface or abstract method +- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods) +- incomingCalls: Find all functions/methods that call the function at a position +- outgoingCalls: Find all functions/methods called by the function at a position + +All operations require: +- file_path: The file to operate on +- line: The line number (1-based, as shown in editors) +- character: The character offset (1-based, as shown in editors) + +Note: LSP servers must be configured for the file type. If no server is available, an error will be returned. diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py new file mode 100644 index 00000000..ec9973bd --- /dev/null +++ b/src/pythinker_code/tools/lsp/tool.py @@ -0,0 +1,389 @@ +"""LSP agent tool.""" + +# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false + +import asyncio +import contextlib +from collections.abc import Sequence +from pathlib import Path +from typing import Any, override +from urllib.parse import unquote + +import pythinker_host +from pythinker_core.tooling import CallableTool2, ToolReturnValue +from pythinker_host.path import HostPath + +from pythinker_code.lsp.recommend import get_matching_lsp_plugins +from pythinker_code.lsp.service import LspInitStatus +from pythinker_code.soul.agent import Runtime +from pythinker_code.tools import SkipThisTool +from pythinker_code.tools.lsp.formatters import MAX_RESULT_SIZE_CHARS, format_result +from pythinker_code.tools.lsp.schemas import Operation, Params +from pythinker_code.tools.lsp.symbol_context import get_symbol_at_position +from pythinker_code.tools.utils import ToolResultBuilder, load_desc +from pythinker_code.utils.logging import logger + +MAX_LSP_FILE_SIZE_BYTES = 10_000_000 +_GIT_CHECK_IGNORE_BATCH_SIZE = 50 +_GIT_CHECK_IGNORE_TIMEOUT = 5.0 + + +class Lsp(CallableTool2[Params]): + name: str = "LSP" + supports_parallel: bool = True + description: str = load_desc(Path(__file__).parent / "tool.md", {}) + params: type[Params] = Params + + def __init__(self, runtime: Runtime) -> None: + super().__init__() + if not runtime.config.lsp.enabled or runtime.lsp is None: + raise SkipThisTool() + self._runtime = runtime + self._lsp = runtime.lsp + self._work_dir = runtime.work_dir + self._recommended_exts: set[str] = set() + + @override + async def __call__(self, params: Params) -> ToolReturnValue: + builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) + + if self._lsp.status() == LspInitStatus.PENDING: + await self._lsp.wait_for_init() + + if self._lsp.status() != LspInitStatus.SUCCESS or self._lsp.manager is None: + return builder.error( + ( + "LSP is still initializing or unavailable. " + "Try again after language servers finish starting." + ), + brief="LSP unavailable", + ) + + absolute_path, validation_error = await self._validate_file(params.file_path) + if validation_error is not None: + return validation_error + + manager = self._lsp.manager + assert absolute_path is not None + + if manager.server_for_file(absolute_path) is None: + ext = Path(absolute_path).suffix + builder.write(f"No LSP server available for file type: {ext or '(none)'}\n") + hint = self._recommendation_hint(absolute_path) + if hint: + builder.write(hint) + builder.mark_untrusted() + return builder.ok(brief=self._brief(params)) + + try: + if not manager.is_file_open(absolute_path): + size_error = await self._ensure_file_open(manager, absolute_path, params.file_path) + if size_error is not None: + return size_error + + method, request_params = _method_and_params(params, absolute_path) + # A None result here means the server ran and returned an empty/null + # response (e.g. definition not found) — distinct from "no server", + # which is handled above. format_result() renders empty as guidance. + result = await manager.send_request(absolute_path, method, request_params) + + if params.operation in (Operation.INCOMING_CALLS, Operation.OUTGOING_CALLS): + call_items = result if isinstance(result, list) else [] + if not call_items: + builder.write("No call hierarchy item found at this position\n") + builder.mark_untrusted() + return builder.ok(brief=self._brief(params)) + + call_method = ( + "callHierarchy/incomingCalls" + if params.operation == Operation.INCOMING_CALLS + else "callHierarchy/outgoingCalls" + ) + result = await manager.send_request( + absolute_path, + call_method, + {"item": call_items[0]}, + ) + + result = await _filter_gitignored_results( + params.operation, + result, + str(self._work_dir), + ) + + formatted, _result_count, _file_count = format_result( + params.operation, + result, + str(self._work_dir), + ) + builder.write(formatted) + builder.mark_untrusted() + return builder.ok(brief=self._brief(params)) + except Exception as exc: + logger.error( + "LSP tool request failed for {operation} on {file_path}: {err}", + operation=params.operation, + file_path=params.file_path, + err=exc, + ) + return builder.error( + f"Error performing {params.operation}: {exc}", + brief=self._brief(params), + ) + + def _recommendation_hint(self, absolute_path: str) -> str | None: + # CLI re-expression of the reference's plugin-recommendation menu: when the + # agent hits a file type with no installed server, suggest a marketplace + # plugin once per extension per session. Gated by recommendation_disabled / + # recommendation_never inside get_matching_lsp_plugins. The reference's + # persisted >=5 ignored-count auto-disable is intentionally NOT wired here: + # it requires incremental writes to the shared global config, and the + # current save_config() rewrites the whole file with no lock/atomic rename + # (multi-instance clobber risk). Deferred until a safe global-write path + # exists; the disabled/never flags still apply. + ext = Path(absolute_path).suffix.lower() + if not ext or ext in self._recommended_exts: + return None + self._recommended_exts.add(ext) + try: + matches = get_matching_lsp_plugins(absolute_path, self._runtime.config) + except Exception: + logger.debug("LSP plugin recommendation lookup failed for {ext}", ext=ext) + return None + if not matches: + return None + top = matches[0] + return ( + f"\nTip: install the '{top.plugin_name}' plugin for {ext} code intelligence " + f"(pythinker plugin add {top.plugin_id}).\n" + ) + + def _brief(self, params: Params) -> str: + symbol = get_symbol_at_position( + _resolve_path(params.file_path, self._work_dir), + params.line, + params.character, + ) + if symbol: + return f"{params.operation} {symbol}" + return f"{params.operation} {params.file_path}:{params.line}:{params.character}" + + async def _validate_file(self, file_path: str) -> tuple[str | None, ToolReturnValue | None]: + builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) + + if _is_unc_path(file_path): + return None, builder.error( + "UNC paths are not supported for LSP operations.", + brief="UNC path rejected", + ) + + absolute = _resolve_path(file_path, self._work_dir) + + if _is_unc_path(absolute): + return None, builder.error( + "UNC paths are not supported for LSP operations.", + brief="UNC path rejected", + ) + + host_path = HostPath(absolute) + try: + if not await host_path.is_file(): + if await host_path.exists(): + return None, builder.error( + f"Path is not a file: {file_path}", + brief="Not a file", + ) + return None, builder.error( + f"File does not exist: {file_path}", + brief="File not found", + ) + except OSError as exc: + return None, builder.error( + f"Cannot access file: {file_path}. {exc}", + brief="File access error", + ) + + return absolute, None + + async def _ensure_file_open( + self, + manager: Any, + absolute_path: str, + display_path: str, + ) -> ToolReturnValue | None: + builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) + host_path = HostPath(absolute_path) + try: + stat = await host_path.stat() + except OSError as exc: + return builder.error( + f"Cannot access file: {display_path}. {exc}", + brief="File access error", + ) + + if stat.st_size > MAX_LSP_FILE_SIZE_BYTES: + size_mb = (stat.st_size + 999_999) // 1_000_000 + builder.write(f"File too large for LSP analysis ({size_mb}MB exceeds 10MB limit)\n") + builder.mark_untrusted() + return builder.ok(brief=_brief_for_path(display_path)) + + content = await host_path.read_text(errors="replace") + await manager.open_file(absolute_path, content) + return None + + +def _brief_for_path(file_path: str) -> str: + return f"LSP {file_path}" + + +def _resolve_path(file_path: str, work_dir: HostPath) -> str: + raw = HostPath(file_path).expanduser() + joined = raw if raw.is_absolute() else work_dir.joinpath(str(raw)) + return str(Path(str(joined)).resolve()) + + +def _is_unc_path(path: str) -> bool: + return path.startswith("\\\\") or path.startswith("//") + + +def _file_uri(path: str) -> str: + return Path(path).resolve().as_uri() + + +def _method_and_params(params: Params, absolute_path: str) -> tuple[str, dict[str, Any]]: + uri = _file_uri(absolute_path) + position = {"line": params.line - 1, "character": params.character - 1} + text_document = {"textDocument": {"uri": uri}, "position": position} + + match params.operation: + case Operation.GO_TO_DEFINITION: + return "textDocument/definition", text_document + case Operation.FIND_REFERENCES: + return "textDocument/references", { + **text_document, + "context": {"includeDeclaration": True}, + } + case Operation.HOVER: + return "textDocument/hover", text_document + case Operation.DOCUMENT_SYMBOL: + return "textDocument/documentSymbol", {"textDocument": {"uri": uri}} + case Operation.WORKSPACE_SYMBOL: + return "workspace/symbol", {"query": ""} + case Operation.GO_TO_IMPLEMENTATION: + return "textDocument/implementation", text_document + case Operation.PREPARE_CALL_HIERARCHY | Operation.INCOMING_CALLS | Operation.OUTGOING_CALLS: + return "textDocument/prepareCallHierarchy", text_document + + +def _to_location(item: dict[str, Any]) -> dict[str, Any]: + if "targetUri" in item: + return { + "uri": item.get("targetUri"), + "range": item.get("targetSelectionRange") or item.get("targetRange") or {}, + } + return item + + +def _uri_to_file_path(uri: str) -> str: + file_path = uri.removeprefix("file://") + if len(file_path) >= 3 and file_path[0] == "/" and file_path[2] == ":": + file_path = file_path[1:] + with contextlib.suppress(Exception): + file_path = unquote(file_path) + return file_path + + +async def _filter_gitignored_results( + operation: Operation, + result: Any, + cwd: str, +) -> Any: + if not result or not isinstance(result, list): + return result + + if operation not in ( + Operation.FIND_REFERENCES, + Operation.GO_TO_DEFINITION, + Operation.GO_TO_IMPLEMENTATION, + Operation.WORKSPACE_SYMBOL, + ): + return result + + if operation == Operation.WORKSPACE_SYMBOL: + locations = [ + sym.get("location") + for sym in result + if isinstance(sym, dict) and (sym.get("location") or {}).get("uri") + ] + filtered = await _filter_gitignored_locations(locations, cwd) + filtered_uris = {loc.get("uri") for loc in filtered if loc.get("uri")} + return [ + sym + for sym in result + if not (sym.get("location") or {}).get("uri") or sym["location"]["uri"] in filtered_uris + ] + + locations = [_to_location(item) for item in result if isinstance(item, dict)] + filtered = await _filter_gitignored_locations(locations, cwd) + filtered_uris = {loc.get("uri") for loc in filtered if loc.get("uri")} + return [ + item + for item in result + if isinstance(item, dict) and _to_location(item).get("uri") in filtered_uris + ] + + +async def _filter_gitignored_locations( + locations: Sequence[dict[str, Any] | None], + cwd: str, +) -> list[dict[str, Any]]: + valid_locations = [loc for loc in locations if loc and loc.get("uri")] + if not valid_locations: + return [] + + uri_to_path: dict[str, str] = {} + for loc in valid_locations: + uri = loc["uri"] + if uri not in uri_to_path: + uri_to_path[uri] = _uri_to_file_path(uri) + + unique_paths = list(dict.fromkeys(uri_to_path.values())) + if not unique_paths: + return valid_locations + + ignored_paths: set[str] = set() + for index in range(0, len(unique_paths), _GIT_CHECK_IGNORE_BATCH_SIZE): + batch = unique_paths[index : index + _GIT_CHECK_IGNORE_BATCH_SIZE] + stdout = await _run_git_check_ignore(cwd, batch) + if stdout: + ignored_paths.update(line.strip() for line in stdout.splitlines() if line.strip()) + + if not ignored_paths: + return valid_locations + + return [loc for loc in valid_locations if uri_to_path.get(loc["uri"], "") not in ignored_paths] + + +async def _run_git_check_ignore(cwd: str, paths: list[str]) -> str | None: + proc = None + try: + proc = await pythinker_host.exec("git", "-C", cwd, "check-ignore", *paths) + proc.stdin.close() + stdout_bytes = await asyncio.wait_for( + proc.stdout.read(-1), + timeout=_GIT_CHECK_IGNORE_TIMEOUT, + ) + exit_code = await asyncio.wait_for(proc.wait(), timeout=_GIT_CHECK_IGNORE_TIMEOUT) + if exit_code == 0: + return stdout_bytes.decode("utf-8", errors="replace") + return None + except TimeoutError: + if proc is not None: + await proc.kill() + await proc.wait() + return None + except Exception: + if proc is not None and proc.returncode is None: + await proc.kill() + await proc.wait() + return None diff --git a/tests/tools/test_lsp_client.py b/tests/tools/test_lsp_client.py new file mode 100644 index 00000000..59fb93c9 --- /dev/null +++ b/tests/tools/test_lsp_client.py @@ -0,0 +1,253 @@ +"""Unit tests for the hand-rolled LSP client transport.""" + +from __future__ import annotations + +import asyncio +import sys +from typing import Any + +import pytest +from pythinker_host.local import LocalHost + +from pythinker_code.lsp.client import LspClient +from pythinker_code.lsp.framing import ( + LspProtocolError, + LspServerDown, + read_message, + write_message, +) +from pythinker_code.lsp.protocol import InitializeParams + +_FAKE_SERVER = """ +import json +import sys + + +def read_msg(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode("ascii").rstrip("\\r\\n") + if text == "": + break + if text.lower().startswith("content-length:"): + content_length = int(text.split(":", 1)[1].strip()) + if content_length is None: + return None + body = sys.stdin.buffer.read(content_length) + return json.loads(body) + + +def write_msg(msg): + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\\r\\n\\r\\n".encode("ascii") + sys.stdout.buffer.write(header + body) + sys.stdout.buffer.flush() + + +while True: + msg = read_msg() + if msg is None: + break + if "id" in msg and "method" in msg: + req_id = msg["id"] + method = msg["method"] + if method == "initialize": + write_msg( + { + "jsonrpc": "2.0", + "id": req_id, + "result": {"capabilities": {"hoverProvider": True}}, + } + ) + elif method == "custom/request": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"value": 42}}) + elif method == "shutdown": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + else: + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + elif msg.get("method") == "initialized": + write_msg( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///tmp/x.py", "diagnostics": []}, + } + ) +""" + +_HANG_SERVER = """ +import json +import sys +import time + + +def read_msg(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode("ascii").rstrip("\\r\\n") + if text == "": + break + if text.lower().startswith("content-length:"): + content_length = int(text.split(":", 1)[1].strip()) + if content_length is None: + return None + body = sys.stdin.buffer.read(content_length) + return json.loads(body) + + +def write_msg(msg): + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\\r\\n\\r\\n".encode("ascii") + sys.stdout.buffer.write(header + body) + sys.stdout.buffer.flush() + + +while True: + msg = read_msg() + if msg is None: + break + if "id" in msg and "method" in msg: + req_id = msg["id"] + method = msg["method"] + if method == "initialize": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"capabilities": {}}}) + elif method == "hang": + while True: + time.sleep(3600) + elif method == "shutdown": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) +""" + + +@pytest.fixture +def local_host() -> LocalHost: + return LocalHost() + + +class TestFraming: + @pytest.mark.asyncio + async def test_round_trip(self) -> None: + async def echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + message = await read_message(reader) + await write_message( + writer, + {"jsonrpc": "2.0", "id": message["id"], "result": "pong"}, + ) + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(echo, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + + payload = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"x": 1}} + await write_message(writer, payload) + received = await read_message(reader) + assert received == {"jsonrpc": "2.0", "id": 1, "result": "pong"} + + writer.close() + await writer.wait_closed() + server.close() + await server.wait_closed() + + @pytest.mark.asyncio + async def test_malformed_frame_raises(self) -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"not a valid header\r\n\r\n{}") + reader.feed_eof() + with pytest.raises(LspProtocolError): + await read_message(reader) + + @pytest.mark.asyncio + async def test_eof_on_header_raises_server_down(self) -> None: + # A dead server's stdout closes with no data: this is the process gone, + # not a malformed frame, so it must surface as LspServerDown (so the read + # loop fails pending requests instead of busy-spinning on LspProtocolError). + reader = asyncio.StreamReader() + reader.feed_eof() + with pytest.raises(LspServerDown): + await read_message(reader) + + @pytest.mark.asyncio + async def test_eof_mid_body_raises_server_down(self) -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"Content-Length: 100\r\n\r\n{}") + reader.feed_eof() + with pytest.raises(LspServerDown): + await read_message(reader) + + +class TestLspClient: + @pytest.mark.asyncio + async def test_initialize_stores_capabilities(self, local_host: LocalHost) -> None: + client = LspClient(local_host) + await client.start(sys.executable, ["-c", _FAKE_SERVER]) + try: + result = await client.initialize(InitializeParams(processId=123, rootUri="file:///tmp")) + assert client.is_initialized + assert client.capabilities is not None + assert result.capabilities.hoverProvider is True + finally: + await client.stop() + + @pytest.mark.asyncio + async def test_request_resolves_on_matching_id(self, local_host: LocalHost) -> None: + client = LspClient(local_host) + await client.start(sys.executable, ["-c", _FAKE_SERVER]) + try: + await client.initialize(InitializeParams(processId=1)) + result = await client.send_request("custom/request", {"q": "x"}) + assert result == {"value": 42} + finally: + await client.stop() + + @pytest.mark.asyncio + async def test_notification_reaches_handler(self, local_host: LocalHost) -> None: + client = LspClient(local_host) + seen: dict[str, Any] = {} + + def handler(params: Any) -> None: + seen["params"] = params + + client.on_notification("textDocument/publishDiagnostics", handler) + await client.start(sys.executable, ["-c", _FAKE_SERVER]) + try: + await client.initialize(InitializeParams(processId=1)) + for _ in range(50): + if "params" in seen: + break + await asyncio.sleep(0.05) + assert seen["params"]["uri"] == "file:///tmp/x.py" + finally: + await client.stop() + + @pytest.mark.asyncio + async def test_process_death_fails_pending_requests(self, local_host: LocalHost) -> None: + client = LspClient(local_host) + await client.start(sys.executable, ["-c", _HANG_SERVER]) + await client.initialize(InitializeParams(processId=1)) + + pending = asyncio.create_task(client.send_request("hang", {})) + await asyncio.sleep(0.2) + assert client._proc is not None + await client._proc.kill() + + with pytest.raises(LspServerDown): + await asyncio.wait_for(pending, timeout=2.0) + + await client.stop() + + @pytest.mark.asyncio + async def test_stop_is_idempotent(self, local_host: LocalHost) -> None: + client = LspClient(local_host) + await client.start(sys.executable, ["-c", _FAKE_SERVER]) + await client.initialize(InitializeParams(processId=1)) + await client.stop() + await client.stop() + assert client._proc is None diff --git a/tests/tools/test_lsp_diagnostics.py b/tests/tools/test_lsp_diagnostics.py new file mode 100644 index 00000000..dfb25f23 --- /dev/null +++ b/tests/tools/test_lsp_diagnostics.py @@ -0,0 +1,342 @@ +"""Tests for passive LSP diagnostics registry, injection provider, and file hooks.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +from pythinker_host.path import HostPath + +from pythinker_code.lsp.diagnostics import ( + SENT_FILE_LRU_CAP, + DiagnosticEntry, + DiagnosticFile, + DiagnosticRegistry, + ServerDiagnostics, + register_publish_diagnostics_handler, + render_diagnostics_block, + uri_to_path, +) +from pythinker_code.lsp.protocol import Position, Range +from pythinker_code.soul.approval import Approval +from pythinker_code.soul.dynamic_injection import ( + DynamicInjection, + collect_within_budget, + dynamic_to_candidate, +) +from pythinker_code.soul.dynamic_injections.lsp_diagnostics import LspDiagnosticsInjectionProvider +from pythinker_code.tools.file.replace import Edit, StrReplaceFile +from pythinker_code.tools.file.replace import Params as ReplaceParams +from pythinker_code.tools.file.write import Params as WriteParams +from pythinker_code.tools.file.write import WriteFile +from tests.conftest import tool_call_context + + +def _entry( + message: str, + severity: int, + line: int = 0, + *, + source: str | None = None, + code: str | int | None = None, +) -> DiagnosticEntry: + return DiagnosticEntry( + message=message, + severity=severity, + range=Range(start=Position(line=line, character=0), end=Position(line=line, character=1)), + source=source, + code=code, + ) + + +def _file(uri: str, diagnostics: list[DiagnosticEntry]) -> DiagnosticFile: + return DiagnosticFile(uri=uri, path=uri_to_path(uri) or uri, diagnostics=diagnostics) + + +class TestDiagnosticRegistry: + def test_dedup_within_batch(self) -> None: + registry = DiagnosticRegistry() + duplicate = _entry("same", 1) + registry.register_pending( + "pyright", + [_file("file:///tmp/a.py", [duplicate, duplicate])], + ) + assert registry.pending_count == 1 + + def test_dedup_across_turns(self) -> None: + registry = DiagnosticRegistry() + file = _file("file:///tmp/a.py", [_entry("error one", 1)]) + registry.register_pending("pyright", [file]) + first = registry.check_for_diagnostics() + assert len(first) == 1 + registry.register_pending("pyright", [file]) + assert registry.check_for_diagnostics() == [] + + def test_volume_cap_per_file(self) -> None: + registry = DiagnosticRegistry() + diagnostics = [_entry(f"msg-{index}", 1, line=index) for index in range(15)] + registry.register_pending("pyright", [_file("file:///tmp/a.py", diagnostics)]) + groups = registry.check_for_diagnostics() + assert sum(len(file.diagnostics) for file in groups[0].files) == 10 + + def test_volume_cap_total(self) -> None: + registry = DiagnosticRegistry() + for index in range(40): + registry.register_pending( + "pyright", + [_file(f"file:///tmp/file{index}.py", [_entry("err", 1)])], + ) + groups = registry.check_for_diagnostics() + total = sum(len(file.diagnostics) for group in groups for file in group.files) + assert total == 30 + + def test_severity_sort_prefers_errors(self) -> None: + registry = DiagnosticRegistry() + registry.register_pending( + "pyright", + [ + _file( + "file:///tmp/a.py", + [ + _entry("warning", 2), + _entry("error", 1), + _entry("hint", 4), + ], + ) + ], + ) + groups = registry.check_for_diagnostics() + messages = [diag.message for diag in groups[0].files[0].diagnostics] + assert messages == ["error", "warning", "hint"] + + def test_clear_for_file_and_clear_all(self) -> None: + registry = DiagnosticRegistry() + registry.register_pending("pyright", [_file("file:///tmp/a.py", [_entry("err", 1)])]) + assert registry.pending_count == 1 + registry.clear_for_file("file:///tmp/a.py") + assert registry.pending_count == 0 + registry.register_pending("pyright", [_file("file:///tmp/b.py", [_entry("err", 1)])]) + registry.clear_all() + assert registry.pending_count == 0 + + def test_sent_file_lru_cap(self) -> None: + registry = DiagnosticRegistry() + for index in range(SENT_FILE_LRU_CAP + 5): + uri = f"file:///tmp/file{index}.py" + registry.register_pending("pyright", [_file(uri, [_entry("err", 1)])]) + registry.check_for_diagnostics() + assert len(registry._sent_keys) == SENT_FILE_LRU_CAP + + def test_render_diagnostics_block(self) -> None: + from pythinker_code.lsp.diagnostics import ServerDiagnostics + + groups = [ + ServerDiagnostics( + server_name="pyright", + files=[ + _file( + "file:///tmp/a.py", [_entry("type error", 1, source="pyright", code="E001")] + ) + ], + ) + ] + text = render_diagnostics_block(groups) + assert "LSP diagnostics" in text + assert "/tmp/a.py" in text + assert "Error (1:1)" in text + assert "type error" in text + + +class TestPublishDiagnosticsHandler: + async def test_handler_registers_and_resets_failures(self) -> None: + registry = DiagnosticRegistry() + instance = MagicMock() + register_publish_diagnostics_handler(registry, "pyright", instance) + handler = instance.on_notification.call_args[0][1] + + await handler( + { + "uri": "file:///tmp/a.py", + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1}, + }, + "message": "bad", + "severity": 1, + } + ], + } + ) + assert registry.pending_count == 1 + + await handler({"uri": "not-a-valid-params"}) + assert registry.pending_count == 1 + + +class TestLspDiagnosticsInjectionProvider: + def _make_runtime(self, *, connected: bool) -> MagicMock: + runtime = MagicMock() + lsp = MagicMock() + lsp.is_connected.return_value = connected + lsp.diagnostics = DiagnosticRegistry() + runtime.lsp = lsp + return runtime + + async def test_returns_nothing_when_disconnected(self) -> None: + provider = LspDiagnosticsInjectionProvider(self._make_runtime(connected=False)) + soul = MagicMock() + assert await provider.get_injections([], soul) == [] + + async def test_returns_block_when_connected(self) -> None: + runtime = self._make_runtime(connected=True) + runtime.lsp.diagnostics.register_pending( + "pyright", + [_file("file:///tmp/a.py", [_entry("oops", 1)])], + ) + provider = LspDiagnosticsInjectionProvider(runtime) + soul = MagicMock() + result = await provider.get_injections([], soul) + assert len(result) == 1 + assert result[0].type == "lsp_diagnostics" + assert "oops" in result[0].content + assert await provider.get_injections([], soul) == [] + + async def test_rearm_allows_reinjection(self) -> None: + runtime = self._make_runtime(connected=True) + provider = LspDiagnosticsInjectionProvider(runtime) + soul = MagicMock() + runtime.lsp.diagnostics.register_pending( + "pyright", + [_file("file:///tmp/a.py", [_entry("first", 1)])], + ) + await provider.get_injections([], soul) + provider.rearm("lsp_diagnostics") + runtime.lsp.diagnostics.register_pending( + "pyright", + [_file("file:///tmp/b.py", [_entry("second", 1)])], + ) + result = await provider.get_injections([], soul) + assert "second" in result[0].content + + def test_budget_truncates_large_block(self) -> None: + lines = "\n".join(f"line {index}" for index in range(200)) + text = render_diagnostics_block( + [ + ServerDiagnostics( + server_name="pyright", + files=[_file("file:///tmp/a.py", [_entry(lines, 1)])], + ) + ] + ) + candidate = dynamic_to_candidate( + DynamicInjection(type="lsp_diagnostics", content=text), + priority=100, + ) + selected = collect_within_budget([candidate], budget_tokens=20) + assert len(selected) == 1 + assert selected[0].content.endswith("…") + + +class TestFileToolLspHooks: + async def test_write_file_calls_lsp_and_rearms( + self, runtime, approval, temp_work_dir: HostPath + ) -> None: + lsp = MagicMock() + lsp.change_file = AsyncMock() + lsp.save_file = AsyncMock() + lsp.diagnostics = MagicMock() + rearmed: list[str] = [] + runtime.lsp = lsp + runtime.rearm_injection = rearmed.append + + target = Path(temp_work_dir.unsafe_to_local_path()) / "hook.py" + with tool_call_context("WriteFile"): + tool = WriteFile(runtime, Approval(yolo=True)) + result = await tool(WriteParams(path=str(target), content="print('hi')\n")) + + assert not result.is_error + lsp.diagnostics.clear_for_file.assert_called_once_with(target.resolve().as_uri()) + lsp.change_file.assert_awaited_once_with(str(target), "print('hi')\n") + lsp.save_file.assert_awaited_once_with(str(target)) + assert rearmed == ["lsp_diagnostics"] + + async def test_str_replace_file_calls_lsp_and_rearms( + self, runtime, approval, temp_work_dir: HostPath + ) -> None: + lsp = MagicMock() + lsp.change_file = AsyncMock() + lsp.save_file = AsyncMock() + lsp.diagnostics = MagicMock() + rearmed: list[str] = [] + runtime.lsp = lsp + runtime.rearm_injection = rearmed.append + + target = Path(temp_work_dir.unsafe_to_local_path()) / "edit.py" + target.write_text("old value\n", encoding="utf-8") + + with tool_call_context("StrReplaceFile"): + tool = StrReplaceFile(runtime, Approval(yolo=True)) + result = await tool(ReplaceParams(path=str(target), edit=Edit(old="old", new="new"))) + + assert not result.is_error + lsp.diagnostics.clear_for_file.assert_called_once_with(target.resolve().as_uri()) + lsp.change_file.assert_awaited_once_with(str(target), "new value\n") + lsp.save_file.assert_awaited_once_with(str(target)) + assert rearmed == ["lsp_diagnostics"] + + async def test_write_file_succeeds_when_lsp_notification_fails( + self, runtime, approval, temp_work_dir: HostPath + ) -> None: + lsp = MagicMock() + lsp.change_file = AsyncMock(side_effect=RuntimeError("LSP server crashed")) + lsp.save_file = AsyncMock() + lsp.diagnostics = MagicMock() + rearmed: list[str] = [] + runtime.lsp = lsp + runtime.rearm_injection = rearmed.append + + target = Path(temp_work_dir.unsafe_to_local_path()) / "hook.py" + with tool_call_context("WriteFile"): + tool = WriteFile(runtime, Approval(yolo=True)) + result = await tool(WriteParams(path=str(target), content="print('hi')\n")) + + assert not result.is_error + assert target.read_text(encoding="utf-8") == "print('hi')\n" + lsp.diagnostics.clear_for_file.assert_called_once_with(target.resolve().as_uri()) + assert rearmed == [] + + async def test_str_replace_file_succeeds_when_lsp_notification_fails( + self, runtime, approval, temp_work_dir: HostPath + ) -> None: + lsp = MagicMock() + lsp.change_file = AsyncMock(side_effect=RuntimeError("LSP server crashed")) + lsp.save_file = AsyncMock() + lsp.diagnostics = MagicMock() + rearmed: list[str] = [] + runtime.lsp = lsp + runtime.rearm_injection = rearmed.append + + target = Path(temp_work_dir.unsafe_to_local_path()) / "edit.py" + target.write_text("old value\n", encoding="utf-8") + + with tool_call_context("StrReplaceFile"): + tool = StrReplaceFile(runtime, Approval(yolo=True)) + result = await tool(ReplaceParams(path=str(target), edit=Edit(old="old", new="new"))) + + assert not result.is_error + assert target.read_text(encoding="utf-8") == "new value\n" + lsp.diagnostics.clear_for_file.assert_called_once_with(target.resolve().as_uri()) + assert rearmed == [] + + async def test_write_file_skips_lsp_when_unwired( + self, runtime, approval, temp_work_dir: HostPath + ) -> None: + runtime.lsp = None + runtime.rearm_injection = None + target = Path(temp_work_dir.unsafe_to_local_path()) / "plain.py" + with tool_call_context("WriteFile"): + tool = WriteFile(runtime, Approval(yolo=True)) + result = await tool(WriteParams(path=str(target), content="x")) + assert not result.is_error diff --git a/tests/tools/test_lsp_manager.py b/tests/tools/test_lsp_manager.py new file mode 100644 index 00000000..0414979c --- /dev/null +++ b/tests/tools/test_lsp_manager.py @@ -0,0 +1,389 @@ +"""Unit tests for LSP server manager and instance lifecycle.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from pythinker_host.local import LocalHost + +from pythinker_code.config import LspServerConfig +from pythinker_code.lsp.framing import LspServerDown, LspStartError +from pythinker_code.lsp.instance import LspServerInstance, LspState +from pythinker_code.lsp.manager import LspServerManager + +_RECORDING_SERVER = """ +import json +import os +import sys + +LOG = os.environ.get("LSP_TEST_LOG") + + +def read_msg(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode("ascii").rstrip("\\r\\n") + if text == "": + break + if text.lower().startswith("content-length:"): + content_length = int(text.split(":", 1)[1].strip()) + if content_length is None: + return None + body = sys.stdin.buffer.read(content_length) + return json.loads(body) + + +def write_msg(msg): + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\\r\\n\\r\\n".encode("ascii") + sys.stdout.buffer.write(header + body) + sys.stdout.buffer.flush() + + +def log_event(name, payload): + if not LOG: + return + with open(LOG, "a", encoding="utf-8") as fh: + fh.write(json.dumps({"event": name, "payload": payload}) + "\\n") + + +while True: + msg = read_msg() + if msg is None: + break + if "id" in msg and "method" in msg: + req_id = msg["id"] + method = msg["method"] + if method == "initialize": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"capabilities": {}}}) + elif method == "shutdown": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + else: + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + elif "method" in msg: + log_event(msg["method"], msg.get("params", {})) + if msg["method"] == "exit": + break +""" + +_RETRY_SERVER = """ +import json +import sys + +STATE = {"attempts": 0} + + +def read_msg(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode("ascii").rstrip("\\r\\n") + if text == "": + break + if text.lower().startswith("content-length:"): + content_length = int(text.split(":", 1)[1].strip()) + if content_length is None: + return None + body = sys.stdin.buffer.read(content_length) + return json.loads(body) + + +def write_msg(msg): + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\\r\\n\\r\\n".encode("ascii") + sys.stdout.buffer.write(header + body) + sys.stdout.buffer.flush() + + +while True: + msg = read_msg() + if msg is None: + break + if "id" in msg and "method" in msg: + req_id = msg["id"] + method = msg["method"] + if method == "initialize": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"capabilities": {}}}) + elif method == "flaky/request": + STATE["attempts"] += 1 + if STATE["attempts"] < 3: + write_msg( + { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32801, "message": "content modified"}, + } + ) + else: + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"ok": True}}) + elif method == "shutdown": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + else: + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + elif msg.get("method") == "exit": + break +""" + + +@pytest.fixture +def local_host() -> LocalHost: + return LocalHost() + + +def _server_config(*, ext: str, language: str, log_file: Path | None = None) -> LspServerConfig: + env: dict[str, str] = {} + if log_file is not None: + env["LSP_TEST_LOG"] = str(log_file) + return LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", _RECORDING_SERVER], + "extensionToLanguage": {ext: language}, + "env": env, + "maxRestarts": 2, + "startupTimeout": 10.0, + } + ) + + +class TestLspServerManager: + @pytest.mark.asyncio + async def test_extension_routing_first_match_wins( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + manager = LspServerManager( + local_host, + { + "pyright": _server_config(ext=".py", language="python"), + "typescript": _server_config(ext=".ts", language="typescript"), + "typescript_dup": LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", _RECORDING_SERVER], + "extensionToLanguage": {".ts": "typescript"}, + } + ), + }, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + + py_server = manager.server_for_file("foo.py") + ts_server = manager.server_for_file("bar.ts") + assert py_server is not None and py_server.name == "pyright" + assert ts_server is not None and ts_server.name == "typescript" + + await manager.shutdown() + + @pytest.mark.asyncio + async def test_open_change_fallback_and_did_save( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + manager = LspServerManager( + local_host, + {"pyright": _server_config(ext=".py", language="python")}, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + server = manager.server_for_file("sample.py") + assert server is not None + + sent: list[str] = [] + original_send = server.send_notification + + async def track_send(method: str, params: Any) -> None: + sent.append(method) + await original_send(method, params) + + server.send_notification = track_send # type: ignore[method-assign] + + target = tmp_path / "sample.py" + target.write_text("x = 1\n", encoding="utf-8") + + await manager.change_file(str(target), "x = 2\n") + assert manager.is_file_open(str(target)) + assert "textDocument/didOpen" in sent + + await manager.change_file(str(target), "x = 3\n") + assert "textDocument/didChange" in sent + + await manager.save_file(str(target)) + assert "textDocument/didSave" in sent + + await manager.shutdown() + + @pytest.mark.asyncio + async def test_shutdown_isolates_failing_server( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + manager = LspServerManager( + local_host, + { + "good": _server_config(ext=".py", language="python"), + "also_good": _server_config(ext=".js", language="javascript"), + }, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + + good = manager.server_for_file("a.py") + bad = manager.server_for_file("b.js") + assert good is not None and bad is not None + await good.start() + await bad.start() + + bad.stop = AsyncMock(side_effect=RuntimeError("stop failed")) # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="Failed to stop"): + await manager.shutdown() + + assert manager.all_servers() == {} + + @pytest.mark.asyncio + async def test_per_server_init_failure_isolated( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + manager = LspServerManager( + local_host, + { + "bad": LspServerConfig.model_validate( + {"command": "", "extensionToLanguage": {".rs": "rust"}} + ), + "good": _server_config(ext=".py", language="python"), + }, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + + assert manager.server_for_file("x.py") is not None + assert manager.server_for_file("x.rs") is None + + await manager.shutdown() + + @pytest.mark.asyncio + async def test_crash_cap_parks_at_error(self, local_host: LocalHost, tmp_path: Path) -> None: + instance = LspServerInstance( + "broken", + LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", "import sys; sys.exit(1)"], + "extensionToLanguage": {".py": "python"}, + "maxRestarts": 2, + } + ), + local_host, + workspace_folder=str(tmp_path), + ) + + with pytest.raises((LspStartError, LspServerDown, RuntimeError, OSError)): + await instance.start() + assert instance.state == LspState.ERROR + + for _ in range(2): + with pytest.raises((LspStartError, LspServerDown, RuntimeError, OSError)): + await instance.restart() + + with pytest.raises(RuntimeError, match="Max restart attempts"): + await instance.restart() + assert instance.state == LspState.ERROR + + @pytest.mark.asyncio + async def test_unexpected_crash_parks_instance_at_error( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + # A server that exits after the first request simulates a mid-session + # crash. The client read loop must notify the instance (on_crash), which + # parks it in ERROR so the crash cap is enforced and is_healthy() is False. + crash_server = ( + "import json, sys\n" + "def read_msg():\n" + " cl = None\n" + " while True:\n" + " line = sys.stdin.buffer.readline()\n" + " if not line:\n" + " return None\n" + " t = line.decode('ascii').rstrip('\\r\\n')\n" + " if t == '':\n" + " break\n" + " if t.lower().startswith('content-length:'):\n" + " cl = int(t.split(':', 1)[1].strip())\n" + " if cl is None:\n" + " return None\n" + " return json.loads(sys.stdin.buffer.read(cl))\n" + "def write_msg(m):\n" + " b = json.dumps(m, separators=(',', ':')).encode('utf-8')\n" + " sys.stdout.buffer.write(f'Content-Length: {len(b)}\\r\\n\\r\\n'.encode('ascii') + b)\n" + " sys.stdout.buffer.flush()\n" + "m = read_msg()\n" + "write_msg({'jsonrpc': '2.0', 'id': m['id'], 'result': {'capabilities': {}}})\n" + "read_msg()\n" # 'initialized' notification + "sys.exit(1)\n" # crash before serving any request + ) + instance = LspServerInstance( + "crasher", + LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", crash_server], + "extensionToLanguage": {".py": "python"}, + } + ), + local_host, + workspace_folder=str(tmp_path), + ) + await instance.start() + assert instance.state == LspState.RUNNING + + with pytest.raises((LspServerDown, RuntimeError)): + await instance.send_request("textDocument/hover", {}) + + for _ in range(20): + if instance.state == LspState.ERROR: + break + await asyncio.sleep(0.05) + assert instance.state == LspState.ERROR + assert not instance.is_healthy() + + await instance.stop() + + @pytest.mark.asyncio + async def test_transient_error_retries_then_succeeds( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + instance = LspServerInstance( + "flaky", + LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", _RETRY_SERVER], + "extensionToLanguage": {".py": "python"}, + } + ), + local_host, + workspace_folder=str(tmp_path), + ) + await instance.start() + try: + result = await instance.send_request("flaky/request", {}) + assert result == {"ok": True} + finally: + await instance.stop() + + @pytest.mark.asyncio + async def test_workspace_configuration_shim( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + from pythinker_code.lsp.manager import _workspace_configuration_handler + + assert _workspace_configuration_handler({"items": [{}, {}]}) == [None, None] diff --git a/tests/tools/test_lsp_plugins.py b/tests/tools/test_lsp_plugins.py new file mode 100644 index 00000000..aae02128 --- /dev/null +++ b/tests/tools/test_lsp_plugins.py @@ -0,0 +1,329 @@ +"""Tests for plugin-based LSP server loading and recommendation.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, cast +from unittest.mock import patch + +import pytest + +from pythinker_code.config import Config, LspConfig +from pythinker_code.lsp.plugin_servers import plugin_lsp_servers +from pythinker_code.lsp.recommend import ( + MAX_IGNORED_COUNT, + get_matching_lsp_plugins, + is_lsp_recommendations_disabled, +) +from pythinker_code.lsp.service import LspInitStatus, LspService +from pythinker_code.plugin import loader, marketplace +from pythinker_code.plugin.installed import InstalledRecord, record_install +from pythinker_code.plugin.marketplace import MarketplaceSource +from pythinker_code.plugin.policy import PluginPolicy +from pythinker_code.soul.agent import Runtime + + +def _install_plugin( + cache: Path, + plugin: str, + *, + manifest_extra: dict[str, Any] | None = None, + lsp_json: dict[str, Any] | None = None, +) -> Path: + root = cache / "mkt" / plugin / "1.0.0" + manifest_dir = root / ".claude-plugin" + manifest_dir.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = {"name": plugin, "version": "1.0.0"} + if manifest_extra: + manifest.update(manifest_extra) + manifest_dir.joinpath("plugin.json").write_text(json.dumps(manifest), encoding="utf-8") + if lsp_json is not None: + root.joinpath(".lsp.json").write_text(json.dumps(lsp_json), encoding="utf-8") + return root + + +def _server_config(*, command: str = "echo", ext: str = ".py") -> dict[str, Any]: + return { + "command": command, + "extensionToLanguage": {ext: "python"}, + } + + +@pytest.fixture +def _no_external(monkeypatch): + monkeypatch.setattr(loader, "claude_plugin_roots", lambda: []) + monkeypatch.setattr(loader, "codex_plugin_roots", lambda: []) + + +@pytest.fixture(autouse=True) +def _share_dir(tmp_path: Path, monkeypatch): + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path / "share")) + + +def test_inline_manifest_lsp_servers(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + _install_plugin( + cache, + "py-lsp", + manifest_extra={ + "lspServers": { + "pyright": _server_config(command="pyright-langserver", ext=".py"), + } + }, + ) + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + + servers = plugin_lsp_servers() + assert list(servers) == ["plugin:py-lsp:pyright"] + cfg = servers["plugin:py-lsp:pyright"] + assert cfg.command == "pyright-langserver" + assert cfg.extension_to_language == {".py": "python"} + + +def test_lsp_json_file_loading(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + _install_plugin( + cache, + "ts-lsp", + lsp_json={"tsserver": _server_config(command="typescript-language-server", ext=".ts")}, + ) + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + + servers = plugin_lsp_servers() + assert "plugin:ts-lsp:tsserver" in servers + assert servers["plugin:ts-lsp:tsserver"].extension_to_language == {".ts": "python"} + + +def test_manifest_string_path_to_lsp_json(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + root = _install_plugin(cache, "go-lsp", manifest_extra={"lspServers": "servers.json"}) + servers_file = root / "servers.json" + servers_file.write_text( + json.dumps({"gopls": _server_config(command="gopls", ext=".go")}), + encoding="utf-8", + ) + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + + servers = plugin_lsp_servers() + assert "plugin:go-lsp:gopls" in servers + + +def test_env_resolution(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + root = _install_plugin( + cache, + "env-lsp", + manifest_extra={ + "lspServers": { + "srv": { + "command": "${PYTHINKER_PLUGIN_ROOT}/bin/lsp", + "args": ["${TEST_LSP_ARG:-fallback}"], + "extensionToLanguage": {".rs": "rust"}, + "env": {"TOKEN": "${user_config.api_key}", "HOME_PATH": "${HOME}"}, + } + }, + "userConfig": {"api_key": {"type": "string"}}, + }, + ) + (root / "bin").mkdir() + (root / "bin" / "lsp").write_text("", encoding="utf-8") + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + monkeypatch.setenv("HOME", "/tmp/home") + monkeypatch.delenv("TEST_LSP_ARG", raising=False) + + policy = PluginPolicy(options={"env-lsp": {"api_key": "secret-token"}}) + servers = plugin_lsp_servers(policy) + cfg = servers["plugin:env-lsp:srv"] + assert cfg.command == f"{root}/bin/lsp" + assert cfg.args == ["fallback"] + assert cfg.env["PYTHINKER_PLUGIN_ROOT"] == str(root) + assert cfg.env["TOKEN"] == "secret-token" + assert cfg.env["HOME_PATH"] == "/tmp/home" + assert "PYTHINKER_PLUGIN_DATA" in cfg.env + + +def test_scope_prefix(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + _install_plugin( + cache, + "one", + manifest_extra={"lspServers": {"shared": _server_config(command="first", ext=".js")}}, + ) + _install_plugin( + cache, + "two", + manifest_extra={"lspServers": {"shared": _server_config(command="second", ext=".js")}}, + ) + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + + servers = plugin_lsp_servers() + assert servers["plugin:one:shared"].command == "first" + assert servers["plugin:two:shared"].command == "second" + + +def test_external_exec_gate(tmp_path: Path, monkeypatch) -> None: + claude = tmp_path / "claude" + root = claude / "exec-lsp" / "exec-lsp" / "1.0.0" + manifest = root / ".claude-plugin" / "plugin.json" + manifest.parent.mkdir(parents=True) + manifest.write_text( + json.dumps( + { + "name": "exec-lsp", + "version": "1.0.0", + "lspServers": {"srv": _server_config(command="srv-bin", ext=".py")}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: tmp_path / "empty") + monkeypatch.setattr(loader, "claude_plugin_roots", lambda: [claude]) + monkeypatch.setattr(loader, "codex_plugin_roots", lambda: []) + + assert plugin_lsp_servers() == {} + servers = plugin_lsp_servers(PluginPolicy(external_exec=True)) + assert "plugin:exec-lsp:srv" in servers + + +def test_bad_plugin_isolated(tmp_path: Path, monkeypatch, _no_external) -> None: + cache = tmp_path / "cache" + _install_plugin( + cache, + "good", + manifest_extra={"lspServers": {"ok": _server_config(command="good-bin", ext=".py")}}, + ) + bad_root = cache / "mkt" / "bad" / "1.0.0" + bad_root.mkdir(parents=True) + bad_root.joinpath(".lsp.json").write_text("{not json", encoding="utf-8") + manifest = bad_root / ".claude-plugin" / "plugin.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text(json.dumps({"name": "bad", "version": "1.0.0"}), encoding="utf-8") + monkeypatch.setattr(loader, "plugin_cache_dir", lambda: cache) + + servers = plugin_lsp_servers() + assert "plugin:good:ok" in servers + + +def _register_marketplace(tmp_path: Path, name: str, manifest_path: Path) -> None: + marketplace.add_marketplace( + name, + MarketplaceSource(source="file", path=str(manifest_path)), + ) + + +def _write_marketplace(tmp_path: Path, name: str, plugins: list[dict[str, Any]]) -> Path: + path = tmp_path / f"{name}.json" + path.write_text( + json.dumps({"name": name, "plugins": plugins}, ensure_ascii=False), + encoding="utf-8", + ) + return path + + +def test_recommendation_filter_matrix(tmp_path: Path, monkeypatch) -> None: + official = _write_marketplace( + tmp_path, + "pythinker-plugins-official", + [ + { + "name": "official-py", + "description": "Official Python LSP", + "lspServers": {"py": _server_config(command="official-py-bin", ext=".py")}, + } + ], + ) + third_party = _write_marketplace( + tmp_path, + "community", + [ + { + "name": "community-ts", + "lspServers": {"ts": _server_config(command="community-ts-bin", ext=".ts")}, + }, + { + "name": "installed-py", + "lspServers": {"py2": _server_config(command="installed-py-bin", ext=".py")}, + }, + { + "name": "never-py", + "lspServers": {"py3": _server_config(command="never-py-bin", ext=".py")}, + }, + { + "name": "no-binary", + "lspServers": {"py4": _server_config(command="missing-binary-xyz", ext=".py")}, + }, + ], + ) + _register_marketplace(tmp_path, "pythinker-plugins-official", official) + _register_marketplace(tmp_path, "community", third_party) + record_install("installed-py", "community", InstalledRecord(installPath="/tmp/x")) + + config = Config( + lsp=LspConfig(recommendation_never=["never-py@community"]), + ) + + def binary_on_path(command: str) -> bool: + return command != "missing-binary-xyz" + + with patch( + "pythinker_code.lsp.recommend.is_binary_installed", + side_effect=binary_on_path, + ): + recs = get_matching_lsp_plugins("main.py", config) + + assert [r.plugin_id for r in recs] == ["official-py@pythinker-plugins-official"] + assert recs[0].is_official is True + + with patch( + "pythinker_code.lsp.recommend.is_binary_installed", + side_effect=binary_on_path, + ): + ts_recs = get_matching_lsp_plugins("app.ts", config) + assert [r.plugin_id for r in ts_recs] == ["community-ts@community"] + + disabled = Config(lsp=LspConfig(recommendation_disabled=True)) + assert get_matching_lsp_plugins("main.py", disabled) == [] + + ignored = Config(lsp=LspConfig(recommendation_ignored_count=MAX_IGNORED_COUNT)) + assert is_lsp_recommendations_disabled(ignored.lsp) + assert get_matching_lsp_plugins("main.py", ignored) == [] + + +@pytest.mark.asyncio +async def test_reinit_generation_guard(monkeypatch) -> None: + init_started = asyncio.Event() + release_init = asyncio.Event() + successful_generations: list[int] = [] + + class FakeRuntime: + work_dir = "/tmp/project" + config = Config(lsp=LspConfig()) + + original_run_init = LspService._run_init + + async def tracking_run_init(self, generation: int) -> None: + await original_run_init(self, generation) + if self.status() == LspInitStatus.SUCCESS and self._generation == generation: + successful_generations.append(generation) + + async def slow_initialize(self) -> None: # noqa: ANN001 + init_started.set() + await release_init.wait() + + monkeypatch.setattr(LspService, "_run_init", tracking_run_init) + monkeypatch.setattr( + "pythinker_code.lsp.service.LspServerManager.initialize", + slow_initialize, + ) + + service = LspService.create(cast(Runtime, FakeRuntime()), servers={}) + await init_started.wait() + await service.reinitialize() + release_init.set() + await service.wait_for_init() + + assert service.status() == LspInitStatus.SUCCESS + assert successful_generations == [2] + await service.shutdown() diff --git a/tests/tools/test_lsp_tool.py b/tests/tools/test_lsp_tool.py new file mode 100644 index 00000000..77f43f5d --- /dev/null +++ b/tests/tools/test_lsp_tool.py @@ -0,0 +1,452 @@ +"""Tests for the LSP agent tool.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from pythinker_core.tooling import ToolReturnValue +from pythinker_host.local import LocalHost +from pythinker_host.path import HostPath + +from pythinker_code.agentspec import DEFAULT_AGENT_FILE, load_agent_spec +from pythinker_code.config import LspServerConfig +from pythinker_code.lsp.service import LspInitStatus, LspService +from pythinker_code.soul.agent import load_agent +from pythinker_code.tools import SkipThisTool +from pythinker_code.tools.lsp import Lsp +from pythinker_code.tools.lsp.schemas import Operation, Params +from tests.tools._untrusted import assert_wrapped + +_FAKE_LSP_SERVER = """ +import json +import os +import sys +from pathlib import Path + +LOG = os.environ.get("LSP_TEST_LOG") +WORKSPACE = os.environ.get("LSP_WORKSPACE", "") +EMPTY = os.environ.get("LSP_EMPTY") == "1" + + +def sample_uri(): + return Path(WORKSPACE, "sample.py").resolve().as_uri() + + +def read_msg(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode("ascii").rstrip("\\r\\n") + if text == "": + break + if text.lower().startswith("content-length:"): + content_length = int(text.split(":", 1)[1].strip()) + if content_length is None: + return None + body = sys.stdin.buffer.read(content_length) + return json.loads(body) + + +def write_msg(msg): + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\\r\\n\\r\\n".encode("ascii") + sys.stdout.buffer.write(header + body) + sys.stdout.buffer.flush() + + +def log_event(name, payload): + if not LOG: + return + with open(LOG, "a", encoding="utf-8") as fh: + fh.write(json.dumps({"method": name, "params": payload}) + "\\n") + + +def hover_result(): + return { + "contents": {"kind": "markdown", "value": "def sample(): pass"}, + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}, + } + + +def location_result(): + uri = sample_uri() + return [{"uri": uri, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 9}}}] + +def document_symbol_result(): + return [ + { + "name": "sample", + "kind": 12, + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 10}}, + "selectionRange": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}, + } + ] + + +def workspace_symbol_result(): + uri = sample_uri() + return [ + { + "name": "sample", + "kind": 12, + "location": { + "uri": uri, + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}, + }, + "containerName": "sample.py", + } + ] + + +def call_item(): + uri = sample_uri() + return [ + { + "name": "sample", + "kind": 12, + "uri": uri, + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 10}}, + "selectionRange": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}, + } + ] + + +def incoming_call(): + source = call_item()[0] + return [{"from": source, "fromRanges": [{"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}]}] + + +def outgoing_call(): + target = call_item()[0] + return [{"to": target, "fromRanges": [{"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 10}}]}] + + +while True: + msg = read_msg() + if msg is None: + break + if "id" in msg and "method" in msg: + req_id = msg["id"] + method = msg["method"] + params = msg.get("params", {}) + if method == "initialize": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": {"capabilities": {}}}) + elif method == "shutdown": + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + elif method == "textDocument/definition": + log_event(method, params) + result = None if EMPTY else location_result() + write_msg({"jsonrpc": "2.0", "id": req_id, "result": result}) + elif method == "textDocument/references": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": location_result()}) + elif method == "textDocument/hover": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": hover_result()}) + elif method == "textDocument/documentSymbol": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": document_symbol_result()}) + elif method == "workspace/symbol": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": workspace_symbol_result()}) + elif method == "textDocument/implementation": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": location_result()}) + elif method == "textDocument/prepareCallHierarchy": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": call_item()}) + elif method == "callHierarchy/incomingCalls": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": incoming_call()}) + elif method == "callHierarchy/outgoingCalls": + log_event(method, params) + write_msg({"jsonrpc": "2.0", "id": req_id, "result": outgoing_call()}) + else: + write_msg({"jsonrpc": "2.0", "id": req_id, "result": None}) + elif "method" in msg: + if msg["method"] == "exit": + break +""" + + +@pytest.fixture +def local_host() -> LocalHost: + return LocalHost() + + +def _tool_output_text(result: ToolReturnValue) -> str: + assert isinstance(result.output, str) + return result.output + + +def _server_config(*, log_file: Path, workspace: Path, empty: bool = False) -> LspServerConfig: + env = { + "LSP_TEST_LOG": str(log_file), + "LSP_WORKSPACE": str(workspace), + } + if empty: + env["LSP_EMPTY"] = "1" + return LspServerConfig.model_validate( + { + "command": sys.executable, + "args": ["-c", _FAKE_LSP_SERVER], + "extensionToLanguage": {".py": "python"}, + "env": env, + "startupTimeout": 10.0, + } + ) + + +async def _setup_lsp_runtime(runtime, tmp_path: Path, *, empty: bool = False): + log_file = tmp_path / "lsp.log" + runtime.config.lsp.enabled = True + runtime.session.work_dir = HostPath(str(tmp_path)) + service = LspService.create( + runtime, + servers={"fake": _server_config(log_file=log_file, workspace=tmp_path, empty=empty)}, + ) + runtime.lsp = service + await service.wait_for_init() + assert service.status() == LspInitStatus.SUCCESS + assert service.manager is not None + return service, log_file + + +def _sample_file(tmp_path: Path) -> Path: + sample = tmp_path / "sample.py" + sample.write_text("def sample():\n return 1\n", encoding="utf-8") + return sample + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("operation", "expected_method", "expected_snippet"), + [ + (Operation.GO_TO_DEFINITION, "textDocument/definition", "Defined in"), + (Operation.FIND_REFERENCES, "textDocument/references", "Found 1 reference"), + (Operation.HOVER, "textDocument/hover", "Hover info at"), + (Operation.DOCUMENT_SYMBOL, "textDocument/documentSymbol", "Document symbols"), + (Operation.WORKSPACE_SYMBOL, "workspace/symbol", "Found 1 symbol in workspace"), + (Operation.GO_TO_IMPLEMENTATION, "textDocument/implementation", "Defined in"), + ( + Operation.PREPARE_CALL_HIERARCHY, + "textDocument/prepareCallHierarchy", + "Call hierarchy item", + ), + (Operation.INCOMING_CALLS, "callHierarchy/incomingCalls", "incoming call"), + (Operation.OUTGOING_CALLS, "callHierarchy/outgoingCalls", "outgoing call"), + ], +) +async def test_all_operations( + runtime, + tmp_path: Path, + operation: Operation, + expected_method: str, + expected_snippet: str, +) -> None: + service, log_file = await _setup_lsp_runtime(runtime, tmp_path) + _sample_file(tmp_path) + tool = Lsp(runtime) + + result = await tool( + Params(operation=operation, file_path="sample.py", line=2, character=5), + ) + + assert not result.is_error + output = _tool_output_text(result) + assert expected_snippet.lower() in output.lower() + assert_wrapped(output) + + logged = [ + json.loads(line) for line in log_file.read_text(encoding="utf-8").splitlines() if line + ] + methods = [entry["method"] for entry in logged] + assert expected_method in methods + + position_entries = [ + entry + for entry in logged + if entry["method"] + in {"textDocument/definition", "textDocument/references", "textDocument/hover"} + ] + if position_entries: + position = position_entries[0]["params"]["position"] + assert position == {"line": 1, "character": 4} + + await service.shutdown() + + +@pytest.mark.asyncio +async def test_one_based_position_conversion(runtime, tmp_path: Path) -> None: + service, log_file = await _setup_lsp_runtime(runtime, tmp_path) + _sample_file(tmp_path) + tool = Lsp(runtime) + + await tool( + Params(operation=Operation.GO_TO_DEFINITION, file_path="sample.py", line=2, character=5), + ) + + logged = json.loads(log_file.read_text(encoding="utf-8").splitlines()[0]) + assert logged["params"]["position"] == {"line": 1, "character": 4} + await service.shutdown() + + +@pytest.mark.asyncio +async def test_unc_path_rejection(runtime, tmp_path: Path) -> None: + service, _ = await _setup_lsp_runtime(runtime, tmp_path) + tool = Lsp(runtime) + + result = await tool( + Params( + operation=Operation.HOVER, + file_path="\\\\server\\share\\sample.py", + line=1, + character=1, + ), + ) + + assert result.is_error + assert "UNC" in result.message + await service.shutdown() + + +@pytest.mark.asyncio +async def test_large_file_rejection(runtime, tmp_path: Path) -> None: + service, _ = await _setup_lsp_runtime(runtime, tmp_path) + large = tmp_path / "large.py" + large.write_bytes(b"x" * (10_000_001)) + tool = Lsp(runtime) + + result = await tool( + Params(operation=Operation.HOVER, file_path="large.py", line=1, character=1), + ) + + assert not result.is_error + assert "10MB limit" in _tool_output_text(result) + await service.shutdown() + + +@pytest.mark.asyncio +async def test_deferred_init_waits_then_succeeds(runtime, tmp_path: Path) -> None: + runtime.config.lsp.enabled = True + runtime.session.work_dir = HostPath(str(tmp_path)) + service = LspService.create( + runtime, + servers={ + "fake": _server_config(log_file=tmp_path / "lsp.log", workspace=tmp_path), + }, + ) + runtime.lsp = service + _sample_file(tmp_path) + + original_wait = service.wait_for_init + + async def tracked_wait() -> None: + assert service.status() == LspInitStatus.PENDING + await original_wait() + + service.wait_for_init = tracked_wait # type: ignore[method-assign] + + tool = Lsp(runtime) + result = await tool( + Params(operation=Operation.HOVER, file_path="sample.py", line=1, character=5), + ) + + assert not result.is_error + assert "Hover info" in _tool_output_text(result) + await service.shutdown() + + +def test_skip_when_lsp_disabled(runtime) -> None: + runtime.config.lsp.enabled = False + runtime.lsp = None + with pytest.raises(SkipThisTool): + Lsp(runtime) + + +@pytest.mark.asyncio +async def test_agent_spec_loads_lsp_tool(runtime) -> None: + runtime.config.lsp.enabled = True + runtime.lsp = LspService(runtime, servers={}) + spec = load_agent_spec(DEFAULT_AGENT_FILE) + assert "pythinker_code.tools.lsp:Lsp" in spec.tools + + agent = await load_agent(DEFAULT_AGENT_FILE, runtime, mcp_configs=[]) + tool_names = {tool.name for tool in agent.toolset.tools} + assert "LSP" in tool_names + + +@pytest.mark.asyncio +async def test_empty_result_is_guidance_not_no_server(runtime, tmp_path: Path) -> None: + # Server is present and serves .py, but returns null (definition not found). + # This must surface operation-specific guidance, NOT "No LSP server available". + service, _ = await _setup_lsp_runtime(runtime, tmp_path, empty=True) + _sample_file(tmp_path) + tool = Lsp(runtime) + + result = await tool( + Params(operation=Operation.GO_TO_DEFINITION, file_path="sample.py", line=2, character=5), + ) + + assert not result.is_error + output = _tool_output_text(result) + assert "No definition found" in output + assert "No LSP server available" not in output + await service.shutdown() + + +@pytest.mark.asyncio +async def test_no_server_for_file_type(runtime, tmp_path: Path) -> None: + service, _ = await _setup_lsp_runtime(runtime, tmp_path) + other = tmp_path / "notes.txt" + other.write_text("hello\n", encoding="utf-8") + tool = Lsp(runtime) + + result = await tool( + Params(operation=Operation.HOVER, file_path="notes.txt", line=1, character=1), + ) + + assert not result.is_error + assert "No LSP server available for file type: .txt" in _tool_output_text(result) + await service.shutdown() + + +@pytest.mark.asyncio +async def test_request_failure_returns_error(runtime, tmp_path: Path) -> None: + service, _ = await _setup_lsp_runtime(runtime, tmp_path) + _sample_file(tmp_path) + tool = Lsp(runtime) + + async def boom(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("server is error") + + service.manager.send_request = boom # type: ignore[union-attr,method-assign] + + result = await tool( + Params(operation=Operation.HOVER, file_path="sample.py", line=1, character=5), + ) + + assert result.is_error + await service.shutdown() + + +@pytest.mark.asyncio +async def test_unavailable_when_init_failed(runtime, tmp_path: Path) -> None: + runtime.config.lsp.enabled = True + runtime.session.work_dir = HostPath(str(tmp_path)) + service = LspService(runtime, servers={}) + runtime.lsp = service + service._status = LspInitStatus.FAILED # noqa: SLF001 + tool = Lsp(runtime) + + result = await tool( + Params(operation=Operation.HOVER, file_path="sample.py", line=1, character=1), + ) + + assert result.is_error + assert "unavailable" in result.message.lower() diff --git a/tests_e2e/test_wire_approvals_tools.py b/tests_e2e/test_wire_approvals_tools.py index 15edcdc2..6b9728f0 100644 --- a/tests_e2e/test_wire_approvals_tools.py +++ b/tests_e2e/test_wire_approvals_tools.py @@ -183,7 +183,7 @@ def test_shell_approval_approve(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -341,7 +341,7 @@ def test_shell_approval_reject(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -516,7 +516,7 @@ def test_approve_for_session(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -752,7 +752,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -1087,7 +1087,7 @@ def test_display_block_todo(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -1240,7 +1240,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -1376,7 +1376,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -1521,7 +1521,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", diff --git a/tests_e2e/test_wire_config.py b/tests_e2e/test_wire_config.py index 0e3355a9..fdd36f72 100644 --- a/tests_e2e/test_wire_config.py +++ b/tests_e2e/test_wire_config.py @@ -91,7 +91,7 @@ def test_config_string(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -204,7 +204,7 @@ def test_model_override(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index 4c2cf8e0..ac5e845b 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -101,7 +101,7 @@ def test_basic_prompt_events(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -349,7 +349,7 @@ def test_max_steps_reached(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -591,7 +591,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index 9aeb0652..6c22483e 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -574,7 +574,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -670,7 +670,7 @@ def test_prompt_without_initialize(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", diff --git a/tests_e2e/test_wire_sessions.py b/tests_e2e/test_wire_sessions.py index 8a7d5fd0..d35ebfc0 100644 --- a/tests_e2e/test_wire_sessions.py +++ b/tests_e2e/test_wire_sessions.py @@ -304,8 +304,8 @@ def test_manual_compact(tmp_path) -> None: "method": "event", "type": "StatusUpdate", "payload": { - "context_usage": 0.01913, - "context_tokens": 1913, + "context_usage": 0.01914, + "context_tokens": 1914, "max_context_tokens": 100000, "token_usage": None, "message_id": None, @@ -508,7 +508,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index 3ca4a9e0..5a016fa5 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -128,7 +128,7 @@ def test_skill_prompt_injects_skill_text(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -242,7 +242,7 @@ def test_flow_skill(tmp_path) -> None: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", @@ -473,7 +473,7 @@ def ping(text: str) -> str: "payload": { "items": [ "- `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff.", - "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", + "- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief.", "- `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it.", '- `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them.', "- `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs). When to use: Use this agent when the required code change is already specified and should be implemented with minimal, idiomatic edits and a quick verification pass. It executes the spec faithfully — escalating instead of improvising when the spec does not match reality — and emits a block so the result can be chained directly into the verifier.", From 9e1edb4c5b1ffba7e318ccb566f0e144f71a4851 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 14:19:20 -0400 Subject: [PATCH 03/26] feat(tui): theme package, slash input UX, and streaming phase 0 Replace monolithic theme.py with spec/palette/resolver adapters, trim periwinkle inline highlights, add slash prefix and subcommand suggest highlighting, grey out low context-usage bar, and coalesce live streaming repaints. Refresh native install script help and progress UX. --- CHANGELOG.md | 8 + docs/public/install.sh | 418 ++++++++-- scripts/install-native.sh | 418 ++++++++-- src/pythinker_code/ui/shell/__init__.py | 25 +- .../ui/shell/components/markdown.py | 48 +- src/pythinker_code/ui/shell/design_system.py | 2 +- src/pythinker_code/ui/shell/mcp_status.py | 2 +- src/pythinker_code/ui/shell/motion.py | 30 + src/pythinker_code/ui/shell/prompt.py | 156 +++- src/pythinker_code/ui/shell/slash.py | 65 +- .../ui/shell/tool_renderers/agent.py | 2 +- .../ui/shell/tool_renderers/ask_user.py | 2 +- .../ui/shell/tool_renderers/background.py | 4 +- .../ui/shell/tool_renderers/edit.py | 2 +- .../ui/shell/tool_renderers/find.py | 2 +- .../ui/shell/tool_renderers/grep.py | 2 +- .../ui/shell/tool_renderers/plan.py | 2 +- .../ui/shell/tool_renderers/read.py | 2 +- .../ui/shell/tool_renderers/skill.py | 2 +- .../ui/shell/tool_renderers/web.py | 4 +- .../ui/shell/tool_renderers/write.py | 2 +- src/pythinker_code/ui/shell/update.py | 2 +- .../ui/shell/visualize/_blocks.py | 189 ++--- .../ui/shell/visualize/_dialog_shell.py | 8 +- .../ui/shell/visualize/_interactive.py | 32 +- .../ui/shell/visualize/_live_view.py | 75 +- .../ui/shell/visualize/_worklog.py | 12 +- src/pythinker_code/ui/theme.py | 782 ------------------ src/pythinker_code/ui/theme/__init__.py | 96 +++ .../ui/theme/adapters/__init__.py | 1 + .../ui/theme/adapters/markdown.py | 32 + .../ui/theme/adapters/task_browser.py | 62 ++ src/pythinker_code/ui/theme/capabilities.py | 27 + src/pythinker_code/ui/theme/palettes.py | 360 ++++++++ src/pythinker_code/ui/theme/registry.py | 248 ++++++ src/pythinker_code/ui/theme/resolver.py | 104 +++ src/pythinker_code/ui/theme/spec.py | 216 +++++ tests/ui_and_conv/test_shell_welcome_info.py | 4 +- tests/ui_and_conv/test_slash_completer.py | 50 +- tests/ui_and_conv/test_slash_highlight.py | 29 +- tests/ui_and_conv/test_statusline_render.py | 2 +- .../test_streaming_content_block.py | 28 +- tests/ui_and_conv/test_theme_contract.py | 87 ++ .../test_tui_card_tool_renderers.py | 4 +- .../ui_and_conv/test_tui_streaming_phase0.py | 160 ++++ tests/ui_and_conv/test_tui_theme_tokens.py | 39 +- web/public/install.sh | 418 ++++++++-- 47 files changed, 2963 insertions(+), 1302 deletions(-) delete mode 100644 src/pythinker_code/ui/theme.py create mode 100644 src/pythinker_code/ui/theme/__init__.py create mode 100644 src/pythinker_code/ui/theme/adapters/__init__.py create mode 100644 src/pythinker_code/ui/theme/adapters/markdown.py create mode 100644 src/pythinker_code/ui/theme/adapters/task_browser.py create mode 100644 src/pythinker_code/ui/theme/capabilities.py create mode 100644 src/pythinker_code/ui/theme/palettes.py create mode 100644 src/pythinker_code/ui/theme/registry.py create mode 100644 src/pythinker_code/ui/theme/resolver.py create mode 100644 src/pythinker_code/ui/theme/spec.py create mode 100644 tests/ui_and_conv/test_theme_contract.py create mode 100644 tests/ui_and_conv/test_tui_streaming_phase0.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1653a3cc..0d6602a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **TUI theme package.** Centralize dark/light palettes, prompt classes, and Rich/PTK + adapters in `ui/theme/` with `/theme current|doctor|tokens` inspection commands. +- **Slash input UX.** Prefix-highlight skills and plugins while typing; ghost-complete + and highlight fixed subcommands such as `/theme current`. +- **TUI streaming smoothness (Phase 0).** Coalesce Rich Live repaints to a 25 Hz frame budget, + render live previews as plain text (no per-token markdown re-parse), stage committed slices + inside the Live region until finalize, and use a fixed-width blinking streaming caret that + does not reflow wrapped lines. - **LSP code intelligence.** Plugin-provided language servers power a new `LSP` agent tool (go-to-definition, find-references, hover, symbols, call hierarchy) with session-scoped server lifecycle, passive diagnostics injected after file edits, and plugin-based server diff --git a/docs/public/install.sh b/docs/public/install.sh index 88da02b5..5a40b392 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -27,12 +27,43 @@ VERSION="" INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" NO_COLOR="${NO_COLOR:-}" +usage() { + cat <<'EOF' +Pythinker Code — native curl-bash installer. + +Downloads the PyInstaller-built single-file binary for your OS + arch from +the latest GitHub Release, verifies its SHA-256, and installs it at + ~/.local/bin/pythinker + +Usage: + curl -fsSL https://pythinker.com/install.sh | bash + + # Pin a specific version: + curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.27.0 + + # Custom install prefix (default $HOME/.local): + curl -fsSL https://pythinker.com/install.sh | bash -s -- --prefix /opt/pythinker + +Supported targets (target triples — matches existing release artifacts): + x86_64-unknown-linux-gnu (Linux x86_64) + aarch64-unknown-linux-gnu (Linux ARM64) + aarch64-apple-darwin (macOS Apple Silicon) + x86_64-apple-darwin (macOS Intel) + +Windows users: download PythinkerSetup-x.y.z.exe from the Releases page. +EOF +} + while [[ $# -gt 0 ]]; do case "$1" in - --version) VERSION="$2"; shift 2 ;; - --prefix) INSTALL_PREFIX="$2"; shift 2 ;; + --version) + [ -n "${2:-}" ] || { echo "--version requires a value" >&2; exit 2; } + VERSION="$2"; shift 2 ;; + --prefix) + [ -n "${2:-}" ] || { echo "--prefix requires a value" >&2; exit 2; } + INSTALL_PREFIX="$2"; shift 2 ;; -h|--help) - sed -n '1,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//' + usage exit 0 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac @@ -42,30 +73,166 @@ REPO="Pythoughts-labs/pythinker-code" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' - IRIS=$'\033[38;5;152m'; CORAL=$'\033[38;5;216m'; DIM=$'\033[2m' + ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' + EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' - HIDE_CURSOR=$'\033[?25l'; SHOW_CURSOR=$'\033[?25h' + SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else - NAVY=""; FACE=""; IRIS=""; CORAL=""; DIM=""; BOLD=""; RESET="" - HIDE_CURSOR=""; SHOW_CURSOR="" + NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" + SHINE=""; SOFT="" fi -# Static logo. Used as the animation fallback (non-TTY, NO_COLOR, dumb term, -# CI, or PYTHINKER_NO_ANIMATION=1) and as the source of truth for the final -# settled frame. -print_logo_static() { - printf '\n' - printf ' %s●%s\n' "$CORAL" "$RESET" +_anim="" +[ -t 1 ] \ + && [ -z "$NO_COLOR" ] \ + && [ "${TERM:-}" != "dumb" ] \ + && [ -z "${PYTHINKER_NO_ANIMATION:-}" ] \ + && [ -z "${CI:-}" ] \ + && _anim=1 + +LOGO_CURSOR_ROWS=0 +ANTENNA_SPIN_ACTIVE="" + +_antenna_tip() { + [ -z "$_anim" ] && return + [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return + printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" +} + +_antenna_spin_start() { + [ -z "$_anim" ] && return + ANTENNA_SPIN_ACTIVE=1 +} + +_antenna_spin_stop() { + [ -z "$ANTENNA_SPIN_ACTIVE" ] && return + ANTENNA_SPIN_ACTIVE="" + _antenna_tip "●" +} + +_content_length() { + curl -fsIL "$1" 2>/dev/null \ + | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' +} + +_download_percent() { + local output="$1" total="$2" size + [ -n "$total" ] && [ "$total" -gt 0 ] 2>/dev/null || return 1 + if [ ! -f "$output" ]; then + printf '0' + return 0 + fi + size="$(wc -c < "$output" | tr -d ' ')" + [ -n "$size" ] || size=0 + local percent=$((size * 100 / total)) + [ "$percent" -gt 99 ] && percent=99 + printf '%s' "$percent" +} + +_print_download_progress() { + local percent="$1" frame="$2" pulse="${3:-0}" + local width=48 filled empty bar="" + filled=$((percent * width / 100)) + empty=$((width - filled)) + + local i + for ((i=0; i/dev/null 2>&1; then + if [ -n "$_anim" ]; then + local total percent i=0 curl_pid + local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') + total="$(_content_length "$url" || true)" + _antenna_spin_start + printf '\033[?25l' + curl -fsSL "$url" -o "$output" & + curl_pid=$! + while kill -0 "$curl_pid" 2>/dev/null; do + local frame_idx=$((i % 8)) + percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" + _antenna_tip "${frames[$frame_idx]}" + local pulse=0 + (( i % 2 == 1 )) && pulse=1 + _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" + sleep 0.10 + i=$((i + 1)) + done + wait "$curl_pid" || { printf '\033[?25h'; return 1; } + _antenna_spin_stop + _print_download_progress 100 "✓" + printf '\n\033[?25h' + else + curl -fsSL "$url" -o "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + fi + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + else + return 127 + fi +} + +_download_quiet() { + local url="$1" output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + else + return 127 + fi +} + +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" printf ' %s│%s\n' "$NAVY" "$RESET" printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$CORAL" "$RESET" "$NAVY" "$RESET" "$IRIS" "$RESET" "$IRIS" "$RESET" "$NAVY" "$RESET" "$CORAL" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + +print_logo_static() { + printf '\n\n' + print_logo_art printf '\n' - printf ' %s%spythinker code%s %s· your next CLI agent%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" + printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" +} + +_type_tagline() { + local tagline='Pythinker Code Think first. Then code.' + local i ch + printf ' ' + for ((i=0; i<${#tagline}; i++)); do + ch="${tagline:$i:1}" + printf '%s' "$ch" + sleep 0.018 + done + printf '\n\n' } -# Tetris-style animated logo. Pieces fall from above the canvas one at a time -# and settle into a 5-row × 13-col grid forming the robot head. print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" @@ -79,8 +246,8 @@ print_logo_animated() { done _set_cell() { - grid_chars[$(( $1 * COLS + $2 ))]="$3" - grid_colors[$(( $1 * COLS + $2 ))]="$4" + grid_chars[$1 * COLS + $2]="$3" + grid_colors[$1 * COLS + $2]="$4" } _render() { @@ -95,8 +262,8 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" rr=$((piece_r + dr)); cc=$((piece_c + dc)) if (( rr >= 0 && rr < ROWS && cc >= 0 && cc < COLS )); then - tc[$((rr*COLS+cc))]="$ch" - tk[$((rr*COLS+cc))]="$color" + tc[rr * COLS + cc]="$ch" + tk[rr * COLS + cc]="$color" fi done fi @@ -127,59 +294,169 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" _set_cell $((target_r + dr)) $((target_c + dc)) "$ch" "$color" done + # Shimmer: flash landed cells white for one beat, then settle. + local -a shine_cells=() + for cell in "${cells[@]}"; do + IFS=',' read -r dr dc ch color <<<"$cell" + shine_cells+=("$dr,$dc,$ch,$SHINE") + done + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "${shine_cells[@]}" + sleep 0.05 + printf '\033[%dA\r' "$ROWS" + _render "" "" if [ "$STAGGER_DELAY" != "0" ]; then sleep "$STAGGER_DELAY"; fi } - printf '%s' "$HIDE_CURSOR" - trap 'printf "%s" "$SHOW_CURSOR"' EXIT - trap 'printf "%s" "$SHOW_CURSOR"; exit 130' INT - trap 'printf "%s" "$SHOW_CURSOR"; exit 143' TERM + _blink_eyes() { + local target_r=$1 target_c=$2 eye_ch=$3 + # Frame 1: glance left in SHINE tone. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$((target_c - 1))" "0,0,$eye_ch,$SHINE" + sleep 0.06 + # Frame 2: closed eye at final column. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,─,$EYE" + sleep 0.05 + _set_cell $target_r $target_c "─" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.04 + # Frame 3: open with shine flash, then settle. + _set_cell $target_r $target_c "$eye_ch" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.06 + _set_cell $target_r $target_c "$eye_ch" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + _drop_antenna_tip() { + local target_r=$1 target_c=$2 + local -a cells=("0,0,●,$TIP") + local r + for ((r=-1; r<=target_r; r++)); do + printf '\033[%dA\r' "$ROWS" + _render "$r" "$target_c" "${cells[@]}" + sleep "$FRAME_DELAY" + done + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,●,$SHINE" + sleep 0.07 + _set_cell $target_r $target_c "●" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.05 + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + printf '\033[?25l' + local _cursor_hidden=1 + + printf '\n\n' for ((i=0; i&2; exit 1; } +fail() { + printf ' %s✗%s %s\n' "$TIP" "$RESET" "$1" >&2 + exit 1 +} -print_logo +trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT +trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM # --- detect target ------------------------------------------------------- os="$(uname -s)" arch="$(uname -m)" case "$os/$arch" in Linux/x86_64|Linux/amd64) - target="x86_64-unknown-linux-gnu" ;; + target="x86_64-unknown-linux-gnu" + platform_display="Linux x64" ;; Linux/aarch64|Linux/arm64) - target="aarch64-unknown-linux-gnu" ;; + target="aarch64-unknown-linux-gnu" + platform_display="Linux arm64" ;; Darwin/arm64) - target="aarch64-apple-darwin" ;; + target="aarch64-apple-darwin" + platform_display="macOS arm64" ;; Darwin/x86_64) - target="x86_64-apple-darwin" ;; + target="x86_64-apple-darwin" + platform_display="macOS x64" ;; MINGW*/*|MSYS*/*|CYGWIN*/*) fail "On Windows, download PythinkerSetup-x.y.z.exe from: https://github.com/${REPO}/releases/latest @@ -192,7 +469,6 @@ esac # --- resolve version ----------------------------------------------------- if [ -z "$VERSION" ]; then - step "Looking up latest Pythinker release" api="https://api.github.com/repos/${REPO}/releases/latest" if command -v curl >/dev/null 2>&1; then payload="$(curl -fsSL "$api")" @@ -203,19 +479,15 @@ if [ -z "$VERSION" ]; then fi VERSION="$(printf '%s' "$payload" | sed -nE 's/.*"tag_name": *"v([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' | head -n 1)" [ -z "$VERSION" ] && fail "could not parse latest release tag from $api" - ok "Latest version is $VERSION" fi tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" -# --- wait for assets to finish publishing ------------------------------- -# The GitHub Release is published before every platform asset finishes -# uploading, and /releases/latest is date-based, so it can briefly advertise a -# version whose archive is still in flight. Confirm this version's archive and -# checksum are attached (via the GitHub API, like the in-app updater) before -# downloading, so a release caught mid-publish does not 404. +print_intro + +# --- wait for assets to finish publishing -------------------------------- release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -226,9 +498,6 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } -# Exponential backoff: the GitHub Release can briefly advertise a version -# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, -# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -239,26 +508,21 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done +[ "$attempt" -gt 0 ] && printf '\r\033[K' -# --- download + verify -------------------------------------------------- +# --- download + verify --------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" -trap 'rm -rf "$tmpdir"' EXIT -step "Downloading $tarball" -if command -v curl >/dev/null 2>&1; then - curl -fsSL "$tarball_url" -o "$tmpdir/$tarball" || fail "download failed: $tarball_url" - curl -fsSL "$sha_url" -o "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -else - wget -q "$tarball_url" -O "$tmpdir/$tarball" || fail "download failed" - wget -q "$sha_url" -O "$tmpdir/$tarball.sha256" || fail "sha256 missing" -fi +trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT + +_download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" +_download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -step "Verifying SHA-256" expected="$(awk '{print $1}' "$tmpdir/$tarball.sha256")" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$tmpdir/$tarball" | awk '{print $1}')" @@ -268,29 +532,25 @@ else fail "need sha256sum or shasum to verify the download" fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" -ok "Checksum OK" +phase_ok "Verifying" -# --- install ----------------------------------------------------------- +# --- install ------------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" -step "Installing into $bin_dir/pythinker" mkdir -p "$bin_dir" -# The existing release tarball contains a single `pythinker` file at the -# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" -ok "Installed $("$bin_dir/pythinker" --version 2>/dev/null || echo "pythinker $VERSION")" +phase_ok "Installing" -# --- PATH guidance -------------------------------------------------------- +# --- PATH guidance ------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) - printf '\n %sNote:%s %s is not on your PATH.\n' "$BOLD" "$RESET" "$bin_dir" - printf ' Add this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):\n' + printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" + printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" ;; esac -printf '\n %s%spythinker%s is ready. Run %s%spythinker%s to launch.\n\n' \ - "$BOLD" "$IRIS" "$RESET" "$BOLD" "$IRIS" "$RESET" +print_done diff --git a/scripts/install-native.sh b/scripts/install-native.sh index 88da02b5..67094b98 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -27,12 +27,43 @@ VERSION="" INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" NO_COLOR="${NO_COLOR:-}" +usage() { + cat <<'EOF' +Pythinker Code — native curl-bash installer. + +Downloads the PyInstaller-built single-file binary for your OS + arch from +the latest GitHub Release, verifies its SHA-256, and installs it at + ~/.local/bin/pythinker + +Usage: + curl -fsSL https://pythinker.com/install.sh | bash + + # Pin a specific version: + curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.27.0 + + # Custom install prefix (default $HOME/.local): + curl -fsSL https://pythinker.com/install.sh | bash -s -- --prefix /opt/pythinker + +Supported targets (target triples — matches existing release artifacts): + x86_64-unknown-linux-gnu (Linux x86_64) + aarch64-unknown-linux-gnu (Linux ARM64) + aarch64-apple-darwin (macOS Apple Silicon) + x86_64-apple-darwin (macOS Intel) + +Windows users: download PythinkerSetup-x.y.z.exe from the Releases page. +EOF +} + while [[ $# -gt 0 ]]; do case "$1" in - --version) VERSION="$2"; shift 2 ;; - --prefix) INSTALL_PREFIX="$2"; shift 2 ;; + --version) + [ -n "${2:-}" ] || { echo "--version requires a value" >&2; exit 2; } + VERSION="$2"; shift 2 ;; + --prefix) + [ -n "${2:-}" ] || { echo "--prefix requires a value" >&2; exit 2; } + INSTALL_PREFIX="$2"; shift 2 ;; -h|--help) - sed -n '1,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//' + usage exit 0 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac @@ -42,30 +73,166 @@ REPO="Pythoughts-labs/pythinker-code" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' - IRIS=$'\033[38;5;152m'; CORAL=$'\033[38;5;216m'; DIM=$'\033[2m' + ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' + EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' - HIDE_CURSOR=$'\033[?25l'; SHOW_CURSOR=$'\033[?25h' + SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else - NAVY=""; FACE=""; IRIS=""; CORAL=""; DIM=""; BOLD=""; RESET="" - HIDE_CURSOR=""; SHOW_CURSOR="" + NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" + SHINE=""; SOFT="" fi -# Static logo. Used as the animation fallback (non-TTY, NO_COLOR, dumb term, -# CI, or PYTHINKER_NO_ANIMATION=1) and as the source of truth for the final -# settled frame. -print_logo_static() { - printf '\n' - printf ' %s●%s\n' "$CORAL" "$RESET" +_anim="" +[ -t 1 ] \ + && [ -z "$NO_COLOR" ] \ + && [ "${TERM:-}" != "dumb" ] \ + && [ -z "${PYTHINKER_NO_ANIMATION:-}" ] \ + && [ -z "${CI:-}" ] \ + && _anim=1 + +LOGO_CURSOR_ROWS=0 +ANTENNA_SPIN_ACTIVE="" + +_antenna_tip() { + [ -z "$_anim" ] && return + [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return + printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" +} + +_antenna_spin_start() { + [ -z "$_anim" ] && return + ANTENNA_SPIN_ACTIVE=1 +} + +_antenna_spin_stop() { + [ -z "$ANTENNA_SPIN_ACTIVE" ] && return + ANTENNA_SPIN_ACTIVE="" + _antenna_tip "●" +} + +_content_length() { + curl -fsIL "$1" 2>/dev/null \ + | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' +} + +_download_percent() { + local output="$1" total="$2" size + [ -n "$total" ] && [ "$total" -gt 0 ] 2>/dev/null || return 1 + if [ ! -f "$output" ]; then + printf '0' + return 0 + fi + size="$(wc -c < "$output" | tr -d ' ')" + [ -n "$size" ] || size=0 + local percent=$((size * 100 / total)) + [ "$percent" -gt 99 ] && percent=99 + printf '%s' "$percent" +} + +_print_download_progress() { + local percent="$1" frame="$2" pulse="${3:-0}" + local width=48 filled empty bar="" + filled=$((percent * width / 100)) + empty=$((width - filled)) + + local i + for ((i=0; i/dev/null 2>&1; then + if [ -n "$_anim" ]; then + local total percent i=0 curl_pid + local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') + total="$(_content_length "$url" || true)" + _antenna_spin_start + printf '\033[?25l' + curl -fsSL "$url" -o "$output" & + curl_pid=$! + while kill -0 "$curl_pid" 2>/dev/null; do + local frame_idx=$((i % 8)) + percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" + _antenna_tip "${frames[$frame_idx]}" + local pulse=0 + (( i % 2 == 1 )) && pulse=1 + _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" + sleep 0.10 + i=$((i + 1)) + done + wait "$curl_pid" || { printf '\033[?25h'; return 1; } + _antenna_spin_stop + _print_download_progress 100 "✓" + printf '\n\033[?25h' + else + curl -fsSL "$url" -o "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + fi + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + else + return 127 + fi +} + +_download_quiet() { + local url="$1" output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + else + return 127 + fi +} + +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" printf ' %s│%s\n' "$NAVY" "$RESET" printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$CORAL" "$RESET" "$NAVY" "$RESET" "$IRIS" "$RESET" "$IRIS" "$RESET" "$NAVY" "$RESET" "$CORAL" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + +print_logo_static() { + printf '\n\n' + print_logo_art printf '\n' - printf ' %s%spythinker code%s %s· your next CLI agent%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" + printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" +} + +_type_tagline() { + local tagline='Pythinker Code Think first. Then code.' + local i ch + printf ' ' + for ((i=0; i<${#tagline}; i++)); do + ch="${tagline:$i:1}" + printf '%s' "$ch" + sleep 0.018 + done + printf '\n\n' } -# Tetris-style animated logo. Pieces fall from above the canvas one at a time -# and settle into a 5-row × 13-col grid forming the robot head. print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" @@ -79,8 +246,8 @@ print_logo_animated() { done _set_cell() { - grid_chars[$(( $1 * COLS + $2 ))]="$3" - grid_colors[$(( $1 * COLS + $2 ))]="$4" + grid_chars[$1 * COLS + $2]="$3" + grid_colors[$1 * COLS + $2]="$4" } _render() { @@ -95,8 +262,8 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" rr=$((piece_r + dr)); cc=$((piece_c + dc)) if (( rr >= 0 && rr < ROWS && cc >= 0 && cc < COLS )); then - tc[$((rr*COLS+cc))]="$ch" - tk[$((rr*COLS+cc))]="$color" + tc[rr * COLS + cc]="$ch" + tk[rr * COLS + cc]="$color" fi done fi @@ -127,59 +294,169 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" _set_cell $((target_r + dr)) $((target_c + dc)) "$ch" "$color" done + # Shimmer: flash landed cells white for one beat, then settle. + local -a shine_cells=() + for cell in "${cells[@]}"; do + IFS=',' read -r dr dc ch color <<<"$cell" + shine_cells+=("$dr,$dc,$ch,$SHINE") + done + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "${shine_cells[@]}" + sleep 0.05 + printf '\033[%dA\r' "$ROWS" + _render "" "" if [ "$STAGGER_DELAY" != "0" ]; then sleep "$STAGGER_DELAY"; fi } - printf '%s' "$HIDE_CURSOR" - trap 'printf "%s" "$SHOW_CURSOR"' EXIT - trap 'printf "%s" "$SHOW_CURSOR"; exit 130' INT - trap 'printf "%s" "$SHOW_CURSOR"; exit 143' TERM + _blink_eyes() { + local target_r=$1 target_c=$2 eye_ch=$3 + # Frame 1: glance left in SHINE tone. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$((target_c - 1))" "0,0,$eye_ch,$SHINE" + sleep 0.06 + # Frame 2: closed eye at final column. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,─,$EYE" + sleep 0.05 + _set_cell $target_r $target_c "─" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.04 + # Frame 3: open with shine flash, then settle. + _set_cell $target_r $target_c "$eye_ch" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.06 + _set_cell $target_r $target_c "$eye_ch" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + _drop_antenna_tip() { + local target_r=$1 target_c=$2 + local -a cells=("0,0,●,$TIP") + local r + for ((r=-1; r<=target_r; r++)); do + printf '\033[%dA\r' "$ROWS" + _render "$r" "$target_c" "${cells[@]}" + sleep "$FRAME_DELAY" + done + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,●,$SHINE" + sleep 0.07 + _set_cell $target_r $target_c "●" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.05 + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + printf '\033[?25l' + local _cursor_hidden=1 + + printf '\n\n' for ((i=0; i&2; exit 1; } +fail() { + printf ' %s✗%s %s\n' "$TIP" "$RESET" "$1" >&2 + exit 1 +} -print_logo +trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT +trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM # --- detect target ------------------------------------------------------- os="$(uname -s)" arch="$(uname -m)" case "$os/$arch" in Linux/x86_64|Linux/amd64) - target="x86_64-unknown-linux-gnu" ;; + target="x86_64-unknown-linux-gnu" + platform_display="Linux x64" ;; Linux/aarch64|Linux/arm64) - target="aarch64-unknown-linux-gnu" ;; + target="aarch64-unknown-linux-gnu" + platform_display="Linux arm64" ;; Darwin/arm64) - target="aarch64-apple-darwin" ;; + target="aarch64-apple-darwin" + platform_display="macOS arm64" ;; Darwin/x86_64) - target="x86_64-apple-darwin" ;; + target="x86_64-apple-darwin" + platform_display="macOS x64" ;; MINGW*/*|MSYS*/*|CYGWIN*/*) fail "On Windows, download PythinkerSetup-x.y.z.exe from: https://github.com/${REPO}/releases/latest @@ -192,7 +469,6 @@ esac # --- resolve version ----------------------------------------------------- if [ -z "$VERSION" ]; then - step "Looking up latest Pythinker release" api="https://api.github.com/repos/${REPO}/releases/latest" if command -v curl >/dev/null 2>&1; then payload="$(curl -fsSL "$api")" @@ -203,19 +479,15 @@ if [ -z "$VERSION" ]; then fi VERSION="$(printf '%s' "$payload" | sed -nE 's/.*"tag_name": *"v([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' | head -n 1)" [ -z "$VERSION" ] && fail "could not parse latest release tag from $api" - ok "Latest version is $VERSION" fi tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" -# --- wait for assets to finish publishing ------------------------------- -# The GitHub Release is published before every platform asset finishes -# uploading, and /releases/latest is date-based, so it can briefly advertise a -# version whose archive is still in flight. Confirm this version's archive and -# checksum are attached (via the GitHub API, like the in-app updater) before -# downloading, so a release caught mid-publish does not 404. +print_intro + +# --- wait for assets to finish publishing -------------------------------- release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -226,9 +498,6 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } -# Exponential backoff: the GitHub Release can briefly advertise a version -# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, -# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -239,26 +508,21 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done +[ "$attempt" -gt 0 ] && printf '\r\033[K' -# --- download + verify -------------------------------------------------- +# --- download + verify --------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" -trap 'rm -rf "$tmpdir"' EXIT -step "Downloading $tarball" -if command -v curl >/dev/null 2>&1; then - curl -fsSL "$tarball_url" -o "$tmpdir/$tarball" || fail "download failed: $tarball_url" - curl -fsSL "$sha_url" -o "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -else - wget -q "$tarball_url" -O "$tmpdir/$tarball" || fail "download failed" - wget -q "$sha_url" -O "$tmpdir/$tarball.sha256" || fail "sha256 missing" -fi +trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT + +_download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" +_download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -step "Verifying SHA-256" expected="$(awk '{print $1}' "$tmpdir/$tarball.sha256")" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$tmpdir/$tarball" | awk '{print $1}')" @@ -268,29 +532,25 @@ else fail "need sha256sum or shasum to verify the download" fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" -ok "Checksum OK" +phase_ok "Verifying" -# --- install ----------------------------------------------------------- +# --- install ------------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" -step "Installing into $bin_dir/pythinker" mkdir -p "$bin_dir" -# The existing release tarball contains a single `pythinker` file at the -# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" -ok "Installed $("$bin_dir/pythinker" --version 2>/dev/null || echo "pythinker $VERSION")" +phase_ok "Installing" -# --- PATH guidance -------------------------------------------------------- +# --- PATH guidance ------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) - printf '\n %sNote:%s %s is not on your PATH.\n' "$BOLD" "$RESET" "$bin_dir" - printf ' Add this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):\n' + printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" + printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" ;; esac -printf '\n %s%spythinker%s is ready. Run %s%spythinker%s to launch.\n\n' \ - "$BOLD" "$IRIS" "$RESET" "$BOLD" "$IRIS" "$RESET" +print_done diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 8d248eac..56a2cfe6 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -90,8 +90,8 @@ visualize, ) from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled +from pythinker_code.ui.theme import BRAND, BrandToken, tui_rich_style from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens -from pythinker_code.ui.theme import tui_rich_style from pythinker_code.update_policy import auto_update_enabled from pythinker_code.utils.aioqueue import QueueShutDown from pythinker_code.utils.envvar import get_env_bool @@ -2273,15 +2273,12 @@ def _cancel_background_tasks(self) -> None: self._background_tasks.clear() -# Fixed brand palette transferred from the animated SVG (pythinker_animated.svg). -# These are the robot mark's identity colors and are intentionally -# theme-independent — do NOT wire them to TuiTokens (the logo must look the same -# in light/dark and must not shift with the accent). -_LOGO_NAVY = "#213853" # outline / chassis (head + body frame, mouth, neck) -_LOGO_FACE = "#F9F2F5" # face / chest interior (cream) -_LOGO_CORAL = "#EE9983" # antenna ball, ears, accent bits -_LOGO_CORAL_LIT = "#FFB9A3" # antenna ball "powered on" — lighter coral glow -_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (brand cyan) +# Fixed brand palette — theme-independent robot mark colors (see ui/theme/palettes.py). +_LOGO_NAVY = BRAND[BrandToken.NAVY] +_LOGO_FACE = BRAND[BrandToken.FACE] +_LOGO_CORAL = BRAND[BrandToken.CORAL] +_LOGO_CORAL_LIT = BRAND[BrandToken.CORAL_LIT] +_LOGO_IRIS = BRAND[BrandToken.IRIS] # Head-only robot mark (antenna, ears, eyes, mouth). Only rendered when # ascii_glyphs_enabled() is false; ASCII terminals get the text-only banner @@ -2401,7 +2398,7 @@ def _value_style_for_label(label: str, level: WelcomeInfoItem.Level) -> str: tokens = get_tui_tokens() label = label.strip() if label == "Directory": - return tokens.accent or "#B3B9F4" + return tokens.info or "#AFE3F1" if label == "Session": return tokens.dim or "grey39" if label == "Model": @@ -2430,7 +2427,7 @@ def _chip(markup: str, style: str) -> Text: if ascii_glyphs_enabled(): markup = markup.translate(_WELCOME_ASCII_FALLBACKS) chip = Text.from_markup(markup) - chip.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", f"bold {style}") + chip.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", style) return chip if update_target: @@ -2549,7 +2546,7 @@ def _copy(markup: str) -> Text: head = _copy("[bold]Welcome to Pythinker — think first, then code.[/]") strapline = _copy(f"[{_t.muted}]Review · Secure · Diagnose · Build with confidence.[/]") help_text = _copy(f"[{_t.muted}]Type /help for commands.[/]") - help_text.highlight_regex(r"/help\b", f"bold {_LOGO_CORAL}") + help_text.highlight_regex(r"/help\b", _LOGO_CORAL) if ascii_mode: # Caller-provided values (tips, notices) may carry the same decorative @@ -2598,7 +2595,7 @@ def _tips_block(width: int, *, with_rule: bool) -> Group: lines = _welcome_tip_lines(item.value, tip_width, ellipsis=ellipsis) for index, line in enumerate(lines): tip_text = Text(line, style=item.level.value, no_wrap=True) - tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", f"bold {_LOGO_CORAL}") + tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", _LOGO_CORAL) tips_table.add_row(bullet if index == 0 else " ", tip_text) parts.append(tips_table) return Group(*parts) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 53c144f2..2a920f57 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -17,6 +17,7 @@ from __future__ import annotations +import functools import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -39,6 +40,7 @@ from pythinker_code.ui.shell.render_constants import MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES from pythinker_code.ui.shell.spacing import CODE_BLOCK_PADDING, blank_row from pythinker_code.ui.theme import ThemeName, get_markdown_colors +from pythinker_code.ui.theme.adapters.markdown import markdown_style_overrides from pythinker_code.utils.rich.markdown import CodeBlock, Markdown, TableElement _MARKDOWN_ICON_REPLACEMENTS: dict[str, str] = { @@ -478,30 +480,7 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR def _markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichStyle]: """Translate the active markdown palette into Rich style names.""" - colors = get_markdown_colors(theme) - return { - "markdown.h1": RichStyle(color=colors.heading, bold=True), - "markdown.h1.border": RichStyle(color=colors.heading), - "markdown.h1.underline": RichStyle(color=colors.heading), - "markdown.h2": RichStyle(color=colors.heading, bold=True, underline=True), - "markdown.h3": RichStyle(color=colors.heading, bold=True), - "markdown.h4": RichStyle(color=colors.heading, bold=True, dim=True), - "markdown.strong": RichStyle(color=colors.strong, bold=True), - "markdown.em": RichStyle(color=colors.emphasis, italic=True), - "markdown.emph": RichStyle(color=colors.emphasis, italic=True), - "markdown.code": RichStyle(color=colors.inline_code, bold=True), - "markdown.link": RichStyle(color=colors.link, underline=True), - # The bracketed URL reads as secondary to the link text. - "markdown.link_url": RichStyle(color=colors.link, underline=True, dim=True), - "markdown.block_quote": RichStyle(color=colors.quote, italic=True), - "markdown.hr": RichStyle(color=colors.code_block_border), - "markdown.code_block": RichStyle(color=colors.inline_code), - "markdown.code_block.border": RichStyle(color=colors.code_block_border, bold=True), - # Ordered markers take the bright-blue accent; unordered bullets stay - # muted (structural, not "important words"). - "markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True), - "markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True), - } + return markdown_style_overrides(theme) def _replace_report_icons(text: str) -> str: @@ -859,13 +838,8 @@ def _get_md_parser() -> MarkdownIt: return _md_parser -def markdown_commit_boundary(text: str) -> int | None: - """Return the offset up to which streamed markdown can be committed. - - The last top-level block is treated as still mutable, so callers only - permanently print completed blocks. Nested tokens (list items, blockquote - children, table rows) stay with their parent block. - """ +@functools.lru_cache(maxsize=64) +def _markdown_commit_boundary_cached(text: str) -> int | None: md = _get_md_parser() tokens = md.parse(text) @@ -891,6 +865,18 @@ def markdown_commit_boundary(text: str) -> int | None: return offset +def markdown_commit_boundary(text: str) -> int | None: + """Return the offset up to which streamed markdown can be committed. + + The last top-level block is treated as still mutable, so callers only + permanently print completed blocks. Nested tokens (list items, blockquote + children, table rows) stay with their parent block. + """ + if not text: + return None + return _markdown_commit_boundary_cached(text) + + def _find_stream_safe_boundary(text: str) -> int | None: """Return an index in ``text`` that is safe to flush, or ``None``. diff --git a/src/pythinker_code/ui/shell/design_system.py b/src/pythinker_code/ui/shell/design_system.py index f54c51cc..c1acd1e1 100644 --- a/src/pythinker_code/ui/shell/design_system.py +++ b/src/pythinker_code/ui/shell/design_system.py @@ -76,7 +76,7 @@ def status_icon(name: StatusName) -> Text: def keyboard_hint(key: str, label: str) -> Text: text = Text() - text.append(key, style=shell_style(ShellTone.ACCENT) + Style(bold=True)) + text.append(key, style=shell_style(ShellTone.INFO)) if label: text.append(f" {label}", style=shell_style(ShellTone.MUTED)) return text diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index 3abcfd1d..607f3844 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -80,7 +80,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: return render_mcp_inventory_loading() renderables: list[RenderableType] = [ - Text("/mcp", style=tui_rich_style("accent")), + Text("/mcp", style=tui_rich_style("info")), Text(""), Text.assemble("🔌 ", ("MCP Tools", "bold")), Text(""), diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index d5586ca0..e2779a42 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -235,6 +235,36 @@ def reduced_motion_enabled() -> bool: _BLINK_PERIOD_S = 0.8 +GUTTER_PULSE_INTERVAL_S = _BLINK_PERIOD_S +STREAM_FPS = 25 +STREAM_FRAME_INTERVAL_S = 1 / STREAM_FPS +CARET_BLINK_INTERVAL_S = 0.5 +REDUCED_MOTION_REVEAL_HZ = 5 +STREAMING_CARET_GLYPH = "\u258d" # ▍ left five eighths block + + +def stream_reveal_interval_s() -> float: + """Frame interval for paced reveal ticks (slower under reduced motion).""" + if reduced_motion_enabled(): + return 1 / REDUCED_MOTION_REVEAL_HZ + return STREAM_FRAME_INTERVAL_S + + +def streaming_caret_visible(now: float | None = None) -> bool: + """Return whether the streaming caret is in its visible half-cycle.""" + if reduced_motion_enabled(): + return True + t = time.monotonic() if now is None else now + return int(t / CARET_BLINK_INTERVAL_S) % 2 == 0 + + +def append_streaming_caret(text: Text, *, now: float | None = None) -> None: + """Append a fixed-width caret slot so blink does not reflow the preview.""" + if streaming_caret_visible(now): + text.append(STREAMING_CARET_GLYPH, style=tui_rich_style("muted")) + else: + # ponytail: reserve the column; removing the glyph shifts wrapped lines. + text.append(" ") def blink_visible(now: float | None = None) -> bool: diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 66c0133f..11fced77 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -163,7 +163,7 @@ class CwdLostError(OSError): def _command_name_set(commands: Sequence[SlashCommand[Any]]) -> frozenset[str]: - """Lowercased names and aliases for exact-match slash highlighting.""" + """Lowercased names and aliases for slash highlighting and completion.""" names: set[str] = set() for cmd in commands: names.add(cmd.name.lower()) @@ -171,6 +171,34 @@ def _command_name_set(commands: Sequence[SlashCommand[Any]]) -> frozenset[str]: return frozenset(names) +def _is_known_slash_command_prefix(name: str, known: frozenset[str]) -> bool: + """True when ``name`` is a registered command or a prefix of one (e.g. ``/skill:py``).""" + lower = name.lower() + if lower in known: + return True + return any(command_name.startswith(lower) for command_name in known) + + +def _slash_first_arg_context( + document: Document, + known_names: frozenset[str], + arg_suggestions: dict[str, tuple[str, ...]], +) -> tuple[str, str] | None: + """When the cursor trails the first argument of a known slash command, return (cmd, partial).""" + if document.text_after_cursor.strip(): + return None + line = document.current_line_before_cursor + if not line.startswith("/"): + return None + match = re.match(r"^/([A-Za-z0-9][A-Za-z0-9_:.-]*)(?:\s+(\S*))?$", line) + if match is None: + return None + command = match.group(1).lower() + if command not in known_names or command not in arg_suggestions: + return None + return command, match.group(2) or "" + + def _slash_command_token_before_cursor(document: Document) -> str | None: """Return the active slash-command token, or ``None`` when completion should stay hidden.""" text = document.text_before_cursor @@ -243,9 +271,12 @@ class InputHighlightLexer(Lexer): Three token kinds are styled, composing on the same line: - - **Slash commands** (``class:slash-command``) -- only exact matches against - the registered command names/aliases, anywhere on the line. Partial or - made-up tokens render as plain text. + - **Slash commands** (``class:slash-command``) -- registered command names, + aliases, and in-progress prefixes (``/cle``, ``/skill:py``), anywhere on + the line. + - **Slash arguments** (``class:slash-arg``) -- the first token after a + command that declares fixed subcommands (e.g. ``current`` in + ``/theme current``). - **``@file`` mentions** (``class:file-mention``) -- agent mode only, styled syntactically at a word boundary. The lexer runs on every keystroke and cannot touch the filesystem, so mentions are not resolution-checked. @@ -259,13 +290,16 @@ def __init__( known_names: Callable[[], frozenset[str]], *, agent_mode: Callable[[], bool], + arg_suggestions: Callable[[], dict[str, tuple[str, ...]]] | None = None, ) -> None: self._known_names = known_names self._agent_mode = agent_mode + self._arg_suggestions = arg_suggestions or _no_arg_suggestions @override def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: known = self._known_names() + arg_suggestions = self._arg_suggestions() agent_mode = self._agent_mode() lines = document.lines @@ -274,14 +308,25 @@ def spans(line: str, lineno: int) -> list[tuple[int, int, str]]: # Leading "!" bash prefix: first line, agent mode, command after it. if agent_mode and lineno == 0 and line.startswith("!") and line[1:].strip(): out.append((0, 1, "class:bash-prefix")) - # Slash commands anywhere on the line (exact registered matches only). + # Slash commands anywhere on the line (registered names and prefixes). for match in _SLASH_TOKEN_RE.finditer(line): - if match.group(1).lower() not in known: + name = match.group(1) + if not _is_known_slash_command_prefix(name, known): continue # Path-like tokens ("/clear/subdir") are not commands. if match.end() < len(line) and line[match.end()] == "/": continue out.append((match.start(), match.end(), "class:slash-command")) + # First argument after a line-start slash command with known subcommands. + if line.startswith("/"): + arg_match = re.match(r"^/([A-Za-z0-9][A-Za-z0-9_:.-]*)(?:\s+(\S+))", line) + if arg_match is not None: + command = arg_match.group(1).lower() + partial = arg_match.group(2) + options = arg_suggestions.get(command) + if options and any(option.startswith(partial.lower()) for option in options): + arg_start = arg_match.start(2) + out.append((arg_start, arg_match.end(2), "class:slash-arg")) # "@path" file mentions at a word boundary (agent mode only). if agent_mode: for match in _MENTION_TOKEN_RE.finditer(line): @@ -319,16 +364,22 @@ def _no_exact_suggestions() -> dict[str, str]: return {} +def _no_arg_suggestions() -> dict[str, tuple[str, ...]]: + return {} + + class SlashCommandAutoSuggest(AutoSuggest): """Inline ghost-text completion for a partially typed slash command. While the user types a ``/name`` token -- at the start of the line *or* mid-sentence (e.g. ``use /desi``) -- the remainder of the best (alphabetically first) matching command renders as dim ghost text after the cursor; Tab - accepts it word-for-word. The dropdown menu stays line-start-only, so - mid-sentence typing never pops a completion list. Rendering and the standard - accept bindings (right-arrow / ctrl-e) come from prompt_toolkit's auto-suggest - plumbing; the Tab binding is added in CustomPromptSession. + accepts it word-for-word. After a command that declares fixed subcommands + (e.g. ``/theme cur``), the first argument is ghost-completed too. The dropdown + menu stays line-start-only, so mid-sentence typing never pops a completion + list. Rendering and the standard accept bindings (right-arrow / ctrl-e) come + from prompt_toolkit's auto-suggest plumbing; the Tab binding is added in + CustomPromptSession. """ def __init__( @@ -336,12 +387,30 @@ def __init__( known_names: Callable[[], frozenset[str]], *, exact_suggestions: Callable[[], dict[str, str]] | None = None, + arg_suggestions: Callable[[], dict[str, tuple[str, ...]]] | None = None, ) -> None: self._known_names = known_names self._exact_suggestions = exact_suggestions or _no_exact_suggestions + self._arg_suggestions = arg_suggestions or _no_arg_suggestions @override def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: + arg_ctx = _slash_first_arg_context(document, self._known_names(), self._arg_suggestions()) + if arg_ctx is not None: + command, partial = arg_ctx + options = self._arg_suggestions()[command] + partial_lower = partial.lower() + if not partial: + return Suggestion(options[0]) + matches = [ + option + for option in options + if option.startswith(partial_lower) and len(option) > len(partial) + ] + if matches: + return Suggestion(matches[0][len(partial) :]) + return None + token = _slash_suggest_token_before_cursor(document) if token is None or len(token) < 2: return None @@ -375,12 +444,24 @@ def __init__( annotate_meta: bool = False, command_scope: str = "command", is_task_running: Callable[[], bool] | None = None, + arg_suggestions: Callable[[], dict[str, tuple[str, ...]]] | None = None, ) -> None: super().__init__() self._available_commands = sorted(available_commands, key=lambda c: c.name) + self._command_names = _command_name_set(available_commands) self._annotate_meta = annotate_meta self._command_scope = command_scope self._is_task_running = is_task_running + self._arg_suggestions = arg_suggestions or _no_arg_suggestions + + def completion_active(self, document: Document) -> bool: + """Return whether slash command or subcommand completion should be active.""" + if _slash_command_token_before_cursor(document) is not None: + return True + return ( + _slash_first_arg_context(document, self._command_names, self._arg_suggestions()) + is not None + ) @staticmethod def should_complete(document: Document) -> bool: @@ -391,8 +472,23 @@ def should_complete(document: Document) -> bool: def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: - if not self.should_complete(document): + if not self.completion_active(document): + return + + arg_ctx = _slash_first_arg_context(document, self._command_names, self._arg_suggestions()) + if arg_ctx is not None: + _, partial = arg_ctx + partial_lower = partial.lower() + for option in self._arg_suggestions()[arg_ctx[0]]: + if partial and not option.startswith(partial_lower): + continue + yield Completion( + text=option[len(partial) :] if partial else option, + start_position=-len(partial), + display=option, + ) return + token = _slash_command_token_before_cursor(document) if token is None: return @@ -2217,25 +2313,33 @@ def __init__( # for consecutive deduplication self._last_history_content = history_entries[-1].content + from pythinker_code.ui.shell.slash import slash_command_arg_suggestions + + self._slash_arg_suggestions = slash_command_arg_suggestions + # Build completers + self._agent_slash_completer = SlashCommandCompleter( + agent_mode_slash_commands, + annotate_meta=True, + command_scope="command", + is_task_running=lambda: self._running_prompt_delegate is not None, + arg_suggestions=self._slash_arg_suggestions, + ) self._agent_mode_completer = merge_completers( [ - SlashCommandCompleter( - agent_mode_slash_commands, - annotate_meta=True, - command_scope="command", - is_task_running=lambda: self._running_prompt_delegate is not None, - ), + self._agent_slash_completer, # TODO(host): we need an async HostFileMentionCompleter LocalFileMentionCompleter(HostPath.cwd().unsafe_to_local_path()), ], deduplicate=True, ) - self._shell_mode_completer = SlashCommandCompleter( + self._shell_slash_completer = SlashCommandCompleter( shell_mode_slash_commands, annotate_meta=True, command_scope="shell", + arg_suggestions=self._slash_arg_suggestions, ) + self._shell_mode_completer = self._shell_slash_completer self._agent_command_names = _command_name_set(agent_mode_slash_commands) self._shell_command_names = _command_name_set(shell_mode_slash_commands) self._input_highlight_lexer = InputHighlightLexer( @@ -2245,6 +2349,7 @@ def __init__( else self._agent_command_names ), agent_mode=lambda: self._mode == PromptMode.AGENT, + arg_suggestions=self._slash_arg_suggestions, ) self._slash_auto_suggest = SlashCommandAutoSuggest( lambda: ( @@ -2253,6 +2358,7 @@ def __init__( else self._agent_command_names ), exact_suggestions=self._exact_slash_suggestions, + arg_suggestions=self._slash_arg_suggestions, ) # Build key bindings @@ -2280,7 +2386,7 @@ def _is_slash_completion() -> bool: return bool( buff.complete_state and buff.complete_state.completions - and SlashCommandCompleter.should_complete(buff.document) + and self._slash_completion_active(buff.document) ) _slash_completion_filter = has_completions & Condition(_is_slash_completion) @@ -2662,7 +2768,7 @@ def _(buffer: Buffer) -> None: if state.complete_index is not None: return if not ( - SlashCommandCompleter.should_complete(buffer.document) + self._slash_completion_active(buffer.document) or LocalFileMentionCompleter.should_complete(buffer.document) ): return @@ -2775,9 +2881,17 @@ def _install_prompt_buffer_visibility(self) -> None: ] self._prompt_buffer_container = buffer_container + def _active_slash_completer(self) -> SlashCommandCompleter: + if self._mode == PromptMode.SHELL: + return self._shell_slash_completer + return self._agent_slash_completer + + def _slash_completion_active(self, document: Document) -> bool: + return self._active_slash_completer().completion_active(document) + def _should_show_slash_completion_menu(self) -> bool: document = self._session.default_buffer.document - return SlashCommandCompleter.should_complete(document) + return self._slash_completion_active(document) def _slash_menu_left_padding(self) -> int: side_padding = _card_side_padding() diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index a0957150..a3a4d7ac 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -63,6 +63,18 @@ def exit(app: Shell, args: str): SKILL_COMMAND_PREFIX = "skill:" +# Ordered first-token hints for slash commands with fixed subcommands (ghost text + menu). +_THEME_ARGS: tuple[str, ...] = ("current", "doctor", "tokens", "dark", "light", "auto") + + +def slash_command_arg_suggestions() -> dict[str, tuple[str, ...]]: + """First-token argument hints keyed by slash command name or alias (lowercase).""" + return { + "theme": _THEME_ARGS, + "color": _THEME_ARGS, + } + + _KEYBOARD_SHORTCUTS = [ ("Ctrl-X", "Toggle agent/shell mode"), ("Shift-Tab", "Toggle plan mode (read-only research)"), @@ -1027,38 +1039,71 @@ async def task(app: Shell, args: str): @registry.command(aliases=["color"]) @shell_mode_registry.command(aliases=["color"]) async def theme(app: Shell, args: str) -> None: - """Switch terminal color theme — interactive picker when no args given""" - from pythinker_code.ui.theme import get_tui_tokens as _get_tok_theme + """Switch or inspect terminal color theme.""" + from pythinker_code.ui.theme import ( + TUI_TOKEN_NAMES, + get_active_theme, + theme_doctor_report, + ) + from pythinker_code.ui.theme import ( + get_tui_tokens as _get_tok_theme, + ) soul = ensure_pythinker_soul(app) if soul is None: return _t_theme = _get_tok_theme() - # Compare against the *configured* value, not the resolved active theme: - # "auto" resolves to dark/light at startup but stays "auto" in config. - current = soul.runtime.config.theme + configured = soul.runtime.config.theme arg = args.strip().lower() + sub, _, rest = arg.partition(" ") + + if sub in ("current", "doctor", "tokens"): + if sub == "current": + console.print( + f"[{_t_theme.info}]Active theme:[/] {get_active_theme()}\n" + f"[{_t_theme.muted}]Configured:[/] {configured}" + ) + return + if sub == "doctor": + report = theme_doctor_report( + configured=configured, + config_path=str(soul.runtime.config.source_file) + if soul.runtime.config.source_file + else None, + ) + console.print(report) + return + if sub == "tokens": + tokens = _get_tok_theme() + lines = [ + f"{name}: {getattr(tokens, name) or '(terminal default)'}" + for name in sorted(TUI_TOKEN_NAMES) + ] + console.print("\n".join(lines)) + return if not arg: from pythinker_code.ui.shell.selectors.theme import run_theme_selector chosen = await run_theme_selector( - current_theme=current, + current_theme=configured, available_themes=["dark", "light", "auto"], ) - if chosen is None or chosen == current: + if chosen is None or chosen == configured: return arg = chosen + elif rest: + arg = sub if arg not in ("dark", "light", "auto"): console.print( f"[{_t_theme.error}]Unknown theme: {_rich_escape(arg)}. " - f"Use 'dark', 'light', or 'auto'.[/]" + f"Use 'dark', 'light', 'auto', 'current', 'doctor', or 'tokens'.[/]" ) return - if arg == current: + if arg == configured: console.print(f"[{_t_theme.warning}]Already using {_rich_escape(arg)} theme.[/]") return @@ -1082,8 +1127,6 @@ async def theme(app: Shell, args: str) -> None: track("theme_switch", theme=arg) if arg == "auto": - # The reload stays in-process, so a probe that failed at startup would - # otherwise pin auto to the dark fallback; let it query the terminal again. from pythinker_code.ui.terminal_background import reset_probe_cache reset_probe_cache() diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index 6e3a800a..8f43dd31 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -774,7 +774,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera if description: label = f"{label}: {description}" line = _subagent_loader(ctx) - line.append(label, style=tui_rich_style("accent") + RichStyle(bold=True)) + line.append(label, style=tui_rich_style("info")) # Hang-indent the detail row under the label (past the 2-cell marker) # so the block nests cleanly inside the result gutter. return Group(line, fg("dim", f" status: {background_status}")) diff --git a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py index 05a25214..25296282 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py +++ b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py @@ -83,7 +83,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: children.append(blank_row()) question_text = as_str(q.get("question")) or "" if question_text: - children.append(fg("accent", f"{QUESTION_MARKER} {question_text}")) + children.append(fg("info", f"{QUESTION_MARKER} {question_text}")) opts = q.get("options") if isinstance(opts, list): opts_list = cast("list[Any]", opts) diff --git a/src/pythinker_code/ui/shell/tool_renderers/background.py b/src/pythinker_code/ui/shell/tool_renderers/background.py index 4eb8cff6..1162778c 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/background.py +++ b/src/pythinker_code/ui/shell/tool_renderers/background.py @@ -91,10 +91,10 @@ def _render_call_with_id( # id as a dim suffix for traceability. task_label = _resolve_task_label(ctx, task_id) if task_label: - summary.append_text(fg("accent", task_label)) + summary.append_text(fg("info", task_label)) summary.append_text(fg("muted", f" · {task_id}")) else: - summary.append_text(fg("accent", task_id)) + summary.append_text(fg("info", task_id)) for extra in extras: summary.append_text(fg("muted", f" · {extra}")) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" diff --git a/src/pythinker_code/ui/shell/tool_renderers/edit.py b/src/pythinker_code/ui/shell/tool_renderers/edit.py index 18070e30..9d4c13ad 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/edit.py +++ b/src/pythinker_code/ui/shell/tool_renderers/edit.py @@ -90,7 +90,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header("Update", summary, style_token=style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/find.py b/src/pythinker_code/ui/shell/tool_renderers/find.py index 836debdc..f8a1fd02 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/find.py +++ b/src/pythinker_code/ui/shell/tool_renderers/find.py @@ -56,7 +56,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", pattern)) + summary.append_text(fg("info", pattern)) summary.append_text(fg("tool_output", " in ")) if "directory" in args and raw_dir is None: diff --git a/src/pythinker_code/ui/shell/tool_renderers/grep.py b/src/pythinker_code/ui/shell/tool_renderers/grep.py index a29f194b..7f09f21a 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/grep.py +++ b/src/pythinker_code/ui/shell/tool_renderers/grep.py @@ -78,7 +78,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", f"/{pattern}/")) + summary.append_text(fg("info", f"/{pattern}/")) path_display = shorten_path(raw_path or ".", cwd=ctx.cwd) if raw_path is not None else None summary.append_text(fg("tool_output", " in ")) diff --git a/src/pythinker_code/ui/shell/tool_renderers/plan.py b/src/pythinker_code/ui/shell/tool_renderers/plan.py index d3955ae7..7ca834fc 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/plan.py +++ b/src/pythinker_code/ui/shell/tool_renderers/plan.py @@ -82,7 +82,7 @@ def _render_exit_call(ctx: ToolRenderContext) -> RenderableType: children: list[RenderableType] = [line] for opt in opts[:3]: label = as_str(opt.get("label")) or "?" - children.append(fg("accent", f" • {label}")) + children.append(fg("info", f" • {label}")) rendered = Group(*children) return running_spinner( rendered, execution_started=ctx.execution_started, has_result=ctx.has_result diff --git a/src/pythinker_code/ui/shell/tool_renderers/read.py b/src/pythinker_code/ui/shell/tool_renderers/read.py index f16663a1..8abdc30a 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/read.py +++ b/src/pythinker_code/ui/shell/tool_renderers/read.py @@ -74,7 +74,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) range_text = _format_line_range(args) if range_text is not None: diff --git a/src/pythinker_code/ui/shell/tool_renderers/skill.py b/src/pythinker_code/ui/shell/tool_renderers/skill.py index 42af50ef..6349884e 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/skill.py +++ b/src/pythinker_code/ui/shell/tool_renderers/skill.py @@ -46,7 +46,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: has_result=ctx.has_result, ) else: - summary = fg("accent", skill_name) + summary = fg("info", skill_name) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header("Skill", summary, style_token=style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/web.py b/src/pythinker_code/ui/shell/tool_renderers/web.py index f0f0e906..f40599bd 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/web.py +++ b/src/pythinker_code/ui/shell/tool_renderers/web.py @@ -60,7 +60,7 @@ def _render_fetch_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", _shorten_url(url))) + summary.append_text(fg("info", _shorten_url(url))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" line = tool_call_header("Fetch", summary, style_token=style_token) return running_spinner(line, execution_started=ctx.execution_started, has_result=ctx.has_result) @@ -147,7 +147,7 @@ def _render_search_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", f'"{query}"')) + summary.append_text(fg("info", f'"{query}"')) extras: list[str] = [] if isinstance(limit, int) and limit != 5: extras.append(f"limit {limit}") diff --git a/src/pythinker_code/ui/shell/tool_renderers/write.py b/src/pythinker_code/ui/shell/tool_renderers/write.py index 38c8b5a5..c3620d47 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/write.py +++ b/src/pythinker_code/ui/shell/tool_renderers/write.py @@ -58,7 +58,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("accent", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" line = tool_call_header( diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index f12fc221..13c49ce6 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -610,7 +610,7 @@ def _update_prompt_text(current_version: str, latest_version: str) -> Text: update_method = _format_upgrade_command(upgrade_command) _t = _get_tui_tokens() return Text.assemble( - ("\n ✨ ", f"bold {_t.accent}"), + ("\n ✨ ", _t.accent), ("Update available!", "bold"), (f" {current_version} -> {latest_version}", _t.muted), ("\n Release notes: ", _t.muted), diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index cde99920..9ed97df1 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -30,13 +30,14 @@ ) from pythinker_code.ui.shell.components.render_utils import render_message_response, sanitize_ansi from pythinker_code.ui.shell.components.report import render_agent_body -from pythinker_code.ui.shell.console import console, current_console_width +from pythinker_code.ui.shell.console import current_console_width from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER, TRANSCRIPT_STATUS_MARKER from pythinker_code.ui.shell.mcp_status import mcp_startup_header from pythinker_code.ui.shell.motion import ( ActivitySnapshot, activity_status_line, - blink_visible, + append_streaming_caret, + reduced_motion_enabled, ) from pythinker_code.ui.shell.spacing import BLANK_ROW from pythinker_code.ui.shell.tips import FEATURE_TIPS @@ -211,77 +212,12 @@ def _advance_by_display_cells(text: str, start: int, cell_budget: int) -> int: return len(text) -def _markdown_fence_marker(line: str) -> tuple[str, int] | None: - stripped = line.lstrip(" ") - if len(line) - len(stripped) > 3 or not stripped.startswith(("```", "~~~")): - return None - marker = stripped[0] - marker_length = len(stripped) - len(stripped.lstrip(marker)) - if marker_length < 3: - return None - return marker, marker_length - - -def _markdown_fence_is_open(text: str) -> bool: - active_marker: str | None = None - active_length = 0 - for line in text.splitlines(): - marker = _markdown_fence_marker(line) - if marker is None: - continue - fence_marker, fence_length = marker - if active_marker is None: - active_marker = fence_marker - active_length = fence_length - elif fence_marker == active_marker and fence_length >= active_length: - active_marker = None - active_length = 0 - return active_marker is not None - - -def _backtick_run_length(text: str, start: int) -> int: - end = start - while end < len(text) and text[end] == "`": - end += 1 - return end - start - - -def _inline_markdown_is_closed(text: str) -> bool: - inline_code_ticks = 0 - strong_markers = 0 - i = 0 - while i < len(text): - char = text[i] - if char == "\\": - i += 2 - continue - if char == "`": - tick_count = _backtick_run_length(text, i) - if inline_code_ticks == 0: - inline_code_ticks = tick_count - elif inline_code_ticks == tick_count: - inline_code_ticks = 0 - i += tick_count - continue - if inline_code_ticks == 0 and text.startswith(("**", "__"), i): - strong_markers += 1 - i += 2 - continue - i += 1 - return inline_code_ticks == 0 and strong_markers % 2 == 0 - - -def _paced_preview_markdown_is_stable(text: str) -> bool: - return not _markdown_fence_is_open(text) and _inline_markdown_is_closed(text) - - class _ContentBlock: """Streaming content block with incremental markdown commitment. - For **composing** (``is_think=False``), confirmed markdown blocks are flushed - to the terminal permanently via ``console.print()`` as they become complete, - giving users real-time streaming output. Only the unconfirmed tail remains - in the transient Rich Live area. + For **composing** (``is_think=False``), confirmed markdown blocks are staged + in the Live compose cache as they become complete. Only the unconfirmed tail + remains as a plain-text preview in the transient Rich Live area. For **thinking** (``is_think=True``), the default behavior is to keep the raw reasoning text only for token accounting and never render it. The @@ -312,6 +248,8 @@ def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced: # this equal to len(raw_text); paced blocks advance it via reveal_tick(). self._revealed_len = 0 self._has_printed_bullet = False + self._committed_renderables: list[RenderableType] = [] + self._block_width = current_console_width() # Sliding window for smooth token-rate display: stores (timestamp, cumulative_tokens) # pairs to compute rate over the last ~1.5s. Float cumulative_tokens avoids # per-sample truncation. @@ -353,6 +291,8 @@ def reveal_tick(self) -> bool: _STREAM_REVEAL_MIN_CELLS, -(-backlog_cells // _STREAM_REVEAL_CATCHUP_TICKS), ) + if reduced_motion_enabled(): + step_cells = max(step_cells, -(-backlog_cells // 2)) self._revealed_len = _advance_by_display_cells( self.raw_text, self._revealed_len, @@ -411,13 +351,32 @@ def compose_final(self) -> RenderableType: if not remaining: return Text("") rendered = self._wrap_bullet(render_agent_body(remaining)) + if self._committed_renderables: + return Group(*self._committed_renderables, BLANK_ROW, rendered) if self._has_printed_bullet: - # Re-create the one-row gap a single markdown pass puts between - # blocks: earlier slices already committed, so the tail needs a - # seam to avoid cramming against the previous block. return Group(BLANK_ROW, rendered) return rendered + def promote_to_scrollback(self) -> RenderableType | None: + """Build the full block renderable for one-shot scrollback promotion.""" + parts: list[RenderableType] = list(self._committed_renderables) + remaining = self._pending_text() + if remaining: + tail = self._wrap_bullet(render_agent_body(remaining)) + if parts: + parts.extend([BLANK_ROW, tail]) + else: + parts = [tail] + if not parts: + return None + return Group(*parts) if len(parts) > 1 else parts[0] + + def has_active_stream_preview(self) -> bool: + """Whether live preview animation (caret / paced drain) should keep ticking.""" + if self.is_think: + return False + return bool(self._pending_text()) or self._revealed_len < len(self.raw_text) + def has_pending(self) -> bool: """Whether there is uncommitted content to flush.""" # Thinking blocks always commit a final trace line if any content @@ -450,11 +409,12 @@ def _wrap_preview_bullet(self, renderable: RenderableType) -> BulletColumns: """ if self._has_printed_bullet: return BulletColumns(renderable, bullet=Text(" ")) - visible = blink_visible() - glyph = TRANSCRIPT_ASSISTANT_MARKER if visible else " " return BulletColumns( renderable, - bullet=Text(glyph, style=tui_rich_style("muted") + Style(bold=True)), + bullet=Text( + TRANSCRIPT_ASSISTANT_MARKER, + style=tui_rich_style("muted") + Style(bold=True), + ), ) @property @@ -463,7 +423,7 @@ def has_emitted_to_scrollback(self) -> bool: return self._has_printed_bullet def _flush_committed(self) -> None: - """Commit confirmed markdown blocks to permanent terminal output.""" + """Stage confirmed markdown blocks for the next Live compose pass.""" pending = self._pending_text() if not pending: return @@ -471,12 +431,9 @@ def _flush_committed(self) -> None: if boundary is None: return committed_text = pending[:boundary] - # A blank seam precedes every committed slice: on the first commit it - # separates this step from the previous block; on later commits it - # re-creates the one-row gap a single markdown pass puts between blocks - # (committing each slice with its own console.print() drops it). - console.print() - console.print(self._wrap_bullet(render_agent_body(committed_text))) + if self._committed_renderables: + self._committed_renderables.append(BLANK_ROW) + self._committed_renderables.append(self._wrap_bullet(render_agent_body(committed_text))) self._committed_len += boundary def _activity_snapshot( @@ -519,22 +476,40 @@ def _record_token_rate_sample(self, now: float) -> int | None: def _compose_composing(self) -> RenderableType: spinner = self._compose_spinner() pending = self._pending_text() + committed = list(self._committed_renderables) if not pending: + if committed: + return Group(*committed, spinner) return spinner - preview = self._build_preview(pending, max_lines=_COMPOSING_PREVIEW_LINES) - if self._paced and not _paced_preview_markdown_is_stable(preview): - # At the fast reveal cadence, half-open inline spans or fences would - # render as raw delimiters and then restyle a frame later. Keep only - # those unstable previews plain; stable previews still use Markdown. - body: RenderableType = Text(sanitize_ansi(preview)) - else: - body = Markdown(preview) - return Group(spinner, BLANK_ROW, self._wrap_preview_bullet(body)) + preview = self._build_preview( + pending, + max_lines=_COMPOSING_PREVIEW_LINES, + reserve_caret=True, + ) + body = self._render_preview_text(preview, caret=True) + preview_row = self._wrap_preview_bullet(body) + if committed: + return Group(*committed, spinner, BLANK_ROW, preview_row) + return Group(spinner, BLANK_ROW, preview_row) + + def _render_preview_text(self, preview: str, *, caret: bool) -> Text: + """Plain-text preview path shared by live compose and finalize.""" + if not preview: + return Text("") + body = Text() + lines = preview.split("\n") + for index, line in enumerate(lines): + if index: + body.append("\n") + body.append(sanitize_ansi(line)) + if caret: + append_streaming_caret(body) + return body def _compose_spinner(self) -> Text: return activity_status_line( self._activity_snapshot("Composing", label_style=tui_rich_style("thinking_text")), - width=current_console_width(), + width=self._layout_width(), ) def _compose_thinking_stream(self) -> RenderableType: @@ -557,12 +532,20 @@ def _compose_thinking_stream(self) -> RenderableType: def _compose_thinking_spinner(self) -> Text: return activity_status_line( self._activity_snapshot("Thinking", label_style=tui_rich_style("thinking_text")), - width=current_console_width(), + width=self._layout_width(), ) - def _build_preview(self, text: str, *, max_lines: int) -> str: - """Tail-trim *text* to ``max_lines`` and clamp it to current terminal width.""" - max_width = current_console_width() - 2 + def _layout_width(self) -> int: + width = current_console_width() + if width != self._block_width: + self._block_width = width + return self._block_width + + def _build_preview(self, text: str, *, max_lines: int, reserve_caret: bool = False) -> str: + """Tail-trim *text* to ``max_lines`` and clamp it to terminal width.""" + max_width = self._layout_width() - 2 + if reserve_caret: + max_width = max(1, max_width - 1) tail_text = _tail_lines(text, max_lines) lines = tail_text.split("\n") return "\n".join(_truncate_to_display_width(line, max_width) for line in lines) @@ -570,7 +553,7 @@ def _build_preview(self, text: str, *, max_lines: int) -> str: def _compose_thinking(self) -> Text: return activity_status_line( self._activity_snapshot("Thinking", label_style=tui_rich_style("thinking_text")), - width=current_console_width(), + width=self._layout_width(), ) @@ -1305,7 +1288,7 @@ def compose(self) -> RenderableType: row = Text("· ", style=tui_rich_style("muted")) row.append(sanitize_ansi(question), style=tui_rich_style("muted")) row.append(" → ", style=tui_rich_style("dim")) - row.append(sanitize_ansi(answer), style=tui_rich_style("accent") + Style(bold=True)) + row.append(sanitize_ansi(answer), style=tui_rich_style("info")) rows.append(row) return BulletColumns( Group(*rows), @@ -1344,13 +1327,13 @@ def __init__(self, event: Suggestion) -> None: def compose(self) -> RenderableType: label = Text( f"Suggested: {sanitize_ansi(self.event.label).strip()}", - style=tui_rich_style("accent") + Style(bold=True), + style=tui_rich_style("info"), ) prefill = sanitize_ansi(self.event.prefill).strip() if not prefill: return BulletColumns( label, - bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=tui_rich_style("accent")), + bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=tui_rich_style("info")), ) hint = Text( f"→ {prefill} (Alt+S to accept)", @@ -1358,7 +1341,7 @@ def compose(self) -> RenderableType: ) return BulletColumns( Group(label, hint), - bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=tui_rich_style("accent")), + bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=tui_rich_style("info")), ) @@ -1440,7 +1423,7 @@ def _render(self) -> RenderableType: filled = int(round(progress * self.BAR_WIDTH)) empty = self.BAR_WIDTH - filled pct = int(progress * 100) - accent = tui_rich_style("accent") + accent = tui_rich_style("info") muted = tui_rich_style("muted") subtle = tui_rich_style("dim") title_style = accent + Style(italic=True) diff --git a/src/pythinker_code/ui/shell/visualize/_dialog_shell.py b/src/pythinker_code/ui/shell/visualize/_dialog_shell.py index d3d03c36..2e9d2ba8 100644 --- a/src/pythinker_code/ui/shell/visualize/_dialog_shell.py +++ b/src/pythinker_code/ui/shell/visualize/_dialog_shell.py @@ -27,15 +27,9 @@ class DialogOption: def _render_option(option: DialogOption) -> Text: - from rich.style import Style as _RStyle - prefix = "→" if option.selected else " " key = f"[{option.key}] " if option.key else "" - style = ( - tui_rich_style("accent") + _RStyle(bold=True) - if option.selected - else tui_rich_style("muted") - ) + style = tui_rich_style("accent") if option.selected else tui_rich_style("muted") text = Text(f"{prefix} {key}{option.label}", style=style) if option.description: text.append(f" {option.description}", style="dim") diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 20017ecb..71e6495c 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -30,7 +30,11 @@ ) from pythinker_code.ui.shell.echo import render_user_echo_text from pythinker_code.ui.shell.keyboard import KeyEvent -from pythinker_code.ui.shell.motion import reduced_motion_enabled +from pythinker_code.ui.shell.motion import ( + STREAM_FRAME_INTERVAL_S, + reduced_motion_enabled, + stream_reveal_interval_s, +) from pythinker_code.ui.shell.prompt import ( CustomPromptSession, UserInput, @@ -76,7 +80,7 @@ _STATUS_REFRESH_REDUCED_INTERVAL_S = 1.0 # Fast tick while paced streamed text is actively revealing (~25 fps) so the # reveal animates smoothly; falls back to the status cadence when idle. -_STREAM_REVEAL_INTERVAL_S = 0.04 +_STREAM_REVEAL_INTERVAL_S = STREAM_FRAME_INTERVAL_S class _PromptLiveView(_LiveView): @@ -222,9 +226,14 @@ async def _status_refresh_loop(self) -> None: # commits. advance_stream_reveal() is a no-op unless a paced block # has backlog, so reduced-motion / unpaced turns fall straight # through to the calm status cadence below. - if self.advance_stream_reveal(): + if self.advance_stream_reveal() or self._streaming_needs_animation_frame(): + self._dirty = True + if self._dirty or self._force_refresh: self._prompt_session.invalidate() - await asyncio.sleep(_STREAM_REVEAL_INTERVAL_S) + self._dirty = False + self._force_refresh = False + self._need_recompose = False + await asyncio.sleep(stream_reveal_interval_s()) continue interval = ( _STATUS_REFRESH_REDUCED_INTERVAL_S @@ -299,11 +308,13 @@ async def visualize_loop(self, wire: WireUISide): self._flush_prompt_refresh() continue self.cleanup(is_interrupt=False) + self._force_refresh = True self._flush_prompt_refresh() break if isinstance(msg, StepInterrupted): self.cleanup(is_interrupt=True) + self._force_refresh = True self._flush_prompt_refresh() break @@ -313,6 +324,7 @@ async def visualize_loop(self, wire: WireUISide): if self._turn_ended: self._turn_start_time = None self._pending_turn_recap = True + self._force_refresh = True self._flush_prompt_refresh() continue @@ -625,6 +637,7 @@ def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ): event.app.create_background_task(self._show_panel_in_pager()) elif self._toggle_latest_tool_card(): + self._force_refresh = True self._flush_prompt_refresh() return @@ -641,6 +654,7 @@ def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: if key == "c-t": self.toggle_pinned_todos() + self._force_refresh = True self._flush_prompt_refresh() return @@ -696,9 +710,15 @@ def _clear_buffer(buffer: Buffer) -> None: buffer.document = Document(text="", cursor_position=0) def _flush_prompt_refresh(self) -> None: - if self._need_recompose: - self._prompt_session.invalidate() + if self._force_refresh: + if self._dirty or self._need_recompose: + self._prompt_session.invalidate() + self._dirty = False + self._force_refresh = False self._need_recompose = False + return + if self._need_recompose: + self._dirty = True def cleanup(self, is_interrupt: bool) -> None: super().cleanup(is_interrupt) diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 7a45ed9b..e96bda28 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -45,6 +45,7 @@ from pythinker_code.ui.shell.keyboard import KeyboardListener, KeyEvent from pythinker_code.ui.shell.mcp_status import render_mcp_startup_text from pythinker_code.ui.shell.motion import ( + STREAM_FRAME_INTERVAL_S, ActivitySnapshot, active_marker_frame, activity_status_line, @@ -256,6 +257,8 @@ def __init__( self._status_block = _StatusBlock(initial_status) self._need_recompose = False + self._dirty = False + self._force_refresh = False self._external_messages: Queue[WireMessage] = Queue() def _reset_live_shape(self, live: Live) -> None: @@ -277,6 +280,37 @@ async def _drain_external_message_after_wire_shutdown( return None, external_task return msg, asyncio.create_task(self._external_messages.get()) + async def _frame_refresh_loop(self, live: Live) -> None: + """Coalesce wire-driven repaints to the streaming frame budget.""" + try: + while True: + await asyncio.sleep(STREAM_FRAME_INTERVAL_S) + if self.advance_stream_reveal() or self._streaming_needs_animation_frame(): + self._dirty = True + if not self._dirty and not self._force_refresh: + continue + live.update(self.compose(), refresh=self._force_refresh) + self._dirty = False + self._force_refresh = False + self._need_recompose = False + except asyncio.CancelledError: + pass + + def _streaming_needs_animation_frame(self) -> bool: + block = self._current_content_block + if block is None: + return False + return block.has_active_stream_preview() + + def _flush_live_refresh(self, live: Live, *, force: bool = False) -> None: + """Paint immediately; use for user-initiated repaints only.""" + if not force and not self._dirty and not self._force_refresh: + return + live.update(self.compose(), refresh=True) + self._dirty = False + self._force_refresh = False + self._need_recompose = False + async def visualize_loop(self, wire: WireUISide): with Live( self.compose(), @@ -348,6 +382,7 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: async with _keyboard_listener(keyboard_handler): wire_task = asyncio.create_task(wire.receive()) external_task = asyncio.create_task(self._external_messages.get()) + frame_task = asyncio.create_task(self._frame_refresh_loop(live)) try: while True: try: @@ -370,34 +405,34 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: ) if msg is not None: self.dispatch_wire_message(msg) - if self._need_recompose: - live.update(self.compose(), refresh=True) - self._need_recompose = False continue self.cleanup(is_interrupt=False) - live.update(self.compose(), refresh=True) + self._flush_live_refresh(live, force=True) break if isinstance(msg, StepInterrupted): self.cleanup(is_interrupt=True) - live.update(self.compose(), refresh=True) + self._flush_live_refresh(live, force=True) break self.dispatch_wire_message(msg) - if self._need_recompose: - live.update(self.compose(), refresh=True) - self._need_recompose = False finally: + frame_task.cancel() wire_task.cancel() external_task.cancel() self._external_messages.shutdown(immediate=True) + with suppress(asyncio.CancelledError, QueueShutDown): + await frame_task with suppress(asyncio.CancelledError, QueueShutDown): await wire_task with suppress(asyncio.CancelledError, QueueShutDown): await external_task - def refresh_soon(self) -> None: + def refresh_soon(self, force: bool = False) -> None: + self._dirty = True self._need_recompose = True + if force: + self._force_refresh = True def advance_stream_reveal(self) -> bool: """Advance paced reveal of the active composing block by one tick. @@ -1208,17 +1243,23 @@ def discard_retry_attempt(self, retry: StepRetry) -> None: def flush_content(self) -> None: """Flush the current content block.""" if self._current_content_block is not None: + block = self._current_content_block # Finalize must show everything: reveal any still-buffered paced text # so the committed block is complete (no text stranded behind the # reveal cursor). - self._current_content_block.reveal_all() - if self._current_content_block.has_pending(): - # One blank row before the block (matching tool cards) so steps - # are separated — unless this block already streamed earlier - # paragraphs, in which case this is its continuation. - if not self._current_content_block.has_emitted_to_scrollback: - console.print() - console.print(self._current_content_block.compose_final()) + block.reveal_all() + block._flush_committed() + if block.is_think: + if block.has_pending(): + if not block.has_emitted_to_scrollback: + console.print() + console.print(block.compose_final()) + else: + renderable = block.promote_to_scrollback() + if renderable is not None: + if not block.has_emitted_to_scrollback: + console.print() + console.print(renderable) self._current_content_block = None self.refresh_soon() diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index 914fb9cf..6aac6585 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -50,12 +50,12 @@ class ToolStyle: "ReadFile": ToolStyle("Read", "->", "info"), "Grep": ToolStyle("Search", "*", "info"), "Glob": ToolStyle("Find", "*", "info"), - "Edit": ToolStyle("Edit", "<-", "accent"), - "Replace": ToolStyle("Edit", "<-", "accent"), - "StrReplaceFile": ToolStyle("Edit", "<-", "accent"), - "Write": ToolStyle("Write", "<-", "accent"), - "WriteFile": ToolStyle("Write", "<-", "accent"), - "ApplyPatch": ToolStyle("Patch", "◆", "accent"), + "Edit": ToolStyle("Edit", "<-", "info"), + "Replace": ToolStyle("Edit", "<-", "info"), + "StrReplaceFile": ToolStyle("Edit", "<-", "info"), + "Write": ToolStyle("Write", "<-", "info"), + "WriteFile": ToolStyle("Write", "<-", "info"), + "ApplyPatch": ToolStyle("Patch", "◆", "info"), "Bash": ToolStyle("Shell", "$", "success"), "Shell": ToolStyle("Shell", "$", "success"), "SetTodoList": ToolStyle("Todo", "☑", "warning"), diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py deleted file mode 100644 index c7b28768..00000000 --- a/src/pythinker_code/ui/theme.py +++ /dev/null @@ -1,782 +0,0 @@ -"""Centralized terminal color theme definitions. - -All UI-facing colors live here so that switching between dark and light -terminal themes only requires changing the active ``ThemeName``. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, fields, replace -from functools import lru_cache -from typing import Any, Literal, cast - -from prompt_toolkit.styles import Style as PTKStyle -from rich.style import Style as RichStyle - -from pythinker_code.ui.color_utils import blend, parse_hex_color, to_hex_color -from pythinker_code.ui.terminal_capabilities import color_depth, colors_disabled - -type ThemeName = Literal["dark", "light"] - - -# Intentionally strips only hex prompt_toolkit color tokens (for example -# ``#RRGGBB``, ``fg:#RRGGBB``, and ``bg:#RRGGBB``). Named/ANSI tokens such as -# ``fg:red`` or ``bg:ansired`` are preserved; if those need no-color support, -# extend this regex and keep ``_strip_ptk_colors`` in sync. -_PTK_COLOR_TOKEN_RE = re.compile(r"^(?:fg:|bg:)?#[0-9A-Fa-f]{6}$") - - -def _strip_ptk_colors(style: str) -> str: - """Remove prompt_toolkit color directives while preserving weight/style.""" - if not style: - return style - return " ".join(part for part in style.split() if not _PTK_COLOR_TOKEN_RE.match(part)) - - -def _strip_ptk_style_map(values: dict[str, str]) -> dict[str, str]: - return {key: _strip_ptk_colors(value) for key, value in values.items()} - - -def _strip_color_dataclass[T](value: T) -> T: - updates: dict[str, Any] = {} - for field in fields(cast(Any, value)): - current = getattr(value, field.name) - if isinstance(current, str): - updates[field.name] = _strip_ptk_colors(current) - result: T = replace(cast(Any, value), **updates) - return result - - -# --------------------------------------------------------------------------- -# Diff colors (used by utils/rich/diff_render.py) -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class DiffColors: - add_bg: RichStyle - del_bg: RichStyle - add_hl: RichStyle - del_hl: RichStyle - - -_DIFF_DARK = DiffColors( - add_bg=RichStyle(bgcolor="#052e05"), - del_bg=RichStyle(bgcolor="#3a0808"), - add_hl=RichStyle(bgcolor="#0e5a0e"), - del_hl=RichStyle(bgcolor="#6b1414"), -) - -_DIFF_LIGHT = DiffColors( - add_bg=RichStyle(bgcolor="#dafbe1"), - del_bg=RichStyle(bgcolor="#ffebe9"), - add_hl=RichStyle(bgcolor="#aff5b4"), - del_hl=RichStyle(bgcolor="#ffc1c0"), -) - -_DIFF_PLAIN = DiffColors( - add_bg=RichStyle(), - del_bg=RichStyle(), - add_hl=RichStyle(), - del_hl=RichStyle(), -) - -# Basic 16-color terminals: the hex background tints above quantize into -# unreadable mud, so fall back to plain green/red foregrounds (the ANSI16 -# diff tier). The fields still act as overlay styles for diff rows. -_DIFF_ANSI16 = DiffColors( - add_bg=RichStyle(color="green"), - del_bg=RichStyle(color="red"), - add_hl=RichStyle(color="green", bold=True), - del_hl=RichStyle(color="red", bold=True), -) - - -# --------------------------------------------------------------------------- -# Task browser colors (used by ui/shell/task_browser.py) -# --------------------------------------------------------------------------- - - -def _task_browser_style_dark() -> PTKStyle: - styles = { - "header": "bg:#1f2937 #e5e7eb", - "header.title": "bg:#1f2937 #F4F4F5 bold", - "header.meta": "bg:#1f2937 #A3A3A3", - "status.running": "bg:#1f2937 #7BC97F bold", - "status.success": "bg:#1f2937 #7BC97F", - "status.warning": "bg:#1f2937 #E6B450", - "status.error": "bg:#1f2937 #EF5E62", - "status.info": "bg:#1f2937 #AFE3F1", - "task-list": "bg:#111827 #d1d5db", - "task-list.checked": "bg:#164e63 #ecfeff bold", - "frame.border": "#3A506D", - "frame.label": "bg:#17182a #F4F4F5 bold", - "footer": "bg:#17182a #A3A3A3", - "footer.key": "bg:#17182a #AFE3F1 bold", - "footer.text": "bg:#17182a #A3A3A3", - "footer.warning": "bg:#4a3315 #E6B450 bold", - "footer.meta": "bg:#17182a #5F6B7E", - } - if colors_disabled(): - styles = _strip_ptk_style_map(styles) - return PTKStyle.from_dict(styles) - - -def _task_browser_style_light() -> PTKStyle: - styles = { - "header": "bg:#e5e7eb #1f2937", - "header.title": "bg:#e5e7eb #213853 bold", - "header.meta": "bg:#e5e7eb #666666", - "status.running": "bg:#e5e7eb #2C7A39 bold", - "status.success": "bg:#e5e7eb #2C7A39", - "status.warning": "bg:#e5e7eb #9A6B18", - "status.error": "bg:#e5e7eb #C0392B", - "status.info": "bg:#e5e7eb #176B7E", - "task-list": "bg:#f9fafb #374151", - "task-list.checked": "bg:#cffafe #164e63 bold", - "frame.border": "#495F7C", - "frame.label": "bg:#f1f5f9 #213853 bold", - "footer": "bg:#f1f5f9 #475569", - "footer.key": "bg:#f1f5f9 #176B7E bold", - "footer.text": "bg:#f1f5f9 #475569", - "footer.warning": "bg:#fee2e2 #C0392B bold", - "footer.meta": "bg:#f1f5f9 #64748b", - } - if colors_disabled(): - styles = _strip_ptk_style_map(styles) - return PTKStyle.from_dict(styles) - - -# --------------------------------------------------------------------------- -# Prompt / completion menu colors (used by ui/shell/prompt.py) -# --------------------------------------------------------------------------- - - -# Selection-row background (accent-family tint). Single source of truth for the -# prompt-toolkit completion/dialog selection styles below AND the `selected_bg` -# TuiTokens field — keep them wired so the two never drift. -_SELECTED_BG_DARK = "#21243B" -_SELECTED_BG_LIGHT = "#E7E9F9" - -_PROMPT_STYLE_DARK = { - "bottom-toolbar": "noreverse", - # Input area — minimal: no background bar, only the prompt glyph is - # colored. Lets the terminal background show through so the input row - # reads as a single line of text rather than a chrome panel. - "compact-input": "", - "compact-input.prompt": "fg:#F4F4F5 bold", - "compact-input.frame": "fg:#8a8d91", - # Muted level word in the top-border effort label (the dot carries the color). - "compact-input.effort": "fg:#A3A3A3", - "running-prompt-placeholder": "fg:#A3A3A3 italic", - "running-prompt-separator": "fg:#b8bcc0", - # Recognized slash commands typed anywhere in the input area. - "slash-command": "fg:#6CA1F5 bold", - # "@file" path mentions typed in the input area. - "file-mention": "fg:#56C7B0", - # Leading "!" that turns the input into a one-shot shell command. - "bash-prefix": "fg:#E5C07B bold", - # Inline ghost text completing a partially typed slash command (Tab accepts). - "auto-suggestion": "fg:#6B7280", - # Slash completion menu — selected row gets the same selected-bg as cards. - "slash-completion-menu": "", - "slash-completion-menu.separator": "fg:#b8bcc0", - "slash-completion-menu.marker": "fg:#b8bcc0", - "slash-completion-menu.marker.current": "fg:#AFE3F1 bold", - "slash-completion-menu.command": "fg:#F4F4F5", - "slash-completion-menu.command.match": "fg:#AFE3F1 bold", - "slash-completion-menu.meta": "fg:#A3A3A3", - "slash-completion-menu.meta.success": "fg:#7BC97F", - "slash-completion-menu.meta.warning": "fg:#B69B64", - "slash-completion-menu.command.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold", - "slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_DARK} fg:#AFE3F1 bold", - "slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_DARK} fg:#A3A3A3", - "slash-completion-menu.meta.success.current": f"bg:{_SELECTED_BG_DARK} fg:#7BC97F", - "slash-completion-menu.meta.warning.current": f"bg:{_SELECTED_BG_DARK} fg:#B69B64", - "slash-completion-menu.row.current": f"bg:{_SELECTED_BG_DARK}", - "file-completion-menu": "", - "file-completion-menu.marker": "fg:#b8bcc0", - "file-completion-menu.marker.current": "fg:#AFE3F1 bold", - "file-completion-menu.name": "fg:#A3A3A3", - "file-completion-menu.name.current": "fg:#AFE3F1 bold", - "file-completion-menu.detail": "fg:#A3A3A3", - "file-completion-menu.detail.current": "fg:#AFE3F1", - "file-completion-menu.count": "fg:#5F6B7E", - "shell-dialog": "fg:#F4F4F5", - "shell-dialog.title": "fg:#F4F4F5 bold", - "shell-dialog.border": "fg:#b8bcc0", - "shell-dialog.option": "fg:#A3A3A3", - "shell-dialog.option.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold", - "shell-footer.key": "fg:#AFE3F1 bold", - "shell-footer.meta": "fg:#A3A3A3", - "shell-footer.warning": "fg:#E6B450", - "shell-footer.error": "fg:#EF5E62", -} - -_PROMPT_STYLE_LIGHT = { - "bottom-toolbar": "noreverse", - "compact-input": "", - "compact-input.prompt": "fg:#213853 bold", - "compact-input.frame": "fg:#495F7C", - # Muted level word in the top-border effort label (the dot carries the color). - "compact-input.effort": "fg:#666666", - "running-prompt-placeholder": "fg:#666666 italic", - "running-prompt-separator": "fg:#C8BEC0", - # Recognized slash commands typed anywhere in the input area. - "slash-command": "fg:#1D63D8 bold", - # "@file" path mentions typed in the input area. - "file-mention": "fg:#0E8C7A", - # Leading "!" that turns the input into a one-shot shell command. - "bash-prefix": "fg:#B45309 bold", - # Inline ghost text completing a partially typed slash command (Tab accepts). - "auto-suggestion": "fg:#8A93A0", - "slash-completion-menu": "", - "slash-completion-menu.separator": "fg:#C8BEC0", - "slash-completion-menu.marker": "fg:#8A93A0", - "slash-completion-menu.marker.current": "fg:#176B7E bold", - "slash-completion-menu.command": "fg:#4b5563", - "slash-completion-menu.command.match": "fg:#176B7E bold", - "slash-completion-menu.meta": "fg:#666666", - "slash-completion-menu.meta.success": "fg:#2C7A39", - "slash-completion-menu.meta.warning": "fg:#9A6B18", - "slash-completion-menu.command.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold", - "slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_LIGHT} fg:#176B7E bold", - "slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_LIGHT} fg:#666666", - "slash-completion-menu.meta.success.current": f"bg:{_SELECTED_BG_LIGHT} fg:#2C7A39", - "slash-completion-menu.meta.warning.current": f"bg:{_SELECTED_BG_LIGHT} fg:#9A6B18", - "slash-completion-menu.row.current": f"bg:{_SELECTED_BG_LIGHT}", - "file-completion-menu": "", - "file-completion-menu.marker": "fg:#8A93A0", - "file-completion-menu.marker.current": "fg:#176B7E bold", - "file-completion-menu.name": "fg:#666666", - "file-completion-menu.name.current": "fg:#176B7E bold", - "file-completion-menu.detail": "fg:#666666", - "file-completion-menu.detail.current": "fg:#176B7E", - "file-completion-menu.count": "fg:#8A93A0", - "shell-dialog": "fg:#374151", - "shell-dialog.title": "fg:#213853 bold", - "shell-dialog.border": "fg:#C8BEC0", - "shell-dialog.option": "fg:#666666", - "shell-dialog.option.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold", - "shell-footer.key": "fg:#176B7E bold", - "shell-footer.meta": "fg:#666666", - "shell-footer.warning": "fg:#9A6B18", - "shell-footer.error": "fg:#C0392B", -} - - -# --------------------------------------------------------------------------- -# Bottom toolbar fragment colors (used by ui/shell/prompt.py) -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class ToolbarColors: - separator: str - yolo_label: str - auto_label: str - plan_label: str - plan_prompt: str - cwd: str - bg_tasks: str - tip: str - tip_key: str - - -_TOOLBAR_DARK = ToolbarColors( - separator="fg:#2B3A52", - yolo_label="bold fg:#E6B450", - auto_label="bold fg:#7BC97F", - plan_label="bold fg:#AFE3F1", - plan_prompt="fg:#AFE3F1", - cwd="fg:#6F6F6F", - bg_tasks="fg:#6F6F6F", - tip="fg:#6F6F6F", - tip_key="fg:#6F6F6F bold", -) - -_TOOLBAR_LIGHT = ToolbarColors( - separator="fg:#C8BEC0", - yolo_label="bold fg:#9A6B18", - auto_label="bold fg:#2C7A39", - plan_label="bold fg:#176B7E", - plan_prompt="fg:#176B7E", - cwd="fg:#8A93A0", - bg_tasks="fg:#666666", - tip="fg:#666666", - tip_key="fg:#666666 bold", -) - - -# --------------------------------------------------------------------------- -# Statusline v2 palette (used by ui/shell/statusline.py) -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class StatusLineColors: - """Statusline v2 palette (prompt_toolkit style strings).""" - - model: str - cost: str - speed: str - effort_hi: str - effort_md: str - effort_lo: str - dir: str - branch: str - add: str - delete: str - label: str - dim: str - warn: str - spinner: str - spinner_idle: str - time: str - usage_ok: str - usage_mid: str - usage_high: str - usage_crit: str - - -_STATUSLINE_DARK = StatusLineColors( - model="bold fg:#dcb4ff", - cost="fg:#ffc850", - speed="fg:#78c8ff", - effort_hi="fg:#78dc8c", - effort_md="fg:#f0c850", - effort_lo="fg:#8ca0b4", - dir="fg:#82bef0", - branch="fg:#64d2c8", - add="fg:#78dc8c", - delete="fg:#ff6e6e", - label="fg:#a0a5b4", - dim="fg:#505564", - warn="bold fg:#ff5050", - spinner="fg:#64b4ff", - spinner_idle="fg:#505564", - time="fg:#b4d2f0", - usage_ok="fg:#64d2a0", - usage_mid="fg:#f0c850", - usage_high="fg:#ffa046", - usage_crit="fg:#ff5050", -) - -# Light variant: same hues darkened for contrast on light backgrounds. -_STATUSLINE_LIGHT = StatusLineColors( - model="bold fg:#7a3fb0", - cost="fg:#9a6b18", - speed="fg:#1a6fb0", - effort_hi="fg:#2c7a39", - effort_md="fg:#9a6b18", - effort_lo="fg:#5c6b7a", - dir="fg:#2a6cb0", - branch="fg:#17776b", - add="fg:#2c7a39", - delete="fg:#b03030", - label="fg:#5c6370", - dim="fg:#9aa0ac", - warn="bold fg:#c01818", - spinner="fg:#1a6fb0", - spinner_idle="fg:#9aa0ac", - time="fg:#3a5a80", - usage_ok="fg:#2c7a39", - usage_mid="fg:#9a6b18", - usage_high="fg:#b05a10", - usage_crit="fg:#c01818", -) - - -def get_statusline_colors() -> StatusLineColors: - """Statusline palette for the active theme (dark default, light variant).""" - colors = _STATUSLINE_LIGHT if _active_theme == "light" else _STATUSLINE_DARK - return _strip_color_dataclass(colors) if colors_disabled() else colors - - -# --------------------------------------------------------------------------- -# Markdown / spinner palette (used by ui/shell markdown renderer and the -# turn-execution spinner). Foreground colors only; resolved to Rich styles -# by ``markdown_rich_style``. Values are Rich color names so they degrade -# gracefully on 16-color terminals. -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class MarkdownColors: - heading: str - emphasis: str - strong: str - inline_code: str - link: str - quote: str - ordered_marker: str - unordered_marker: str - table_border: str - code_block_border: str - code_block_bg: str - spinner_active: str - spinner_done: str - spinner_failed: str - - -# Markdown/report role mapping. Headings/strong use primary text, emphasis and -# unordered bullets use muted grey, status accents stay green/red — all derived -# from TuiTokens. The four enumerated elements (inline code, links, blockquotes, -# ordered-list markers) instead use terminal-native ANSI names so they adapt to -# the user's terminal palette in both light and dark modes (see the design spec -# 2026-06-08). -def _build_markdown_colors(tokens: TuiTokens) -> MarkdownColors: - return MarkdownColors( - heading=tokens.tool_title, - emphasis=tokens.muted, - strong=tokens.tool_title, - inline_code=tokens.accent, # periwinkle accent — matches skill/branch highlight color - link="cyan", # cyan, rendered underlined - quote="green", # terminal-native ANSI green - ordered_marker="bright_blue", # ordered markers take the bright-blue accent - unordered_marker=tokens.muted, # unordered bullets stay muted - table_border=tokens.border_muted, - code_block_border=tokens.border_muted, - code_block_bg=tokens.code_block_bg, - spinner_active=tokens.info, - spinner_done=tokens.success, - spinner_failed=tokens.error, - ) - - -def get_markdown_colors(theme: ThemeName | None = None) -> MarkdownColors: - name = theme if theme is not None else _active_theme - tokens = _TUI_TOKENS_LIGHT if name == "light" else _TUI_TOKENS_DARK - return _build_markdown_colors(tokens) - - -def markdown_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: - """Resolve a MarkdownColors field name to a Rich Style. - - Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; - everything else produces a style with ``color``. Color is suppressed when - the terminal environment requests plain output. - """ - if colors_disabled(): - return RichStyle() - colors = get_markdown_colors(theme) - value = getattr(colors, token) - if not value: - return RichStyle() - if token.endswith("_bg"): - return RichStyle(bgcolor=value) - return RichStyle(color=value) - - -# --------------------------------------------------------------------------- -# MCP status prompt colors (used by ui/shell/mcp_status.py) -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class MCPPromptColors: - text: str - detail: str - connected: str - connecting: str - pending: str - failed: str - - -_MCP_PROMPT_DARK = MCPPromptColors( - text="fg:#d4d4d4", - detail="fg:#A3A3A3", - connected="fg:#7BC97F", - connecting="fg:#AFE3F1", - pending="fg:#E6B450", - failed="fg:#EF5E62", -) - -_MCP_PROMPT_LIGHT = MCPPromptColors( - text="fg:#213853", - detail="fg:#666666", - connected="fg:#2C7A39", - connecting="fg:#176B7E", - pending="fg:#9A6B18", - failed="fg:#C0392B", -) - - -# --------------------------------------------------------------------------- -# Public API — resolve by theme name -# --------------------------------------------------------------------------- - -_active_theme: ThemeName = "dark" - - -def set_active_theme(theme: ThemeName) -> None: - global _active_theme - _active_theme = theme - - -def get_active_theme() -> ThemeName: - return _active_theme - - -def get_diff_colors() -> DiffColors: - if colors_disabled(): - return _DIFF_PLAIN - if color_depth() == "16": - return _DIFF_ANSI16 - return _DIFF_LIGHT if _active_theme == "light" else _DIFF_DARK - - -def get_task_browser_style() -> PTKStyle: - return _task_browser_style_light() if _active_theme == "light" else _task_browser_style_dark() - - -def get_prompt_style() -> PTKStyle: - d = _PROMPT_STYLE_LIGHT if _active_theme == "light" else _PROMPT_STYLE_DARK - if colors_disabled(): - d = _strip_ptk_style_map(d) - return PTKStyle.from_dict(d) - - -def get_toolbar_colors() -> ToolbarColors: - colors = _TOOLBAR_LIGHT if _active_theme == "light" else _TOOLBAR_DARK - return _strip_color_dataclass(colors) if colors_disabled() else colors - - -def get_mcp_prompt_colors() -> MCPPromptColors: - colors = _MCP_PROMPT_LIGHT if _active_theme == "light" else _MCP_PROMPT_DARK - return _strip_color_dataclass(colors) if colors_disabled() else colors - - -# --------------------------------------------------------------------------- -# Pythinker semantic TUI tokens (used by ui/shell/components/* and the tool -# renderer registry). Default semantic token palette -# and light themes so the Pythinker code path renders with the reference -# palette. Existing pythinker styles continue to work — these tokens add a -# parallel naming layer keyed by *semantic role* rather than concrete color. -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class TuiTokens: - """Pythinker semantic theme tokens. - - Values are hex strings (``"#rrggbb"``) or the empty string for "use - terminal default". Background tokens (``*_bg``) are intended for - Rich ``bgcolor=`` arguments; foreground tokens for ``color=``. - """ - - # Core - accent: str - border: str - border_accent: str - border_muted: str - info: str - success: str - error: str - warning: str - muted: str - dim: str - text: str - thinking_text: str - activity_label: str - activity_verb: str - activity_verb_mid: str - activity_verb_highlight: str - activity_spinner: str - # Backgrounds - selected_bg: str - user_message_bg: str - user_message_text: str - custom_message_bg: str - custom_message_text: str - custom_message_label: str - tool_pending_bg: str - tool_error_bg: str - tool_title: str - tool_output: str - # Diffs - tool_diff_added: str - tool_diff_removed: str - tool_diff_context: str - # Bash mode accent - bash_mode: str - # Code block background (used by markdown renderer) - code_block_bg: str - - -TUI_TOKEN_NAMES = frozenset(field.name for field in fields(TuiTokens)) - - -_TUI_TOKENS_DARK = TuiTokens( - accent="#B3B9F4", - border="#8a8d91", - border_accent="#7C88DE", - border_muted="#b8bcc0", - info="#AFE3F1", - success="#7BC97F", - error="#EF5E62", - warning="#E6B450", - muted="#6F6F6F", - dim="#5F5F5F", - text="", - thinking_text="#D4D4D4", - activity_label="#F4F4F5", - activity_verb="#C68D7E", - activity_verb_mid="#D8AC9E", - activity_verb_highlight="#E9CDC2", - activity_spinner="#B8C0CC", - selected_bg=_SELECTED_BG_DARK, - user_message_bg="#333333", - user_message_text="", - custom_message_bg="#16242E", - custom_message_text="", - custom_message_label="#AFE3F1", - tool_pending_bg="#1B2230", - tool_error_bg="#2E1D24", - tool_title="#F4F4F5", - tool_output="#D4D4D4", - tool_diff_added="#81C784", - tool_diff_removed="#E57373", - tool_diff_context="", # match normal body text (terminal default fg), not muted grey - bash_mode="#7BC97F", - code_block_bg="#1f2030", -) - - -_TUI_TOKENS_LIGHT = TuiTokens( - accent="#0B114E", - border="#495F7C", - border_accent="#3B469B", - border_muted="#C8BEC0", - info="#176B7E", - success="#2C7A39", - error="#C0392B", - warning="#9A6B18", - muted="#666666", - dim="#8A93A0", - text="#213853", - thinking_text="#7A7A7A", - activity_label="#213853", - activity_verb="#B26A52", - activity_verb_mid="#9E563E", - activity_verb_highlight="#82412D", - activity_spinner="#6B7280", - selected_bg=_SELECTED_BG_LIGHT, - user_message_bg="#E0E0E0", - user_message_text="", - custom_message_bg="#E6F2F6", - custom_message_text="", - custom_message_label="#176B7E", - tool_pending_bg="#EFE7E8", - tool_error_bg="#F6E3E3", - tool_title="#213853", - tool_output="#666666", - tool_diff_added="#2C7A39", - tool_diff_removed="#C0392B", - tool_diff_context="#213853", # match normal body text (theme `text`), not muted grey - bash_mode="#2C7A39", - code_block_bg="#f1f5f9", -) - -# Pre-built markdown palettes derived from the canonical token instances. -_MARKDOWN_DARK = _build_markdown_colors(_TUI_TOKENS_DARK) -_MARKDOWN_LIGHT = _build_markdown_colors(_TUI_TOKENS_LIGHT) - - -def get_tui_tokens(theme: ThemeName | None = None) -> TuiTokens: - """Return Pythinker semantic tokens for *theme* (defaults to active).""" - name = theme if theme is not None else _active_theme - return _TUI_TOKENS_LIGHT if name == "light" else _TUI_TOKENS_DARK - - -def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: - """Resolve a TuiTokens field name to a Rich Style. - - Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; - everything else produces a style with ``color``. Empty hex values - (``""``) yield an empty style — Rich falls back to terminal defaults. - Color is suppressed when the terminal environment requests plain output. - - Raises: - ValueError: If *token* is not a known TuiTokens field. - """ - if token not in TUI_TOKEN_NAMES: - known = ", ".join(sorted(TUI_TOKEN_NAMES)) - raise ValueError(f"Unknown TUI token {token!r}. Known tokens: {known}") - if colors_disabled(): - return RichStyle() - tokens = get_tui_tokens(theme) - value = getattr(tokens, token) - if not value: - return RichStyle() - if token.endswith("_bg"): - return RichStyle(bgcolor=value) - return RichStyle(color=value) - - -# --------------------------------------------------------------------------- -# Thinking-level prompt frame colors (Shift+Tab cycle). Keyed by the plain level -# string to avoid a theme<->selector import cycle. ``minimal`` is the canonical -# ThinkingLevel value; ``min`` is accepted as the compact palette step alias. -# --------------------------------------------------------------------------- - -# A single cold→hot gradient so the levels read as one dial: slate when off, -# cool blue/teal at low effort, warming amber/orange, ending on dark red. -_THINKING_FRAME_SCALE: dict[str, str] = { - "off": "#64748b", # muted grey / slate-500 - "min": "#60a5fa", # cool blue / blue-400 - "minimal": "#60a5fa", # canonical value for minimum - "low": "#2dd4bf", # teal / teal-400 - "medium": "#fbbf24", # warm amber / amber-400 - "high": "#f97316", # hot orange / orange-500 - "xhigh": "#b91c1c", # dark red / red-700 - "max": "#7f1d1d", # deepest red / red-900 -} - -_THINKING_FRAME_DARK: dict[str, str] = _THINKING_FRAME_SCALE -_THINKING_FRAME_LIGHT: dict[str, str] = _THINKING_FRAME_SCALE - - -def thinking_frame_color(level: str, *, theme: ThemeName | None = None) -> str: - """Hex frame color for thinking *level*; unmapped levels fall back to ``border``.""" - name = theme if theme is not None else _active_theme - table = _THINKING_FRAME_LIGHT if name == "light" else _THINKING_FRAME_DARK - return table.get(level) or get_tui_tokens(theme).border - - -@lru_cache(maxsize=32) -def _dimmed_frame_hex(level: str, name: ThemeName) -> str: - color = thinking_frame_color(level, theme=name) - rgb = parse_hex_color(color) - if rgb is not None: - pole = (255, 255, 255) if name == "light" else (0, 0, 0) - color = to_hex_color(blend(rgb, pole, 0.7)) - return color - - -def thinking_frame_style(level: str, *, theme: ThemeName | None = None) -> str: - """prompt_toolkit input-bar style for *level*, or ``""`` when colors are off. - - The bars are chrome, not content: the level color is dimmed (blended - toward the theme's background pole) so the input frame hints at the - effort level without competing with the text being typed. The blend is - cached — it is re-derived on every prompt_toolkit redraw otherwise. - """ - if colors_disabled(): - return "" - name = theme if theme is not None else _active_theme - return f"fg:{_dimmed_frame_hex(level, name)}" - - -def thinking_dot_style(level: str, *, theme: ThemeName | None = None) -> str: - """prompt_toolkit style for the small effort *dot* on the input top border. - - Unlike :func:`thinking_frame_style` (which dims the color because it paints - a full-width bar), the dot is a single glyph, so it carries the level color - at full strength — the one intentional accent on an otherwise static-grey - border. Returns ``""`` when colors are disabled. - """ - if colors_disabled(): - return "" - return f"fg:{thinking_frame_color(level, theme=theme)}" diff --git a/src/pythinker_code/ui/theme/__init__.py b/src/pythinker_code/ui/theme/__init__.py new file mode 100644 index 00000000..5ce6d5fc --- /dev/null +++ b/src/pythinker_code/ui/theme/__init__.py @@ -0,0 +1,96 @@ +"""Public theme API — backward-compatible re-exports.""" + +from __future__ import annotations + +from .palettes import ( + BRAND, + PROMPT_STYLE_DARK, + PROMPT_STYLE_LIGHT, + SELECTED_BG_DARK, + SELECTED_BG_LIGHT, + THEME_SPECS, +) +from .registry import ( + active_resolver, + get_active_theme, + get_diff_colors, + get_markdown_colors, + get_mcp_prompt_colors, + get_prompt_style, + get_resolver, + get_statusline_colors, + get_task_browser_style, + get_theme_spec, + get_toolbar_colors, + get_tui_tokens, + markdown_rich_style, + set_active_theme, + strip_ptk_colors, + strip_ptk_style_map, + theme_doctor_report, + thinking_dot_style, + thinking_frame_color, + thinking_frame_style, + tui_rich_style, +) +from .spec import ( + TUI_TOKEN_NAMES, + BrandToken, + CoreToken, + DiffColors, + MarkdownColors, + MCPPromptColors, + PromptToken, + StatusLineColors, + ThemeMode, + ThemeName, + ThemeSpec, + ToolbarColors, + TuiTokens, +) + +# Back-compat private names referenced by tests. +_PROMPT_STYLE_DARK = PROMPT_STYLE_DARK +_PROMPT_STYLE_LIGHT = PROMPT_STYLE_LIGHT +_SELECTED_BG_DARK = SELECTED_BG_DARK +_SELECTED_BG_LIGHT = SELECTED_BG_LIGHT +_TUI_TOKENS_DARK = THEME_SPECS[ThemeMode.DARK].tokens +_TUI_TOKENS_LIGHT = THEME_SPECS[ThemeMode.LIGHT].tokens +_strip_ptk_colors = strip_ptk_colors +_strip_ptk_style_map = strip_ptk_style_map + +__all__ = [ + "BRAND", + "BrandToken", + "CoreToken", + "DiffColors", + "MarkdownColors", + "MCPPromptColors", + "PromptToken", + "StatusLineColors", + "ThemeMode", + "ThemeName", + "ThemeSpec", + "ToolbarColors", + "TUI_TOKEN_NAMES", + "TuiTokens", + "active_resolver", + "get_active_theme", + "get_diff_colors", + "get_markdown_colors", + "get_mcp_prompt_colors", + "get_prompt_style", + "get_resolver", + "get_statusline_colors", + "get_task_browser_style", + "get_theme_spec", + "get_toolbar_colors", + "get_tui_tokens", + "markdown_rich_style", + "set_active_theme", + "theme_doctor_report", + "thinking_dot_style", + "thinking_frame_color", + "thinking_frame_style", + "tui_rich_style", +] diff --git a/src/pythinker_code/ui/theme/adapters/__init__.py b/src/pythinker_code/ui/theme/adapters/__init__.py new file mode 100644 index 00000000..96cf71e5 --- /dev/null +++ b/src/pythinker_code/ui/theme/adapters/__init__.py @@ -0,0 +1 @@ +"""Theme output adapters (Rich, prompt_toolkit, markdown).""" diff --git a/src/pythinker_code/ui/theme/adapters/markdown.py b/src/pythinker_code/ui/theme/adapters/markdown.py new file mode 100644 index 00000000..1de68172 --- /dev/null +++ b/src/pythinker_code/ui/theme/adapters/markdown.py @@ -0,0 +1,32 @@ +"""Rich markdown style overrides derived from the active theme.""" + +from __future__ import annotations + +from rich.style import Style as RichStyle + +from ..registry import get_markdown_colors +from ..spec import ThemeName + + +def markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichStyle]: + colors = get_markdown_colors(theme) + return { + "markdown.h1": RichStyle(color=colors.heading, bold=True), + "markdown.h1.border": RichStyle(color=colors.heading), + "markdown.h1.underline": RichStyle(color=colors.heading), + "markdown.h2": RichStyle(color=colors.heading, bold=True, underline=True), + "markdown.h3": RichStyle(color=colors.heading, bold=True), + "markdown.h4": RichStyle(color=colors.heading, bold=True, dim=True), + "markdown.strong": RichStyle(color=colors.strong, bold=True), + "markdown.em": RichStyle(color=colors.emphasis, italic=True), + "markdown.emph": RichStyle(color=colors.emphasis, italic=True), + "markdown.code": RichStyle(color=colors.inline_code), + "markdown.link": RichStyle(color=colors.link, underline=True), + "markdown.link_url": RichStyle(color=colors.link, underline=True, dim=True), + "markdown.block_quote": RichStyle(color=colors.quote, italic=True), + "markdown.hr": RichStyle(color=colors.code_block_border), + "markdown.code_block": RichStyle(color=colors.inline_code), + "markdown.code_block.border": RichStyle(color=colors.code_block_border, bold=True), + "markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True), + "markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True), + } diff --git a/src/pythinker_code/ui/theme/adapters/task_browser.py b/src/pythinker_code/ui/theme/adapters/task_browser.py new file mode 100644 index 00000000..a679277e --- /dev/null +++ b/src/pythinker_code/ui/theme/adapters/task_browser.py @@ -0,0 +1,62 @@ +"""prompt_toolkit adapters.""" + +from __future__ import annotations + +from prompt_toolkit.styles import Style as PTKStyle + +from pythinker_code.ui.terminal_capabilities import colors_disabled + +from ..palettes import THEME_SPECS +from ..registry import strip_ptk_style_map +from ..spec import ThemeMode + + +def _fg(hex_color: str) -> str: + return f"fg:{hex_color}" if hex_color else "" + + +def build_task_browser_style(mode: ThemeMode) -> PTKStyle: + tokens = THEME_SPECS[mode].tokens + if mode is ThemeMode.LIGHT: + styles = { + "header": "bg:#e5e7eb #1f2937", + "header.title": f"bg:#e5e7eb {_fg(tokens.tool_title)} bold", + "header.meta": "bg:#e5e7eb #666666", + "status.running": f"bg:#e5e7eb {_fg(tokens.success)} bold", + "status.success": f"bg:#e5e7eb {_fg(tokens.success)}", + "status.warning": f"bg:#e5e7eb {_fg(tokens.warning)}", + "status.error": f"bg:#e5e7eb {_fg(tokens.error)}", + "status.info": f"bg:#e5e7eb {_fg(tokens.info)}", + "task-list": "bg:#f9fafb #374151", + "task-list.checked": "bg:#cffafe #164e63 bold", + "frame.border": tokens.border, + "frame.label": f"bg:#f1f5f9 {_fg(tokens.tool_title)} bold", + "footer": "bg:#f1f5f9 #475569", + "footer.key": f"bg:#f1f5f9 {_fg(tokens.info)} bold", + "footer.text": "bg:#f1f5f9 #475569", + "footer.warning": f"bg:#fee2e2 {_fg(tokens.error)} bold", + "footer.meta": "bg:#f1f5f9 #64748b", + } + else: + styles = { + "header": "bg:#1f2937 #e5e7eb", + "header.title": f"bg:#1f2937 {_fg(tokens.tool_title)} bold", + "header.meta": "bg:#1f2937 #A3A3A3", + "status.running": f"bg:#1f2937 {_fg(tokens.success)} bold", + "status.success": f"bg:#1f2937 {_fg(tokens.success)}", + "status.warning": f"bg:#1f2937 {_fg(tokens.warning)}", + "status.error": f"bg:#1f2937 {_fg(tokens.error)}", + "status.info": f"bg:#1f2937 {_fg(tokens.info)}", + "task-list": "bg:#111827 #d1d5db", + "task-list.checked": "bg:#164e63 #ecfeff bold", + "frame.border": "#3A506D", + "frame.label": f"bg:#17182a {_fg(tokens.tool_title)} bold", + "footer": "bg:#17182a #A3A3A3", + "footer.key": f"bg:#17182a {_fg(tokens.info)} bold", + "footer.text": "bg:#17182a #A3A3A3", + "footer.warning": f"bg:#4a3315 {_fg(tokens.warning)} bold", + "footer.meta": "bg:#17182a #5F6B7E", + } + if colors_disabled(): + styles = strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) diff --git a/src/pythinker_code/ui/theme/capabilities.py b/src/pythinker_code/ui/theme/capabilities.py new file mode 100644 index 00000000..c1bef86c --- /dev/null +++ b/src/pythinker_code/ui/theme/capabilities.py @@ -0,0 +1,27 @@ +"""Terminal capability detection for theme resolution.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from pythinker_code.ui.terminal_capabilities import color_depth, colors_disabled + + +@dataclass(frozen=True, slots=True) +class TerminalCapabilities: + color_enabled: bool + truecolor: bool + color_256: bool + dumb: bool + + +def get_terminal_capabilities() -> TerminalCapabilities: + depth = color_depth() + term = (os.environ.get("TERM") or "").strip().lower() + return TerminalCapabilities( + color_enabled=not colors_disabled(), + truecolor=depth == "truecolor", + color_256=depth in ("256", "truecolor"), + dumb=term == "dumb", + ) diff --git a/src/pythinker_code/ui/theme/palettes.py b/src/pythinker_code/ui/theme/palettes.py new file mode 100644 index 00000000..eedb7541 --- /dev/null +++ b/src/pythinker_code/ui/theme/palettes.py @@ -0,0 +1,360 @@ +"""Canonical hex palettes — the only place UI color literals should live.""" + +from __future__ import annotations + +from .spec import ( + BrandToken, + MarkdownAnsiToken, + MCPPromptColors, + PromptToken, + StatusLineColors, + ThemeMode, + ThemeSpec, + ToolbarColors, + TuiTokens, +) + +# Shared selection-row backgrounds (wired to CoreToken.SELECTED_BG). +SELECTED_BG_DARK = "#252944" +SELECTED_BG_LIGHT = "#E7E9F9" + +# Theme-independent robot mark (see BrandToken). +BRAND: dict[BrandToken, str] = { + BrandToken.NAVY: "#213853", + BrandToken.FACE: "#F9F2F5", + BrandToken.CORAL: "#EE9983", + BrandToken.CORAL_LIT: "#FFB9A3", + BrandToken.IRIS: "#AFE3F1", +} + +# Dark core tokens — refined contrast per design spec §16. +_CORE_DARK: dict[str, str] = { + "accent": "#AEB7FF", + "border": "#8a8d91", + "border_accent": "#7C88DE", + "border_muted": "#b8bcc0", + "info": "#8FDDEA", + "success": "#7CCF8A", + "error": "#F87171", + "warning": "#EAB85F", + "muted": "#8A8A8A", + "dim": "#6F6F6F", + "text": "", + "thinking_text": "#D4D4D4", + "activity_label": "#F4F4F5", + "activity_verb": "#C68D7E", + "activity_verb_mid": "#D8AC9E", + "activity_verb_highlight": "#E9CDC2", + "activity_spinner": "#B8C0CC", + "selected_bg": SELECTED_BG_DARK, + "user_message_bg": "#333333", + "user_message_text": "", + "custom_message_bg": "#16242E", + "custom_message_text": "", + "custom_message_label": "#8FDDEA", + "tool_pending_bg": "#1B2230", + "tool_error_bg": "#2E1D24", + "tool_title": "#F4F4F5", + "tool_output": "#D7D7DB", + "tool_diff_added": "#81C784", + "tool_diff_removed": "#E57373", + "tool_diff_context": "", + "bash_mode": "#7CCF8A", + "code_block_bg": "#1B1D2B", +} + +_CORE_LIGHT: dict[str, str] = { + "accent": "#0B114E", + "border": "#495F7C", + "border_accent": "#3B469B", + "border_muted": "#C8BEC0", + "info": "#176B7E", + "success": "#2C7A39", + "error": "#C0392B", + "warning": "#9A6B18", + "muted": "#666666", + "dim": "#8A93A0", + "text": "#213853", + "thinking_text": "#7A7A7A", + "activity_label": "#213853", + "activity_verb": "#B26A52", + "activity_verb_mid": "#9E563E", + "activity_verb_highlight": "#82412D", + "activity_spinner": "#6B7280", + "selected_bg": SELECTED_BG_LIGHT, + "user_message_bg": "#E0E0E0", + "user_message_text": "", + "custom_message_bg": "#E6F2F6", + "custom_message_text": "", + "custom_message_label": "#176B7E", + "tool_pending_bg": "#EFE7E8", + "tool_error_bg": "#F6E3E3", + "tool_title": "#213853", + "tool_output": "#666666", + "tool_diff_added": "#2C7A39", + "tool_diff_removed": "#C0392B", + "tool_diff_context": "#213853", + "bash_mode": "#2C7A39", + "code_block_bg": "#f1f5f9", +} + +_PROMPT_HEX_DARK: dict[PromptToken, str] = { + PromptToken.SLASH_COMMAND: "#6CA1F5", + PromptToken.MENTION: "#56C7B0", + PromptToken.BASH_PREFIX: "#E5C07B", + PromptToken.GHOST_TEXT: "#6B7280", + PromptToken.PROMPT_GLYPH: "#F4F4F5", + PromptToken.FRAME: "#8a8d91", + PromptToken.EFFORT: "#A3A3A3", + PromptToken.PLACEHOLDER: "#A3A3A3", + PromptToken.SEPARATOR: "#b8bcc0", + PromptToken.MENU_MATCH: "#8FDDEA", + PromptToken.MENU_TEXT: "#F4F4F5", + PromptToken.MENU_META: "#A3A3A3", + PromptToken.DIALOG_TEXT: "#F4F4F5", + PromptToken.DIALOG_BORDER: "#b8bcc0", + PromptToken.FOOTER_KEY: "#8FDDEA", + PromptToken.FOOTER_META: "#A3A3A3", +} + +_PROMPT_HEX_LIGHT: dict[PromptToken, str] = { + PromptToken.SLASH_COMMAND: "#1D63D8", + PromptToken.MENTION: "#0E8C7A", + PromptToken.BASH_PREFIX: "#B45309", + PromptToken.GHOST_TEXT: "#8A93A0", + PromptToken.PROMPT_GLYPH: "#213853", + PromptToken.FRAME: "#495F7C", + PromptToken.EFFORT: "#666666", + PromptToken.PLACEHOLDER: "#666666", + PromptToken.SEPARATOR: "#C8BEC0", + PromptToken.MENU_MATCH: "#176B7E", + PromptToken.MENU_TEXT: "#4b5563", + PromptToken.MENU_META: "#666666", + PromptToken.DIALOG_TEXT: "#374151", + PromptToken.DIALOG_BORDER: "#C8BEC0", + PromptToken.FOOTER_KEY: "#176B7E", + PromptToken.FOOTER_META: "#666666", +} + +_MARKDOWN_ANSI = { + MarkdownAnsiToken.LINK: "cyan", + MarkdownAnsiToken.QUOTE: "green", + MarkdownAnsiToken.ORDERED_MARKER: "bright_blue", +} + +_THINKING_FRAME_SCALE: dict[str, str] = { + "off": "#64748b", + "min": "#60a5fa", + "minimal": "#60a5fa", + "low": "#2dd4bf", + "medium": "#fbbf24", + "high": "#f97316", + "xhigh": "#b91c1c", + "max": "#7f1d1d", +} + +_DIFF_HEX_DARK = { + "add_bg": "#052e05", + "del_bg": "#3a0808", + "add_hl": "#0e5a0e", + "del_hl": "#6b1414", +} + +_DIFF_HEX_LIGHT = { + "add_bg": "#dafbe1", + "del_bg": "#ffebe9", + "add_hl": "#aff5b4", + "del_hl": "#ffc1c0", +} + + +def _tokens_from_core(core: dict[str, str]) -> TuiTokens: + return TuiTokens(**core) + + +def _statusline(mode: ThemeMode) -> StatusLineColors: + if mode is ThemeMode.LIGHT: + return StatusLineColors( + model="bold fg:#7a3fb0", + cost="fg:#9a6b18", + speed="fg:#1a6fb0", + effort_hi="fg:#2c7a39", + effort_md="fg:#9a6b18", + effort_lo="fg:#5c6b7a", + dir="fg:#2a6cb0", + branch="fg:#17776b", + add="fg:#2c7a39", + delete="fg:#b03030", + label="fg:#5c6370", + dim="fg:#9aa0ac", + warn="bold fg:#c01818", + spinner="fg:#1a6fb0", + spinner_idle="fg:#9aa0ac", + time="fg:#3a5a80", + usage_ok="fg:#9aa0ac", + usage_mid="fg:#9a6b18", + usage_high="fg:#b05a10", + usage_crit="fg:#c01818", + ) + return StatusLineColors( + model="bold fg:#dcb4ff", + cost="fg:#ffc850", + speed="fg:#78c8ff", + effort_hi="fg:#78dc8c", + effort_md="fg:#f0c850", + effort_lo="fg:#8ca0b4", + dir="fg:#82bef0", + branch="fg:#64d2c8", + add="fg:#78dc8c", + delete="fg:#ff6e6e", + label="fg:#a0a5b4", + dim="fg:#505564", + warn="bold fg:#ff5050", + spinner="fg:#64b4ff", + spinner_idle="fg:#505564", + time="fg:#b4d2f0", + usage_ok="fg:#505564", + usage_mid="fg:#f0c850", + usage_high="fg:#ffa046", + usage_crit="fg:#ff5050", + ) + + +def _toolbar(mode: ThemeMode, tokens: TuiTokens) -> ToolbarColors: + if mode is ThemeMode.LIGHT: + return ToolbarColors( + separator="fg:#C8BEC0", + yolo_label="bold fg:#9A6B18", + auto_label="bold fg:#2C7A39", + plan_label="bold fg:#176B7E", + plan_prompt="fg:#176B7E", + cwd="fg:#8A93A0", + bg_tasks="fg:#666666", + tip="fg:#666666", + tip_key="fg:#666666 bold", + ) + return ToolbarColors( + separator="fg:#2B3A52", + yolo_label="bold fg:#EAB85F", + auto_label="bold fg:#7CCF8A", + plan_label="bold fg:#8FDDEA", + plan_prompt="fg:#8FDDEA", + cwd=f"fg:{tokens.muted}", + bg_tasks=f"fg:{tokens.muted}", + tip=f"fg:{tokens.muted}", + tip_key=f"fg:{tokens.muted} bold", + ) + + +def _mcp(mode: ThemeMode, tokens: TuiTokens) -> MCPPromptColors: + if mode is ThemeMode.LIGHT: + return MCPPromptColors( + text="fg:#213853", + detail="fg:#666666", + connected="fg:#2C7A39", + connecting="fg:#176B7E", + pending="fg:#9A6B18", + failed="fg:#C0392B", + ) + return MCPPromptColors( + text="fg:#d4d4d4", + detail="fg:#A3A3A3", + connected=f"fg:{tokens.success}", + connecting=f"fg:{tokens.info}", + pending=f"fg:{tokens.warning}", + failed=f"fg:{tokens.error}", + ) + + +def _build_prompt_classes( + mode: ThemeMode, + prompt: dict[PromptToken, str], + tokens: TuiTokens, +) -> dict[str, str]: + selected_bg = tokens.selected_bg + p = prompt + success = tokens.success + menu_warning = "#B69B64" if mode is ThemeMode.DARK else "#9A6B18" + dialog_title = p[PromptToken.MENU_TEXT] if mode is ThemeMode.DARK else tokens.tool_title + dialog_option = p[PromptToken.MENU_META] + footer_warning = tokens.warning if mode is ThemeMode.DARK else "#9A6B18" + footer_error = tokens.error if mode is ThemeMode.DARK else "#C0392B" + return { + "bottom-toolbar": "noreverse", + "compact-input": "", + "compact-input.prompt": f"fg:{p[PromptToken.PROMPT_GLYPH]} bold", + "compact-input.frame": f"fg:{p[PromptToken.FRAME]}", + "compact-input.effort": f"fg:{p[PromptToken.EFFORT]}", + "running-prompt-placeholder": f"fg:{p[PromptToken.PLACEHOLDER]} italic", + "running-prompt-separator": f"fg:{p[PromptToken.SEPARATOR]}", + "slash-command": f"fg:{p[PromptToken.SLASH_COMMAND]}", + "slash-arg": f"fg:{p[PromptToken.SLASH_COMMAND]}", + "file-mention": f"fg:{p[PromptToken.MENTION]}", + "bash-prefix": f"fg:{p[PromptToken.BASH_PREFIX]}", + "auto-suggestion": f"fg:{p[PromptToken.GHOST_TEXT]}", + "slash-completion-menu": "", + "slash-completion-menu.separator": f"fg:{p[PromptToken.SEPARATOR]}", + "slash-completion-menu.marker": f"fg:{p[PromptToken.SEPARATOR]}", + "slash-completion-menu.marker.current": f"fg:{p[PromptToken.MENU_MATCH]} bold", + "slash-completion-menu.command": f"fg:{p[PromptToken.MENU_TEXT]}", + "slash-completion-menu.command.match": f"fg:{p[PromptToken.MENU_MATCH]} bold", + "slash-completion-menu.meta": f"fg:{p[PromptToken.MENU_META]}", + "slash-completion-menu.meta.success": f"fg:{success}", + "slash-completion-menu.meta.warning": f"fg:{menu_warning}", + "slash-completion-menu.command.current": ( + f"bg:{selected_bg} fg:{p[PromptToken.MENU_TEXT]} bold" + ), + "slash-completion-menu.command.match.current": ( + f"bg:{selected_bg} fg:{p[PromptToken.MENU_MATCH]} bold" + ), + "slash-completion-menu.meta.current": f"bg:{selected_bg} fg:{p[PromptToken.MENU_META]}", + "slash-completion-menu.meta.success.current": f"bg:{selected_bg} fg:{success}", + "slash-completion-menu.meta.warning.current": f"bg:{selected_bg} fg:{menu_warning}", + "slash-completion-menu.row.current": f"bg:{selected_bg}", + "file-completion-menu": "", + "file-completion-menu.marker": f"fg:{p[PromptToken.SEPARATOR]}", + "file-completion-menu.marker.current": f"fg:{p[PromptToken.MENU_MATCH]} bold", + "file-completion-menu.name": f"fg:{p[PromptToken.MENU_META]}", + "file-completion-menu.name.current": f"fg:{p[PromptToken.MENU_MATCH]} bold", + "file-completion-menu.detail": f"fg:{p[PromptToken.MENU_META]}", + "file-completion-menu.detail.current": f"fg:{p[PromptToken.MENU_MATCH]}", + "file-completion-menu.count": "fg:#5F6B7E" if mode is ThemeMode.DARK else "fg:#8A93A0", + "shell-dialog": f"fg:{p[PromptToken.DIALOG_TEXT]}", + "shell-dialog.title": f"fg:{dialog_title} bold", + "shell-dialog.border": f"fg:{p[PromptToken.DIALOG_BORDER]}", + "shell-dialog.option": f"fg:{dialog_option}", + "shell-dialog.option.current": f"bg:{selected_bg} fg:{p[PromptToken.MENU_TEXT]} bold", + "shell-footer.key": f"fg:{p[PromptToken.FOOTER_KEY]} bold", + "shell-footer.meta": f"fg:{p[PromptToken.FOOTER_META]}", + "shell-footer.warning": f"fg:{footer_warning}", + "shell-footer.error": f"fg:{footer_error}", + } + + +def build_theme_spec(mode: ThemeMode) -> ThemeSpec: + core = _CORE_DARK if mode is ThemeMode.DARK else _CORE_LIGHT + tokens = _tokens_from_core(core) + prompt = _PROMPT_HEX_DARK if mode is ThemeMode.DARK else _PROMPT_HEX_LIGHT + return ThemeSpec( + mode=mode, + tokens=tokens, + prompt=dict(prompt), + prompt_classes=_build_prompt_classes(mode, prompt, tokens), + status=_statusline(mode), + toolbar=_toolbar(mode, tokens), + mcp=_mcp(mode, tokens), + brand=dict(BRAND), + markdown_ansi=dict(_MARKDOWN_ANSI), + diff_hex=_DIFF_HEX_DARK if mode is ThemeMode.DARK else _DIFF_HEX_LIGHT, + thinking_frame=dict(_THINKING_FRAME_SCALE), + ) + + +THEME_SPECS: dict[ThemeMode, ThemeSpec] = { + ThemeMode.DARK: build_theme_spec(ThemeMode.DARK), + ThemeMode.LIGHT: build_theme_spec(ThemeMode.LIGHT), +} + +# Back-compat aliases for tests that import private prompt style dicts. +PROMPT_STYLE_DARK = THEME_SPECS[ThemeMode.DARK].prompt_classes +PROMPT_STYLE_LIGHT = THEME_SPECS[ThemeMode.LIGHT].prompt_classes diff --git a/src/pythinker_code/ui/theme/registry.py b/src/pythinker_code/ui/theme/registry.py new file mode 100644 index 00000000..779ab19e --- /dev/null +++ b/src/pythinker_code/ui/theme/registry.py @@ -0,0 +1,248 @@ +"""Active theme registry and public resolution helpers.""" + +from __future__ import annotations + +import re +from dataclasses import fields, replace +from functools import lru_cache +from typing import Any, cast + +from prompt_toolkit.styles import Style as PTKStyle +from rich.style import Style as RichStyle + +from pythinker_code.ui.color_utils import blend, parse_hex_color, to_hex_color +from pythinker_code.ui.terminal_capabilities import color_depth, colors_disabled + +from .capabilities import get_terminal_capabilities +from .palettes import THEME_SPECS +from .resolver import StyleResolver +from .spec import ( + TUI_TOKEN_NAMES, + DiffColors, + MarkdownAnsiToken, + MarkdownColors, + MCPPromptColors, + StatusLineColors, + ThemeMode, + ThemeName, + ThemeSpec, + ToolbarColors, + TuiTokens, +) + +_PTK_COLOR_TOKEN_RE = re.compile(r"^(?:fg:|bg:)?#[0-9A-Fa-f]{6}$") + +_active_theme: ThemeName = "dark" + + +def _strip_ptk_colors(style: str) -> str: + if not style: + return style + return " ".join(part for part in style.split() if not _PTK_COLOR_TOKEN_RE.match(part)) + + +def _strip_ptk_style_map(values: dict[str, str]) -> dict[str, str]: + return {key: _strip_ptk_colors(value) for key, value in values.items()} + + +def _strip_color_dataclass[T](value: T) -> T: + updates: dict[str, Any] = {} + for field in fields(cast(Any, value)): + current = getattr(value, field.name) + if isinstance(current, str): + updates[field.name] = _strip_ptk_colors(current) + return replace(cast(Any, value), **updates) + + +def set_active_theme(theme: ThemeName) -> None: + global _active_theme + _active_theme = theme + + +def get_active_theme() -> ThemeName: + return _active_theme + + +def _mode(name: ThemeName | None = None) -> ThemeMode: + resolved = name if name is not None else _active_theme + return ThemeMode.LIGHT if resolved == "light" else ThemeMode.DARK + + +def get_theme_spec(theme: ThemeName | None = None) -> ThemeSpec: + return THEME_SPECS[_mode(theme)] + + +@lru_cache(maxsize=4) +def get_resolver(theme: ThemeName = "dark") -> StyleResolver: + return StyleResolver(THEME_SPECS[_mode(theme)], get_terminal_capabilities()) + + +def active_resolver() -> StyleResolver: + return get_resolver(_active_theme) + + +def get_tui_tokens(theme: ThemeName | None = None) -> TuiTokens: + return get_theme_spec(theme).tokens + + +def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: + if token not in TUI_TOKEN_NAMES: + known = ", ".join(sorted(TUI_TOKEN_NAMES)) + raise ValueError(f"Unknown TUI token {token!r}. Known tokens: {known}") + if colors_disabled(): + return RichStyle() + resolver = get_resolver(theme or _active_theme) + value = resolver.core_hex(token) + if not value: + return RichStyle() + if token.endswith("_bg"): + return RichStyle(bgcolor=value) + return RichStyle(color=value) + + +def get_markdown_colors(theme: ThemeName | None = None) -> MarkdownColors: + spec = get_theme_spec(theme) + tokens = spec.tokens + ansi = spec.markdown_ansi + return MarkdownColors( + heading=tokens.tool_title, + emphasis=tokens.muted, + strong=tokens.tool_title, + inline_code=tokens.info, + link=ansi[MarkdownAnsiToken.LINK], + quote=ansi[MarkdownAnsiToken.QUOTE], + ordered_marker=ansi[MarkdownAnsiToken.ORDERED_MARKER], + unordered_marker=tokens.muted, + table_border=tokens.border_muted, + code_block_border=tokens.border_muted, + code_block_bg=tokens.code_block_bg, + spinner_active=tokens.info, + spinner_done=tokens.success, + spinner_failed=tokens.error, + ) + + +def markdown_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: + if colors_disabled(): + return RichStyle() + colors = get_markdown_colors(theme) + value = getattr(colors, token) + if not value: + return RichStyle() + if token.endswith("_bg"): + return RichStyle(bgcolor=value) + return RichStyle(color=value) + + +def get_statusline_colors() -> StatusLineColors: + colors = get_theme_spec().status + return _strip_color_dataclass(colors) if colors_disabled() else colors + + +def get_toolbar_colors() -> ToolbarColors: + colors = get_theme_spec().toolbar + return _strip_color_dataclass(colors) if colors_disabled() else colors + + +def get_mcp_prompt_colors() -> MCPPromptColors: + colors = get_theme_spec().mcp + return _strip_color_dataclass(colors) if colors_disabled() else colors + + +def get_prompt_style() -> PTKStyle: + spec = get_theme_spec() + styles = spec.prompt_classes + if colors_disabled(): + styles = _strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) + + +_DIFF_PLAIN = DiffColors( + add_bg=RichStyle(), + del_bg=RichStyle(), + add_hl=RichStyle(), + del_hl=RichStyle(), +) + +_DIFF_ANSI16 = DiffColors( + add_bg=RichStyle(color="green"), + del_bg=RichStyle(color="red"), + add_hl=RichStyle(color="green", bold=True), + del_hl=RichStyle(color="red", bold=True), +) + + +def get_diff_colors() -> DiffColors: + if colors_disabled(): + return _DIFF_PLAIN + if color_depth() == "16": + return _DIFF_ANSI16 + spec = get_theme_spec() + hx = spec.diff_hex + return DiffColors( + add_bg=RichStyle(bgcolor=hx["add_bg"]), + del_bg=RichStyle(bgcolor=hx["del_bg"]), + add_hl=RichStyle(bgcolor=hx["add_hl"]), + del_hl=RichStyle(bgcolor=hx["del_hl"]), + ) + + +def get_task_browser_style() -> PTKStyle: + from .adapters.task_browser import build_task_browser_style + + return build_task_browser_style(_mode()) + + +def thinking_frame_color(level: str, *, theme: ThemeName | None = None) -> str: + spec = get_theme_spec(theme) + return spec.thinking_frame.get(level) or get_tui_tokens(theme).border + + +@lru_cache(maxsize=32) +def _dimmed_frame_hex(level: str, name: ThemeName) -> str: + color = thinking_frame_color(level, theme=name) + rgb = parse_hex_color(color) + if rgb is not None: + pole = (255, 255, 255) if name == "light" else (0, 0, 0) + color = to_hex_color(blend(rgb, pole, 0.7)) + return color + + +def thinking_frame_style(level: str, *, theme: ThemeName | None = None) -> str: + if colors_disabled(): + return "" + name = theme if theme is not None else _active_theme + return f"fg:{_dimmed_frame_hex(level, name)}" + + +def thinking_dot_style(level: str, *, theme: ThemeName | None = None) -> str: + if colors_disabled(): + return "" + return f"fg:{thinking_frame_color(level, theme=theme)}" + + +def strip_ptk_colors(style: str) -> str: + """Remove prompt_toolkit color directives while preserving weight/style.""" + return _strip_ptk_colors(style) + + +def strip_ptk_style_map(values: dict[str, str]) -> dict[str, str]: + return _strip_ptk_style_map(values) + + +def theme_doctor_report(*, configured: str, config_path: str | None) -> str: + caps = get_terminal_capabilities() + spec = get_theme_spec() + lines = [ + f"Theme: {get_active_theme()}", + f"Configured: {configured}", + f"Resolved from: {config_path or '(defaults)'}", + f"Color enabled: {'yes' if caps.color_enabled else 'no'}", + f"Truecolor: {'yes' if caps.truecolor else 'no'}", + f"256-color: {'yes' if caps.color_256 else 'no'}", + f"TERM dumb: {'yes' if caps.dumb else 'no'}", + f"Core tokens: {len(TUI_TOKEN_NAMES)}", + f"Prompt classes: {len(spec.prompt_classes)}", + f"Brand tokens: {len(spec.brand)}", + ] + return "\n".join(lines) diff --git a/src/pythinker_code/ui/theme/resolver.py b/src/pythinker_code/ui/theme/resolver.py new file mode 100644 index 00000000..82336af4 --- /dev/null +++ b/src/pythinker_code/ui/theme/resolver.py @@ -0,0 +1,104 @@ +"""Central style resolution — the only place that understands theme + terminal.""" + +from __future__ import annotations + +from rich.style import Style as RichStyle + +from .capabilities import TerminalCapabilities, get_terminal_capabilities +from .spec import ( + TUI_TOKEN_NAMES, + BrandToken, + CoreToken, + MarkdownAnsiToken, + PromptToken, + ThemeSpec, +) + + +class StyleResolver: + """Resolve semantic tokens to Rich / prompt_toolkit style fragments.""" + + def __init__( + self, + theme: ThemeSpec, + capabilities: TerminalCapabilities | None = None, + ) -> None: + self.theme = theme + self.capabilities = capabilities or get_terminal_capabilities() + + def core_hex(self, token: CoreToken | str) -> str: + key = token.value if isinstance(token, CoreToken) else token + if key not in TUI_TOKEN_NAMES: + msg = f"Unknown core token {key!r}" + raise ValueError(msg) + return getattr(self.theme.tokens, key) + + def prompt_hex(self, token: PromptToken) -> str: + return self.theme.prompt[token] + + def brand_hex(self, token: BrandToken) -> str: + return self.theme.brand[token] + + def color( + self, + token: CoreToken | PromptToken | BrandToken | MarkdownAnsiToken | str, + ) -> str: + if not self.capabilities.color_enabled: + return "" + if isinstance(token, CoreToken): + return self.core_hex(token) + if isinstance(token, PromptToken): + return self.prompt_hex(token) + if isinstance(token, BrandToken): + return self.brand_hex(token) + if isinstance(token, MarkdownAnsiToken): + return self.theme.markdown_ansi[token] + if token in TUI_TOKEN_NAMES: + return self.core_hex(token) + return token + + def rich_style( + self, + token: CoreToken | str, + *, + bold: bool = False, + italic: bool = False, + underline: bool = False, + bgcolor: CoreToken | str | None = None, + ) -> RichStyle: + if not self.capabilities.color_enabled: + return RichStyle(bold=bold, italic=italic, underline=underline) + fg = self.color(token) + bg = self.color(bgcolor) if bgcolor is not None else None + key = token.value if isinstance(token, CoreToken) else token + if key.endswith("_bg"): + return RichStyle(bgcolor=fg or None, bold=bold, italic=italic, underline=underline) + if bg and fg: + return RichStyle( + color=fg, + bgcolor=bg, + bold=bold, + italic=italic, + underline=underline, + ) + if bg: + return RichStyle(bgcolor=bg, bold=bold, italic=italic, underline=underline) + if fg: + return RichStyle(color=fg, bold=bold, italic=italic, underline=underline) + return RichStyle(bold=bold, italic=italic, underline=underline) + + def ptk_fg(self, token: PromptToken | CoreToken) -> str: + color = self.color(token) + return f"fg:{color}" if color else "" + + def markdown_inline_code_style(self) -> RichStyle: + """Inline code: color only — headers stay bold elsewhere.""" + return self.rich_style(CoreToken.INFO) + + def markdown_heading_style(self, *, level: int = 1) -> RichStyle: + style = self.rich_style(CoreToken.TOOL_TITLE, bold=True) + if level == 2: + return RichStyle(color=style.color, bold=True, underline=True) + if level == 4: + return RichStyle(color=style.color, bold=True, dim=True) + return style diff --git a/src/pythinker_code/ui/theme/spec.py b/src/pythinker_code/ui/theme/spec.py new file mode 100644 index 00000000..6006b105 --- /dev/null +++ b/src/pythinker_code/ui/theme/spec.py @@ -0,0 +1,216 @@ +"""Theme token enums and spec types.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +from enum import StrEnum +from typing import Literal + +from rich.style import Style as RichStyle + +type ThemeName = Literal["dark", "light"] + + +class ThemeMode(StrEnum): + DARK = "dark" + LIGHT = "light" + + +class CoreToken(StrEnum): + """Semantic Rich/TUI tokens — values map to ``TuiTokens`` field names.""" + + ACCENT = "accent" + BORDER = "border" + BORDER_ACCENT = "border_accent" + BORDER_MUTED = "border_muted" + INFO = "info" + SUCCESS = "success" + ERROR = "error" + WARNING = "warning" + MUTED = "muted" + DIM = "dim" + TEXT = "text" + THINKING_TEXT = "thinking_text" + ACTIVITY_LABEL = "activity_label" + ACTIVITY_VERB = "activity_verb" + ACTIVITY_VERB_MID = "activity_verb_mid" + ACTIVITY_VERB_HIGHLIGHT = "activity_verb_highlight" + ACTIVITY_SPINNER = "activity_spinner" + SELECTED_BG = "selected_bg" + USER_MESSAGE_BG = "user_message_bg" + USER_MESSAGE_TEXT = "user_message_text" + CUSTOM_MESSAGE_BG = "custom_message_bg" + CUSTOM_MESSAGE_TEXT = "custom_message_text" + CUSTOM_MESSAGE_LABEL = "custom_message_label" + TOOL_PENDING_BG = "tool_pending_bg" + TOOL_ERROR_BG = "tool_error_bg" + TOOL_TITLE = "tool_title" + TOOL_OUTPUT = "tool_output" + TOOL_DIFF_ADDED = "tool_diff_added" + TOOL_DIFF_REMOVED = "tool_diff_removed" + TOOL_DIFF_CONTEXT = "tool_diff_context" + BASH_MODE = "bash_mode" + CODE_BLOCK_BG = "code_block_bg" + + +class PromptToken(StrEnum): + SLASH_COMMAND = "slash_command" + MENTION = "mention" + BASH_PREFIX = "bash_prefix" + GHOST_TEXT = "ghost_text" + PROMPT_GLYPH = "prompt_glyph" + FRAME = "frame" + EFFORT = "effort" + PLACEHOLDER = "placeholder" + SEPARATOR = "separator" + MENU_MATCH = "menu_match" + MENU_TEXT = "menu_text" + MENU_META = "menu_meta" + DIALOG_TEXT = "dialog_text" + DIALOG_BORDER = "dialog_border" + FOOTER_KEY = "footer_key" + FOOTER_META = "footer_meta" + + +class BrandToken(StrEnum): + NAVY = "navy" + FACE = "face" + CORAL = "coral" + CORAL_LIT = "coral_lit" + IRIS = "iris" + + +class MarkdownAnsiToken(StrEnum): + LINK = "link" + QUOTE = "quote" + ORDERED_MARKER = "ordered_marker" + + +@dataclass(frozen=True, slots=True) +class TuiTokens: + accent: str + border: str + border_accent: str + border_muted: str + info: str + success: str + error: str + warning: str + muted: str + dim: str + text: str + thinking_text: str + activity_label: str + activity_verb: str + activity_verb_mid: str + activity_verb_highlight: str + activity_spinner: str + selected_bg: str + user_message_bg: str + user_message_text: str + custom_message_bg: str + custom_message_text: str + custom_message_label: str + tool_pending_bg: str + tool_error_bg: str + tool_title: str + tool_output: str + tool_diff_added: str + tool_diff_removed: str + tool_diff_context: str + bash_mode: str + code_block_bg: str + + +TUI_TOKEN_NAMES = frozenset(field.name for field in fields(TuiTokens)) +CORE_TOKEN_BY_FIELD = {token.value: token for token in CoreToken} + + +@dataclass(frozen=True, slots=True) +class DiffColors: + add_bg: RichStyle + del_bg: RichStyle + add_hl: RichStyle + del_hl: RichStyle + + +@dataclass(frozen=True, slots=True) +class ToolbarColors: + separator: str + yolo_label: str + auto_label: str + plan_label: str + plan_prompt: str + cwd: str + bg_tasks: str + tip: str + tip_key: str + + +@dataclass(frozen=True, slots=True) +class StatusLineColors: + model: str + cost: str + speed: str + effort_hi: str + effort_md: str + effort_lo: str + dir: str + branch: str + add: str + delete: str + label: str + dim: str + warn: str + spinner: str + spinner_idle: str + time: str + usage_ok: str + usage_mid: str + usage_high: str + usage_crit: str + + +@dataclass(frozen=True, slots=True) +class MarkdownColors: + heading: str + emphasis: str + strong: str + inline_code: str + link: str + quote: str + ordered_marker: str + unordered_marker: str + table_border: str + code_block_border: str + code_block_bg: str + spinner_active: str + spinner_done: str + spinner_failed: str + + +@dataclass(frozen=True, slots=True) +class MCPPromptColors: + text: str + detail: str + connected: str + connecting: str + pending: str + failed: str + + +@dataclass(frozen=True, slots=True) +class ThemeSpec: + """Single source of truth for one resolved theme mode.""" + + mode: ThemeMode + tokens: TuiTokens + prompt: dict[PromptToken, str] + prompt_classes: dict[str, str] + status: StatusLineColors + toolbar: ToolbarColors + mcp: MCPPromptColors + brand: dict[BrandToken, str] + markdown_ansi: dict[MarkdownAnsiToken, str] + diff_hex: dict[str, str] + thinking_frame: dict[str, str] diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 51bdc7ab..446f4c46 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -19,13 +19,13 @@ def test_shell_welcome_uses_pythinker_code_copy(monkeypatch): assert "think first" in output -def test_directory_label_uses_accent_token(): +def test_directory_label_uses_info_token(): from pythinker_code.ui.shell import WelcomeInfoItem, _value_style_for_label from pythinker_code.ui.theme import get_tui_tokens, set_active_theme set_active_theme("dark") style = _value_style_for_label("Directory", WelcomeInfoItem.Level.INFO) - assert get_tui_tokens("dark").accent in style # "#B3B9F4" periwinkle + assert get_tui_tokens("dark").info in style def test_welcome_banner_chip_shown_in_output(monkeypatch): diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index f418a249..1994f389 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -22,6 +22,7 @@ _find_prompt_float_container, _wrap_to_width, ) +from pythinker_code.ui.shell.slash import slash_command_arg_suggestions from pythinker_code.utils.slashcmd import SlashCommand @@ -52,6 +53,13 @@ def _completions(completer: SlashCommandCompleter, text: str): return list(completer.get_completions(document, event)) +def _theme_completer() -> SlashCommandCompleter: + return SlashCommandCompleter( + [_make_command("theme", aliases=["color"]), _make_command("help")], + arg_suggestions=slash_command_arg_suggestions, + ) + + def test_exact_command_match_keeps_completions_visible(): """Exact matches should still show completions so the slash menu stays open.""" completer = SlashCommandCompleter( @@ -144,8 +152,22 @@ def test_should_complete_only_for_root_slash_token(): assert not SlashCommandCompleter.should_complete(Document(text="/he next", cursor_position=8)) +def test_completion_active_for_theme_subcommand(): + completer = _theme_completer() + assert completer.completion_active(Document(text="/theme ", cursor_position=len("/theme "))) + assert completer.completion_active( + Document(text="/theme cur", cursor_position=len("/theme cur")) + ) + assert not completer.completion_active( + Document(text="/help foo", cursor_position=len("/help foo")) + ) + + def _suggestion_text(names: frozenset[str], text: str) -> str | None: - suggest = SlashCommandAutoSuggest(lambda: names) + suggest = SlashCommandAutoSuggest( + lambda: names, + arg_suggestions=slash_command_arg_suggestions, + ) document = Document(text=text, cursor_position=len(text)) suggestion = suggest.get_suggestion(Buffer(), document) return suggestion.text if suggestion else None @@ -203,6 +225,32 @@ def test_auto_suggest_is_case_insensitive_on_typed_prefix(): assert _suggestion_text(names, "/He") == "lp" +def test_auto_suggest_theme_subcommand_after_space(): + names = frozenset({"theme", "color"}) + assert _suggestion_text(names, "/theme ") == "current" + + +def test_auto_suggest_theme_subcommand_prefix(): + names = frozenset({"theme"}) + assert _suggestion_text(names, "/theme cur") == "rent" + assert _suggestion_text(names, "/theme doc") == "tor" + + +def test_theme_subcommand_completions(): + completer = _theme_completer() + texts = _completion_texts(completer, "/theme ") + assert "current" in texts + assert "doctor" in texts + assert "tokens" in texts + + +def test_theme_subcommand_prefix_completions(): + completer = _theme_completer() + completions = _completions(completer, "/theme cur") + assert len(completions) == 1 + assert completions[0].text == "rent" + + def test_file_mention_should_complete_for_active_at_fragment(): assert LocalFileMentionCompleter.should_complete( Document(text="check @src", cursor_position=10) diff --git a/tests/ui_and_conv/test_slash_highlight.py b/tests/ui_and_conv/test_slash_highlight.py index 66a1b172..d0e7ec68 100644 --- a/tests/ui_and_conv/test_slash_highlight.py +++ b/tests/ui_and_conv/test_slash_highlight.py @@ -11,6 +11,7 @@ InputHighlightLexer, _command_name_set, ) +from pythinker_code.ui.shell.slash import slash_command_arg_suggestions from pythinker_code.utils.slashcmd import SlashCommand @@ -34,12 +35,17 @@ def _make_command( _make_command("clear"), _make_command("statusline", aliases=["sl"]), _make_command("skill:best-practices"), + _make_command("theme", aliases=["color"]), ] ) def _lex_line(text: str, lineno: int = 0, *, agent_mode: bool = True) -> StyleAndTextTuples: - lexer = InputHighlightLexer(lambda: _KNOWN, agent_mode=lambda: agent_mode) + lexer = InputHighlightLexer( + lambda: _KNOWN, + agent_mode=lambda: agent_mode, + arg_suggestions=slash_command_arg_suggestions, + ) return list(lexer.lex_document(Document(text))(lineno)) @@ -51,6 +57,10 @@ def _highlighted(fragments: StyleAndTextTuples) -> list[str]: return _styled(fragments, "class:slash-command") +def _arg_highlighted(fragments: StyleAndTextTuples) -> list[str]: + return _styled(fragments, "class:slash-arg") + + def test_known_command_highlighted_mid_text(): fragments = _lex_line("we need commands like /clear here") assert _highlighted(fragments) == ["/clear"] @@ -65,8 +75,9 @@ def test_unknown_command_not_highlighted(): assert _highlighted(_lex_line("run deep review /best now")) == [] -def test_partial_name_not_highlighted(): - assert _highlighted(_lex_line("/cle")) == [] +def test_partial_name_is_highlighted(): + assert _highlighted(_lex_line("/cle")) == ["/cle"] + assert _highlighted(_lex_line("/skill:best")) == ["/skill:best"] def test_alias_and_namespaced_command_highlighted(): @@ -129,6 +140,18 @@ def test_slash_and_mention_compose_on_one_line(): assert _mentions(fragments) == ["@src/app.py"] +def test_theme_subcommand_argument_highlighted(): + fragments = _lex_line("/theme cur") + assert _highlighted(fragments) == ["/theme"] + assert _arg_highlighted(fragments) == ["cur"] + + +def test_theme_subcommand_full_argument_highlighted(): + fragments = _lex_line("/theme current") + assert _highlighted(fragments) == ["/theme"] + assert _arg_highlighted(fragments) == ["current"] + + def _bash(fragments: StyleAndTextTuples) -> list[str]: return _styled(fragments, "class:bash-prefix") diff --git a/tests/ui_and_conv/test_statusline_render.py b/tests/ui_and_conv/test_statusline_render.py index f3512710..628f43fd 100644 --- a/tests/ui_and_conv/test_statusline_render.py +++ b/tests/ui_and_conv/test_statusline_render.py @@ -19,7 +19,7 @@ def test_statusline_colors_dark_palette(): colors = get_statusline_colors() assert isinstance(colors, StatusLineColors) assert colors.model == "bold fg:#dcb4ff" - assert colors.usage_ok == "fg:#64d2a0" + assert colors.usage_ok == "fg:#505564" assert colors.usage_crit == "fg:#ff5050" assert colors.dim == "fg:#505564" diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 8ec37b45..2d92f8ce 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -314,7 +314,8 @@ def test_paced_composing_preview_renders_complete_inline_markdown(monkeypatch): output = console.export_text() assert "Planning agent tasks" in output - assert "**Planning agent tasks**" not in output + # Live preview uses plain Text; delimiters stay visible until finalize. + assert "**Planning agent tasks**" in output def test_paced_composing_preview_keeps_incomplete_inline_markdown_plain(monkeypatch): @@ -409,21 +410,14 @@ def test_report_fence_continuation_keeps_gap_after_streamed_prose(self): output_console.print(block.compose_final()) output = output_console.export_text() - assert output.startswith("\n") assert "Deep Code Scan Results" in output - def test_streamed_prose_blocks_match_single_pass_spacing(self, monkeypatch): + def test_streamed_prose_blocks_match_single_pass_spacing(self): """Regression: streamed multi-paragraph bodies used to render every paragraph crammed onto consecutive lines. Each committed block and the final tail must keep the one-row gap a single markdown pass puts between blocks.""" - import importlib - - # ``visualize`` re-exports a function of the same name that shadows the - # submodule for attribute walking, so resolve the module via sys.modules. - blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") rec = Console(record=True, width=80, color_system=None) - monkeypatch.setattr(blocks_mod, "console", rec) block = _ContentBlock(is_think=False) body = ( @@ -518,25 +512,13 @@ def _assert_no_mid_table_commit(block: _ContentBlock) -> None: f"before data row arrived (data row ends at {table_data_end})" ) - def test_unpaced_composing_block_does_not_commit_table_mid_row(self, monkeypatch): - import importlib - - blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") - rec = Console(record=True, width=120, color_system=None) - monkeypatch.setattr(blocks_mod, "console", rec) - + def test_unpaced_composing_block_does_not_commit_table_mid_row(self): block = _ContentBlock(is_think=False) for ch in self._FULL_TABLE: block.append(ch) self._assert_no_mid_table_commit(block) - def test_paced_composing_block_does_not_commit_table_mid_row(self, monkeypatch): - import importlib - - blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") - rec = Console(record=True, width=120, color_system=None) - monkeypatch.setattr(blocks_mod, "console", rec) - + def test_paced_composing_block_does_not_commit_table_mid_row(self): block = _ContentBlock(is_think=False, paced=True) for ch in self._FULL_TABLE: block.append(ch) diff --git a/tests/ui_and_conv/test_theme_contract.py b/tests/ui_and_conv/test_theme_contract.py new file mode 100644 index 00000000..defc4071 --- /dev/null +++ b/tests/ui_and_conv/test_theme_contract.py @@ -0,0 +1,87 @@ +"""Theme system contract tests.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from rich.style import Style as RichStyle + +from pythinker_code.ui.theme import ( + TUI_TOKEN_NAMES, + get_markdown_colors, + get_prompt_style, + get_theme_spec, + get_tui_tokens, + set_active_theme, + tui_rich_style, +) +from pythinker_code.ui.theme.adapters.markdown import markdown_style_overrides +from pythinker_code.ui.theme.palettes import PROMPT_STYLE_DARK, THEME_SPECS +from pythinker_code.ui.theme.spec import ThemeMode + +_HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}") +_THEME_PKG = Path(__file__).resolve().parents[2] / "src" / "pythinker_code" / "ui" / "theme" +_PALETTE_ONLY_FILES = {_THEME_PKG / "palettes.py", _THEME_PKG / "adapters" / "task_browser.py"} + + +@pytest.fixture(autouse=True) +def _restore_theme(): + from pythinker_code.ui.theme import get_active_theme + + saved = get_active_theme() + yield + set_active_theme(saved) + + +def test_all_core_tokens_exist_in_dark_and_light(): + for mode in (ThemeMode.DARK, ThemeMode.LIGHT): + tokens = THEME_SPECS[mode].tokens + for name in TUI_TOKEN_NAMES: + assert hasattr(tokens, name) + + +def test_prompt_styles_derive_from_theme_spec(): + assert PROMPT_STYLE_DARK is THEME_SPECS[ThemeMode.DARK].prompt_classes + rules = dict(get_prompt_style().style_rules) + assert rules["slash-command"] == PROMPT_STYLE_DARK["slash-command"] + + +def test_no_bold_inline_code(): + style = markdown_style_overrides("dark")["markdown.code"] + assert style.bold is not True + + +def test_inline_code_uses_info_not_accent(): + colors = get_markdown_colors("dark") + tokens = get_tui_tokens("dark") + assert colors.inline_code == tokens.info + assert colors.inline_code != tokens.accent + + +def test_unknown_token_raises(): + with pytest.raises(ValueError, match="Unknown TUI token"): + tui_rich_style("not_a_real_token") + + +def test_theme_spec_single_source_for_selected_bg(): + dark = get_theme_spec("dark") + assert dark.tokens.selected_bg in PROMPT_STYLE_DARK["slash-completion-menu.row.current"] + + +def test_theme_logic_modules_have_no_hex_literals(): + for name in ("spec.py", "registry.py", "resolver.py", "capabilities.py", "__init__.py"): + text = (_THEME_PKG / name).read_text(encoding="utf-8") + assert _HEX_RE.search(text) is None, name + + +def test_resolver_heading_bold_inline_not_bold(): + from pythinker_code.ui.theme import get_resolver + + resolver = get_resolver("dark") + heading = resolver.markdown_heading_style(level=1) + inline = resolver.markdown_inline_code_style() + assert heading.bold is True + assert inline.bold is not True + assert inline.color == RichStyle(color=get_tui_tokens("dark").info).color diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index ffb60628..dece1ea1 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -772,7 +772,9 @@ def test_render_diff_signs_match_body_foreground(): if isinstance(style, str): continue if style.color in accent_fgs: - pytest.fail(f"diff sign/body used accent fg {style.color!r} on {text.plain[span.start:span.end]!r}") + pytest.fail( + f"diff sign/body used accent fg {style.color!r} on {text.plain[span.start : span.end]!r}" + ) tinted = [ text.plain[span.start : span.end] diff --git a/tests/ui_and_conv/test_tui_streaming_phase0.py b/tests/ui_and_conv/test_tui_streaming_phase0.py new file mode 100644 index 00000000..f0d178c4 --- /dev/null +++ b/tests/ui_and_conv/test_tui_streaming_phase0.py @@ -0,0 +1,160 @@ +"""Phase 0 streaming pipeline tests (frame scheduler, preview path, commit cache).""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from unittest.mock import MagicMock, patch + +import pytest +from rich.console import Console, Group +from rich.text import Text + +from pythinker_code.ui.shell.components.markdown import ( + PythinkerMarkdown, + _markdown_commit_boundary_cached, + markdown_commit_boundary, +) +from pythinker_code.ui.shell.motion import STREAMING_CARET_GLYPH +from pythinker_code.ui.shell.visualize._blocks import _ContentBlock +from pythinker_code.ui.shell.visualize._live_view import _LiveView +from pythinker_code.wire.types import StatusUpdate + + +@pytest.fixture +def live_view() -> _LiveView: + return _LiveView(StatusUpdate()) + + +def test_stream_deltas_mark_dirty_without_immediate_refresh(live_view: _LiveView) -> None: + live_view.refresh_soon() + assert live_view._dirty is True + assert live_view._need_recompose is True + assert live_view._force_refresh is False + + +@pytest.mark.asyncio +async def test_frame_scheduler_coalesces_multiple_deltas(live_view: _LiveView) -> None: + live = MagicMock() + live_view.refresh_soon() + live_view.refresh_soon() + live_view.refresh_soon() + + with patch.object(live_view, "compose", return_value=Text("composed")): + task = asyncio.create_task(live_view._frame_refresh_loop(live)) + await asyncio.sleep(0.06) + task.cancel() + with suppress(asyncio.CancelledError): + await task + + assert live.update.call_count >= 1 + assert live.update.call_args.kwargs.get("refresh") is False + assert live_view._dirty is False + + +def test_preview_uses_plain_text_not_markdown() -> None: + block = _ContentBlock(is_think=False, paced=True) + block.append("plain streaming preview line") + block.reveal_all() + composed = block.compose() + rec = Console(record=True, width=100, color_system=None) + rec.print(composed) + output = rec.export_text() + assert "plain streaming preview line" in output + + def _contains_markdown_widget(renderable: object) -> bool: + if isinstance(renderable, PythinkerMarkdown): + return True + if isinstance(renderable, Group): + return any(_contains_markdown_widget(child) for child in renderable.renderables) + return False + + assert not _contains_markdown_widget(composed) + + +def test_no_console_print_inside_live_context() -> None: + block = _ContentBlock(is_think=False) + block.append("First paragraph.\n\nSecond paragraph.\n\nThird.") + assert block._committed_renderables + assert all(not isinstance(r, str) for r in block._committed_renderables) + + +def test_final_output_matches_committed_render() -> None: + block = _ContentBlock(is_think=False) + block.append("Alpha paragraph.\n\nBeta paragraph.\n\nGamma tail.") + final = block.compose_final() + promoted = block.promote_to_scrollback() + assert promoted is not None + rec = Console(record=True, width=100, color_system=None) + rec.print(final) + rec2 = Console(record=True, width=100, color_system=None) + rec2.print(promoted) + assert rec.export_text() == rec2.export_text() + + +def test_reduced_motion_disables_blinking(monkeypatch: pytest.MonkeyPatch) -> None: + from pythinker_code.ui.shell.motion import streaming_caret_visible + + monkeypatch.setattr( + "pythinker_code.ui.shell.motion.reduced_motion_enabled", + lambda: True, + ) + assert streaming_caret_visible() is True + + +def test_streaming_caret_reserves_width_when_hidden() -> None: + from rich.cells import cell_len + + from pythinker_code.ui.shell.motion import append_streaming_caret, streaming_caret_visible + + visible = Text("hello") + append_streaming_caret(visible, now=0.0 if streaming_caret_visible(0.0) else 999.0) + hidden = Text("hello") + append_streaming_caret(hidden, now=999.0 if not streaming_caret_visible(999.0) else 0.0) + assert cell_len(visible.plain) == cell_len(hidden.plain) + + +def test_no_color_keeps_plain_status_labels(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_COLOR", "1") + block = _ContentBlock(is_think=False, paced=True) + block.append("streaming") + block.reveal_tick() + composed = block.compose() + rec = Console(record=True, width=80, color_system=None, force_terminal=False) + rec.print(composed) + assert "Composing" in rec.export_text() + + +def test_unclosed_code_fence_does_not_commit_markdown() -> None: + block = _ContentBlock(is_think=False) + block.append("Before.\n\n```python\ndef foo():\n pass") + pending = block._pending_text() + assert "```python" in pending + assert "def foo" in pending + assert block._committed_len <= len("Before.\n\n") + + +def test_long_code_block_does_not_reparse_per_tick() -> None: + _markdown_commit_boundary_cached.cache_clear() + fence = "```python\n" + "\n".join(f"x = {i}" for i in range(120)) + "\n```\n\nAfter.\n" + text = "Intro.\n\n" + fence + first = markdown_commit_boundary(text) + with patch("pythinker_code.ui.shell.components.markdown._get_md_parser") as parser_factory: + parser_factory.side_effect = AssertionError("parse should be cached") + second = markdown_commit_boundary(text) + assert first == second + + +def test_streaming_caret_appended_during_compose(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "pythinker_code.ui.shell.motion.streaming_caret_visible", + lambda now=None: True, + ) + block = _ContentBlock(is_think=False, paced=True) + block.append("hello") + block.reveal_all() + composed = block.compose() + assert isinstance(composed, Group) + rec = Console(record=True, width=80, color_system=None) + rec.print(composed) + assert STREAMING_CARET_GLYPH in rec.export_text() diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index 192b1e28..2f8a5c0a 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -40,12 +40,12 @@ def _restore_active_theme(): def test_dark_tokens_have_brand_values(): set_active_theme("dark") t = get_tui_tokens() - assert t.accent == "#B3B9F4" # periwinkle brand accent (≈ Catppuccin Mocha lavender) + assert t.accent == "#AEB7FF" assert t.border_accent == "#7C88DE" # accent-family chrome (active borders) assert t.border == "#8a8d91" # mid grey - assert t.info == "#AFE3F1" # cyan (unchanged; markdown code/links use ANSI cyan) - assert t.success == "#7BC97F" - assert t.error == "#EF5E62" + assert t.info == "#8FDDEA" + assert t.success == "#7CCF8A" + assert t.error == "#F87171" assert t.thinking_text == "#D4D4D4" # light neutral grey, not purple-tinted muted assert t.thinking_text != t.muted assert t.activity_verb == "#C68D7E" # muted clay-coral resting @@ -92,7 +92,7 @@ def test_selected_bg_reharmonized_and_drives_prompt_selection(): source for the completion/dialog selection rows (no parallel literals).""" from pythinker_code.ui.theme import _PROMPT_STYLE_DARK, _PROMPT_STYLE_LIGHT - assert get_tui_tokens("dark").selected_bg == "#21243B" + assert get_tui_tokens("dark").selected_bg == "#252944" assert get_tui_tokens("light").selected_bg == "#E7E9F9" assert _PROMPT_STYLE_DARK["slash-completion-menu.row.current"] == ( f"bg:{get_tui_tokens('dark').selected_bg}" @@ -160,12 +160,12 @@ def test_dark_markdown_uses_professional_report_roles(): colors = get_markdown_colors("dark") assert colors.heading == "#F4F4F5" # primary white, not coral/orange assert colors.strong == "#F4F4F5" - assert colors.emphasis == "#6F6F6F" # neutral UI grey - assert colors.inline_code == "#B3B9F4" # periwinkle accent + assert colors.emphasis == "#8A8A8A" # neutral UI grey (refined muted contrast) + assert colors.inline_code == "#8FDDEA" assert colors.link == "cyan" - assert colors.spinner_active == "#AFE3F1" # spinners still use the info token - assert colors.spinner_done == "#7BC97F" - assert colors.spinner_failed == "#EF5E62" + assert colors.spinner_active == "#8FDDEA" + assert colors.spinner_done == "#7CCF8A" + assert colors.spinner_failed == "#F87171" assert markdown_rich_style("link", theme="dark").color is not None @@ -174,25 +174,25 @@ def test_light_markdown_uses_professional_report_roles(): assert colors.heading == "#213853" assert colors.strong == "#213853" assert colors.emphasis == "#666666" - assert colors.inline_code == "#0B114E" # periwinkle accent (light) + assert colors.inline_code == "#176B7E" # info token (light) assert colors.spinner_active == "#176B7E" # spinners still use the info token def test_markdown_ansi_styles_resolve_to_terminal_colors(): """Link, quote, and ordered_marker use ANSI terminal colors; inline_code - now uses the themed accent hex so it matches the skill/branch highlight color.""" + uses the themed info token so inline highlights stay out of the accent family.""" for mode in ("dark", "light"): assert _color_name(markdown_rich_style("link", theme=mode)) == "cyan" assert _color_name(markdown_rich_style("quote", theme=mode)) == "green" assert _color_name(markdown_rich_style("ordered_marker", theme=mode)) == "bright_blue" - # inline_code is now a themed hex (periwinkle accent), not ANSI cyan. + # inline_code uses the info token, not periwinkle accent or ANSI cyan/green. assert _color_name(markdown_rich_style("inline_code", theme=mode)) not in ("cyan", "green") # Unordered bullets stay muted (a hex), not an ANSI accent. assert _color_name(markdown_rich_style("unordered_marker", theme=mode)) != "green" def test_info_token_exists_and_is_cyan(): - assert get_tui_tokens("dark").info == "#AFE3F1" + assert get_tui_tokens("dark").info == "#8FDDEA" assert get_tui_tokens("light").info == "#176B7E" # resolver works for the new token set_active_theme("dark") @@ -216,7 +216,7 @@ def test_activity_tokens_in_token_names(): def test_code_block_bg_dark_value(): - assert get_tui_tokens("dark").code_block_bg == "#1f2030" + assert get_tui_tokens("dark").code_block_bg == "#1B1D2B" def test_code_block_bg_light_value(): @@ -243,9 +243,8 @@ def test_markdown_colors_derived_from_tokens_dark(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - # inline_code now uses the accent token (periwinkle) for visual consistency - # with skill/branch highlights; link/quote/ordered_marker remain ANSI. - assert c.inline_code == t.accent + # inline_code uses the info token for inline highlights; link/quote/ordered_marker remain ANSI. + assert c.inline_code == t.info assert c.link == "cyan" assert c.quote == "green" assert c.ordered_marker == "bright_blue" @@ -264,8 +263,8 @@ def test_markdown_colors_derived_from_tokens_light(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - # inline_code uses the accent token (periwinkle); link remains ANSI cyan. - assert c.inline_code == t.accent + # inline_code uses the info token; link remains ANSI cyan. + assert c.inline_code == t.info assert c.link == "cyan" assert c.quote == "green" assert c.ordered_marker == "bright_blue" diff --git a/web/public/install.sh b/web/public/install.sh index 88da02b5..5a40b392 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -27,12 +27,43 @@ VERSION="" INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" NO_COLOR="${NO_COLOR:-}" +usage() { + cat <<'EOF' +Pythinker Code — native curl-bash installer. + +Downloads the PyInstaller-built single-file binary for your OS + arch from +the latest GitHub Release, verifies its SHA-256, and installs it at + ~/.local/bin/pythinker + +Usage: + curl -fsSL https://pythinker.com/install.sh | bash + + # Pin a specific version: + curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.27.0 + + # Custom install prefix (default $HOME/.local): + curl -fsSL https://pythinker.com/install.sh | bash -s -- --prefix /opt/pythinker + +Supported targets (target triples — matches existing release artifacts): + x86_64-unknown-linux-gnu (Linux x86_64) + aarch64-unknown-linux-gnu (Linux ARM64) + aarch64-apple-darwin (macOS Apple Silicon) + x86_64-apple-darwin (macOS Intel) + +Windows users: download PythinkerSetup-x.y.z.exe from the Releases page. +EOF +} + while [[ $# -gt 0 ]]; do case "$1" in - --version) VERSION="$2"; shift 2 ;; - --prefix) INSTALL_PREFIX="$2"; shift 2 ;; + --version) + [ -n "${2:-}" ] || { echo "--version requires a value" >&2; exit 2; } + VERSION="$2"; shift 2 ;; + --prefix) + [ -n "${2:-}" ] || { echo "--prefix requires a value" >&2; exit 2; } + INSTALL_PREFIX="$2"; shift 2 ;; -h|--help) - sed -n '1,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//' + usage exit 0 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac @@ -42,30 +73,166 @@ REPO="Pythoughts-labs/pythinker-code" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' - IRIS=$'\033[38;5;152m'; CORAL=$'\033[38;5;216m'; DIM=$'\033[2m' + ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' + EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' - HIDE_CURSOR=$'\033[?25l'; SHOW_CURSOR=$'\033[?25h' + SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else - NAVY=""; FACE=""; IRIS=""; CORAL=""; DIM=""; BOLD=""; RESET="" - HIDE_CURSOR=""; SHOW_CURSOR="" + NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" + SHINE=""; SOFT="" fi -# Static logo. Used as the animation fallback (non-TTY, NO_COLOR, dumb term, -# CI, or PYTHINKER_NO_ANIMATION=1) and as the source of truth for the final -# settled frame. -print_logo_static() { - printf '\n' - printf ' %s●%s\n' "$CORAL" "$RESET" +_anim="" +[ -t 1 ] \ + && [ -z "$NO_COLOR" ] \ + && [ "${TERM:-}" != "dumb" ] \ + && [ -z "${PYTHINKER_NO_ANIMATION:-}" ] \ + && [ -z "${CI:-}" ] \ + && _anim=1 + +LOGO_CURSOR_ROWS=0 +ANTENNA_SPIN_ACTIVE="" + +_antenna_tip() { + [ -z "$_anim" ] && return + [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return + printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" +} + +_antenna_spin_start() { + [ -z "$_anim" ] && return + ANTENNA_SPIN_ACTIVE=1 +} + +_antenna_spin_stop() { + [ -z "$ANTENNA_SPIN_ACTIVE" ] && return + ANTENNA_SPIN_ACTIVE="" + _antenna_tip "●" +} + +_content_length() { + curl -fsIL "$1" 2>/dev/null \ + | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' +} + +_download_percent() { + local output="$1" total="$2" size + [ -n "$total" ] && [ "$total" -gt 0 ] 2>/dev/null || return 1 + if [ ! -f "$output" ]; then + printf '0' + return 0 + fi + size="$(wc -c < "$output" | tr -d ' ')" + [ -n "$size" ] || size=0 + local percent=$((size * 100 / total)) + [ "$percent" -gt 99 ] && percent=99 + printf '%s' "$percent" +} + +_print_download_progress() { + local percent="$1" frame="$2" pulse="${3:-0}" + local width=48 filled empty bar="" + filled=$((percent * width / 100)) + empty=$((width - filled)) + + local i + for ((i=0; i/dev/null 2>&1; then + if [ -n "$_anim" ]; then + local total percent i=0 curl_pid + local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') + total="$(_content_length "$url" || true)" + _antenna_spin_start + printf '\033[?25l' + curl -fsSL "$url" -o "$output" & + curl_pid=$! + while kill -0 "$curl_pid" 2>/dev/null; do + local frame_idx=$((i % 8)) + percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" + _antenna_tip "${frames[$frame_idx]}" + local pulse=0 + (( i % 2 == 1 )) && pulse=1 + _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" + sleep 0.10 + i=$((i + 1)) + done + wait "$curl_pid" || { printf '\033[?25h'; return 1; } + _antenna_spin_stop + _print_download_progress 100 "✓" + printf '\n\033[?25h' + else + curl -fsSL "$url" -o "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + fi + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + _print_download_progress 100 "✓" + printf '\n' + else + return 127 + fi +} + +_download_quiet() { + local url="$1" output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" || return 1 + else + return 127 + fi +} + +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" printf ' %s│%s\n' "$NAVY" "$RESET" printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$CORAL" "$RESET" "$NAVY" "$RESET" "$IRIS" "$RESET" "$IRIS" "$RESET" "$NAVY" "$RESET" "$CORAL" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + +print_logo_static() { + printf '\n\n' + print_logo_art printf '\n' - printf ' %s%spythinker code%s %s· your next CLI agent%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" + printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" +} + +_type_tagline() { + local tagline='Pythinker Code Think first. Then code.' + local i ch + printf ' ' + for ((i=0; i<${#tagline}; i++)); do + ch="${tagline:$i:1}" + printf '%s' "$ch" + sleep 0.018 + done + printf '\n\n' } -# Tetris-style animated logo. Pieces fall from above the canvas one at a time -# and settle into a 5-row × 13-col grid forming the robot head. print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" @@ -79,8 +246,8 @@ print_logo_animated() { done _set_cell() { - grid_chars[$(( $1 * COLS + $2 ))]="$3" - grid_colors[$(( $1 * COLS + $2 ))]="$4" + grid_chars[$1 * COLS + $2]="$3" + grid_colors[$1 * COLS + $2]="$4" } _render() { @@ -95,8 +262,8 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" rr=$((piece_r + dr)); cc=$((piece_c + dc)) if (( rr >= 0 && rr < ROWS && cc >= 0 && cc < COLS )); then - tc[$((rr*COLS+cc))]="$ch" - tk[$((rr*COLS+cc))]="$color" + tc[rr * COLS + cc]="$ch" + tk[rr * COLS + cc]="$color" fi done fi @@ -127,59 +294,169 @@ print_logo_animated() { IFS=',' read -r dr dc ch color <<<"$cell" _set_cell $((target_r + dr)) $((target_c + dc)) "$ch" "$color" done + # Shimmer: flash landed cells white for one beat, then settle. + local -a shine_cells=() + for cell in "${cells[@]}"; do + IFS=',' read -r dr dc ch color <<<"$cell" + shine_cells+=("$dr,$dc,$ch,$SHINE") + done + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "${shine_cells[@]}" + sleep 0.05 + printf '\033[%dA\r' "$ROWS" + _render "" "" if [ "$STAGGER_DELAY" != "0" ]; then sleep "$STAGGER_DELAY"; fi } - printf '%s' "$HIDE_CURSOR" - trap 'printf "%s" "$SHOW_CURSOR"' EXIT - trap 'printf "%s" "$SHOW_CURSOR"; exit 130' INT - trap 'printf "%s" "$SHOW_CURSOR"; exit 143' TERM + _blink_eyes() { + local target_r=$1 target_c=$2 eye_ch=$3 + # Frame 1: glance left in SHINE tone. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$((target_c - 1))" "0,0,$eye_ch,$SHINE" + sleep 0.06 + # Frame 2: closed eye at final column. + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,─,$EYE" + sleep 0.05 + _set_cell $target_r $target_c "─" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.04 + # Frame 3: open with shine flash, then settle. + _set_cell $target_r $target_c "$eye_ch" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.06 + _set_cell $target_r $target_c "$eye_ch" "$EYE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + _drop_antenna_tip() { + local target_r=$1 target_c=$2 + local -a cells=("0,0,●,$TIP") + local r + for ((r=-1; r<=target_r; r++)); do + printf '\033[%dA\r' "$ROWS" + _render "$r" "$target_c" "${cells[@]}" + sleep "$FRAME_DELAY" + done + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "$target_r" "$target_c" "0,0,●,$SHINE" + sleep 0.07 + _set_cell $target_r $target_c "●" "$SHINE" + printf '\033[%dA\r' "$ROWS" + _render "" "" + sleep 0.05 + _set_cell $target_r $target_c "●" "$TIP" + printf '\033[%dA\r' "$ROWS" + _render "" "" + } + + printf '\033[?25l' + local _cursor_hidden=1 + + printf '\n\n' for ((i=0; i&2; exit 1; } +fail() { + printf ' %s✗%s %s\n' "$TIP" "$RESET" "$1" >&2 + exit 1 +} -print_logo +trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT +trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM # --- detect target ------------------------------------------------------- os="$(uname -s)" arch="$(uname -m)" case "$os/$arch" in Linux/x86_64|Linux/amd64) - target="x86_64-unknown-linux-gnu" ;; + target="x86_64-unknown-linux-gnu" + platform_display="Linux x64" ;; Linux/aarch64|Linux/arm64) - target="aarch64-unknown-linux-gnu" ;; + target="aarch64-unknown-linux-gnu" + platform_display="Linux arm64" ;; Darwin/arm64) - target="aarch64-apple-darwin" ;; + target="aarch64-apple-darwin" + platform_display="macOS arm64" ;; Darwin/x86_64) - target="x86_64-apple-darwin" ;; + target="x86_64-apple-darwin" + platform_display="macOS x64" ;; MINGW*/*|MSYS*/*|CYGWIN*/*) fail "On Windows, download PythinkerSetup-x.y.z.exe from: https://github.com/${REPO}/releases/latest @@ -192,7 +469,6 @@ esac # --- resolve version ----------------------------------------------------- if [ -z "$VERSION" ]; then - step "Looking up latest Pythinker release" api="https://api.github.com/repos/${REPO}/releases/latest" if command -v curl >/dev/null 2>&1; then payload="$(curl -fsSL "$api")" @@ -203,19 +479,15 @@ if [ -z "$VERSION" ]; then fi VERSION="$(printf '%s' "$payload" | sed -nE 's/.*"tag_name": *"v([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' | head -n 1)" [ -z "$VERSION" ] && fail "could not parse latest release tag from $api" - ok "Latest version is $VERSION" fi tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" -# --- wait for assets to finish publishing ------------------------------- -# The GitHub Release is published before every platform asset finishes -# uploading, and /releases/latest is date-based, so it can briefly advertise a -# version whose archive is still in flight. Confirm this version's archive and -# checksum are attached (via the GitHub API, like the in-app updater) before -# downloading, so a release caught mid-publish does not 404. +print_intro + +# --- wait for assets to finish publishing -------------------------------- release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -226,9 +498,6 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } -# Exponential backoff: the GitHub Release can briefly advertise a version -# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, -# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -239,26 +508,21 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done +[ "$attempt" -gt 0 ] && printf '\r\033[K' -# --- download + verify -------------------------------------------------- +# --- download + verify --------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" -trap 'rm -rf "$tmpdir"' EXIT -step "Downloading $tarball" -if command -v curl >/dev/null 2>&1; then - curl -fsSL "$tarball_url" -o "$tmpdir/$tarball" || fail "download failed: $tarball_url" - curl -fsSL "$sha_url" -o "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -else - wget -q "$tarball_url" -O "$tmpdir/$tarball" || fail "download failed" - wget -q "$sha_url" -O "$tmpdir/$tarball.sha256" || fail "sha256 missing" -fi +trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT + +_download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" +_download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" -step "Verifying SHA-256" expected="$(awk '{print $1}' "$tmpdir/$tarball.sha256")" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$tmpdir/$tarball" | awk '{print $1}')" @@ -268,29 +532,25 @@ else fail "need sha256sum or shasum to verify the download" fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" -ok "Checksum OK" +phase_ok "Verifying" -# --- install ----------------------------------------------------------- +# --- install ------------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" -step "Installing into $bin_dir/pythinker" mkdir -p "$bin_dir" -# The existing release tarball contains a single `pythinker` file at the -# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" -ok "Installed $("$bin_dir/pythinker" --version 2>/dev/null || echo "pythinker $VERSION")" +phase_ok "Installing" -# --- PATH guidance -------------------------------------------------------- +# --- PATH guidance ------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) - printf '\n %sNote:%s %s is not on your PATH.\n' "$BOLD" "$RESET" "$bin_dir" - printf ' Add this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):\n' + printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" + printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" ;; esac -printf '\n %s%spythinker%s is ready. Run %s%spythinker%s to launch.\n\n' \ - "$BOLD" "$IRIS" "$RESET" "$BOLD" "$IRIS" "$RESET" +print_done From bb7effd9c9d32d26f57766e1f881ebd55789507b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 14:35:59 -0400 Subject: [PATCH 04/26] feat(tui): polish theme, transcript spacing, usage activity, and install UX Switch inline code and ANSI syntax highlights from cyan to accent/periwinkle and blue, add trailing scrollback gaps between tool output and agent prose, style the welcome banner branch with muted warning yellow, and ship the token-activity heatmap for /usage. Refresh install banner animation and tests. --- CHANGELOG.md | 13 +- docs/public/install.sh | 253 ++++++-- scripts/install-native.sh | 255 +++++++-- src/pythinker_code/ui/shell/__init__.py | 4 +- src/pythinker_code/ui/shell/echo.py | 4 +- src/pythinker_code/ui/shell/spacing.py | 13 +- .../ui/shell/tool_renderers/edit.py | 8 +- .../ui/shell/tool_renderers/write.py | 2 + src/pythinker_code/ui/shell/usage.py | 53 +- src/pythinker_code/ui/shell/usage_activity.py | 541 ++++++++++++++++++ .../ui/shell/visualize/_live_view.py | 15 +- src/pythinker_code/ui/theme/palettes.py | 2 +- src/pythinker_code/ui/theme/registry.py | 2 +- src/pythinker_code/ui/theme/resolver.py | 2 +- src/pythinker_code/utils/rich/syntax.py | 44 +- tests/test_installation_docs.py | 9 +- tests/ui/test_usage_activity.py | 286 +++++++++ tests/ui_and_conv/test_plan_display_panel.py | 5 +- tests/ui_and_conv/test_shell_prompt_echo.py | 9 + tests/ui_and_conv/test_shell_welcome_info.py | 9 + tests/ui_and_conv/test_spacing_primitives.py | 11 + tests/ui_and_conv/test_theme_contract.py | 8 +- tests/ui_and_conv/test_tui_theme_tokens.py | 24 +- web/public/install.sh | 253 ++++++-- 24 files changed, 1581 insertions(+), 244 deletions(-) create mode 100644 src/pythinker_code/ui/shell/usage_activity.py create mode 100644 tests/ui/test_usage_activity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6602a0..447a70d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,13 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased -- **TUI theme package.** Centralize dark/light palettes, prompt classes, and Rich/PTK +- **TUI inline code color.** Inline `` `code` `` highlights and the `pythinker-ansi` + syntax theme now use brand periwinkle/accent and blue ANSI roles instead of cyan. +- **TUI transcript spacing.** User prompts leave one blank row before the agent stream + starts; finished tool cards and flushed agent paragraphs leave a trailing blank row + before the next block (Bash/Read output → next ⏺ paragraph, etc.). +- **Welcome banner branch color.** Branch name on the startup panel uses the muted + yellow warning token instead of the status-line teal. adapters in `ui/theme/` with `/theme current|doctor|tokens` inspection commands. - **Slash input UX.** Prefix-highlight skills and plugins while typing; ghost-complete and highlight fixed subcommands such as `/theme current`. @@ -27,6 +33,11 @@ GitHub Releases page; `0.8.0` is the new starting line. (go-to-definition, find-references, hover, symbols, call hierarchy) with session-scoped server lifecycle, passive diagnostics injected after file edits, and plugin-based server discovery/recommendation — no bundled language-server binaries. +- **Token activity card.** `/usage daily|weekly|cumulative` (and the bare `/usage` default + when no provider adapter is configured) now render a Codex-style 52-week × 7-day heatmap of + total tokens consumed each day, with a `Lifetime · Peak · Streak · Longest task` summary + line and a footer that lets the user switch between daily/weekly/cumulative views. Data is + read from the local session wire files; the per-provider adapter behavior is unchanged. ## 0.47.0 (2026-06-16) diff --git a/docs/public/install.sh b/docs/public/install.sh index 5a40b392..e1b6fc2a 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -76,6 +76,7 @@ if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' + # Shimmer / pulse tones for the "piece landed" + "leading edge" beats. SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" @@ -90,13 +91,40 @@ _anim="" && [ -z "${CI:-}" ] \ && _anim=1 -LOGO_CURSOR_ROWS=0 ANTENNA_SPIN_ACTIVE="" +# Bookmarked cursor position for the antenna-tip spin. We save the +# cursor right after print_logo_animated's final static re-render, and +# _antenna_tip restores from that bookmark before writing the tip. +# That way the tip always lands on the same screen row as the +# bookmark regardless of how many rows the metadata block consumed +# (which varies if any metadata row wraps). Using an absolute bookmark +# avoids the relative-cursor-up arithmetic that miscounted when the +# install layout differed from the developer's test environment. +ANTENNA_TIP_BOOKMARK="" +ANTENNA_TIP_COL=7 +# Absolute row for the progress bar and "Waiting" line. Set by +# print_intro after the metadata block finishes. Both the waiting +# retry and the download progress use this row via absolute positioning +# so they never depend on where the cursor happens to be — the bar +# always lands one row below the metadata, not on whatever row the +# cursor drifted to. +PROGRESS_ROW="" _antenna_tip() { [ -z "$_anim" ] && return - [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return - printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" + [ -n "$ANTENNA_TIP_BOOKMARK" ] || return + # Restore the bookmarked position (row right below the base), then + # move up 5 rows to reach the antenna tip row, and write. We do NOT + # re-save the bookmark at the tip row — that would drag the + # reference point up 5 rows on every call, so the second tick + # would land 5 rows above the tip, the third 10 rows above, and + # the fourth would clamp to row 0. The bookmark stays at the + # row right below the base for the lifetime of the install. + # Restore the bookmark (row right below the grid base), then + # absolute-position to the antenna tip row. We do NOT re-save the + # bookmark at the tip row — that would drag the reference point + # up on every call. + printf '\033[u\033[%d;%dH%s%s%s' "$GRID_ORIGIN_ROW" "$ANTENNA_TIP_COL" "$TIP" "$1" "$RESET" } _antenna_spin_start() { @@ -110,6 +138,12 @@ _antenna_spin_stop() { _antenna_tip "●" } +# (Eye blink during the install was attempted here but the row offset +# depends on the terminal's starting cursor position, which varies +# between environments. The intro's own bounce at _blink_eyes is the +# reliable eye animation; the download phase keeps the antenna spin +# going but leaves the eyes static at their final `◉` color.) + _content_length() { curl -fsIL "$1" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' @@ -139,18 +173,32 @@ _print_download_progress() { for ((i=0; i/dev/null 2>&1; then if [ -n "$_anim" ]; then local total percent i=0 curl_pid + # 8-frame spin: ◌ ◍ ◎ ◍ ● ◍ ◎ ◍ — every 4th tick the tip "blooms" + # to a filled circle so the head reads as alive, not as a spinner. local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') total="$(_content_length "$url" || true)" _antenna_spin_start + # Hide the cursor during the spin loop so the in-place bar updates + # do not flash a stray cursor block at the rewrite position. printf '\033[?25l' curl -fsSL "$url" -o "$output" & curl_pid=$! @@ -171,6 +223,8 @@ _download_with_progress() { local frame_idx=$((i % 8)) percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" _antenna_tip "${frames[$frame_idx]}" + # Pulse the leading edge on odd ticks so the bar visibly advances + # even when the byte count has not yet ticked. local pulse=0 (( i % 2 == 1 )) && pulse=1 _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" @@ -179,6 +233,7 @@ _download_with_progress() { done wait "$curl_pid" || { printf '\033[?25h'; return 1; } _antenna_spin_stop + # Settle on a solid bar with the check mark, no pulse. _print_download_progress 100 "✓" printf '\n\033[?25h' else @@ -206,38 +261,84 @@ _download_quiet() { fi } -print_logo_art() { - printf ' %s●%s\n' "$TIP" "$RESET" - printf ' %s│%s\n' "$NAVY" "$RESET" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +print_intro() { + if [ -n "$_anim" ]; then + print_logo_animated + else + print_logo_static + fi + printf ' %-11s %s\n' "Version" "$VERSION" + printf ' %-11s %s\n' "Platform" "$platform_display" + printf ' %-11s %s\n' "Package" "$tarball" + # Reserve the progress row one line below the metadata. The cursor + # is now on that row; save it so the "Waiting" retry and the + # download progress bar can absolute-position to it without + # relying on cursor tracking (which drifted to the grid's base + # row in some terminals and caused the bar to overwrite the grid). + # Layout from the top: 4 blank rows, grid (5 rows), 1 blank row, + # tagline, 2 blank rows, 3 metadata rows → progress row is row 17. + PROGRESS_ROW=17 + printf '\n' } print_logo_static() { - printf '\n\n' + printf '\n\n\n\n' print_logo_art printf '\n' + # Bookmark the cursor (row right below the grid) for the antenna + # spin during download. In the static path the cursor is at the + # same position as the animated path's final re-render (row + # immediately below the base), so the bookmark is equivalent. + printf '\033[s' + ANTENNA_TIP_BOOKMARK=1 printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" } _type_tagline() { + # Print the tagline one glyph at a time so the intro breathes. The static + # path prints the same line in one shot via print_logo_static. local tagline='Pythinker Code Think first. Then code.' - local i ch + local i ch out="" printf ' ' for ((i=0; i<${#tagline}; i++)); do ch="${tagline:$i:1}" + out+="$ch" printf '%s' "$ch" sleep 0.018 done printf '\n\n' } +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" + printf ' %s│%s\n' "$NAVY" "$RESET" + printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" + printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" local STAGGER_DELAY="${PYTHINKER_LOGO_STAGGER_DELAY:-0.04}" + # Hide the text cursor while we redraw in place — otherwise the block + # cursor on terminals like Terminal.app renders as a stray white block + # at the cursor's current cell. Show it again before returning so the + # user can type after the installer finishes. We use a flag and restore + # in the function footer so this works regardless of how the function + # exits (note: `trap ... RETURN` does not fire on plain function return + # in bash, so an explicit show at every return site is required). + printf '\033[?25l' + _cursor_hidden=1 + + # The 4 leading newlines push the cursor from row 1 to row 5, so the + # grid sits at rows 5-9. Publish this to the top-level so the antenna + # spin can use absolute cursor moves after the intro returns. We use + # absolute positioning for every render so a caller that left the + # cursor at the wrong row can't leave a ghost frame behind. + GRID_ORIGIN_ROW=5 + local -a grid_chars grid_colors local i for ((i=0; i&2 exit 1 } +# Always restore the text cursor on signal/exit — the animation paths +# hide it, and a stray Ctrl-C would otherwise leave the user's terminal +# with no cursor until they run `tput cnorm` themselves. The EXIT trap +# is intentionally set here without cleanup; the tmpdir cleanup is +# layered on top later in this script. trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM @@ -487,7 +612,12 @@ sha_url="${tarball_url}.sha256" print_intro -# --- wait for assets to finish publishing -------------------------------- +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -498,6 +628,9 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -508,18 +641,18 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\r\033[K' +[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" -# --- download + verify --------------------------------------------------- +# --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" +# Layer: keep the cursor-show on every exit path, then clean up tmpdir. trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT - _download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" _download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" @@ -534,22 +667,24 @@ fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" phase_ok "Verifying" -# --- install ------------------------------------------------------------- +# --- install ----------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" mkdir -p "$bin_dir" +# The existing release tarball contains a single `pythinker` file at the +# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" phase_ok "Installing" -# --- PATH guidance ------------------------------------------------------- +# --- PATH guidance -------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" - printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" + printf '\n %sexport PATH="%s:%sPATH"%s\n\n' "$DIM" "$bin_dir" "\$" "$RESET" ;; esac diff --git a/scripts/install-native.sh b/scripts/install-native.sh index 67094b98..e1b6fc2a 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -76,6 +76,7 @@ if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' + # Shimmer / pulse tones for the "piece landed" + "leading edge" beats. SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" @@ -90,13 +91,40 @@ _anim="" && [ -z "${CI:-}" ] \ && _anim=1 -LOGO_CURSOR_ROWS=0 ANTENNA_SPIN_ACTIVE="" +# Bookmarked cursor position for the antenna-tip spin. We save the +# cursor right after print_logo_animated's final static re-render, and +# _antenna_tip restores from that bookmark before writing the tip. +# That way the tip always lands on the same screen row as the +# bookmark regardless of how many rows the metadata block consumed +# (which varies if any metadata row wraps). Using an absolute bookmark +# avoids the relative-cursor-up arithmetic that miscounted when the +# install layout differed from the developer's test environment. +ANTENNA_TIP_BOOKMARK="" +ANTENNA_TIP_COL=7 +# Absolute row for the progress bar and "Waiting" line. Set by +# print_intro after the metadata block finishes. Both the waiting +# retry and the download progress use this row via absolute positioning +# so they never depend on where the cursor happens to be — the bar +# always lands one row below the metadata, not on whatever row the +# cursor drifted to. +PROGRESS_ROW="" _antenna_tip() { [ -z "$_anim" ] && return - [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return - printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" + [ -n "$ANTENNA_TIP_BOOKMARK" ] || return + # Restore the bookmarked position (row right below the base), then + # move up 5 rows to reach the antenna tip row, and write. We do NOT + # re-save the bookmark at the tip row — that would drag the + # reference point up 5 rows on every call, so the second tick + # would land 5 rows above the tip, the third 10 rows above, and + # the fourth would clamp to row 0. The bookmark stays at the + # row right below the base for the lifetime of the install. + # Restore the bookmark (row right below the grid base), then + # absolute-position to the antenna tip row. We do NOT re-save the + # bookmark at the tip row — that would drag the reference point + # up on every call. + printf '\033[u\033[%d;%dH%s%s%s' "$GRID_ORIGIN_ROW" "$ANTENNA_TIP_COL" "$TIP" "$1" "$RESET" } _antenna_spin_start() { @@ -110,6 +138,12 @@ _antenna_spin_stop() { _antenna_tip "●" } +# (Eye blink during the install was attempted here but the row offset +# depends on the terminal's starting cursor position, which varies +# between environments. The intro's own bounce at _blink_eyes is the +# reliable eye animation; the download phase keeps the antenna spin +# going but leaves the eyes static at their final `◉` color.) + _content_length() { curl -fsIL "$1" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' @@ -139,20 +173,34 @@ _print_download_progress() { for ((i=0; i/dev/null 2>&1; then if [ -n "$_anim" ]; then local total percent i=0 curl_pid + # 8-frame spin: ◌ ◍ ◎ ◍ ● ◍ ◎ ◍ — every 4th tick the tip "blooms" + # to a filled circle so the head reads as alive, not as a spinner. local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') total="$(_content_length "$url" || true)" _antenna_spin_start + # Hide the cursor during the spin loop so the in-place bar updates + # do not flash a stray cursor block at the rewrite position. printf '\033[?25l' curl -fsSL "$url" -o "$output" & curl_pid=$! @@ -171,6 +223,8 @@ _download_with_progress() { local frame_idx=$((i % 8)) percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" _antenna_tip "${frames[$frame_idx]}" + # Pulse the leading edge on odd ticks so the bar visibly advances + # even when the byte count has not yet ticked. local pulse=0 (( i % 2 == 1 )) && pulse=1 _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" @@ -179,6 +233,7 @@ _download_with_progress() { done wait "$curl_pid" || { printf '\033[?25h'; return 1; } _antenna_spin_stop + # Settle on a solid bar with the check mark, no pulse. _print_download_progress 100 "✓" printf '\n\033[?25h' else @@ -206,38 +261,84 @@ _download_quiet() { fi } -print_logo_art() { - printf ' %s●%s\n' "$TIP" "$RESET" - printf ' %s│%s\n' "$NAVY" "$RESET" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +print_intro() { + if [ -n "$_anim" ]; then + print_logo_animated + else + print_logo_static + fi + printf ' %-11s %s\n' "Version" "$VERSION" + printf ' %-11s %s\n' "Platform" "$platform_display" + printf ' %-11s %s\n' "Package" "$tarball" + # Reserve the progress row one line below the metadata. The cursor + # is now on that row; save it so the "Waiting" retry and the + # download progress bar can absolute-position to it without + # relying on cursor tracking (which drifted to the grid's base + # row in some terminals and caused the bar to overwrite the grid). + # Layout from the top: 4 blank rows, grid (5 rows), 1 blank row, + # tagline, 2 blank rows, 3 metadata rows → progress row is row 17. + PROGRESS_ROW=17 + printf '\n' } print_logo_static() { - printf '\n\n' + printf '\n\n\n\n' print_logo_art printf '\n' + # Bookmark the cursor (row right below the grid) for the antenna + # spin during download. In the static path the cursor is at the + # same position as the animated path's final re-render (row + # immediately below the base), so the bookmark is equivalent. + printf '\033[s' + ANTENNA_TIP_BOOKMARK=1 printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" } _type_tagline() { + # Print the tagline one glyph at a time so the intro breathes. The static + # path prints the same line in one shot via print_logo_static. local tagline='Pythinker Code Think first. Then code.' - local i ch + local i ch out="" printf ' ' for ((i=0; i<${#tagline}; i++)); do ch="${tagline:$i:1}" + out+="$ch" printf '%s' "$ch" sleep 0.018 done printf '\n\n' } +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" + printf ' %s│%s\n' "$NAVY" "$RESET" + printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" + printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" local STAGGER_DELAY="${PYTHINKER_LOGO_STAGGER_DELAY:-0.04}" + # Hide the text cursor while we redraw in place — otherwise the block + # cursor on terminals like Terminal.app renders as a stray white block + # at the cursor's current cell. Show it again before returning so the + # user can type after the installer finishes. We use a flag and restore + # in the function footer so this works regardless of how the function + # exits (note: `trap ... RETURN` does not fire on plain function return + # in bash, so an explicit show at every return site is required). + printf '\033[?25l' + _cursor_hidden=1 + + # The 4 leading newlines push the cursor from row 1 to row 5, so the + # grid sits at rows 5-9. Publish this to the top-level so the antenna + # spin can use absolute cursor moves after the intro returns. We use + # absolute positioning for every render so a caller that left the + # cursor at the wrong row can't leave a ghost frame behind. + GRID_ORIGIN_ROW=5 + local -a grid_chars grid_colors local i for ((i=0; i&2 exit 1 } +# Always restore the text cursor on signal/exit — the animation paths +# hide it, and a stray Ctrl-C would otherwise leave the user's terminal +# with no cursor until they run `tput cnorm` themselves. The EXIT trap +# is intentionally set here without cleanup; the tmpdir cleanup is +# layered on top later in this script. trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM @@ -487,7 +612,12 @@ sha_url="${tarball_url}.sha256" print_intro -# --- wait for assets to finish publishing -------------------------------- +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -498,6 +628,9 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -508,18 +641,18 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\r\033[K' +[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" -# --- download + verify --------------------------------------------------- +# --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" +# Layer: keep the cursor-show on every exit path, then clean up tmpdir. trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT - _download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" _download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" @@ -534,22 +667,24 @@ fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" phase_ok "Verifying" -# --- install ------------------------------------------------------------- +# --- install ----------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" mkdir -p "$bin_dir" +# The existing release tarball contains a single `pythinker` file at the +# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" phase_ok "Installing" -# --- PATH guidance ------------------------------------------------------- +# --- PATH guidance -------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" - printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" + printf '\n %sexport PATH="%s:%sPATH"%s\n\n' "$DIM" "$bin_dir" "\$" "$RESET" ;; esac diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 56a2cfe6..2231a905 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2404,9 +2404,7 @@ def _value_style_for_label(label: str, level: WelcomeInfoItem.Level) -> str: if label == "Model": return f"bold {tokens.text}" if tokens.text else "bold bright_white" if label == "Branch": - from pythinker_code.ui.theme import get_statusline_colors - - return get_statusline_colors().branch.removeprefix("fg:") + return tokens.warning or "#EAB85F" if label == "Auto-save": return tokens.muted or "grey50" return level.value diff --git a/src/pythinker_code/ui/shell/echo.py b/src/pythinker_code/ui/shell/echo.py index a862dc2a..32eb86b8 100644 --- a/src/pythinker_code/ui/shell/echo.py +++ b/src/pythinker_code/ui/shell/echo.py @@ -34,7 +34,9 @@ def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measure return Measurement.get(console, options, self._block) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - yield from console.render(Group(BLANK_ROW, self._block), options) + # Leading blank separates from prior scrollback; trailing blank gives one + # row of breathing room before the agent stream starts in the Live area. + yield from console.render(Group(BLANK_ROW, self._block, BLANK_ROW), options) def render_user_echo(message: Message) -> RenderableType: diff --git a/src/pythinker_code/ui/shell/spacing.py b/src/pythinker_code/ui/shell/spacing.py index 5bf0cf30..c55b2e6f 100644 --- a/src/pythinker_code/ui/shell/spacing.py +++ b/src/pythinker_code/ui/shell/spacing.py @@ -7,6 +7,10 @@ and code renderers own only their *internal* layout. Never let two layers space the same seam. + Scrollback commits (finished tool cards, flushed agent paragraphs, hooks) emit + one trailing blank row after each block. The next block starts on the following + line — do not also add a leading blank before the next commit. + The canonical blank row is ``Text("")`` (an empty string), not ``Text(" ")`` — an empty row never picks up stray background styling. Padding constants are Rich ``(vertical, horizontal)`` tuples; the standard keeps vertical padding at 0 on cards @@ -17,7 +21,7 @@ from typing import TYPE_CHECKING, Final -from rich.console import RenderableType +from rich.console import Console, RenderableType from rich.text import Text if TYPE_CHECKING: @@ -35,6 +39,7 @@ "CODE_BLOCK_PADDING", "blank_row", "append_gap", + "emit_scrollback_block", "ensure_prompt_newline", ] @@ -70,6 +75,12 @@ def append_gap(renderables: list[RenderableType], rows: int = STREAM_GAP_ROWS) - renderables.append(blank_row()) +def emit_scrollback_block(console: Console, block: RenderableType) -> None: + """Commit one finished action block to scrollback with a trailing blank row.""" + console.print(block) + console.print() + + def ensure_prompt_newline(fragments: StyleAndTextTuples) -> None: """Ensure prompt-toolkit *fragments* end on a newline boundary. diff --git a/src/pythinker_code/ui/shell/tool_renderers/edit.py b/src/pythinker_code/ui/shell/tool_renderers/edit.py index 9d4c13ad..c48f79fc 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/edit.py +++ b/src/pythinker_code/ui/shell/tool_renderers/edit.py @@ -29,6 +29,8 @@ diff_frame, preview_from_result, ) +from pythinker_code.ui.shell.components.render_utils import render_message_response +from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, @@ -115,9 +117,9 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: if not diff_text: return head added, removed = _fallback_diff_counts(diff_text) - return Group( - head, + body = Group( change_summary_text(added, removed), + blank_row(), diff_frame( diff_text, width=ctx.width or 80, @@ -125,6 +127,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: state=ctx.state, ), ) + return Group(head, render_message_response(body)) def _fallback_diff_counts(diff_text: str) -> tuple[int, int]: @@ -167,6 +170,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera return Group( change_summary_text(added, removed), + blank_row(), diff_frame( preview_diff, width=ctx.width or 80, diff --git a/src/pythinker_code/ui/shell/tool_renderers/write.py b/src/pythinker_code/ui/shell/tool_renderers/write.py index c3620d47..16e9d236 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/write.py +++ b/src/pythinker_code/ui/shell/tool_renderers/write.py @@ -24,6 +24,7 @@ diff_frame, preview_from_diff_blocks, ) +from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, @@ -135,6 +136,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera ): return Group( change_summary_text(preview.added, preview.removed), + blank_row(), diff_frame( preview.diff_text, width=ctx.width or 80, diff --git a/src/pythinker_code/ui/shell/usage.py b/src/pythinker_code/ui/shell/usage.py index fcef0cf5..19dfe1cd 100644 --- a/src/pythinker_code/ui/shell/usage.py +++ b/src/pythinker_code/ui/shell/usage.py @@ -15,6 +15,18 @@ from pythinker_code.ui.shell.slash import registry from pythinker_code.ui.shell.stats_collector import AllStats from pythinker_code.ui.shell.stats_collector import load_all_stats as _load_all_stats_raw +from pythinker_code.ui.shell.usage_activity import ( + TokenActivityView, +) +from pythinker_code.ui.shell.usage_activity import ( + load_activity as _load_activity, +) +from pythinker_code.ui.shell.usage_activity import ( + parse_view as _parse_activity_view, +) +from pythinker_code.ui.shell.usage_activity import ( + render_activity as _render_activity, +) from pythinker_code.ui.shell.usage_adapters import ADAPTERS from pythinker_code.ui.shell.usage_adapters.base import ( UsageAdapter, @@ -264,7 +276,9 @@ async def _maybe_print_cost_panel() -> None: async def usage(app: Shell, args: str): """Display usage for the current model's provider. - Pass `all` for every provider, or a provider key to filter. + Pass `all` for every provider, or a provider key to filter. Pass + `daily`, `weekly`, or `cumulative` to render the token activity card + driven by local session history. """ assert isinstance(app.soul, PythinkerSoul) await _refresh_catalog() @@ -278,6 +292,12 @@ async def usage(app: Shell, args: str): json_mode = "--json" in tokens positional = [token for token in tokens if token != "--json"] + # `daily|weekly|cumulative` is the token activity card; it is not a + # provider filter, so route to it before we try to interpret the argument + # as a managed provider key. + if positional and _parse_activity_view(positional[0]) is not None: + await _print_activity_card(positional[0], json_mode=json_mode) + return scoped_to_active = False active_provider_key: str | None = None if positional: @@ -358,3 +378,34 @@ async def usage(app: Shell, args: str): console.print(build_panel(report)) await _maybe_print_cost_panel() + + +async def _print_activity_card(arg: str, *, json_mode: bool) -> None: + """Render the token activity card. + + Falls back to a single-line warning if the user asks for an activity + view but the rich console is unavailable (e.g. the JSON mode test path + that bypasses the live render). The chart itself is intentionally + text-only; structured JSON output is reserved for the per-provider + adapter reports and is not part of the activity card surface. + """ + + view = _parse_activity_view(arg) or TokenActivityView.DAILY + activity = await asyncio.to_thread(_load_activity) + if json_mode: + _print_json( + { + "view": view.label, + "summary": { + "lifetime_tokens": activity.summary.lifetime_tokens, + "peak_daily_tokens": activity.summary.peak_daily_tokens, + "current_streak_days": activity.summary.current_streak_days, + "longest_streak_days": activity.summary.longest_streak_days, + "longest_task_seconds": activity.summary.longest_task_seconds, + }, + "daily_values": list(activity.daily_values), + } + ) + return + width = console.size.width + console.print(_render_activity(activity, view, width=width)) diff --git a/src/pythinker_code/ui/shell/usage_activity.py b/src/pythinker_code/ui/shell/usage_activity.py new file mode 100644 index 00000000..7c1c5ea3 --- /dev/null +++ b/src/pythinker_code/ui/shell/usage_activity.py @@ -0,0 +1,541 @@ +"""Renders the account token activity card for ``/usage``. + +The card is a 52-week × 7-day GitHub-style heatmap of total tokens consumed +each day, a one-line summary of headline numbers, and a footer that lets the +user switch between daily/weekly/cumulative views. Data comes from the local +session wire files (no remote usage API is required), so the card is always +representative of the same on-disk activity that the existing cost panel +reads. Bucketing, level grading, and Rich rendering are isolated here so the +dispatcher in :mod:`pythinker_code.ui.shell.usage` stays slim. +""" + +from __future__ import annotations + +import enum +from collections import Counter +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta + +from rich.console import Group, RenderableType +from rich.style import Style +from rich.text import Text + +from pythinker_code.ui.shell.stats_collector import ( + StepRecord, + collect_session_files, + get_sessions_root, + parse_wire_file, +) +from pythinker_code.ui.theme import tui_rich_style +from pythinker_code.utils.datetime import format_duration + +WEEK_COUNT = 52 +DAY_COUNT = 7 +CELL_COUNT = WEEK_COUNT * DAY_COUNT +CHART_LEFT_WIDTH = 4 +LEGEND_LEFT_PAD = " " + +# Glyph policy: a filled square for the +# GitHub-style daily view, a full block for bar views, and a hollow square +# reserved for low-color fallbacks if we ever need to detect one. +ACTIVE_GLYPH = "■" +EMPTY_GLYPH = "□" +BAR_GLYPH = "█" +WEEKDAY_LABELS: tuple[str, ...] = ("Su", "Mo", "Tu", "We", "Th", "Fr", "Sa") + + +class TokenActivityView(enum.Enum): + """Aggregation for one ``/usage`` card render.""" + + DAILY = "daily" + WEEKLY = "weekly" + CUMULATIVE = "cumulative" + + @property + def label(self) -> str: + return self.value + + +def parse_view(value: str) -> TokenActivityView | None: + """Map a free-form argument to a supported view, or ``None`` if unsupported. + + Empty input defaults to the daily view so ``/usage`` and ``/usage daily`` + behave identically. Returning ``None`` for unknown values lets the caller + surface a clear error instead of silently picking a view. + """ + + key = value.strip().lower() + if key in {"", "day", "daily"}: + return TokenActivityView.DAILY + if key in {"week", "weekly"}: + return TokenActivityView.WEEKLY + if key == "cumulative": + return TokenActivityView.CUMULATIVE + return None + + +@dataclass(slots=True, frozen=True) +class ActivitySummary: + """Headline numbers for the one-line summary above the chart.""" + + lifetime_tokens: int + peak_daily_tokens: int + current_streak_days: int + longest_streak_days: int + longest_task_seconds: int + + +@dataclass(slots=True, frozen=True) +class TokenActivity: + """Renderable payload for a single ``/usage`` card.""" + + summary: ActivitySummary + daily_values: tuple[int, ...] # length == CELL_COUNT + today_index: int # position in daily_values that maps to ``today`` + + +def load_activity(today: date | None = None) -> TokenActivity: + """Load and aggregate local session usage for the activity card. + + Returns an empty activity (zeroed summary, all-zero cells) when no + sessions exist, so the card still renders with a "No token activity" + hint rather than raising. + """ + + today = today or datetime.now(tz=UTC).date() + steps = _collect_steps() + return _build_activity(steps, today) + + +def _collect_steps() -> list[StepRecord]: + seen: set[str] = set() + out: list[StepRecord] = [] + root = get_sessions_root() + for wire_path in collect_session_files(root): + # Mirror the session-id resolution in ``stats_collector.load_all_stats``: + # nested subagent files share their parent session's identity so the + # activity card double-counts subagent work the same way the cost panel + # already does. + if wire_path.parent.parent.name == "subagents": + session_id = f"{wire_path.parents[3].name}/{wire_path.parents[2].name}" + else: + session_id = f"{wire_path.parents[1].name}/{wire_path.parents[0].name}" + out.extend(parse_wire_file(wire_path, session_id, seen)) + return out + + +def _build_activity(steps: Iterable[StepRecord], today: date) -> TokenActivity: + start = _chart_start(today) + end = start + timedelta(days=CELL_COUNT) + counts: Counter[date] = Counter() + for step in steps: + if not step.timestamp: + continue + # timestamps come from the wire; treat them as UTC instants. + try: + ts = datetime.fromtimestamp(step.timestamp, tz=UTC).date() + except (OverflowError, OSError, ValueError): + continue + if ts < start or ts >= end or ts > today: + continue + counts[ts] += max(step.total_tokens, 0) + + values = tuple(counts.get(start + timedelta(days=offset), 0) for offset in range(CELL_COUNT)) + today_offset = (today - start).days + if 0 <= today_offset < CELL_COUNT: + # ``today`` always lands on the rightmost week; clamp any drift so the + # "future cells" branch in the renderer stays consistent. + today_index = min(today_offset, CELL_COUNT - 1) + else: + today_index = CELL_COUNT - 1 + + return TokenActivity( + summary=_summarize(values, today), + daily_values=values, + today_index=today_index, + ) + + +def _summarize(values: Sequence[int], today: date) -> ActivitySummary: + start = _chart_start(today) + lifetime = sum(values) + peak = max(values, default=0) + current_streak = _current_streak(values, today_offset=(today - start).days) + longest_streak = _longest_streak(values) + return ActivitySummary( + lifetime_tokens=lifetime, + peak_daily_tokens=peak, + current_streak_days=current_streak, + longest_streak_days=longest_streak, + longest_task_seconds=0, + ) + + +def _current_streak(values: Sequence[int], today_offset: int) -> int: + """Count consecutive non-zero days ending at ``today``. + + The reference renderer treats the current streak as the number of days, + ending today, that have any activity. We do the same without treating + today as a "miss" when its bucket is empty: the user is most often on + this card mid-day and we don't want a partial day to look like the + streak ended. + """ + + streak = 0 + end = min(today_offset, len(values) - 1) + for offset in range(end, -1, -1): + if values[offset] <= 0: + break + streak += 1 + return streak + + +def _longest_streak(values: Sequence[int]) -> int: + best = 0 + current = 0 + for value in values: + if value > 0: + current += 1 + best = max(best, current) + else: + current = 0 + return best + + +def _chart_start(today: date) -> date: + """First cell of the 52-week window (a Sunday).""" + + week_start = today - timedelta(days=(today.weekday() + 1) % DAY_COUNT) + return week_start - timedelta(weeks=WEEK_COUNT - 1) + + +def _graded_levels(values: Sequence[int]) -> list[int]: + """Assign each daily bucket an intensity level in ``0..4``. + + Mirrors the upstream 5-step scale: the peak day hits level 4, zero days + sit at level 0, and the boundaries land at 1/4, 1/2, and 3/4 of the + peak. Doing it this way keeps the heatmap readable when the user has + a single busy day and a long tail of quieter ones. + """ + + peak = max(values, default=0) + if peak <= 0: + return [0] * len(values) + out: list[int] = [] + for value in values: + if value <= 0: + out.append(0) + elif value * 4 > peak * 3: + out.append(4) + elif value * 2 > peak: + out.append(3) + elif value * 4 > peak: + out.append(2) + else: + out.append(1) + return out + + +def _weekly_totals(values: Sequence[int]) -> list[int]: + return [sum(values[row : row + DAY_COUNT]) for row in range(0, len(values), DAY_COUNT)] + + +def _bar_levels(weekly: Sequence[int]) -> list[int]: + """Height (0..DAY_COUNT) of each column in the weekly/cumulative views. + + Stored as a flat list of length ``CELL_COUNT`` so the renderer can + index by ``column * DAY_COUNT + row`` exactly like the daily view. A + positive column fills from the bottom up, leaving the empty rows at + the top; the gutter shows ``max``/``0`` to read the column as a + mini bar chart. + """ + + peak = max(weekly, default=0) + out: list[int] = [] + for total in weekly: + # Round-up integer division so a column at 1/7 of the peak still + # renders a single visible block. + height = 0 if peak <= 0 or total <= 0 else (total * DAY_COUNT + peak - 1) // peak + height = min(height, DAY_COUNT) + for row in range(DAY_COUNT): + out.append(4 if DAY_COUNT - row <= height else 0) + return out + + +def _shown_columns(width: int) -> int: + """How many of the 52 weekly columns the terminal can fit.""" + + if width <= 0: + return 0 + usable = max(width - CHART_LEFT_WIDTH, 0) + 1 + return min(usable // 2, WEEK_COUNT) + + +def _format_compact(value: int) -> str: + """Compact integer formatter matching the upstream ``format_tokens_compact``. + + Keeps a leading sign of magnitude so ``260_000_000`` prints as ``260M`` + and ``21_400_000_000`` prints as ``21.4B``. The exact suffixes match + what users see in the upstream ``/usage`` card. + """ + + abs_value = abs(value) + if abs_value >= 1_000_000_000: + scaled = value / 1_000_000_000 + return f"{scaled:.1f}B".replace(".0B", "B") + if abs_value >= 1_000_000: + scaled = value / 1_000_000 + return f"{scaled:.1f}M".replace(".0M", "M") + if abs_value >= 1_000: + scaled = value / 1_000 + return f"{scaled:.1f}K".replace(".0K", "K") + return str(value) + + +def _format_optional_tokens(value: int) -> str: + return _format_compact(value) if value > 0 else "-" + + +def _format_streak(current: int, longest: int) -> str: + if current <= 0 and longest <= 0: + return "-" + if longest <= 0 or current == longest: + return f"{current}d" + return f"{current}d (best {longest}d)" + + +def _format_optional_duration(value: int) -> str: + if value <= 0: + return "-" + return format_duration(value) + + +def render_activity( + activity: TokenActivity, + view: TokenActivityView, + width: int = 80, +) -> RenderableType: + """Build the Rich renderable for a ``/usage`` card.""" + + width = max(width, 0) + lines: list[RenderableType] = [] + + title = Text() + title.append(" Token activity", style="bold") + title.append(" last 12 months", style=tui_rich_style("muted")) + lines.append(title) + + lines.extend(_summary_lines(activity.summary, width)) + + if not any(activity.daily_values): + lines.append(Text(" ")) + lines.append( + Text( + " No token activity in the last 12 months", + style=tui_rich_style("muted"), + ) + ) + return Group(*lines) + + lines.append(Text(" ")) + lines.extend(_chart_lines(activity, view, width)) + return Group(*lines) + + +def _summary_lines(summary: ActivitySummary, width: int) -> list[RenderableType]: + """Greedy-packing summary into as many lines as the terminal allows.""" + + fields: list[tuple[str, str]] = [ + ("Lifetime", _format_optional_tokens(summary.lifetime_tokens)), + ("Peak", _format_optional_tokens(summary.peak_daily_tokens)), + ("Streak", _format_streak(summary.current_streak_days, summary.longest_streak_days)), + ("Longest task", _format_optional_duration(summary.longest_task_seconds)), + ] + if width <= 0: + return [Text(_join_fields(fields))] + max_width = max(width - 1, 1) + groups: list[list[tuple[str, str]]] = [] + current: list[tuple[str, str]] = [] + for field in fields: + candidate = current + [field] + if current and len(_join_fields(candidate)) > max_width: + groups.append(current) + current = [field] + else: + current = candidate + if current: + groups.append(current) + return [Text(" " + _join_fields(group)) for group in groups] + + +def _join_fields(fields: Sequence[tuple[str, str]]) -> str: + parts: list[str] = [] + for label, value in fields: + parts.append(f"{label} {value}") + return " · ".join(parts) + + +def _month_labels(today: date, first_column: int, shown_columns: int) -> Text: + cells = [" "] * (shown_columns * 2 - 1) + last_end = 0 + absolute_start = _chart_start(today) + for column in range(first_column, WEEK_COUNT): + cell_date = absolute_start + timedelta(days=column * DAY_COUNT) + if cell_date.day > 7: + continue + label = cell_date.strftime("%b") + offset = (column - first_column) * 2 + if offset < last_end or offset + len(label) > len(cells): + continue + for index, ch in enumerate(label): + cells[offset + index] = ch + last_end = offset + len(label) + 1 + line = Text(" " * CHART_LEFT_WIDTH, style=tui_rich_style("muted")) + line.append("".join(cells), style=tui_rich_style("muted")) + return line + + +def _chart_lines( + activity: TokenActivity, view: TokenActivityView, width: int +) -> list[RenderableType]: + shown = _shown_columns(width) + if shown == 0: + return [ + Text( + " Widen terminal to show activity graph", + style=tui_rich_style("muted"), + ) + ] + first_column = WEEK_COUNT - shown + today = datetime.now(tz=UTC).date() + out: list[RenderableType] = [_month_labels(today, first_column, shown)] + + if view is TokenActivityView.DAILY: + levels = _graded_levels(activity.daily_values) + elif view is TokenActivityView.WEEKLY: + levels = _bar_levels(_weekly_totals(activity.daily_values)) + else: # Cumulative + totals = _weekly_totals(activity.daily_values) + running: list[int] = [] + accumulator = 0 + for total in totals: + accumulator += total + running.append(accumulator) + levels = _bar_levels(running) + + empty_style = tui_rich_style("muted") + active_style = tui_rich_style("success") + future_style = tui_rich_style("muted") + + chart_start = _chart_start(today) + + for row in range(DAY_COUNT): + gutter = Text(_gutter_label(view, row), style=tui_rich_style("muted")) + line = Text() + line.append_text(gutter) + for column in range(first_column, WEEK_COUNT): + if column > first_column: + line.append(" ") + index = column * DAY_COUNT + row + level = levels[index] + cell_date = chart_start + timedelta(days=index) + if view is TokenActivityView.DAILY and cell_date > today: + # Upcoming cells stay blank so the heatmap doesn't pretend to + # show data we don't have. + line.append(" ", style=future_style) + continue + glyph, style = _glyph_and_style(view, level, empty_style, active_style) + line.append(glyph, style=style) + out.append(line) + + out.append(Text(" ")) + if view is TokenActivityView.DAILY: + out.append(_legend_line(empty_style, active_style)) + else: + out.append(_bar_caption(view, activity, empty_style, active_style)) + out.append(_view_footer(view)) + return out + + +def _gutter_label(view: TokenActivityView, row: int) -> str: + if view is TokenActivityView.DAILY: + return f" {WEEKDAY_LABELS[row]} " + if row == 0: + return "max " + if row == DAY_COUNT - 1: + return " 0 " + return " " + + +def _glyph_and_style( + view: TokenActivityView, + level: int, + empty_style: Style, + active_style: Style, +) -> tuple[str, Style]: + if view is not TokenActivityView.DAILY: + glyph = BAR_GLYPH if level > 0 else " " + return glyph, active_style if level > 0 else empty_style + if level <= 0: + return EMPTY_GLYPH, empty_style + return ACTIVE_GLYPH, active_style + + +def _legend_line(empty_style: Style, active_style: Style) -> Text: + line = Text(LEGEND_LEFT_PAD + "Less ", style=tui_rich_style("muted")) + for level in range(5): + if level > 0: + line.append(" ") + glyph, style = _glyph_and_style(TokenActivityView.DAILY, level, empty_style, active_style) + line.append(glyph, style=style) + line.append(" More", style=tui_rich_style("muted")) + return line + + +def _bar_caption( + view: TokenActivityView, + activity: TokenActivity, + empty_style: Style, + active_style: Style, +) -> Text: + del empty_style, active_style # caption only re-uses the muted + bold styles + weekly = _weekly_totals(activity.daily_values) + if view is TokenActivityView.WEEKLY: + peak = max(weekly, default=0) + lead = "Each column = 1 week · tallest " + else: + peak = sum(weekly) + lead = "Running total · top " + line = Text(LEGEND_LEFT_PAD, style=tui_rich_style("muted")) + if peak <= 0: + line.append("No token activity in the last 12 months", style=tui_rich_style("muted")) + return line + line.append(lead, style=tui_rich_style("muted")) + line.append(_format_compact(peak), style="bold") + return line + + +def _view_footer(active: TokenActivityView) -> Text: + line = Text(LEGEND_LEFT_PAD, style=tui_rich_style("muted")) + views = [ + (TokenActivityView.DAILY, "daily"), + (TokenActivityView.WEEKLY, "weekly"), + (TokenActivityView.CUMULATIVE, "cumulative"), + ] + for index, (view, name) in enumerate(views): + if index > 0: + line.append(" · ", style=tui_rich_style("muted")) + style = "bold" if view is active else tui_rich_style("muted") + line.append(name, style=style) + return line + + +__all__ = [ + "ActivitySummary", + "TokenActivity", + "TokenActivityView", + "load_activity", + "parse_view", + "render_activity", +] diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index e96bda28..c3636adb 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -53,7 +53,7 @@ reduced_motion_enabled, shimmer_text, ) -from pythinker_code.ui.shell.spacing import BLANK_ROW +from pythinker_code.ui.shell.spacing import BLANK_ROW, emit_scrollback_block from pythinker_code.ui.shell.spinner_words import spinner_message from pythinker_code.ui.shell.tips import current_tip from pythinker_code.ui.shell.visualize._approval_panel import ( @@ -151,9 +151,8 @@ def _append_action_block( def _print_action_block(block: RenderableType) -> None: - """Commit a completed action block to scrollback with one leading blank row.""" - console.print() - console.print(block) + """Commit a completed action block to scrollback with one trailing blank row.""" + emit_scrollback_block(console, block) def _format_step_retry(retry: StepRetry) -> Text: @@ -1251,15 +1250,11 @@ def flush_content(self) -> None: block._flush_committed() if block.is_think: if block.has_pending(): - if not block.has_emitted_to_scrollback: - console.print() - console.print(block.compose_final()) + emit_scrollback_block(console, block.compose_final()) else: renderable = block.promote_to_scrollback() if renderable is not None: - if not block.has_emitted_to_scrollback: - console.print() - console.print(renderable) + emit_scrollback_block(console, renderable) self._current_content_block = None self.refresh_soon() diff --git a/src/pythinker_code/ui/theme/palettes.py b/src/pythinker_code/ui/theme/palettes.py index eedb7541..8f04e49f 100644 --- a/src/pythinker_code/ui/theme/palettes.py +++ b/src/pythinker_code/ui/theme/palettes.py @@ -137,7 +137,7 @@ } _MARKDOWN_ANSI = { - MarkdownAnsiToken.LINK: "cyan", + MarkdownAnsiToken.LINK: "bright_blue", MarkdownAnsiToken.QUOTE: "green", MarkdownAnsiToken.ORDERED_MARKER: "bright_blue", } diff --git a/src/pythinker_code/ui/theme/registry.py b/src/pythinker_code/ui/theme/registry.py index 779ab19e..f9f6ace0 100644 --- a/src/pythinker_code/ui/theme/registry.py +++ b/src/pythinker_code/ui/theme/registry.py @@ -108,7 +108,7 @@ def get_markdown_colors(theme: ThemeName | None = None) -> MarkdownColors: heading=tokens.tool_title, emphasis=tokens.muted, strong=tokens.tool_title, - inline_code=tokens.info, + inline_code=tokens.accent, link=ansi[MarkdownAnsiToken.LINK], quote=ansi[MarkdownAnsiToken.QUOTE], ordered_marker=ansi[MarkdownAnsiToken.ORDERED_MARKER], diff --git a/src/pythinker_code/ui/theme/resolver.py b/src/pythinker_code/ui/theme/resolver.py index 82336af4..3dfb94ec 100644 --- a/src/pythinker_code/ui/theme/resolver.py +++ b/src/pythinker_code/ui/theme/resolver.py @@ -93,7 +93,7 @@ def ptk_fg(self, token: PromptToken | CoreToken) -> str: def markdown_inline_code_style(self) -> RichStyle: """Inline code: color only — headers stay bold elsewhere.""" - return self.rich_style(CoreToken.INFO) + return self.rich_style(CoreToken.ACCENT) def markdown_heading_style(self, *, level: int = 1) -> RichStyle: style = self.rich_style(CoreToken.TOOL_TITLE, bold=True) diff --git a/src/pythinker_code/utils/rich/syntax.py b/src/pythinker_code/utils/rich/syntax.py index af8e301e..ed8fa93b 100644 --- a/src/pythinker_code/utils/rich/syntax.py +++ b/src/pythinker_code/utils/rich/syntax.py @@ -33,28 +33,28 @@ PygmentsToken: Style(color="default"), PygmentsText: Style(color="default"), Comment: Style(color="bright_black", italic=True), - Keyword: Style(color="cyan"), - Keyword.Constant: Style(color="cyan"), - Keyword.Declaration: Style(color="cyan"), - Keyword.Namespace: Style(color="cyan"), - Keyword.Pseudo: Style(color="cyan"), - Keyword.Reserved: Style(color="cyan"), - Keyword.Type: Style(color="cyan"), + Keyword: Style(color="bright_blue"), + Keyword.Constant: Style(color="bright_blue"), + Keyword.Declaration: Style(color="bright_blue"), + Keyword.Namespace: Style(color="bright_blue"), + Keyword.Pseudo: Style(color="bright_blue"), + Keyword.Reserved: Style(color="bright_blue"), + Keyword.Type: Style(color="bright_blue"), Name: Style(color="default"), - Name.Attribute: Style(color="cyan"), + Name.Attribute: Style(color="bright_blue"), Name.Builtin: Style(color="bright_yellow"), - Name.Builtin.Pseudo: Style(color="cyan"), + Name.Builtin.Pseudo: Style(color="bright_blue"), Name.Builtin.Type: Style(color="bright_yellow", bold=True), Name.Class: Style(color="bright_yellow", bold=True), - Name.Constant: Style(color="cyan"), - Name.Decorator: Style(color="bright_cyan"), + Name.Constant: Style(color="bright_blue"), + Name.Decorator: Style(color="blue"), Name.Entity: Style(color="bright_yellow"), Name.Exception: Style(color="bright_yellow", bold=True), - Name.Function: Style(color="bright_cyan"), - Name.Label: Style(color="cyan"), - Name.Namespace: Style(color="cyan"), - Name.Other: Style(color="bright_cyan"), - Name.Property: Style(color="cyan"), + Name.Function: Style(color="blue"), + Name.Label: Style(color="bright_blue"), + Name.Namespace: Style(color="bright_blue"), + Name.Other: Style(color="blue"), + Name.Property: Style(color="bright_blue"), Name.Tag: Style(color="bright_green"), Name.Variable: Style(color="bright_yellow"), PygmentsLiteral: Style(color="#CE9178"), @@ -62,20 +62,20 @@ String: Style(color="#CE9178"), String.Doc: Style(color="#CE9178", italic=True), String.Interpol: Style(color="#CE9178"), - String.Affix: Style(color="cyan"), - Number: Style(color="cyan"), + String.Affix: Style(color="bright_blue"), + Number: Style(color="bright_blue"), Operator: Style(color="default"), - Operator.Word: Style(color="cyan"), + Operator.Word: Style(color="bright_blue"), Punctuation: Style(color="default"), Generic.Deleted: Style(color="red"), Generic.Emph: Style(italic=True), Generic.Error: Style(color="bright_red", bold=True), - Generic.Heading: Style(color="cyan", bold=True), + Generic.Heading: Style(color="bright_blue", bold=True), Generic.Inserted: Style(color="green"), Generic.Output: Style(color="bright_black"), - Generic.Prompt: Style(color="bright_cyan"), + Generic.Prompt: Style(color="blue"), Generic.Strong: Style(bold=True), - Generic.Subheading: Style(color="cyan"), + Generic.Subheading: Style(color="bright_blue"), Generic.Traceback: Style(color="bright_red", bold=True), } ) diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index b39097fd..1ba2b566 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -92,9 +92,12 @@ def test_native_curl_installer_shows_robot_logo() -> None: assert "print_logo_static()" in installer assert "print_logo_animated()" in installer - assert "\nprint_logo\n\n# --- detect target" in installer - assert "pythinker code" in installer - assert "is ready. Run %s%spythinker%s to launch." in installer + assert "print_logo_art()" in installer + assert "GRID_ORIGIN_ROW=5" in installer + assert "PROGRESS_ROW=17" in installer + assert "Pythinker Code" in installer + assert "Think first. Then code." in installer + assert "Ready. Start with:" in installer def test_native_powershell_installer_shows_robot_logo() -> None: diff --git a/tests/ui/test_usage_activity.py b/tests/ui/test_usage_activity.py new file mode 100644 index 00000000..38b9e319 --- /dev/null +++ b/tests/ui/test_usage_activity.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest +from rich.console import Console +from rich.text import Text + +from pythinker_code.ui.shell.usage_activity import ( + ActivitySummary, + TokenActivity, + TokenActivityView, + _bar_levels, + _chart_start, + _graded_levels, + _month_labels, + _summary_lines, + _weekly_totals, + load_activity, + parse_view, + render_activity, +) + +# ----- parse_view ----- + + +def test_parse_view_defaults_daily_for_empty() -> None: + assert parse_view("") is TokenActivityView.DAILY + assert parse_view("daily") is TokenActivityView.DAILY + assert parse_view(" day ") is TokenActivityView.DAILY + assert parse_view("weekly") is TokenActivityView.WEEKLY + assert parse_view("cumulative") is TokenActivityView.CUMULATIVE + assert parse_view("year") is None + assert parse_view("monthly") is None + + +# ----- bucketing helpers ----- + + +def test_chart_start_is_a_sunday_52_weeks_before_today() -> None: + today = date(2026, 5, 29) # Friday in the upstream snapshot test + start = _chart_start(today) + # ``weekday()`` returns Monday=0; a Sunday is 6. + assert start.weekday() == 6 + expected_offset = (52 - 1) * 7 + (today.weekday() + 1) % 7 + assert (today - start).days == expected_offset + + +def test_graded_levels_use_5_step_scale() -> None: + # Peak is 16; boundaries fall at 1/4 (4), 1/2 (8), and 3/4 (12). + values = [0, 1, 4, 8, 9, 12, 16] + levels = _graded_levels(values) + assert levels == [0, 1, 1, 2, 3, 3, 4] + + +def test_graded_levels_zero_when_all_zero() -> None: + assert _graded_levels([0, 0, 0]) == [0, 0, 0] + + +def test_bar_levels_fill_from_bottom() -> None: + # 1:0 split → second column fills every row, first column is empty. + levels = _bar_levels([0, 10]) + assert levels[:7] == [0] * 7 + assert levels[7:] == [4] * 7 + + +def test_weekly_totals_chunk_by_seven() -> None: + values = list(range(1, 15)) # 14 values, exactly two weeks + assert _weekly_totals(values) == [sum(range(1, 8)), sum(range(8, 15))] + + +# ----- summary formatting ----- + + +def test_summary_lines_pack_into_wide_terminal() -> None: + summary = ActivitySummary( + lifetime_tokens=21_400_000_000, + peak_daily_tokens=835_000_000, + current_streak_days=54, + longest_streak_days=54, + longest_task_seconds=13_920, + ) + lines = _summary_lines(summary, width=120) + text = "".join(_plain_text(line) for line in lines) + assert "Lifetime 21.4B" in text + assert "Peak 835M" in text + assert "Streak 54d" in text + assert "Longest task 3h 52m" in text + + +def test_summary_lines_split_when_too_narrow() -> None: + summary = ActivitySummary( + lifetime_tokens=21_400_000_000, + peak_daily_tokens=835_000_000, + current_streak_days=54, + longest_streak_days=54, + longest_task_seconds=13_920, + ) + lines = _summary_lines(summary, width=44) + joined = "\n".join(_plain_text(line) for line in lines) + # The "Longest task" field should drop to the second line. + assert "Streak 54d" in joined + assert joined.count("\n") >= 1 + + +def test_summary_streak_uses_best_format() -> None: + summary = ActivitySummary( + lifetime_tokens=0, + peak_daily_tokens=0, + current_streak_days=12, + longest_streak_days=54, + longest_task_seconds=0, + ) + text = "".join(_plain_text(line) for line in _summary_lines(summary, width=120)) + assert "12d (best 54d)" in text + + +# ----- rendering ----- + + +def test_month_labels_show_unique_abbrevs() -> None: + today = date(2026, 5, 29) + # 26 weeks ≈ 6 months ending in late May. The label rule prints a + # month label only when the first day of the column falls on day 1-7, + # so the rendered row should label the months the chart covers + # without labelling May (the current month) or anything past it. + line = _month_labels(today, first_column=0, shown_columns=26) + text = _plain_text(line) + for month in ("Jul", "Aug", "Sep", "Oct", "Nov"): + assert month in text, f"missing {month} in {text!r}" + assert "May" not in text, "May is the current month and should not yet be labelled" + + +def test_render_activity_includes_title_summary_and_footer() -> None: + activity = TokenActivity( + summary=ActivitySummary( + lifetime_tokens=120_000_000, + peak_daily_tokens=12_000_000, + current_streak_days=3, + longest_streak_days=10, + longest_task_seconds=0, + ), + daily_values=tuple(1 if idx % 5 == 0 else 0 for idx in range(7 * 52)), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.DAILY, width=120)) + assert "Token activity" in text + assert "last 12 months" in text + assert "Lifetime" in text + assert "Less" in text and "More" in text + assert "daily" in text and "weekly" in text and "cumulative" in text + + +def test_render_activity_wide_left_aligns_chart() -> None: + # Non-zero data is required for the heatmap to render; an empty history + # short-circuits to the "No token activity" placeholder. + activity = TokenActivity( + summary=ActivitySummary(1, 1, 0, 0, 0), + daily_values=(0,) * (7 * 52 - 1) + (1,), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.DAILY, width=160)) + lines = text.splitlines() + chart_rows = [line for line in lines if line.startswith((" Su ", " Mo ", " Tu "))] + assert chart_rows, "expected to find weekday rows in the wide render" + # Wide render: 52 columns × 2 cells - 1 = 103 cells, plus a 4-char gutter. + # The first weekday row should be at least 100 chars wide. + assert max(len(line) for line in chart_rows) >= 100 + + +def test_render_activity_weekly_uses_bar_chart() -> None: + activity = TokenActivity( + summary=ActivitySummary( + lifetime_tokens=18, + peak_daily_tokens=9, + current_streak_days=0, + longest_streak_days=0, + longest_task_seconds=0, + ), + daily_values=_sample_weekly_buckets(), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.WEEKLY, width=22)) + # In the bar view, the gutter shows "max" / "0" instead of weekday labels. + assert "max" in text + assert "Each column = 1 week" in text + + +def test_render_activity_cumulative_caption() -> None: + activity = TokenActivity( + summary=ActivitySummary( + lifetime_tokens=18, + peak_daily_tokens=9, + current_streak_days=0, + longest_streak_days=0, + longest_task_seconds=0, + ), + daily_values=_sample_weekly_buckets(), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.CUMULATIVE, width=22)) + assert "Running total" in text + + +def test_render_activity_narrow_widens_terminal_hint() -> None: + activity = TokenActivity( + summary=ActivitySummary(0, 0, 0, 0, 0), + daily_values=(1,) * (7 * 52), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.DAILY, width=2)) + assert "Widen terminal" in text + + +def test_render_activity_empty_history_shows_placeholder() -> None: + activity = TokenActivity( + summary=ActivitySummary(0, 0, 0, 0, 0), + daily_values=(0,) * (7 * 52), + today_index=7 * 52 - 1, + ) + text = _render(render_activity(activity, TokenActivityView.DAILY, width=80)) + assert "No token activity in the last 12 months" in text + + +# ----- integration with local wire files ----- + + +def test_load_activity_uses_local_wire_files(monkeypatch: pytest.MonkeyPatch) -> None: + today = datetime(2026, 5, 29, tzinfo=UTC).date() + timestamps = [ + datetime(2026, 5, 22, 12, 0, tzinfo=UTC).timestamp() + offset * 86_400 + for offset in range(7) + ] + steps = _make_steps(timestamps=timestamps, tokens=[10] * 7) + # Patch the lowest-level collector so we don't need to fabricate wire + # files on disk. ``_collect_steps`` is the only function that walks the + # session tree; bypassing it isolates the bucketing logic the test + # actually cares about. + monkeypatch.setattr( + "pythinker_code.ui.shell.usage_activity._collect_steps", + lambda: steps, + ) + activity = load_activity(today=today) + assert activity.summary.lifetime_tokens == 70 + assert activity.summary.peak_daily_tokens == 10 + assert activity.summary.longest_streak_days == 7 + + +# ----- helpers ----- + + +def _sample_weekly_buckets() -> tuple[int, ...]: + """Three weeks of values whose per-week totals are 3, 6, 9.""" + + pattern = [3] + [0] * 6 + [6] + [0] * 6 + [9] + [0] * 6 + return tuple(pattern + [0] * (7 * 52 - len(pattern))) + + +def _make_steps(*, timestamps: list[float], tokens: list[int]) -> list: + from pythinker_code.ui.shell.stats_collector import StepRecord + + return [ + StepRecord( + session_id="s", + timestamp=ts, + model_name="m", + provider_key="managed:test", + input_other=tok, + output=0, + input_cache_read=0, + input_cache_creation=0, + ) + for ts, tok in zip(timestamps, tokens, strict=True) + ] + + +def _plain_text(line) -> str: + if isinstance(line, Text): + return line.plain + return str(line) + + +def _render(renderable) -> str: + console = Console(force_terminal=False, width=160, record=True) + console.print(renderable) + return console.export_text() diff --git a/tests/ui_and_conv/test_plan_display_panel.py b/tests/ui_and_conv/test_plan_display_panel.py index ec872efc..aead34c3 100644 --- a/tests/ui_and_conv/test_plan_display_panel.py +++ b/tests/ui_and_conv/test_plan_display_panel.py @@ -32,9 +32,8 @@ def fake_render_worklog_card(title, body, *, subtitle=None, border_style="grey50 assert card_call["title"] == "Plan" assert card_call["subtitle"] == "plans/one.md" assert card_call["border_style"] == tui_rich_style("border") - # _print_action_block emits a leading blank line (zero-arg console.print()) - # before the panel; only positional args are captured, so printed holds the - # panel alone. + # _print_action_block commits the panel with a trailing blank row; only the + # panel is captured because the zero-arg console.print() calls have no args. assert printed == [card_call["panel"]] console = Console(record=True, width=120, color_system=None) diff --git a/tests/ui_and_conv/test_shell_prompt_echo.py b/tests/ui_and_conv/test_shell_prompt_echo.py index dc0c163e..2883b05a 100644 --- a/tests/ui_and_conv/test_shell_prompt_echo.py +++ b/tests/ui_and_conv/test_shell_prompt_echo.py @@ -190,6 +190,15 @@ def test_user_echo_renders_pasted_markdown_tables() -> None: assert "| --- |" not in plain +def test_user_echo_leaves_trailing_blank_before_agent_stream() -> None: + from rich.console import Console + + console = Console(record=True, width=40, color_system=None) + console.print(render_user_echo_text("apply")) + lines = console.export_text().splitlines() + assert lines[-1] == "" + + def test_user_echo_wraps_message_in_tinted_block() -> None: from rich.console import Console diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 446f4c46..adffa6a9 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -28,6 +28,15 @@ def test_directory_label_uses_info_token(): assert get_tui_tokens("dark").info in style +def test_branch_label_uses_muted_warning_yellow(): + from pythinker_code.ui.shell import WelcomeInfoItem, _value_style_for_label + from pythinker_code.ui.theme import get_tui_tokens, set_active_theme + + set_active_theme("dark") + style = _value_style_for_label("Branch", WelcomeInfoItem.Level.INFO) + assert style == get_tui_tokens("dark").warning + + def test_welcome_banner_chip_shown_in_output(monkeypatch): console = Console(record=True, width=120, color_system=None) monkeypatch.setattr(shell_module, "console", console) diff --git a/tests/ui_and_conv/test_spacing_primitives.py b/tests/ui_and_conv/test_spacing_primitives.py index f12031af..e02ba218 100644 --- a/tests/ui_and_conv/test_spacing_primitives.py +++ b/tests/ui_and_conv/test_spacing_primitives.py @@ -55,6 +55,17 @@ def test_padding_constants_have_zero_vertical() -> None: assert pad[0] == 0 +def test_emit_scrollback_block_appends_trailing_blank() -> None: + from rich.console import Console + from rich.text import Text + + console = Console(record=True, width=40, color_system=None) + spacing.emit_scrollback_block(console, Text("tool body")) + lines = console.export_text().splitlines() + assert lines[-2].endswith("tool body") + assert lines[-1] == "" + + def test_ensure_prompt_newline_appends_when_missing() -> None: fragments: StyleAndTextTuples = [("", "hello")] spacing.ensure_prompt_newline(fragments) diff --git a/tests/ui_and_conv/test_theme_contract.py b/tests/ui_and_conv/test_theme_contract.py index defc4071..6d7fb829 100644 --- a/tests/ui_and_conv/test_theme_contract.py +++ b/tests/ui_and_conv/test_theme_contract.py @@ -53,11 +53,11 @@ def test_no_bold_inline_code(): assert style.bold is not True -def test_inline_code_uses_info_not_accent(): +def test_inline_code_uses_accent_not_info(): colors = get_markdown_colors("dark") tokens = get_tui_tokens("dark") - assert colors.inline_code == tokens.info - assert colors.inline_code != tokens.accent + assert colors.inline_code == tokens.accent + assert colors.inline_code != tokens.info def test_unknown_token_raises(): @@ -84,4 +84,4 @@ def test_resolver_heading_bold_inline_not_bold(): inline = resolver.markdown_inline_code_style() assert heading.bold is True assert inline.bold is not True - assert inline.color == RichStyle(color=get_tui_tokens("dark").info).color + assert inline.color == RichStyle(color=get_tui_tokens("dark").accent).color diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index 2f8a5c0a..35c9884f 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -161,8 +161,8 @@ def test_dark_markdown_uses_professional_report_roles(): assert colors.heading == "#F4F4F5" # primary white, not coral/orange assert colors.strong == "#F4F4F5" assert colors.emphasis == "#8A8A8A" # neutral UI grey (refined muted contrast) - assert colors.inline_code == "#8FDDEA" - assert colors.link == "cyan" + assert colors.inline_code == "#AEB7FF" + assert colors.link == "bright_blue" assert colors.spinner_active == "#8FDDEA" assert colors.spinner_done == "#7CCF8A" assert colors.spinner_failed == "#F87171" @@ -174,18 +174,18 @@ def test_light_markdown_uses_professional_report_roles(): assert colors.heading == "#213853" assert colors.strong == "#213853" assert colors.emphasis == "#666666" - assert colors.inline_code == "#176B7E" # info token (light) + assert colors.inline_code == "#0B114E" # accent token (light) assert colors.spinner_active == "#176B7E" # spinners still use the info token def test_markdown_ansi_styles_resolve_to_terminal_colors(): """Link, quote, and ordered_marker use ANSI terminal colors; inline_code - uses the themed info token so inline highlights stay out of the accent family.""" + uses the themed accent token so inline highlights match brand periwinkle.""" for mode in ("dark", "light"): - assert _color_name(markdown_rich_style("link", theme=mode)) == "cyan" + assert _color_name(markdown_rich_style("link", theme=mode)) == "bright_blue" assert _color_name(markdown_rich_style("quote", theme=mode)) == "green" assert _color_name(markdown_rich_style("ordered_marker", theme=mode)) == "bright_blue" - # inline_code uses the info token, not periwinkle accent or ANSI cyan/green. + # inline_code uses the accent token, not info cyan/green. assert _color_name(markdown_rich_style("inline_code", theme=mode)) not in ("cyan", "green") # Unordered bullets stay muted (a hex), not an ANSI accent. assert _color_name(markdown_rich_style("unordered_marker", theme=mode)) != "green" @@ -243,9 +243,9 @@ def test_markdown_colors_derived_from_tokens_dark(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - # inline_code uses the info token for inline highlights; link/quote/ordered_marker remain ANSI. - assert c.inline_code == t.info - assert c.link == "cyan" + # inline_code uses the accent token for inline highlights; link/quote/ordered_marker remain ANSI. + assert c.inline_code == t.accent + assert c.link == "bright_blue" assert c.quote == "green" assert c.ordered_marker == "bright_blue" assert c.unordered_marker == t.muted # unordered bullets stay muted @@ -263,9 +263,9 @@ def test_markdown_colors_derived_from_tokens_light(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - # inline_code uses the info token; link remains ANSI cyan. - assert c.inline_code == t.info - assert c.link == "cyan" + # inline_code uses the accent token; link remains ANSI bright_blue. + assert c.inline_code == t.accent + assert c.link == "bright_blue" assert c.quote == "green" assert c.ordered_marker == "bright_blue" assert c.unordered_marker == t.muted diff --git a/web/public/install.sh b/web/public/install.sh index 5a40b392..e1b6fc2a 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -76,6 +76,7 @@ if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' + # Shimmer / pulse tones for the "piece landed" + "leading edge" beats. SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' else NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" @@ -90,13 +91,40 @@ _anim="" && [ -z "${CI:-}" ] \ && _anim=1 -LOGO_CURSOR_ROWS=0 ANTENNA_SPIN_ACTIVE="" +# Bookmarked cursor position for the antenna-tip spin. We save the +# cursor right after print_logo_animated's final static re-render, and +# _antenna_tip restores from that bookmark before writing the tip. +# That way the tip always lands on the same screen row as the +# bookmark regardless of how many rows the metadata block consumed +# (which varies if any metadata row wraps). Using an absolute bookmark +# avoids the relative-cursor-up arithmetic that miscounted when the +# install layout differed from the developer's test environment. +ANTENNA_TIP_BOOKMARK="" +ANTENNA_TIP_COL=7 +# Absolute row for the progress bar and "Waiting" line. Set by +# print_intro after the metadata block finishes. Both the waiting +# retry and the download progress use this row via absolute positioning +# so they never depend on where the cursor happens to be — the bar +# always lands one row below the metadata, not on whatever row the +# cursor drifted to. +PROGRESS_ROW="" _antenna_tip() { [ -z "$_anim" ] && return - [ "$LOGO_CURSOR_ROWS" -gt 0 ] || return - printf '\033[s\033[%dA\r\033[6C%s%s%s\033[u' "$LOGO_CURSOR_ROWS" "$TIP" "$1" "$RESET" + [ -n "$ANTENNA_TIP_BOOKMARK" ] || return + # Restore the bookmarked position (row right below the base), then + # move up 5 rows to reach the antenna tip row, and write. We do NOT + # re-save the bookmark at the tip row — that would drag the + # reference point up 5 rows on every call, so the second tick + # would land 5 rows above the tip, the third 10 rows above, and + # the fourth would clamp to row 0. The bookmark stays at the + # row right below the base for the lifetime of the install. + # Restore the bookmark (row right below the grid base), then + # absolute-position to the antenna tip row. We do NOT re-save the + # bookmark at the tip row — that would drag the reference point + # up on every call. + printf '\033[u\033[%d;%dH%s%s%s' "$GRID_ORIGIN_ROW" "$ANTENNA_TIP_COL" "$TIP" "$1" "$RESET" } _antenna_spin_start() { @@ -110,6 +138,12 @@ _antenna_spin_stop() { _antenna_tip "●" } +# (Eye blink during the install was attempted here but the row offset +# depends on the terminal's starting cursor position, which varies +# between environments. The intro's own bounce at _blink_eyes is the +# reliable eye animation; the download phase keeps the antenna spin +# going but leaves the eyes static at their final `◉` color.) + _content_length() { curl -fsIL "$1" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2); bytes=$2 } END { if (bytes ~ /^[0-9]+$/) print bytes }' @@ -139,18 +173,32 @@ _print_download_progress() { for ((i=0; i/dev/null 2>&1; then if [ -n "$_anim" ]; then local total percent i=0 curl_pid + # 8-frame spin: ◌ ◍ ◎ ◍ ● ◍ ◎ ◍ — every 4th tick the tip "blooms" + # to a filled circle so the head reads as alive, not as a spinner. local -a frames=('●' '◐' '◌' '◍' '◌' '◑' '◍' '⬤') total="$(_content_length "$url" || true)" _antenna_spin_start + # Hide the cursor during the spin loop so the in-place bar updates + # do not flash a stray cursor block at the rewrite position. printf '\033[?25l' curl -fsSL "$url" -o "$output" & curl_pid=$! @@ -171,6 +223,8 @@ _download_with_progress() { local frame_idx=$((i % 8)) percent="$(_download_percent "$output" "$total" || printf '%s' $((i % 20 * 5)))" _antenna_tip "${frames[$frame_idx]}" + # Pulse the leading edge on odd ticks so the bar visibly advances + # even when the byte count has not yet ticked. local pulse=0 (( i % 2 == 1 )) && pulse=1 _print_download_progress "$percent" "${frames[$frame_idx]}" "$pulse" @@ -179,6 +233,7 @@ _download_with_progress() { done wait "$curl_pid" || { printf '\033[?25h'; return 1; } _antenna_spin_stop + # Settle on a solid bar with the check mark, no pulse. _print_download_progress 100 "✓" printf '\n\033[?25h' else @@ -206,38 +261,84 @@ _download_quiet() { fi } -print_logo_art() { - printf ' %s●%s\n' "$TIP" "$RESET" - printf ' %s│%s\n' "$NAVY" "$RESET" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +print_intro() { + if [ -n "$_anim" ]; then + print_logo_animated + else + print_logo_static + fi + printf ' %-11s %s\n' "Version" "$VERSION" + printf ' %-11s %s\n' "Platform" "$platform_display" + printf ' %-11s %s\n' "Package" "$tarball" + # Reserve the progress row one line below the metadata. The cursor + # is now on that row; save it so the "Waiting" retry and the + # download progress bar can absolute-position to it without + # relying on cursor tracking (which drifted to the grid's base + # row in some terminals and caused the bar to overwrite the grid). + # Layout from the top: 4 blank rows, grid (5 rows), 1 blank row, + # tagline, 2 blank rows, 3 metadata rows → progress row is row 17. + PROGRESS_ROW=17 + printf '\n' } print_logo_static() { - printf '\n\n' + printf '\n\n\n\n' print_logo_art printf '\n' + # Bookmark the cursor (row right below the grid) for the antenna + # spin during download. In the static path the cursor is at the + # same position as the animated path's final re-render (row + # immediately below the base), so the bookmark is equivalent. + printf '\033[s' + ANTENNA_TIP_BOOKMARK=1 printf ' %s%sPythinker Code%s %sThink first. Then code.%s\n\n' "$BOLD" "$FACE" "$RESET" "$DIM" "$RESET" } _type_tagline() { + # Print the tagline one glyph at a time so the intro breathes. The static + # path prints the same line in one shot via print_logo_static. local tagline='Pythinker Code Think first. Then code.' - local i ch + local i ch out="" printf ' ' for ((i=0; i<${#tagline}; i++)); do ch="${tagline:$i:1}" + out+="$ch" printf '%s' "$ch" sleep 0.018 done printf '\n\n' } +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" + printf ' %s│%s\n' "$NAVY" "$RESET" + printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' "$TIP" "$RESET" "$NAVY" "$RESET" "$EYE" "$RESET" "$EYE" "$RESET" "$NAVY" "$RESET" "$TIP" "$RESET" + printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' "$NAVY" "$RESET" "$FACE" "$RESET" "$NAVY" "$RESET" +} + print_logo_animated() { local ROWS=5 COLS=13 local FRAME_DELAY="${PYTHINKER_LOGO_FRAME_DELAY:-0.06}" local STAGGER_DELAY="${PYTHINKER_LOGO_STAGGER_DELAY:-0.04}" + # Hide the text cursor while we redraw in place — otherwise the block + # cursor on terminals like Terminal.app renders as a stray white block + # at the cursor's current cell. Show it again before returning so the + # user can type after the installer finishes. We use a flag and restore + # in the function footer so this works regardless of how the function + # exits (note: `trap ... RETURN` does not fire on plain function return + # in bash, so an explicit show at every return site is required). + printf '\033[?25l' + _cursor_hidden=1 + + # The 4 leading newlines push the cursor from row 1 to row 5, so the + # grid sits at rows 5-9. Publish this to the top-level so the antenna + # spin can use absolute cursor moves after the intro returns. We use + # absolute positioning for every render so a caller that left the + # cursor at the wrong row can't leave a ghost frame behind. + GRID_ORIGIN_ROW=5 + local -a grid_chars grid_colors local i for ((i=0; i&2 exit 1 } +# Always restore the text cursor on signal/exit — the animation paths +# hide it, and a stray Ctrl-C would otherwise leave the user's terminal +# with no cursor until they run `tput cnorm` themselves. The EXIT trap +# is intentionally set here without cleanup; the tmpdir cleanup is +# layered on top later in this script. trap 'printf "\033[?25h" 2>/dev/null || true; exit 130' INT trap 'printf "\033[?25h" 2>/dev/null || true; exit 143' TERM @@ -487,7 +612,12 @@ sha_url="${tarball_url}.sha256" print_intro -# --- wait for assets to finish publishing -------------------------------- +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. release_has_assets() { _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" if command -v curl >/dev/null 2>&1; then @@ -498,6 +628,9 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 delay=4 elapsed=0 @@ -508,18 +641,18 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\r\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\r\033[K' +[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" -# --- download + verify --------------------------------------------------- +# --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" +# Layer: keep the cursor-show on every exit path, then clean up tmpdir. trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT - _download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" _download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" @@ -534,22 +667,24 @@ fi [ "$expected" != "$actual" ] && fail "SHA-256 mismatch: expected $expected, got $actual" phase_ok "Verifying" -# --- install ------------------------------------------------------------- +# --- install ----------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" mkdir -p "$bin_dir" +# The existing release tarball contains a single `pythinker` file at the +# tarball root (PyInstaller --onefile output). tar -C "$tmpdir" -xzf "$tmpdir/$tarball" [ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" phase_ok "Installing" -# --- PATH guidance ------------------------------------------------------- +# --- PATH guidance -------------------------------------------------------- case ":$PATH:" in *":$bin_dir:"*) ;; *) printf '\n %sNote:%s %s%s%s is not on your PATH.\n' "$BOLD" "$RESET" "$DIM" "$bin_dir" "$RESET" printf ' %sAdd this to your shell profile (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):%s\n' "$DIM" "$RESET" - printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "$bin_dir" "$RESET" + printf '\n %sexport PATH="%s:%sPATH"%s\n\n' "$DIM" "$bin_dir" "\$" "$RESET" ;; esac From b9662b7df3027da5f060a51f7feb043b0938cb82 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 15:41:53 -0400 Subject: [PATCH 05/26] feat(tui): pythinker-x theme port, tool header accent tokens, and diff/spacing polish Port pythinker-x theme: 32 bundled syntax theme names, Catppuccin Frappe/Macchiato styles, `/theme code` syntax picker, and aligned diff palette. Tool-call subject highlights switch from cyan to brand periwinkle accent; welcome banner uses neutral grey for branch and yellow for model name. Fix diff marker spacing for @-prefixed lines and add composing-block blank-row before the activity line. --- CHANGELOG.md | 16 ++- src/pythinker_code/ui/shell/__init__.py | 4 +- .../ui/shell/components/diff.py | 10 +- .../ui/shell/components/report.py | 11 +- .../ui/shell/selectors/code_theme.py | 44 ++++++ src/pythinker_code/ui/shell/slash.py | 85 +++++++++++- .../ui/shell/tool_renderers/_render_utils.py | 6 + .../ui/shell/tool_renderers/ask_user.py | 3 +- .../ui/shell/tool_renderers/background.py | 5 +- .../ui/shell/tool_renderers/edit.py | 7 +- .../ui/shell/tool_renderers/find.py | 3 +- .../ui/shell/tool_renderers/grep.py | 3 +- .../ui/shell/tool_renderers/plan.py | 3 +- .../ui/shell/tool_renderers/read.py | 3 +- .../ui/shell/tool_renderers/skill.py | 3 +- .../ui/shell/tool_renderers/web.py | 5 +- .../ui/shell/tool_renderers/write.py | 5 +- .../ui/shell/visualize/_blocks.py | 6 +- .../ui/shell/visualize/_worklog.py | 40 +++--- .../ui/theme/adapters/markdown.py | 4 +- src/pythinker_code/ui/theme/palettes.py | 44 ++---- .../ui/theme/pythinker_themes.py | 131 ++++++++++++++++++ src/pythinker_code/ui/theme/resolver.py | 5 +- src/pythinker_code/utils/rich/markdown.py | 12 +- src/pythinker_code/utils/rich/syntax.py | 105 ++++++++++++-- tests/ui_and_conv/test_live_view_todos.py | 12 +- .../ui_and_conv/test_pythinker_themes_port.py | 67 +++++++++ tests/ui_and_conv/test_shell_panel.py | 2 +- tests/ui_and_conv/test_shell_welcome_info.py | 11 +- .../test_streaming_content_block.py | 16 +++ tests/ui_and_conv/test_theme.py | 4 +- tests/ui_and_conv/test_theme_contract.py | 4 +- .../test_tui_card_tool_renderers.py | 21 ++- tests/ui_and_conv/test_tui_theme_tokens.py | 37 ++--- 34 files changed, 592 insertions(+), 145 deletions(-) create mode 100644 src/pythinker_code/ui/shell/selectors/code_theme.py create mode 100644 src/pythinker_code/ui/theme/pythinker_themes.py create mode 100644 tests/ui_and_conv/test_pythinker_themes_port.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 447a70d1..f3b74e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,24 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Tool header highlights.** Read/Write/Edit/Grep and similar tool-call subjects + now use the brand periwinkle `accent` token instead of cyan `info`; line ranges + stay on the yellow `warning` token. +- **pythinker-x theme port.** Diff palette, 32 bundled syntax theme names, Catppuccin + Frappe/Macchiato styles, and `/theme code` syntax picker aligned with the Pythinker-X TUI. - **TUI inline code color.** Inline `` `code` `` highlights and the `pythinker-ansi` syntax theme now use brand periwinkle/accent and blue ANSI roles instead of cyan. - **TUI transcript spacing.** User prompts leave one blank row before the agent stream starts; finished tool cards and flushed agent paragraphs leave a trailing blank row before the next block (Bash/Read output → next ⏺ paragraph, etc.). -- **Welcome banner branch color.** Branch name on the startup panel uses the muted - yellow warning token instead of the status-line teal. +- **Welcome banner colors.** Branch uses light neutral grey; model name uses the muted + yellow warning token. +- **TUI theme package.** Centralize dark/light palettes, prompt classes, and Rich/PTK adapters in `ui/theme/` with `/theme current|doctor|tokens` inspection commands. +- **TUI diff markers.** Inline diff rows now leave a space after `+`/`-` markers so + `@`-prefixed lines (e.g. CSS `@keyframes`) do not run together with the sign. +- **Composing block spacing.** Staged agent paragraphs keep one blank row before the + Composing activity line while the stream is still live. - **Slash input UX.** Prefix-highlight skills and plugins while typing; ghost-complete and highlight fixed subcommands such as `/theme current`. - **TUI streaming smoothness (Phase 0).** Coalesce Rich Live repaints to a 25 Hz frame budget, @@ -34,7 +44,7 @@ GitHub Releases page; `0.8.0` is the new starting line. server lifecycle, passive diagnostics injected after file edits, and plugin-based server discovery/recommendation — no bundled language-server binaries. - **Token activity card.** `/usage daily|weekly|cumulative` (and the bare `/usage` default - when no provider adapter is configured) now render a Codex-style 52-week × 7-day heatmap of + when no provider adapter is configured) now render a 52-week × 7-day heatmap of total tokens consumed each day, with a `Lifetime · Peak · Streak · Longest task` summary line and a footer that lets the user switch between daily/weekly/cumulative views. Data is read from the local session wire files; the per-provider adapter behavior is unchanged. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 2231a905..1c033d6b 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2402,9 +2402,9 @@ def _value_style_for_label(label: str, level: WelcomeInfoItem.Level) -> str: if label == "Session": return tokens.dim or "grey39" if label == "Model": - return f"bold {tokens.text}" if tokens.text else "bold bright_white" - if label == "Branch": return tokens.warning or "#EAB85F" + if label == "Branch": + return tokens.thinking_text or "grey70" if label == "Auto-save": return tokens.muted or "grey50" return level.value diff --git a/src/pythinker_code/ui/shell/components/diff.py b/src/pythinker_code/ui/shell/components/diff.py index e8146f09..66d0653b 100644 --- a/src/pythinker_code/ui/shell/components/diff.py +++ b/src/pythinker_code/ui/shell/components/diff.py @@ -313,28 +313,28 @@ def _newline() -> None: _replace_tabs(acontent), ) _newline() - row = Text(f"{rln} -", style=removed_sign) + row = Text(f"{rln} - ", style=removed_sign) # Underlay the row tint so word-level highlight spans stay on top. rem_inner.stylize_before(removed_body) row.append_text(rem_inner) out.append_text(row) _newline() - row = Text(f"{aln} +", style=added_sign) + row = Text(f"{aln} + ", style=added_sign) add_inner.stylize_before(added_body) row.append_text(add_inner) out.append_text(row) else: for ln, content in removed_block: _newline() - out.append(f"{ln} -", style=removed_sign) + out.append(f"{ln} - ", style=removed_sign) out.append(_replace_tabs(content), style=removed_body) for ln, content in added_block: _newline() - out.append(f"{ln} +", style=added_sign) + out.append(f"{ln} + ", style=added_sign) out.append(_replace_tabs(content), style=added_body) elif prefix == "+": _newline() - out.append(f"{line_num} +", style=added_sign) + out.append(f"{line_num} + ", style=added_sign) out.append(_replace_tabs(content), style=added_body) i += 1 else: diff --git a/src/pythinker_code/ui/shell/components/report.py b/src/pythinker_code/ui/shell/components/report.py index 48050793..c732528d 100644 --- a/src/pythinker_code/ui/shell/components/report.py +++ b/src/pythinker_code/ui/shell/components/report.py @@ -63,7 +63,7 @@ "high": ("error", False), "medium": ("warning", False), "low": ("accent", False), - "info": ("muted", False), + "info": ("activity_spinner", False), } _DOT = "●" @@ -313,16 +313,17 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab title.add_column(overflow="fold") title.add_row( Text(_DOT, style=_severity_style(finding.severity, theme)), - Text(finding.title, style=tui_rich_style("border", theme=theme) + RichStyle(bold=True)), + Text(finding.title, style=tui_rich_style("text", theme=theme)), ) rows.append(title) if finding.location: + rows.append(Text("")) # Keep wrapped file paths in the same hanging-indent column. A raw # leading-space Text only indents the first physical line after Rich # wraps, which makes long locations drift left inside wide reports. rows.append( - Padding(Text(finding.location, style=tui_rich_style("dim", theme=theme)), (0, 0, 0, 2)) + Padding(Text(finding.location, style=tui_rich_style("muted", theme=theme)), (0, 0, 0, 2)) ) if finding.body.strip(): @@ -337,7 +338,7 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab def render_report(report: Report, *, theme: ThemeName | None = None) -> RenderableType: """Render *report* as a padded, syntax-friendly Rich report panel.""" counts = _counts(report.findings) - border = tui_rich_style("border_muted", theme=theme) + border = tui_rich_style("border", theme=theme) blank = Text("") rows: list[RenderableType] = [] @@ -362,7 +363,7 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab Text(report.note, style=tui_rich_style("muted", theme=theme)), ] - title = Text(report.title, style=tui_rich_style("warning", theme=theme) + RichStyle(bold=True)) + title = Text(report.title, style=tui_rich_style("tool_title", theme=theme) + RichStyle(bold=True)) return Panel( Group(*rows), title=title, diff --git a/src/pythinker_code/ui/shell/selectors/code_theme.py b/src/pythinker_code/ui/shell/selectors/code_theme.py new file mode 100644 index 00000000..135aa319 --- /dev/null +++ b/src/pythinker_code/ui/shell/selectors/code_theme.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Callable + +from pythinker_code.ui.shell.selector import SelectorConfig, SelectorItem, run_selector + + +def _build_code_theme_config( + current_theme: str, + available_themes: list[str], + on_preview: Callable[[str], None] | None = None, + *, + theme_matches_current: Callable[[str, str], bool] | None = None, +) -> SelectorConfig[str]: + matches = theme_matches_current or (lambda theme, configured: theme == configured) + return SelectorConfig( + title="Select syntax theme", + items=[ + SelectorItem( + value=theme, + label=theme, + is_current=matches(theme, current_theme), + ) + for theme in available_themes + ], + on_change=on_preview, + ) + + +async def run_code_theme_selector( + current_theme: str, + available_themes: list[str], + on_preview: Callable[[str], None] | None = None, + *, + theme_matches_current: Callable[[str, str], bool] | None = None, +) -> str | None: + return await run_selector( + _build_code_theme_config( + current_theme, + available_themes, + on_preview, + theme_matches_current=theme_matches_current, + ) + ) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index a3a4d7ac..b62f4667 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -64,7 +64,7 @@ def exit(app: Shell, args: str): SKILL_COMMAND_PREFIX = "skill:" # Ordered first-token hints for slash commands with fixed subcommands (ghost text + menu). -_THEME_ARGS: tuple[str, ...] = ("current", "doctor", "tokens", "dark", "light", "auto") +_THEME_ARGS: tuple[str, ...] = ("current", "doctor", "tokens", "code", "dark", "light", "auto") def slash_command_arg_suggestions() -> dict[str, tuple[str, ...]]: @@ -1036,6 +1036,73 @@ async def task(app: Shell, args: str): await TaskBrowserApp(soul).run() +async def _theme_code_picker(app: Shell, soul: PythinkerSoul, arg: str) -> None: + """pythinker-x-style syntax theme picker (live preview + persist).""" + from pythinker_code.share import get_share_dir + from pythinker_code.ui.shell.selectors.code_theme import run_code_theme_selector + from pythinker_code.ui.theme import get_tui_tokens as _get_tok_theme + from pythinker_code.utils.rich.syntax import ( + code_themes_match_for_picker, + get_active_code_theme, + list_picker_code_themes, + set_active_code_theme, + ) + + _t = _get_tok_theme() + configured = soul.runtime.config.tui.code_theme + available = list_picker_code_themes(get_share_dir()) + + if arg: + if arg not in available: + console.print( + f"[{_t.error}]Unknown code theme: {_rich_escape(arg)}. " + f"Use `/theme code` to pick from {len(available)} themes.[/]" + ) + return + chosen = arg + else: + saved = get_active_code_theme() + + def _preview(name: str) -> None: + set_active_code_theme(name) + + chosen = await run_code_theme_selector( + current_theme=configured, + available_themes=available, + on_preview=_preview, + theme_matches_current=code_themes_match_for_picker, + ) + if chosen is None: + set_active_code_theme(saved) + return + if chosen == configured or code_themes_match_for_picker(chosen, configured): + set_active_code_theme(saved) + return + + config_file = soul.runtime.config.source_file + if config_file is None: + set_active_code_theme(chosen) + console.print( + f"[{_t.warning}]Cannot persist code theme: no config file. " + f"Using {_rich_escape(chosen)} for this session only. " + f"Restart without --config to save settings.[/]" + ) + return + + try: + config_for_save = load_config(config_file) + config_for_save.tui.code_theme = chosen + save_config(config_for_save, config_file) + except (ConfigError, OSError) as exc: + console.print(f"[{_t.error}]Failed to save config: {_rich_escape(exc)}[/]") + set_active_code_theme(configured) + return + + set_active_code_theme(chosen) + console.print(f"[{_t.success}]Switched code theme to {_rich_escape(chosen)}. Reloading...[/]") + raise Reload(session_id=soul.runtime.session.id) + + @registry.command(aliases=["color"]) @shell_mode_registry.command(aliases=["color"]) async def theme(app: Shell, args: str) -> None: @@ -1058,11 +1125,16 @@ async def theme(app: Shell, args: str) -> None: arg = args.strip().lower() sub, _, rest = arg.partition(" ") - if sub in ("current", "doctor", "tokens"): + if sub in ("current", "doctor", "tokens", "code"): if sub == "current": + from pythinker_code.utils.rich.syntax import get_active_code_theme + console.print( - f"[{_t_theme.info}]Active theme:[/] {get_active_theme()}\n" - f"[{_t_theme.muted}]Configured:[/] {configured}" + f"[{_t_theme.info}]Active UI theme:[/] {get_active_theme()}\n" + f"[{_t_theme.muted}]Configured UI theme:[/] {configured}\n" + f"[{_t_theme.info}]Active code theme:[/] {get_active_code_theme()}\n" + f"[{_t_theme.muted}]Configured code theme:[/] " + f"{soul.runtime.config.tui.code_theme}" ) return if sub == "doctor": @@ -1082,6 +1154,9 @@ async def theme(app: Shell, args: str) -> None: ] console.print("\n".join(lines)) return + if sub == "code": + await _theme_code_picker(app, soul, rest.strip()) + return if not arg: from pythinker_code.ui.shell.selectors.theme import run_theme_selector @@ -1099,7 +1174,7 @@ async def theme(app: Shell, args: str) -> None: if arg not in ("dark", "light", "auto"): console.print( f"[{_t_theme.error}]Unknown theme: {_rich_escape(arg)}. " - f"Use 'dark', 'light', 'auto', 'current', 'doctor', or 'tokens'.[/]" + f"Use 'dark', 'light', 'auto', 'code', 'current', 'doctor', or 'tokens'.[/]" ) return diff --git a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py index d01fa3d0..5bb4ad53 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py +++ b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py @@ -21,6 +21,7 @@ __all__ = [ "as_str", "fg", + "fg_subject", "format_lines_block", "format_numbered_lines_block", "invalid_arg", @@ -63,6 +64,11 @@ def fg(token: str, content: str | Text) -> Text: return Text(sanitize_ansi(content), style=style) +def fg_subject(content: str | Text) -> Text: + """Brand periwinkle highlight for tool-call subjects (paths, patterns, URLs).""" + return fg("accent", content) + + def tool_title(label: str) -> Text: """Bold tool-name title .""" base = tui_rich_style("tool_title") diff --git a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py index 25296282..9e6c912f 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py +++ b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py @@ -17,6 +17,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -83,7 +84,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: children.append(blank_row()) question_text = as_str(q.get("question")) or "" if question_text: - children.append(fg("info", f"{QUESTION_MARKER} {question_text}")) + children.append(fg_subject(f"{QUESTION_MARKER} {question_text}")) opts = q.get("options") if isinstance(opts, list): opts_list = cast("list[Any]", opts) diff --git a/src/pythinker_code/ui/shell/tool_renderers/background.py b/src/pythinker_code/ui/shell/tool_renderers/background.py index 1162778c..25adb6f5 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/background.py +++ b/src/pythinker_code/ui/shell/tool_renderers/background.py @@ -20,6 +20,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -91,10 +92,10 @@ def _render_call_with_id( # id as a dim suffix for traceability. task_label = _resolve_task_label(ctx, task_id) if task_label: - summary.append_text(fg("info", task_label)) + summary.append_text(fg_subject(task_label)) summary.append_text(fg("muted", f" · {task_id}")) else: - summary.append_text(fg("info", task_id)) + summary.append_text(fg_subject(task_id)) for extra in extras: summary.append_text(fg("muted", f" · {extra}")) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" diff --git a/src/pythinker_code/ui/shell/tool_renderers/edit.py b/src/pythinker_code/ui/shell/tool_renderers/edit.py index c48f79fc..0c7e2e0b 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/edit.py +++ b/src/pythinker_code/ui/shell/tool_renderers/edit.py @@ -19,6 +19,8 @@ from rich.text import Text from pythinker_code.ui.shell.components import compute_edit_diff_string +from pythinker_code.ui.shell.components.render_utils import render_message_response +from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -29,11 +31,10 @@ diff_frame, preview_from_result, ) -from pythinker_code.ui.shell.components.render_utils import render_message_response -from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, invalid_arg, missing_required_arg, pending_tool_call_header, @@ -92,7 +93,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg_subject(shorten_path(raw_path, cwd=ctx.cwd))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header("Update", summary, style_token=style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/find.py b/src/pythinker_code/ui/shell/tool_renderers/find.py index f8a1fd02..77aaa432 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/find.py +++ b/src/pythinker_code/ui/shell/tool_renderers/find.py @@ -17,6 +17,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -56,7 +57,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", pattern)) + summary.append_text(fg_subject(pattern)) summary.append_text(fg("tool_output", " in ")) if "directory" in args and raw_dir is None: diff --git a/src/pythinker_code/ui/shell/tool_renderers/grep.py b/src/pythinker_code/ui/shell/tool_renderers/grep.py index 7f09f21a..ef819db3 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/grep.py +++ b/src/pythinker_code/ui/shell/tool_renderers/grep.py @@ -21,6 +21,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -78,7 +79,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", f"/{pattern}/")) + summary.append_text(fg_subject(f"/{pattern}/")) path_display = shorten_path(raw_path or ".", cwd=ctx.cwd) if raw_path is not None else None summary.append_text(fg("tool_output", " in ")) diff --git a/src/pythinker_code/ui/shell/tool_renderers/plan.py b/src/pythinker_code/ui/shell/tool_renderers/plan.py index 7ca834fc..6658a901 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/plan.py +++ b/src/pythinker_code/ui/shell/tool_renderers/plan.py @@ -14,6 +14,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, running_spinner, tool_call_header, @@ -82,7 +83,7 @@ def _render_exit_call(ctx: ToolRenderContext) -> RenderableType: children: list[RenderableType] = [line] for opt in opts[:3]: label = as_str(opt.get("label")) or "?" - children.append(fg("info", f" • {label}")) + children.append(fg_subject(f" • {label}")) rendered = Group(*children) return running_spinner( rendered, execution_started=ctx.execution_started, has_result=ctx.has_result diff --git a/src/pythinker_code/ui/shell/tool_renderers/read.py b/src/pythinker_code/ui/shell/tool_renderers/read.py index 8abdc30a..4a2cb0c4 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/read.py +++ b/src/pythinker_code/ui/shell/tool_renderers/read.py @@ -20,6 +20,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_numbered_lines_block, invalid_arg, missing_required_arg, @@ -74,7 +75,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg_subject(shorten_path(raw_path, cwd=ctx.cwd))) range_text = _format_line_range(args) if range_text is not None: diff --git a/src/pythinker_code/ui/shell/tool_renderers/skill.py b/src/pythinker_code/ui/shell/tool_renderers/skill.py index 6349884e..986ee611 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/skill.py +++ b/src/pythinker_code/ui/shell/tool_renderers/skill.py @@ -18,6 +18,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -46,7 +47,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: has_result=ctx.has_result, ) else: - summary = fg("info", skill_name) + summary = fg_subject(skill_name) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header("Skill", summary, style_token=style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/web.py b/src/pythinker_code/ui/shell/tool_renderers/web.py index f40599bd..318ea221 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/web.py +++ b/src/pythinker_code/ui/shell/tool_renderers/web.py @@ -16,6 +16,7 @@ from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, invalid_arg, missing_required_arg, @@ -60,7 +61,7 @@ def _render_fetch_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", _shorten_url(url))) + summary.append_text(fg_subject(_shorten_url(url))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" line = tool_call_header("Fetch", summary, style_token=style_token) return running_spinner(line, execution_started=ctx.execution_started, has_result=ctx.has_result) @@ -147,7 +148,7 @@ def _render_search_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", f'"{query}"')) + summary.append_text(fg_subject(f'"{query}"')) extras: list[str] = [] if isinstance(limit, int) and limit != 5: extras.append(f"limit {limit}") diff --git a/src/pythinker_code/ui/shell/tool_renderers/write.py b/src/pythinker_code/ui/shell/tool_renderers/write.py index 16e9d236..0480383c 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/write.py +++ b/src/pythinker_code/ui/shell/tool_renderers/write.py @@ -13,6 +13,7 @@ from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.render_constants import expand_hint +from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -24,10 +25,10 @@ diff_frame, preview_from_diff_blocks, ) -from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, fg, + fg_subject, format_lines_block, format_numbered_lines_block, invalid_arg, @@ -59,7 +60,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: line, execution_started=ctx.execution_started, has_result=ctx.has_result ) else: - summary.append_text(fg("info", shorten_path(raw_path, cwd=ctx.cwd))) + summary.append_text(fg_subject(shorten_path(raw_path, cwd=ctx.cwd))) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" line = tool_call_header( diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 9ed97df1..a718b5d5 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -479,7 +479,7 @@ def _compose_composing(self) -> RenderableType: committed = list(self._committed_renderables) if not pending: if committed: - return Group(*committed, spinner) + return Group(*committed, BLANK_ROW, spinner) return spinner preview = self._build_preview( pending, @@ -489,7 +489,7 @@ def _compose_composing(self) -> RenderableType: body = self._render_preview_text(preview, caret=True) preview_row = self._wrap_preview_bullet(body) if committed: - return Group(*committed, spinner, BLANK_ROW, preview_row) + return Group(*committed, BLANK_ROW, spinner, BLANK_ROW, preview_row) return Group(spinner, BLANK_ROW, preview_row) def _render_preview_text(self, preview: str, *, caret: bool) -> Text: @@ -1423,7 +1423,7 @@ def _render(self) -> RenderableType: filled = int(round(progress * self.BAR_WIDTH)) empty = self.BAR_WIDTH - filled pct = int(progress * 100) - accent = tui_rich_style("info") + accent = tui_rich_style("accent") muted = tui_rich_style("muted") subtle = tui_rich_style("dim") title_style = accent + Style(italic=True) diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index 6aac6585..c8458e4d 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -46,16 +46,16 @@ class ToolStyle: _TOOL_STYLES: dict[str, ToolStyle] = { - "Read": ToolStyle("Read", "->", "info"), - "ReadFile": ToolStyle("Read", "->", "info"), - "Grep": ToolStyle("Search", "*", "info"), - "Glob": ToolStyle("Find", "*", "info"), - "Edit": ToolStyle("Edit", "<-", "info"), - "Replace": ToolStyle("Edit", "<-", "info"), - "StrReplaceFile": ToolStyle("Edit", "<-", "info"), - "Write": ToolStyle("Write", "<-", "info"), - "WriteFile": ToolStyle("Write", "<-", "info"), - "ApplyPatch": ToolStyle("Patch", "◆", "info"), + "Read": ToolStyle("Read", "->", "accent"), + "ReadFile": ToolStyle("Read", "->", "accent"), + "Grep": ToolStyle("Search", "*", "accent"), + "Glob": ToolStyle("Find", "*", "accent"), + "Edit": ToolStyle("Edit", "<-", "accent"), + "Replace": ToolStyle("Edit", "<-", "accent"), + "StrReplaceFile": ToolStyle("Edit", "<-", "accent"), + "Write": ToolStyle("Write", "<-", "accent"), + "WriteFile": ToolStyle("Write", "<-", "accent"), + "ApplyPatch": ToolStyle("Patch", "◆", "accent"), "Bash": ToolStyle("Shell", "$", "success"), "Shell": ToolStyle("Shell", "$", "success"), "SetTodoList": ToolStyle("Todo", "☑", "warning"), @@ -65,15 +65,15 @@ class ToolStyle: "Task": ToolStyle("Subagent", TRANSCRIPT_ACTIVE_MARKER, "muted"), "AskUser": ToolStyle("Ask", "?", "warning"), "AskUserQuestion": ToolStyle("Ask", "?", "warning"), - "FetchURL": ToolStyle("Fetch", "%", "info"), - "WebFetch": ToolStyle("Fetch", "%", "info"), - "WebSearch": ToolStyle("Search", "◈", "info"), - "SearchWeb": ToolStyle("Search", "◈", "info"), - "TaskList": ToolStyle("Tasks", "☷", "info"), - "TaskOutput": ToolStyle("TaskOutput", "☷", "info"), + "FetchURL": ToolStyle("Fetch", "%", "accent"), + "WebFetch": ToolStyle("Fetch", "%", "accent"), + "WebSearch": ToolStyle("Search", "◈", "accent"), + "SearchWeb": ToolStyle("Search", "◈", "accent"), + "TaskList": ToolStyle("Tasks", "☷", "accent"), + "TaskOutput": ToolStyle("TaskOutput", "☷", "accent"), "TaskStop": ToolStyle("TaskStop", "■", "warning"), - "ReadSkill": ToolStyle("Skill", "◇", "info"), - "Skill": ToolStyle("Skill", "◇", "info"), + "ReadSkill": ToolStyle("Skill", "◇", "accent"), + "Skill": ToolStyle("Skill", "◇", "accent"), } @@ -107,7 +107,7 @@ def _tool_token_style(token_name: str) -> str: def tool_style(name: str) -> ToolStyle: - style = _TOOL_STYLES.get(name, ToolStyle(name, "⚙", "info")) + style = _TOOL_STYLES.get(name, ToolStyle(name, "⚙", "accent")) return ToolStyle(style.label, style.icon, _tool_token_style(style.style)) @@ -134,7 +134,7 @@ def render_worklog_entry( state: WorkLogState, detail: str | None = None, icon: str = "•", - icon_style: str = "info", + icon_style: str = "accent", icon_renderable: RenderableType | None = None, children: list[RenderableType] | None = None, ) -> RenderableType: diff --git a/src/pythinker_code/ui/theme/adapters/markdown.py b/src/pythinker_code/ui/theme/adapters/markdown.py index 1de68172..01d7e26f 100644 --- a/src/pythinker_code/ui/theme/adapters/markdown.py +++ b/src/pythinker_code/ui/theme/adapters/markdown.py @@ -20,12 +20,12 @@ def markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichSt "markdown.strong": RichStyle(color=colors.strong, bold=True), "markdown.em": RichStyle(color=colors.emphasis, italic=True), "markdown.emph": RichStyle(color=colors.emphasis, italic=True), - "markdown.code": RichStyle(color=colors.inline_code), + "markdown.code": RichStyle(color=colors.inline_code, bold=False), "markdown.link": RichStyle(color=colors.link, underline=True), "markdown.link_url": RichStyle(color=colors.link, underline=True, dim=True), "markdown.block_quote": RichStyle(color=colors.quote, italic=True), "markdown.hr": RichStyle(color=colors.code_block_border), - "markdown.code_block": RichStyle(color=colors.inline_code), + "markdown.code_block": RichStyle(color=colors.inline_code, bold=False), "markdown.code_block.border": RichStyle(color=colors.code_block_border, bold=True), "markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True), "markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True), diff --git a/src/pythinker_code/ui/theme/palettes.py b/src/pythinker_code/ui/theme/palettes.py index 8f04e49f..3b16806f 100644 --- a/src/pythinker_code/ui/theme/palettes.py +++ b/src/pythinker_code/ui/theme/palettes.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .pythinker_themes import DIFF_HEX_DARK, DIFF_HEX_LIGHT from .spec import ( BrandToken, MarkdownAnsiToken, @@ -27,25 +28,25 @@ BrandToken.IRIS: "#AFE3F1", } -# Dark core tokens — refined contrast per design spec §16. +# Dark core tokens — WCAG AA on #141414 (see design spec §16). _CORE_DARK: dict[str, str] = { - "accent": "#AEB7FF", - "border": "#8a8d91", + "accent": "#A9B4FF", + "border": "#9AA3AD", "border_accent": "#7C88DE", - "border_muted": "#b8bcc0", + "border_muted": "#5D6570", "info": "#8FDDEA", "success": "#7CCF8A", - "error": "#F87171", - "warning": "#EAB85F", - "muted": "#8A8A8A", - "dim": "#6F6F6F", - "text": "", + "error": "#FF7A7A", + "warning": "#FFD166", + "muted": "#8F969E", + "dim": "#6F767E", + "text": "#D7DBDF", "thinking_text": "#D4D4D4", - "activity_label": "#F4F4F5", + "activity_label": "#F1F3F5", "activity_verb": "#C68D7E", "activity_verb_mid": "#D8AC9E", "activity_verb_highlight": "#E9CDC2", - "activity_spinner": "#B8C0CC", + "activity_spinner": "#A8ADB4", "selected_bg": SELECTED_BG_DARK, "user_message_bg": "#333333", "user_message_text": "", @@ -54,8 +55,8 @@ "custom_message_label": "#8FDDEA", "tool_pending_bg": "#1B2230", "tool_error_bg": "#2E1D24", - "tool_title": "#F4F4F5", - "tool_output": "#D7D7DB", + "tool_title": "#F1F3F5", + "tool_output": "#D7DBDF", "tool_diff_added": "#81C784", "tool_diff_removed": "#E57373", "tool_diff_context": "", @@ -153,21 +154,6 @@ "max": "#7f1d1d", } -_DIFF_HEX_DARK = { - "add_bg": "#052e05", - "del_bg": "#3a0808", - "add_hl": "#0e5a0e", - "del_hl": "#6b1414", -} - -_DIFF_HEX_LIGHT = { - "add_bg": "#dafbe1", - "del_bg": "#ffebe9", - "add_hl": "#aff5b4", - "del_hl": "#ffc1c0", -} - - def _tokens_from_core(core: dict[str, str]) -> TuiTokens: return TuiTokens(**core) @@ -345,7 +331,7 @@ def build_theme_spec(mode: ThemeMode) -> ThemeSpec: mcp=_mcp(mode, tokens), brand=dict(BRAND), markdown_ansi=dict(_MARKDOWN_ANSI), - diff_hex=_DIFF_HEX_DARK if mode is ThemeMode.DARK else _DIFF_HEX_LIGHT, + diff_hex=DIFF_HEX_DARK if mode is ThemeMode.DARK else DIFF_HEX_LIGHT, thinking_frame=dict(_THINKING_FRAME_SCALE), ) diff --git a/src/pythinker_code/ui/theme/pythinker_themes.py b/src/pythinker_code/ui/theme/pythinker_themes.py new file mode 100644 index 00000000..3ea62893 --- /dev/null +++ b/src/pythinker_code/ui/theme/pythinker_themes.py @@ -0,0 +1,131 @@ +"""pythinker-x (TUI) theme constants — ported verbatim where possible. +""" + +from __future__ import annotations + +from pathlib import Path + +from pythinker_code.ui.color_utils import blend, to_hex_color + +# highlight.rs BUILTIN_THEME_NAMES (32 bundled syntax themes, sorted). +BUNDLED_SYNTAX_THEME_NAMES: tuple[str, ...] = ( + "1337", + "ansi", + "base16", + "base16-256", + "base16-eighties-dark", + "base16-mocha-dark", + "base16-ocean-dark", + "base16-ocean-light", + "catppuccin-frappe", + "catppuccin-latte", + "catppuccin-macchiato", + "catppuccin-mocha", + "coldark-cold", + "coldark-dark", + "dark-neon", + "dracula", + "github", + "gruvbox-dark", + "gruvbox-light", + "inspired-github", + "monokai-extended", + "monokai-extended-bright", + "monokai-extended-light", + "monokai-extended-origin", + "nord", + "one-half-dark", + "one-half-light", + "solarized-dark", + "solarized-light", + "sublime-snazzy", + "two-dark", + "zenburn", +) + +# diff_render.rs truecolor palette (GitHub-style light, muted dark tints). +DIFF_HEX_DARK: dict[str, str] = { + "add_bg": "#213A2B", + "del_bg": "#4A221D", + "add_hl": "#2E6B4A", + "del_hl": "#6B3430", +} + +DIFF_HEX_LIGHT: dict[str, str] = { + "add_bg": "#dafbe1", + "del_bg": "#ffebe9", + "add_hl": "#aceebb", + "del_hl": "#ffcecb", +} + +# style.rs adaptive accent + user-message blend parameters. +LIGHT_BG_ACCENT_RGB = (0, 95, 135) +USER_MESSAGE_BLEND_LIGHT = ((0, 0, 0), 0.04) +USER_MESSAGE_BLEND_DARK = ((255, 255, 255), 0.12) +TABLE_SEPARATOR_FG_ALPHA = 0.20 + +# ponytail: Pygments lacks two_face's bat themes; map bundled names to closest stock styles. +PYGMENTS_THEME_ALIASES: dict[str, str] = { + "1337": "monokai", + "ansi": "pythinker-ansi", + "base16": "default", + "base16-256": "default", + "base16-eighties-dark": "default", + "base16-mocha-dark": "default", + "base16-ocean-dark": "default", + "base16-ocean-light": "default", + "catppuccin-frappe": "catppuccin-frappe", + "catppuccin-latte": "catppuccin-latte", + "catppuccin-macchiato": "catppuccin-macchiato", + "catppuccin-mocha": "catppuccin-mocha", + "coldark-cold": "default", + "coldark-dark": "default", + "dark-neon": "dracula", + "dracula": "dracula", + "github": "github-dark", + "gruvbox-dark": "gruvbox-dark", + "gruvbox-light": "gruvbox-light", + "inspired-github": "github-dark", + "monokai-extended": "monokai", + "monokai-extended-bright": "monokai", + "monokai-extended-light": "monokai", + "monokai-extended-origin": "monokai", + "nord": "nord", + "one-half-dark": "native", + "one-half-light": "native", + "solarized-dark": "solarized-dark", + "solarized-light": "solarized-light", + "sublime-snazzy": "monokai", + "two-dark": "monokai", + "zenburn": "zenburn", +} + + +def user_message_bg_for_terminal(bg_rgb: tuple[int, int, int]) -> str: + """Terminal-adaptive user bubble bg (style.rs ``user_message_bg``).""" + from pythinker_code.ui.color_utils import is_light + + top, alpha = USER_MESSAGE_BLEND_LIGHT if is_light(bg_rgb) else USER_MESSAGE_BLEND_DARK + return to_hex_color(blend(top, bg_rgb, alpha)) + + +def discover_custom_syntax_themes(share_dir: Path | None) -> list[str]: + """Custom ``.tmTheme`` stems under ``{share_dir}/themes/`` (picker listing only).""" + if share_dir is None: + return [] + themes_dir = share_dir / "themes" + if not themes_dir.is_dir(): + return [] + names: list[str] = [] + for path in sorted(themes_dir.glob("*.tmTheme")): + stem = path.stem + if stem and stem not in BUNDLED_SYNTAX_THEME_NAMES: + names.append(stem) + return names + + +def list_syntax_theme_names(share_dir: Path | None = None) -> list[str]: + """Bundled + custom theme names, sorted case-insensitively like pythinker-x.""" + custom = discover_custom_syntax_themes(share_dir) + merged = sorted(set(BUNDLED_SYNTAX_THEME_NAMES) | set(custom), key=str.casefold) + return merged diff --git a/src/pythinker_code/ui/theme/resolver.py b/src/pythinker_code/ui/theme/resolver.py index 3dfb94ec..a1f07eec 100644 --- a/src/pythinker_code/ui/theme/resolver.py +++ b/src/pythinker_code/ui/theme/resolver.py @@ -92,8 +92,9 @@ def ptk_fg(self, token: PromptToken | CoreToken) -> str: return f"fg:{color}" if color else "" def markdown_inline_code_style(self) -> RichStyle: - """Inline code: color only — headers stay bold elsewhere.""" - return self.rich_style(CoreToken.ACCENT) + """Inline code: accent color only — no bold (headers stay bold elsewhere).""" + style = self.rich_style(CoreToken.ACCENT) + return RichStyle(color=style.color, bold=False) def markdown_heading_style(self, *, level: int = 1) -> RichStyle: style = self.rich_style(CoreToken.TOOL_TITLE, bold=True) diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index e06edad0..87acadb9 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -41,7 +41,7 @@ "markdown.h4": Style(bold=True), "markdown.h5": Style(bold=True), "markdown.h6": Style(dim=True, italic=True), - "markdown.code": Style(color="bright_cyan", bold=True), + "markdown.code": Style(color="bright_cyan"), "markdown.code_block": Style(color="bright_cyan"), "markdown.item": Style(), "markdown.item.bullet": Style(), @@ -641,12 +641,10 @@ def enter_style(self, style_name: str | Style) -> Style: style = self.console.get_style(style_name, default=fallback) style = fallback + style style = style.copy() - if ( - isinstance(style_name, str) - and style_name in {"markdown.code", "markdown.code_block"} - and style._bgcolor is not None - ): - style._bgcolor = None + if isinstance(style_name, str) and style_name in {"markdown.code", "markdown.code_block"}: + if style.bgcolor is not None: + style = style + Style(bgcolor=None) + style = style + Style(bold=False) self.style_stack.push(style) return self.current_style diff --git a/src/pythinker_code/utils/rich/syntax.py b/src/pythinker_code/utils/rich/syntax.py index ed8fa93b..ed729cb5 100644 --- a/src/pythinker_code/utils/rich/syntax.py +++ b/src/pythinker_code/utils/rich/syntax.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from pygments.style import Style as PygmentsStyle @@ -91,6 +92,8 @@ CATPPUCCIN_ADAPTIVE_THEME_NAME = "catppuccin-adaptive" CATPPUCCIN_MOCHA_THEME_NAME = "catppuccin-mocha" CATPPUCCIN_LATTE_THEME_NAME = "catppuccin-latte" +CATPPUCCIN_FRAPPE_THEME_NAME = "catppuccin-frappe" +CATPPUCCIN_MACCHIATO_THEME_NAME = "catppuccin-macchiato" # Official palettes (catppuccin.com/palette). _CATPPUCCIN_MOCHA = { @@ -123,6 +126,36 @@ "blue": "#1e66f5", "pink": "#ea76cb", } +_CATPPUCCIN_FRAPPE = { + "base": "#303446", + "text": "#c6d0f5", + "overlay0": "#737994", + "overlay2": "#949cbb", + "mauve": "#ca9ee6", + "red": "#e78284", + "peach": "#ef9f76", + "yellow": "#e5c890", + "green": "#a6d189", + "teal": "#81c8be", + "sky": "#99d1db", + "blue": "#8caaee", + "pink": "#f4b8e4", +} +_CATPPUCCIN_MACCHIATO = { + "base": "#24273a", + "text": "#cad3f5", + "overlay0": "#6e738d", + "overlay2": "#939ab7", + "mauve": "#c6a0f6", + "red": "#ed8796", + "peach": "#f5a97f", + "yellow": "#eed49f", + "green": "#a6da95", + "teal": "#8bd5ca", + "sky": "#91d7e3", + "blue": "#8aadf4", + "pink": "#f5bde6", +} def _catppuccin_styles(p: dict[str, str]) -> dict[Any, str]: @@ -192,8 +225,29 @@ class CatppuccinLatteStyle(PygmentsStyle): styles = _catppuccin_styles(_CATPPUCCIN_LATTE) +class CatppuccinFrappeStyle(PygmentsStyle): + name = "catppuccin-frappe" + background_color = _CATPPUCCIN_FRAPPE["base"] + styles = _catppuccin_styles(_CATPPUCCIN_FRAPPE) + + +class CatppuccinMacchiatoStyle(PygmentsStyle): + name = "catppuccin-macchiato" + background_color = _CATPPUCCIN_MACCHIATO["base"] + styles = _catppuccin_styles(_CATPPUCCIN_MACCHIATO) + + CATPPUCCIN_MOCHA_THEME = PygmentsSyntaxTheme(CatppuccinMochaStyle) CATPPUCCIN_LATTE_THEME = PygmentsSyntaxTheme(CatppuccinLatteStyle) +CATPPUCCIN_FRAPPE_THEME = PygmentsSyntaxTheme(CatppuccinFrappeStyle) +CATPPUCCIN_MACCHIATO_THEME = PygmentsSyntaxTheme(CatppuccinMacchiatoStyle) + +_BUILTIN_CATPPUCCIN_THEMES: dict[str, PygmentsSyntaxTheme] = { + CATPPUCCIN_MOCHA_THEME_NAME: CATPPUCCIN_MOCHA_THEME, + CATPPUCCIN_LATTE_THEME_NAME: CATPPUCCIN_LATTE_THEME, + CATPPUCCIN_FRAPPE_THEME_NAME: CATPPUCCIN_FRAPPE_THEME, + CATPPUCCIN_MACCHIATO_THEME_NAME: CATPPUCCIN_MACCHIATO_THEME, +} def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme: @@ -210,29 +264,54 @@ def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme: if get_active_theme() == "light": return CATPPUCCIN_LATTE_THEME return CATPPUCCIN_MOCHA_THEME - if name == CATPPUCCIN_MOCHA_THEME_NAME: - return CATPPUCCIN_MOCHA_THEME - if name == CATPPUCCIN_LATTE_THEME_NAME: - return CATPPUCCIN_LATTE_THEME + if name in _BUILTIN_CATPPUCCIN_THEMES: + return _BUILTIN_CATPPUCCIN_THEMES[name] + from pythinker_code.ui.theme.pythinker_themes import PYGMENTS_THEME_ALIASES + + alias = PYGMENTS_THEME_ALIASES.get(name, name) + if alias == PYTHINKER_ANSI_THEME_NAME: + return PYTHINKER_ANSI_THEME + if alias in _BUILTIN_CATPPUCCIN_THEMES: + return _BUILTIN_CATPPUCCIN_THEMES[alias] + return alias return theme def available_code_themes() -> list[str]: - """Accepted ``code_theme`` values: the Catppuccin + ANSI sentinels plus every - stock Pygments style. - - Imported lazily so the (modest) Pygments style enumeration cost is only paid - when a config value is validated, not on every ``syntax`` import. - """ + """Accepted ``code_theme`` values: pythinker-x bundled names, sentinels, custom, Pygments.""" from pygments.styles import get_all_styles - return [ + from pythinker_code.ui.theme.pythinker_themes import list_syntax_theme_names + + bundled = list_syntax_theme_names() + extras = [ CATPPUCCIN_ADAPTIVE_THEME_NAME, - CATPPUCCIN_MOCHA_THEME_NAME, - CATPPUCCIN_LATTE_THEME_NAME, PYTHINKER_ANSI_THEME_NAME, *sorted(get_all_styles()), ] + merged = sorted(set(bundled) | set(extras), key=str.casefold) + return merged + + +def list_picker_code_themes(share_dir: Path | None = None) -> list[str]: + """Themes shown in ``/theme code``: validator union plus share-dir ``.tmTheme`` files.""" + from pythinker_code.ui.theme.pythinker_themes import discover_custom_syntax_themes + + custom = discover_custom_syntax_themes(share_dir) + return sorted(set(available_code_themes()) | set(custom), key=str.casefold) + + +def code_themes_match_for_picker(theme: str, configured: str) -> bool: + """Whether *theme* should appear selected for a configured ``code_theme`` value.""" + if theme.casefold() == configured.casefold(): + return True + resolved_theme = resolve_code_theme(theme) + resolved_configured = resolve_code_theme(configured) + if isinstance(resolved_theme, str) and isinstance(resolved_configured, str): + return resolved_theme.casefold() == resolved_configured.casefold() + if not isinstance(resolved_theme, str) and not isinstance(resolved_configured, str): + return type(resolved_theme) is type(resolved_configured) + return False # Process-wide default code-fence theme, resolved once at shell startup from diff --git a/tests/ui_and_conv/test_live_view_todos.py b/tests/ui_and_conv/test_live_view_todos.py index 2897667a..2564b7c6 100644 --- a/tests/ui_and_conv/test_live_view_todos.py +++ b/tests/ui_and_conv/test_live_view_todos.py @@ -230,11 +230,11 @@ def test_active_pinned_todo_row_uses_neutral_title_not_shimmer() -> None: elapsed_s=0.88, ) - # Reference design: coral box; the title is bold in the terminal's - # default text color (white on dark) — no color override. + # Reference design: coral box; title uses explicit primary text on dark. coral = _color_hex(tui_rich_style("activity_verb").color) + primary = _color_hex(tui_rich_style("text").color) assert _span_colors_for(row, "■") == {coral} - assert _span_colors_for(row, "Implement pinned todos") == set() + assert _span_colors_for(row, "Implement pinned todos") == {primary} def test_secondary_in_progress_todo_rows_use_light_grey() -> None: @@ -248,11 +248,11 @@ def test_secondary_in_progress_todo_rows_use_light_grey() -> None: elapsed_s=0.88, ) - # Every in-progress row shares the same design: coral box, bold - # default-color (white) title. + # Every in-progress row shares the same design: coral box, primary text title. coral = _color_hex(tui_rich_style("activity_verb").color) + primary = _color_hex(tui_rich_style("text").color) assert _span_colors_for(row, "■") == {coral} - assert _span_colors_for(row, "Deep code review on diff") == set() + assert _span_colors_for(row, "Deep code review on diff") == {primary} def test_pinned_todo_rows_align_icons_and_titles() -> None: diff --git a/tests/ui_and_conv/test_pythinker_themes_port.py b/tests/ui_and_conv/test_pythinker_themes_port.py new file mode 100644 index 00000000..5859d45e --- /dev/null +++ b/tests/ui_and_conv/test_pythinker_themes_port.py @@ -0,0 +1,67 @@ +"""pythinker-x theme port contract tests.""" + +from __future__ import annotations + +from pythinker_code.ui.theme.palettes import THEME_SPECS +from pythinker_code.ui.theme.pythinker_themes import ( + BUNDLED_SYNTAX_THEME_NAMES, + DIFF_HEX_DARK, + DIFF_HEX_LIGHT, + PYGMENTS_THEME_ALIASES, + list_syntax_theme_names, +) +from pythinker_code.ui.theme.spec import ThemeMode +from pythinker_code.utils.rich.syntax import ( + available_code_themes, + code_themes_match_for_picker, + list_picker_code_themes, + resolve_code_theme, +) + + +def test_bundled_syntax_theme_count_matches_pythinker_x(): + assert len(BUNDLED_SYNTAX_THEME_NAMES) == 32 + + +def test_diff_hex_matches_pythinker_x(): + assert THEME_SPECS[ThemeMode.DARK].diff_hex == DIFF_HEX_DARK + assert THEME_SPECS[ThemeMode.LIGHT].diff_hex == DIFF_HEX_LIGHT + assert DIFF_HEX_DARK["add_bg"] == "#213A2B" + assert DIFF_HEX_DARK["del_bg"] == "#4A221D" + assert DIFF_HEX_LIGHT["add_hl"] == "#aceebb" + + +def test_all_bundled_themes_accepted_by_config_validator(): + allowed = set(available_code_themes()) + for name in BUNDLED_SYNTAX_THEME_NAMES: + assert name in allowed + + +def test_resolve_code_theme_maps_bundled_names(): + assert resolve_code_theme("dracula") == "dracula" + assert resolve_code_theme("ansi") != "ansi" + assert resolve_code_theme("github") == "github-dark" + + +def test_pygments_aliases_cover_all_bundled_names(): + for name in BUNDLED_SYNTAX_THEME_NAMES: + assert name in PYGMENTS_THEME_ALIASES + + +def test_list_syntax_theme_names_sorted(): + names = list_syntax_theme_names() + assert names == sorted(names, key=str.casefold) + + +def test_list_picker_code_themes_includes_pygments_and_bundled(): + picker = list_picker_code_themes() + assert "monokai" in picker + assert "github-dark" in picker + assert BUNDLED_SYNTAX_THEME_NAMES[0] in picker + assert len(picker) > len(BUNDLED_SYNTAX_THEME_NAMES) + + +def test_code_themes_match_for_picker_resolves_aliases(): + assert code_themes_match_for_picker("github", "github") + assert code_themes_match_for_picker("github-dark", "github") + assert not code_themes_match_for_picker("dracula", "github") diff --git a/tests/ui_and_conv/test_shell_panel.py b/tests/ui_and_conv/test_shell_panel.py index 367be269..3de2ce8f 100644 --- a/tests/ui_and_conv/test_shell_panel.py +++ b/tests/ui_and_conv/test_shell_panel.py @@ -9,7 +9,7 @@ def test_brand_panel_is_rounded_and_uses_border_token(): p = brand_panel("hello", title="Demo") assert p.box is box.ROUNDED # border style resolves to the mid grey border token - assert "#8a8d91" in str(p.border_style).lower() + assert "#9aa3ad" in str(p.border_style).lower() def test_brand_panel_active_uses_accent_border(): diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index adffa6a9..1c907ea2 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -28,12 +28,21 @@ def test_directory_label_uses_info_token(): assert get_tui_tokens("dark").info in style -def test_branch_label_uses_muted_warning_yellow(): +def test_branch_label_uses_light_grey(): from pythinker_code.ui.shell import WelcomeInfoItem, _value_style_for_label from pythinker_code.ui.theme import get_tui_tokens, set_active_theme set_active_theme("dark") style = _value_style_for_label("Branch", WelcomeInfoItem.Level.INFO) + assert style == get_tui_tokens("dark").thinking_text + + +def test_model_label_uses_muted_warning_yellow(): + from pythinker_code.ui.shell import WelcomeInfoItem, _value_style_for_label + from pythinker_code.ui.theme import get_tui_tokens, set_active_theme + + set_active_theme("dark") + style = _value_style_for_label("Model", WelcomeInfoItem.Level.INFO) assert style == get_tui_tokens("dark").warning diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 2d92f8ce..3d133394 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -286,6 +286,22 @@ def test_assert_blank_line_after_activity_reports_missing_following_line() -> No _assert_blank_line_after_activity("Composing\n", "Composing") +def test_composing_committed_prose_has_gap_before_spinner() -> None: + """Staged paragraphs must not run flush into the Composing activity line.""" + block = _ContentBlock(is_think=False) + block.append("First paragraph here.\n\nSecond paragraph here.\n\n") + block.append("Third still streaming") + assert block._committed_renderables + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + lines = [line.rstrip() for line in console.export_text().splitlines()] + first_idx = next(i for i, line in enumerate(lines) if "First paragraph" in line) + composing_idx = next(i for i, line in enumerate(lines) if "Composing" in line) + assert composing_idx > first_idx + assert composing_idx - first_idx >= 2 + assert any(lines[j] == "" for j in range(first_idx + 1, composing_idx)) + + def test_composing_preview_has_standard_gap_after_activity_line(monkeypatch): from pythinker_code.ui.shell.visualize import _blocks as blocks_module diff --git a/tests/ui_and_conv/test_theme.py b/tests/ui_and_conv/test_theme.py index 180f42e7..9ab3fff3 100644 --- a/tests/ui_and_conv/test_theme.py +++ b/tests/ui_and_conv/test_theme.py @@ -73,12 +73,12 @@ def test_set_and_get_active_theme(): @pytest.mark.parametrize( ("theme", "expected_add_bg_fragment"), - [("dark", "#052e05"), ("light", "#dafbe1")], + [("dark", "#213A2B"), ("light", "#dafbe1")], ) def test_diff_colors_by_theme(theme: str, expected_add_bg_fragment: str): set_active_theme(theme) # type: ignore[arg-type] colors = get_diff_colors() - assert expected_add_bg_fragment in str(colors.add_bg) + assert expected_add_bg_fragment.lower() in str(colors.add_bg).lower() def test_all_getters_respond_to_theme_switch(): diff --git a/tests/ui_and_conv/test_theme_contract.py b/tests/ui_and_conv/test_theme_contract.py index 6d7fb829..f94f3e7e 100644 --- a/tests/ui_and_conv/test_theme_contract.py +++ b/tests/ui_and_conv/test_theme_contract.py @@ -50,7 +50,7 @@ def test_prompt_styles_derive_from_theme_spec(): def test_no_bold_inline_code(): style = markdown_style_overrides("dark")["markdown.code"] - assert style.bold is not True + assert style.bold is False def test_inline_code_uses_accent_not_info(): @@ -83,5 +83,5 @@ def test_resolver_heading_bold_inline_not_bold(): heading = resolver.markdown_heading_style(level=1) inline = resolver.markdown_inline_code_style() assert heading.bold is True - assert inline.bold is not True + assert inline.bold is False assert inline.color == RichStyle(color=get_tui_tokens("dark").accent).color diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index dece1ea1..58ba5be7 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -240,7 +240,7 @@ def test_write_existing_file_renders_diff_for_add_only_change(): ) assert "Added 1 line" in rendered - assert " 2 +new section" in rendered + assert " 2 + new section" in rendered assert "Wrote 2 lines" not in rendered @@ -315,8 +315,8 @@ def test_edit_renders_inline_diff(): assert "Added 1 line" in rendered assert "return 1" in rendered assert "return 2" in rendered - assert " 1 -return 1" in rendered - assert " 1 +return 2" in rendered + assert " 1 - return 1" in rendered + assert " 1 + return 2" in rendered def test_edit_multi_count_in_header(): @@ -360,8 +360,8 @@ def test_edit_prefers_structured_result_diff_blocks(): rendered = render_plain(comp.render(), width=100) assert "removed 1 line" in rendered assert "Added 1 line" in rendered - assert "41 -old" in rendered - assert "41 +new" in rendered + assert "41 - old" in rendered + assert "41 + new" in rendered def test_summary_diff_blocks_count_each_line(): @@ -753,6 +753,17 @@ def test_render_diff_colorizes_added_removed(): assert "world" in plain +def test_render_diff_spaces_marker_before_at_rule(): + old = "@keyframes drawer-fade-in { from { opacity: 0; } to { opacity: 1); } }\n" + new = "@keyframes drawer-fade-in { from { opacity: 0; } to { opacity: 1; } }\n" + diff = compute_edit_diff_string(old, new).diff + plain = render_plain(render_diff(diff), width=120) + assert " - @keyframes" in plain + assert " + @keyframes" in plain + assert "-@" not in plain + assert "+@" not in plain + + def test_render_diff_signs_match_body_foreground(): """+/- markers and line numbers use default fg on tinted rows, not green/red.""" from pythinker_code.ui.theme import get_diff_colors, set_active_theme, tui_rich_style diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index 35c9884f..4a21d871 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -40,19 +40,25 @@ def _restore_active_theme(): def test_dark_tokens_have_brand_values(): set_active_theme("dark") t = get_tui_tokens() - assert t.accent == "#AEB7FF" + assert t.accent == "#A9B4FF" assert t.border_accent == "#7C88DE" # accent-family chrome (active borders) - assert t.border == "#8a8d91" # mid grey + assert t.border == "#9AA3AD" + assert t.border_muted == "#5D6570" + assert t.muted == "#8F969E" + assert t.dim == "#6F767E" + assert t.text == "#D7DBDF" assert t.info == "#8FDDEA" assert t.success == "#7CCF8A" - assert t.error == "#F87171" + assert t.error == "#FF7A7A" + assert t.warning == "#FFD166" assert t.thinking_text == "#D4D4D4" # light neutral grey, not purple-tinted muted assert t.thinking_text != t.muted assert t.activity_verb == "#C68D7E" # muted clay-coral resting assert t.activity_verb_mid == "#D8AC9E" # soft coral assert t.activity_verb_highlight == "#E9CDC2" # calm coral spark - assert t.activity_spinner == "#B8C0CC" - assert t.tool_title == t.activity_label + assert t.activity_spinner == "#A8ADB4" + assert t.tool_title == t.activity_label == "#F1F3F5" + assert t.tool_output == "#D7DBDF" assert t.tool_pending_bg == "#1B2230" assert t.tool_error_bg == "#2E1D24" @@ -81,10 +87,8 @@ def test_get_tui_tokens_with_explicit_theme_arg(): assert light.tool_pending_bg == "#EFE7E8" -def test_text_token_is_empty_string_for_terminal_default(): - # Dark theme: empty string = use terminal's default fg color. - # Light theme uses an explicit navy text color (#213853). - assert get_tui_tokens("dark").text == "" +def test_text_token_is_explicit_primary_on_dark(): + assert get_tui_tokens("dark").text == "#D7DBDF" def test_selected_bg_reharmonized_and_drives_prompt_selection(): @@ -143,11 +147,10 @@ def test_tui_rich_style_fg_token_produces_color(): assert style.bgcolor is None -def test_tui_rich_style_empty_token_produces_empty_style(): - # text="" means terminal default — should not set color or bgcolor. +def test_tui_rich_style_text_token_produces_primary_color(): set_active_theme("dark") style = tui_rich_style("text") - assert style.color is None + assert style.color is not None assert style.bgcolor is None @@ -158,14 +161,14 @@ def test_tui_rich_style_unknown_token_raises(): def test_dark_markdown_uses_professional_report_roles(): colors = get_markdown_colors("dark") - assert colors.heading == "#F4F4F5" # primary white, not coral/orange - assert colors.strong == "#F4F4F5" - assert colors.emphasis == "#8A8A8A" # neutral UI grey (refined muted contrast) - assert colors.inline_code == "#AEB7FF" + assert colors.heading == "#F1F3F5" # primary white, not coral/orange + assert colors.strong == "#F1F3F5" + assert colors.emphasis == "#8F969E" # WCAG-safe muted metadata grey + assert colors.inline_code == "#A9B4FF" assert colors.link == "bright_blue" assert colors.spinner_active == "#8FDDEA" assert colors.spinner_done == "#7CCF8A" - assert colors.spinner_failed == "#F87171" + assert colors.spinner_failed == "#FF7A7A" assert markdown_rich_style("link", theme="dark").color is not None From 9081f3fac4a2ec414d147755227661b924e64c3b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 16:17:39 -0400 Subject: [PATCH 06/26] feat(tui): report panel polish, secondary token, LSP subagent guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report rendering: - Add `pythinker_report_markdown()` + `report_markdown_style_overrides()` so only H1 headings render bold inside report panels; all other roles drop bold weight - Replace `### section` headings with `# title` routed through report markdown so section headers are bold while body prose stays regular - Add `REPORT_FILE_MARKER` glyph (⌁ / +) for finding location rows; use a grid table to keep long paths indented consistently - Add `secondary` CoreToken and `TuiTokens.secondary` field (dark #AAB0B6, light #8A93A0) for scope/note text that needs less emphasis than `muted` - Replace `Rule(severity …)` section separators with `_render_section_header()` (title line + plain rule below) for cleaner visual hierarchy - Pill-background summary line: severity dots and counts share `tool_pending_bg` background for compact badge appearance - Correct `PROMPT_GLYPH` dark hex to #F1F3F5 (was left behind when activity_label was updated in b9662b7) Worklog / blocks: - `_tool_token_style` now raises `ValueError` on unknown token names instead of silently falling back to `info`; all reachable style values are valid TUI tokens - Insert `BLANK_ROW` between the collapsed card and its sub-tool activity group Selector: - Extract `_default_match` typed nested function in `code_theme.py` to fix pyright `reportUnknownLambdaType` on the prior lambda LSP gaps (from lsp-code-scan-analysis report): - Add `test_lsp_provider_registered_in_subagent_soul` guard test: asserts `LspDiagnosticsInjectionProvider` is in `_injection_providers` and `rearm_injection` is wired for subagent-role `PythinkerSoul` instances - Update blackbox port-status ledger: LSP row promoted from future-approved-only to done (d936b32, 63 tests, PLIP-10) --- .../ui/shell/components/markdown.py | 18 +++- .../ui/shell/components/report.py | 89 +++++++++++++------ src/pythinker_code/ui/shell/glyphs.py | 3 + .../ui/shell/selectors/code_theme.py | 5 +- .../ui/shell/visualize/_blocks.py | 2 +- .../ui/shell/visualize/_worklog.py | 5 +- .../ui/theme/adapters/markdown.py | 19 ++++ src/pythinker_code/ui/theme/palettes.py | 5 +- .../ui/theme/pythinker_themes.py | 3 +- src/pythinker_code/ui/theme/spec.py | 2 + tasks/blackbox-port-status.md | 2 +- tests/tools/test_lsp_diagnostics.py | 17 ++++ tests/ui_and_conv/test_report.py | 11 +++ .../test_tui_blocks_integration.py | 38 ++++++++ tests/ui_and_conv/test_tui_theme_tokens.py | 1 + 15 files changed, 183 insertions(+), 37 deletions(-) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 2a920f57..a268bdde 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -792,7 +792,8 @@ class PythinkerMarkdown(Markdown): "table_open": _ReportTableElement, } - def __init__(self, markup: str, *args: Any, **kwargs: Any) -> None: + def __init__(self, markup: str, *args: Any, report: bool = False, **kwargs: Any) -> None: + self._report_mode = report safe_markup = sanitize_ansi(markup) unwrapped_markup = _unwrap_fenced_markdown_tables(safe_markup) repaired_markup = _repair_crammed_markdown_tables(unwrapped_markup) @@ -801,7 +802,13 @@ def __init__(self, markup: str, *args: Any, **kwargs: Any) -> None: super().__init__(_simplify_markdown_report_icons(loosened_markup), *args, **kwargs) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - overrides = _markdown_style_overrides() + from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides + + overrides = ( + report_markdown_style_overrides() + if self._report_mode + else _markdown_style_overrides() + ) with console.use_theme(Theme(overrides, inherit=True)): yield from super().__rich_console__(console, options) @@ -814,6 +821,13 @@ def pythinker_markdown(text: str, *, code_theme: str | None = None) -> Pythinker return PythinkerMarkdown(text, code_theme=code_theme) +def pythinker_report_markdown( + text: str, *, code_theme: str | None = None, style: str | RichStyle = "none" +) -> PythinkerMarkdown: + """Report-body markdown: only H1 headings render bold; everything else is regular weight.""" + return PythinkerMarkdown(text, code_theme=code_theme, style=style, report=True) + + # --------------------------------------------------------------------------- # Streaming boundary helper # --------------------------------------------------------------------------- diff --git a/src/pythinker_code/ui/shell/components/report.py b/src/pythinker_code/ui/shell/components/report.py index c732528d..52eb4487 100644 --- a/src/pythinker_code/ui/shell/components/report.py +++ b/src/pythinker_code/ui/shell/components/report.py @@ -34,9 +34,10 @@ from rich.table import Table from rich.text import Text -from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown, pythinker_markdown +from pythinker_code.ui.shell.components.markdown import pythinker_markdown, pythinker_report_markdown +from pythinker_code.ui.shell.glyphs import REPORT_FILE_MARKER from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING -from pythinker_code.ui.theme import ThemeName, tui_rich_style +from pythinker_code.ui.theme import ThemeName, get_tui_tokens, tui_rich_style _log = logging.getLogger(__name__) @@ -56,10 +57,10 @@ _SEVERITY_ORDER: tuple[Severity, ...] = get_args(Severity) _SEVERITY_SET = frozenset(_SEVERITY_ORDER) -# severity -> (token name, bold). Muted theme tokens only; critical is the one -# emphasis (bold) so the eye lands on it without a brighter colour. +# severity -> (token name, bold). Report panels keep body text regular weight; +# only the panel title (H1 equivalent) uses bold. _SEVERITY_TOKEN: dict[Severity, tuple[str, bool]] = { - "critical": ("error", True), + "critical": ("error", False), "high": ("error", False), "medium": ("warning", False), "low": ("accent", False), @@ -251,19 +252,19 @@ def _render_report_prose(text: str, *, theme: ThemeName | None = None) -> Render rows: list[RenderableType] = [] if report.preamble.strip(): - rows.append(pythinker_markdown(report.preamble)) + rows.append(pythinker_report_markdown(report.preamble)) body_style = tui_rich_style("text", theme=theme) for section in report.sections: if rows: rows.append(Text("")) - # Use a lower-level Markdown heading so inline code / links inside labels - # keep the standard muted-blue highlight without promoting every report - # subsection to the muted-yellow H1 treatment. - rows.append(PythinkerMarkdown(f"### {section.title}")) + rows.append(pythinker_report_markdown(f"# {section.title}")) if section.body.strip(): rows.append( - Padding(PythinkerMarkdown(section.body.strip(), style=body_style), (0, 0, 0, 2)) + Padding( + pythinker_report_markdown(section.body.strip(), style=body_style), + (0, 0, 0, 2), + ) ) return Group(*rows) @@ -282,24 +283,50 @@ def _severity_style(severity: Severity, theme: ThemeName | None) -> RichStyle: return style + RichStyle(bold=True) if bold else style +def _strong_style(theme: ThemeName | None) -> RichStyle: + return tui_rich_style("tool_title", theme=theme) + RichStyle(bold=True) + + +def _primary_style(theme: ThemeName | None) -> RichStyle: + return tui_rich_style("text", theme=theme) + + +def _secondary_style(theme: ThemeName | None) -> RichStyle: + return tui_rich_style("secondary", theme=theme) + + +def _muted_style(theme: ThemeName | None) -> RichStyle: + return tui_rich_style("muted", theme=theme) + + def _summary_line(counts: dict[Severity, int], theme: ThemeName | None) -> Text: line = Text() + pill_bg = get_tui_tokens(theme).tool_pending_bg + bg = RichStyle(bgcolor=pill_bg) first = True for severity in _SEVERITY_ORDER: count = counts[severity] if not count: continue if not first: - line.append(" ") + line.append(" ") first = False - line.append(f"{_DOT} ", style=_severity_style(severity, theme)) - line.append(f"{count} {severity}", style=tui_rich_style("text", theme=theme)) + line.append(f" {_DOT} ", style=_severity_style(severity, theme) + bg) + line.append(f"{count} {severity} ", style=_primary_style(theme) + bg) if not counts["critical"] and not counts["high"]: - prefix = " " if not first else "" - line.append(f"{prefix}no critical or high", style=tui_rich_style("muted", theme=theme)) + prefix = " " if not first else "" + line.append(f"{prefix}no critical or high", style=_secondary_style(theme)) return line +def _render_section_header(severity: Severity, theme: ThemeName | None) -> Group: + border = tui_rich_style("border", theme=theme) + return Group( + Text(severity.capitalize(), style=tui_rich_style("tool_title", theme=theme)), + Rule(style=border, characters="─"), + ) + + def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> RenderableType: rows: list[RenderableType] = [] @@ -313,23 +340,29 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab title.add_column(overflow="fold") title.add_row( Text(_DOT, style=_severity_style(finding.severity, theme)), - Text(finding.title, style=tui_rich_style("text", theme=theme)), + Text(finding.title, style=_primary_style(theme)), ) rows.append(title) if finding.location: rows.append(Text("")) - # Keep wrapped file paths in the same hanging-indent column. A raw - # leading-space Text only indents the first physical line after Rich - # wraps, which makes long locations drift left inside wide reports. - rows.append( - Padding(Text(finding.location, style=tui_rich_style("muted", theme=theme)), (0, 0, 0, 2)) + muted = _muted_style(theme) + location = Table.grid(padding=0) + location.add_column(width=2, no_wrap=True) + location.add_column(overflow="fold") + location.add_row( + Text(REPORT_FILE_MARKER, style=muted), + Text(finding.location, style=muted), ) + rows.append(location) if finding.body.strip(): - body_style = tui_rich_style("text", theme=theme) + body_style = _primary_style(theme) rows.append( - Padding(PythinkerMarkdown(finding.body.strip(), style=body_style), (0, 0, 0, 2)) + Padding( + pythinker_report_markdown(finding.body.strip(), style=body_style), + (0, 0, 0, 2), + ) ) return Group(*rows) @@ -343,7 +376,7 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab rows: list[RenderableType] = [] if report.scope: - rows += [Text(report.scope, style=tui_rich_style("dim", theme=theme)), blank] + rows += [Text(report.scope, style=_secondary_style(theme)), blank] rows.append(_summary_line(counts, theme)) for severity in _SEVERITY_ORDER: @@ -351,7 +384,7 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab if not group: continue rows.append(blank) - rows.append(Rule(f" {severity.capitalize()} ", align="left", style=border, characters="─")) + rows.append(_render_section_header(severity, theme)) for finding in group: rows.append(blank) rows.append(_render_finding(finding, theme)) @@ -360,10 +393,10 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab rows += [ blank, Rule(style=border, characters="─"), - Text(report.note, style=tui_rich_style("muted", theme=theme)), + Text(report.note, style=_secondary_style(theme)), ] - title = Text(report.title, style=tui_rich_style("tool_title", theme=theme) + RichStyle(bold=True)) + title = Text(report.title, style=_strong_style(theme)) return Panel( Group(*rows), title=title, diff --git a/src/pythinker_code/ui/shell/glyphs.py b/src/pythinker_code/ui/shell/glyphs.py index 8d3131ef..64c9f0ff 100644 --- a/src/pythinker_code/ui/shell/glyphs.py +++ b/src/pythinker_code/ui/shell/glyphs.py @@ -64,6 +64,8 @@ #: inline view, interactive dialog body, pager, and ``prompt_other_input``. #: ASCII mode falls back to plain ``?`` for legacy terminals. QUESTION_MARKER: Final = "?" if _ASCII_GLYPHS else "❓" +#: Report finding location rows (file path + line refs). +REPORT_FILE_MARKER: Final = "+" if _ASCII_GLYPHS else "⌁" __all__ = [ "SPINNER_FRAMES", @@ -82,4 +84,5 @@ "TRANSCRIPT_TOOL_GUTTER", "LIST_BULLET", "QUESTION_MARKER", + "REPORT_FILE_MARKER", ] diff --git a/src/pythinker_code/ui/shell/selectors/code_theme.py b/src/pythinker_code/ui/shell/selectors/code_theme.py index 135aa319..bbf975c9 100644 --- a/src/pythinker_code/ui/shell/selectors/code_theme.py +++ b/src/pythinker_code/ui/shell/selectors/code_theme.py @@ -12,7 +12,10 @@ def _build_code_theme_config( *, theme_matches_current: Callable[[str, str], bool] | None = None, ) -> SelectorConfig[str]: - matches = theme_matches_current or (lambda theme, configured: theme == configured) + def _default_match(theme: str, configured: str) -> bool: + return theme == configured + + matches = theme_matches_current or _default_match return SelectorConfig( title="Select syntax theme", items=[ diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index a718b5d5..78ed44a2 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -1068,7 +1068,7 @@ def _compose_card(self) -> RenderableType | None: ) ) if activity_children: - return Group(card_rendered, *activity_children) + return Group(card_rendered, BLANK_ROW, *activity_children) return card_rendered def _streamed_output_text(self) -> str: diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index c8458e4d..ec04c428 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -22,6 +22,7 @@ from pythinker_code.ui.shell.motion import blink_visible from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING, WORKLOG_PANEL_PADDING from pythinker_code.ui.theme import get_tui_tokens, tui_rich_style +from pythinker_code.ui.theme.spec import TUI_TOKEN_NAMES from pythinker_code.utils.rich.columns import BulletColumns from pythinker_code.utils.rich.diff_render import ( collect_diff_hunks, @@ -102,8 +103,10 @@ def _state_icon(state: WorkLogState) -> Text: def _tool_token_style(token_name: str) -> str: + if token_name not in TUI_TOKEN_NAMES: + raise ValueError(f"Unknown TUI token: {token_name!r}") tokens = get_tui_tokens() - return getattr(tokens, token_name, tokens.info) + return getattr(tokens, token_name) def tool_style(name: str) -> ToolStyle: diff --git a/src/pythinker_code/ui/theme/adapters/markdown.py b/src/pythinker_code/ui/theme/adapters/markdown.py index 01d7e26f..e874d267 100644 --- a/src/pythinker_code/ui/theme/adapters/markdown.py +++ b/src/pythinker_code/ui/theme/adapters/markdown.py @@ -30,3 +30,22 @@ def markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichSt "markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True), "markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True), } + + +def report_markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichStyle]: + """Report body palette: only ``markdown.h1`` stays bold; all other roles are regular weight.""" + overrides = markdown_style_overrides(theme) + report: dict[str, RichStyle] = {} + for name, style in overrides.items(): + if name == "markdown.h1": + report[name] = style + continue + report[name] = RichStyle( + color=style.color, + bgcolor=style.bgcolor, + bold=False, + italic=style.italic, + underline=style.underline, + dim=style.dim, + ) + return report diff --git a/src/pythinker_code/ui/theme/palettes.py b/src/pythinker_code/ui/theme/palettes.py index 3b16806f..07112ad1 100644 --- a/src/pythinker_code/ui/theme/palettes.py +++ b/src/pythinker_code/ui/theme/palettes.py @@ -40,6 +40,7 @@ "warning": "#FFD166", "muted": "#8F969E", "dim": "#6F767E", + "secondary": "#AAB0B6", "text": "#D7DBDF", "thinking_text": "#D4D4D4", "activity_label": "#F1F3F5", @@ -75,6 +76,7 @@ "warning": "#9A6B18", "muted": "#666666", "dim": "#8A93A0", + "secondary": "#8A93A0", "text": "#213853", "thinking_text": "#7A7A7A", "activity_label": "#213853", @@ -104,7 +106,7 @@ PromptToken.MENTION: "#56C7B0", PromptToken.BASH_PREFIX: "#E5C07B", PromptToken.GHOST_TEXT: "#6B7280", - PromptToken.PROMPT_GLYPH: "#F4F4F5", + PromptToken.PROMPT_GLYPH: "#F1F3F5", PromptToken.FRAME: "#8a8d91", PromptToken.EFFORT: "#A3A3A3", PromptToken.PLACEHOLDER: "#A3A3A3", @@ -154,6 +156,7 @@ "max": "#7f1d1d", } + def _tokens_from_core(core: dict[str, str]) -> TuiTokens: return TuiTokens(**core) diff --git a/src/pythinker_code/ui/theme/pythinker_themes.py b/src/pythinker_code/ui/theme/pythinker_themes.py index 3ea62893..ebf18439 100644 --- a/src/pythinker_code/ui/theme/pythinker_themes.py +++ b/src/pythinker_code/ui/theme/pythinker_themes.py @@ -1,5 +1,4 @@ -"""pythinker-x (TUI) theme constants — ported verbatim where possible. -""" +"""pythinker-x (TUI) theme constants — ported verbatim where possible.""" from __future__ import annotations diff --git a/src/pythinker_code/ui/theme/spec.py b/src/pythinker_code/ui/theme/spec.py index 6006b105..d4a1a967 100644 --- a/src/pythinker_code/ui/theme/spec.py +++ b/src/pythinker_code/ui/theme/spec.py @@ -29,6 +29,7 @@ class CoreToken(StrEnum): WARNING = "warning" MUTED = "muted" DIM = "dim" + SECONDARY = "secondary" TEXT = "text" THINKING_TEXT = "thinking_text" ACTIVITY_LABEL = "activity_label" @@ -98,6 +99,7 @@ class TuiTokens: warning: str muted: str dim: str + secondary: str text: str thinking_text: str activity_label: str diff --git a/tasks/blackbox-port-status.md b/tasks/blackbox-port-status.md index 011ab22f..c5fd2caf 100644 --- a/tasks/blackbox-port-status.md +++ b/tasks/blackbox-port-status.md @@ -42,7 +42,7 @@ | Constants/schemas | `constants/**`, `schemas/**` | `agents/default/system.md`, `wire/types.py` | adapt | verify-existing | Phase 1.1 | | Native TS | `native-ts/file-index/` | `ui/shell/prompt.py` | adapt ideas | done | Phase 7.4 | | File/search UX | `tools/GrepTool`, `tools/FileReadTool` | `tools/file/**` | adapt | verify-existing | Phase 1.2 | -| LSP | `services/lsp/**` | none | future-approved-only | future-approved-only | Too large without approval | +| LSP | `services/lsp/**` | `src/pythinker_code/lsp/` + `tools/lsp/` | done | done | d936b32; 63 tests pass (PLIP-10) | | Sandbox | `utils/sandbox/**` | none | future-approved-only | future-approved-only | Phase 7.8 decision | | Computer/voice/buddy | `voice/**`, `buddy/**` | none | skip | skipped | Product-only features | | Remote/bridge | `bridge/**`, `remote/**` | `acp/`, `wire/` | native-equivalent IDE | skipped | CCR not a goal | diff --git a/tests/tools/test_lsp_diagnostics.py b/tests/tools/test_lsp_diagnostics.py index dfb25f23..f9765d4d 100644 --- a/tests/tools/test_lsp_diagnostics.py +++ b/tests/tools/test_lsp_diagnostics.py @@ -18,6 +18,7 @@ uri_to_path, ) from pythinker_code.lsp.protocol import Position, Range +from pythinker_code.soul.agent import Runtime from pythinker_code.soul.approval import Approval from pythinker_code.soul.dynamic_injection import ( DynamicInjection, @@ -340,3 +341,19 @@ async def test_write_file_skips_lsp_when_unwired( tool = WriteFile(runtime, Approval(yolo=True)) result = await tool(WriteParams(path=str(target), content="x")) assert not result.is_error + + +def test_lsp_provider_registered_in_subagent_soul(runtime: Runtime, tmp_path: Path) -> None: + """LspDiagnosticsInjectionProvider must be wired into every PythinkerSoul, including subagents.""" + from pythinker_core.tooling.empty import EmptyToolset + + from pythinker_code.soul.agent import Agent + from pythinker_code.soul.context import Context + from pythinker_code.soul.pythinkersoul import PythinkerSoul + + sub_runtime = runtime.copy_for_subagent(agent_id="sa-1", subagent_type="coder") + agent = Agent(name="test", system_prompt="", toolset=EmptyToolset(), runtime=sub_runtime) + soul = PythinkerSoul(agent, context=Context(file_backend=tmp_path / "h.jsonl")) + + assert any(isinstance(p, LspDiagnosticsInjectionProvider) for p in soul._injection_providers) + assert sub_runtime.rearm_injection is not None diff --git a/tests/ui_and_conv/test_report.py b/tests/ui_and_conv/test_report.py index edf00e11..738f621e 100644 --- a/tests/ui_and_conv/test_report.py +++ b/tests/ui_and_conv/test_report.py @@ -231,6 +231,17 @@ def test_render_agent_body_plain_markdown_unchanged(): assert "text" in out +def test_report_markdown_only_h1_is_bold(): + from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides + + overrides = report_markdown_style_overrides() + assert overrides["markdown.h1"].bold is True + assert overrides["markdown.h2"].bold is False + assert overrides["markdown.h3"].bold is False + assert overrides["markdown.strong"].bold is False + assert overrides["markdown.item.bullet"].bold is False + + def test_render_agent_body_report_prose_gets_section_rhythm(): text = ( "Exit codes: both `0`. Only a vendored warning remains.\n" diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index 7bae8e9e..702c115b 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -214,6 +214,44 @@ def test_card_style_finished_subagent_shows_compact_result(_force_card_style, mo assert "Agent finished" not in rendered +def test_card_style_completed_subagent_has_blank_before_tools_rollup(_force_card_style): + import json + + from pythinker_code.ui.shell.tool_renderers import register_builtin_renderers + from pythinker_code.wire.types import ToolResult + from pythinker_core.tooling import ToolOk + + register_builtin_renderers() + block = _ToolCallBlock( + _make_tool_call(name="Agent", args='{"description":"Audit UI","prompt":"check"}') + ) + long_result = "\n".join(f"detail line {index}" for index in range(40)) + block.finish(_ok_result(long_result)) + for index in range(3): + call = ToolCall( + id=f"sub-{index}", + function=ToolCall.FunctionBody( + name="Grep", + arguments=json.dumps({"pattern": f"term{index}"}), + ), + ) + block.append_sub_tool_call(call) + block.finish_sub_tool_call( + ToolResult(tool_call_id=call.id, return_value=ToolOk(output="")) + ) + + rendered = render_plain(block.compose(), width=120) + lines = [line.rstrip() for line in rendered.splitlines()] + expand_idx = next( + index + for index, line in enumerate(lines) + if "expand" in line.lower() and "ctrl" in line.lower() + ) + tools_idx = next(index for index, line in enumerate(lines) if "tools:" in line) + assert tools_idx > expand_idx + assert any(lines[j] == "" for j in range(expand_idx + 1, tools_idx)) + + def test_card_style_running_task_output_uses_solid_circle(_force_card_style, monkeypatch): from pythinker_code.ui.shell.tool_renderers import register_builtin_renderers diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index 4a21d871..d2f237bc 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -46,6 +46,7 @@ def test_dark_tokens_have_brand_values(): assert t.border_muted == "#5D6570" assert t.muted == "#8F969E" assert t.dim == "#6F767E" + assert t.secondary == "#AAB0B6" assert t.text == "#D7DBDF" assert t.info == "#8FDDEA" assert t.success == "#7CCF8A" From e0f0bf40b16c9326ef41219f8dbbe943f0b6960b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 16:48:18 -0400 Subject: [PATCH 07/26] feat(tui): split markdown pipeline and add audit report rendering Refactor shell markdown into focused modules with shared fence scanning, size guards, and capped streaming parse cache. Add audit-profile normalization for dense parity reports (collapsed matrices, field tables, path compaction, and quote gutters) and fix report-prose misparsing of aligned field lines. Align dark prompt border tokens with core border palette. --- .../ui/shell/components/markdown.py | 995 ++---------------- .../ui/shell/components/report.py | 27 +- .../ui/shell/markdown/__init__.py | 38 + src/pythinker_code/ui/shell/markdown/audit.py | 640 +++++++++++ .../ui/shell/markdown/elements.py | 199 ++++ .../ui/shell/markdown/fences.py | 61 ++ .../ui/shell/markdown/normalizers.py | 658 ++++++++++++ .../ui/shell/markdown/renderer.py | 84 ++ .../ui/shell/markdown/streaming.py | 109 ++ src/pythinker_code/ui/theme/palettes.py | 9 +- .../test_audit_report_rendering.py | 103 ++ .../test_md_normalization_matrix.py | 153 +++ tests/ui_and_conv/test_report.py | 22 + .../test_tui_blocks_integration.py | 3 +- .../ui_and_conv/test_tui_streaming_phase0.py | 2 +- 15 files changed, 2165 insertions(+), 938 deletions(-) create mode 100644 src/pythinker_code/ui/shell/markdown/__init__.py create mode 100644 src/pythinker_code/ui/shell/markdown/audit.py create mode 100644 src/pythinker_code/ui/shell/markdown/elements.py create mode 100644 src/pythinker_code/ui/shell/markdown/fences.py create mode 100644 src/pythinker_code/ui/shell/markdown/normalizers.py create mode 100644 src/pythinker_code/ui/shell/markdown/renderer.py create mode 100644 src/pythinker_code/ui/shell/markdown/streaming.py create mode 100644 tests/ui_and_conv/test_audit_report_rendering.py create mode 100644 tests/ui_and_conv/test_md_normalization_matrix.py diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index a268bdde..02d1cd2a 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -1,942 +1,79 @@ -"""Pythinker markdown renderer with bordered code blocks and themed accents. +"""Backward-compatible re-exports for the markdown package. -Wraps Rich's ``Markdown`` element with three changes: - -1. Fenced code blocks are framed by ``╭─ {lang}`` / ``╰─`` rules in the - markdown palette's ``code_block_border`` color and tinted with the - ``code_block_bg`` background. -2. Inline elements (headings, strong, emphasis, links, inline code, block - quotes) resolve against ``pythinker_code.ui.theme.get_markdown_colors`` - so dark/light themes share the same renderer. -3. The public function :func:`markdown_commit_boundary` returns the safe - commit offset for streamed markdown; the production rendering path in - ``_ContentBlock._flush_committed`` calls it directly. The lower-level - :class:`PythinkerMarkdownStream` class is an internal/testing helper and - is not part of the public API. +Prefer importing from :mod:`pythinker_code.ui.shell.markdown` in new code. """ from __future__ import annotations -import functools -import re -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from markdown_it import MarkdownIt - -from rich import box -from rich.console import Console, ConsoleOptions, Group, RenderResult -from rich.padding import Padding -from rich.panel import Panel -from rich.style import Style as RichStyle -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text -from rich.theme import Theme - -from pythinker_code.ui.shell.components.render_utils import sanitize_ansi -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER -from pythinker_code.ui.shell.render_constants import MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES -from pythinker_code.ui.shell.spacing import CODE_BLOCK_PADDING, blank_row -from pythinker_code.ui.theme import ThemeName, get_markdown_colors -from pythinker_code.ui.theme.adapters.markdown import markdown_style_overrides -from pythinker_code.utils.rich.markdown import CodeBlock, Markdown, TableElement - -_MARKDOWN_ICON_REPLACEMENTS: dict[str, str] = { - # Model text mimicking the CLI transcript keeps the row-marker look; on - # platforms where U+23FA degrades (Windows emoji tile, ASCII mode) it - # normalizes to that platform's marker glyph. - "⏺": TRANSCRIPT_ASSISTANT_MARKER, - "✅": "✓", - "☑️": "✓", - "☑": "✓", - "✔️": "✓", - "✔": "✓", - "❌": "×", - "✖️": "×", - "✖": "×", - "🚫": "×", - "⚠️": "!", - "⚠": "!", - "🔴": "●", - "🟠": "●", - "🟡": "●", - "🟢": "●", - "🔵": "●", - "🟣": "●", - "⚫": "●", - "⚪": "○", - "🔍": "⌕", - "🔎": "⌕", - "📋": "▣", - "📝": "▣", - "📌": "•", -} -_MARKDOWN_ICON_KEYS: tuple[str, ...] = tuple( - sorted(_MARKDOWN_ICON_REPLACEMENTS, key=len, reverse=True) +from pythinker_code.ui.shell.markdown import ( + MAX_MARKDOWN_NORMALIZE_BYTES, + MAX_STREAM_PARSE_BYTES, + MarkdownNormalizationResult, + PythinkerMarkdown, + PythinkerMarkdownStream, + markdown_commit_boundary, + normalize_model_markdown, + pythinker_markdown, + pythinker_report_markdown, ) -_FENCE_RE = re.compile(r"^(?P {0,3})(?P`{3,}|~{3,})") -_OL_ITEM_RE = re.compile(r"^\d+\.\s") -_PRIORITY_MATRIX_ROW_RE = re.compile( - r"^\s*(?P[A-Z]{1,3}\d+)\s*(?:[─━—-]|\s){2,}\s*" - r"(?PCRITICAL|HIGH|MEDIUM|LOW|INFO)\s*$", - re.IGNORECASE, +from pythinker_code.ui.shell.markdown.elements import _BorderedCodeBlock, _ReportTableElement +from pythinker_code.ui.shell.markdown.normalizers import ( + _loosen_tight_ordered_lists, + _normalize_markdown_tables, + _normalize_space_aligned_report_blocks, + _normalize_table_block, + _parse_aligned_field_line, + _repair_crammed_markdown_tables, + _simplify_markdown_report_icons, + _unwrap_fenced_markdown_tables, + loosen_tight_ordered_lists, + normalize_markdown_tables, + normalize_space_aligned_report_blocks, + normalize_table_block, + parse_aligned_field_line, + repair_crammed_markdown_tables, + simplify_markdown_report_icons, + unwrap_fenced_markdown_tables, +) +from pythinker_code.ui.shell.markdown.renderer import _markdown_style_overrides +from pythinker_code.ui.shell.markdown.streaming import ( + _get_md_parser, + _markdown_commit_boundary_cached, ) -_PRIORITY_MATRIX_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO") -_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") -# A GFM table delimiter run, e.g. ``|---|:--:|---|``. Two or more dashes per -# cell keeps stray inline ``|-|`` out of the match. -_DELIM_RUN_RE = re.compile(r"\|(?:\s*:?-{2,}:?\s*\|)+") -# A header line: optional prose prefix, then a trailing run of pipe cells. -_HEADER_RE = re.compile(r"^(?P.*?)(?P(?:\|[^\n|]*)+\|)\s*$") - __all__ = [ + "MarkdownNormalizationResult", + "MAX_MARKDOWN_NORMALIZE_BYTES", + "MAX_STREAM_PARSE_BYTES", "PythinkerMarkdown", + "PythinkerMarkdownStream", + "markdown_commit_boundary", + "normalize_model_markdown", "pythinker_markdown", + "pythinker_report_markdown", ] - -def _priority_matrix_rows(code_text: str) -> list[tuple[str, str]] | None: - rows: list[tuple[str, str]] = [] - meaningful_lines = 0 - for line in code_text.splitlines(): - stripped = line.strip() - if not stripped or set(stripped) <= {"─", "━", "—", "-", " "}: - continue - meaningful_lines += 1 - match = _PRIORITY_MATRIX_ROW_RE.match(stripped) - if match is None: - return None - rows.append((match.group("id"), match.group("severity").upper())) - if len(rows) < 3 or meaningful_lines != len(rows): - return None - return rows - - -def _render_priority_matrix(rows: list[tuple[str, str]]) -> Table: - grouped: dict[str, list[str]] = {severity: [] for severity in _PRIORITY_MATRIX_SEVERITIES} - for item_id, severity in rows: - grouped.setdefault(severity, []).append(item_id) - - table = Table.grid(padding=(0, 2)) - table.add_column(justify="right", no_wrap=True) - table.add_column(no_wrap=False) - for severity in _PRIORITY_MATRIX_SEVERITIES: - items = grouped.get(severity) or [] - if not items: - continue - table.add_row(Text(severity.title(), style="bold"), " ".join(items)) - return table - - -def _is_table_separator_line(line: str) -> bool: - return _TABLE_SEPARATOR_RE.match(line) is not None - - -def _is_table_header_fragment(fragment: str) -> bool: - stripped = fragment.strip() - if not stripped.startswith("|") or not stripped.endswith("|"): - return False - if _is_table_separator_line(stripped): - return False - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - return len(cells) >= 2 and any(cells) - - -def _find_crammed_table_header_start(line: str) -> int | None: - if line.lstrip().startswith("|"): - return None - for index, char in enumerate(line): - if char != "|" or not line[:index].strip(): - continue - if _is_table_header_fragment(line[index:]): - return index - return None - - -def _repair_crammed_markdown_tables(markup: str) -> str: - """Split report headings accidentally glued to a following Markdown table. - - Streaming model output occasionally drops the newline between a section - title and a table header, producing text such as ``Medium| # | File |``. - Markdown then treats the whole table as a paragraph and renders dense report - rows as crammed prose. Repair only this narrow, table-separator-confirmed - shape, and never touch fenced code. - """ - if "|" not in markup: - return markup - - lines = markup.splitlines(keepends=True) - if len(lines) < 2: - return markup - - repaired: list[str] = [] - in_fence = False - fence_char = "" - fence_len = 0 - for index, line in enumerate(lines): - body = line.rstrip("\r\n") - eol = line[len(body) :] - match = _FENCE_RE.match(body) - if in_fence: - repaired.append(line) - if match is not None: - fence = match.group("fence") - if fence.startswith(fence_char) and len(fence) >= fence_len: - in_fence = False - fence_char = "" - fence_len = 0 - continue - if match is not None: - fence = match.group("fence") - in_fence = True - fence_char = fence[0] - fence_len = len(fence) - repaired.append(line) - continue - - split_at: int | None = None - if index + 1 < len(lines): - next_body = lines[index + 1].rstrip("\r\n") - if _is_table_separator_line(next_body): - split_at = _find_crammed_table_header_start(body) - if split_at is None: - repaired.append(line) - continue - - prefix = body[:split_at].rstrip() - header = body[split_at:].lstrip() - if prefix: - repaired.append(f"{prefix}\n") - repaired.append(f"{header}{eol}") - - return "".join(repaired) - - -_MARKDOWN_FENCE_INFOS = frozenset({"md", "markdown"}) - - -def _contains_markdown_table(lines: list[str]) -> bool: - """Whether *lines* hold a pipe-table header immediately above a delimiter row.""" - previous: str | None = None - for raw in lines: - line = raw.strip() - if not line: - previous = None - continue - if ( - previous is not None - and _is_table_separator_line(line) - and _is_table_header_fragment(previous) - ): - return True - previous = line - return False - - -def _unwrap_fenced_markdown_tables(markup: str) -> str: - """Unwrap ```` ```md ```` fences whose body contains a markdown table. - - Models sometimes wrap a whole markdown answer — tables included — in a - ``md``/``markdown`` fence, which renders the table as opaque code. Apply - a conservative heuristic: only fences explicitly tagged ``md`` or - ``markdown`` *and* containing a header+delimiter pair are unwrapped. - Other languages, untagged fences, md fences without tables, and unclosed - fences pass through unchanged. - """ - if "```" not in markup and "~~~" not in markup: - return markup - - lines = markup.splitlines(keepends=True) - out: list[str] = [] - in_other_fence = False - other_char = "" - other_len = 0 - i = 0 - while i < len(lines): - line = lines[i] - body = line.rstrip("\r\n") - match = _FENCE_RE.match(body) - if in_other_fence: - out.append(line) - if match is not None: - fence = match.group("fence") - # CommonMark closing fences carry no info string; without that - # check a "```python" line inside the open fence would end it. - if ( - fence[0] == other_char - and len(fence) >= other_len - and not body[match.end() :].strip() - ): - in_other_fence = False - i += 1 - continue - if match is None: - out.append(line) - i += 1 - continue - fence = match.group("fence") - info = body[match.end() :].strip().lower() - if info not in _MARKDOWN_FENCE_INFOS: - in_other_fence = True - other_char = fence[0] - other_len = len(fence) - out.append(line) - i += 1 - continue - - # ``md`` fence: find the matching close (same char, same-or-longer - # marker, no info string — CommonMark closing-fence rules). - close_index: int | None = None - for j in range(i + 1, len(lines)): - inner_body = lines[j].rstrip("\r\n") - inner_match = _FENCE_RE.match(inner_body) - if ( - inner_match is not None - and inner_match.group("fence")[0] == fence[0] - and len(inner_match.group("fence")) >= len(fence) - and not inner_body[inner_match.end() :].strip() - ): - close_index = j - break - if close_index is None: - out.append(line) - i += 1 - continue - - fenced_body = lines[i + 1 : close_index] - if not _contains_markdown_table([raw.rstrip("\r\n") for raw in fenced_body]): - out.extend(lines[i : close_index + 1]) - i = close_index + 1 - continue - - # Unwrap: drop the fence markers and keep the body as block-level - # markdown, padded with blank lines so adjacent prose can't glue on. - if out and out[-1].strip(): - out.append("\n") - out.extend(fenced_body) - next_line = lines[close_index + 1] if close_index + 1 < len(lines) else None - ends_blank = bool(fenced_body) and not fenced_body[-1].strip() - if next_line is not None and next_line.strip() and not ends_blank: - out.append("\n") - i = close_index + 1 - return "".join(out) - - -class _ReportTableElement(TableElement): - """Markdown tables that stay readable in long reports. - - Compact, low-column tables keep the normal bordered grid. Wide report - tables become stacked records so long paths and prose wrap in one generous - value column instead of being sliced across many narrow grid cells. - """ - - def _header_cells(self) -> list[Text]: - if self.header is None or self.header.row is None: - return [] - return [cell.content for cell in self.header.row.cells] - - def _body_rows(self) -> list[list[Text]]: - if self.body is None: - return [] - return [[cell.content for cell in row.cells] for row in self.body.rows] - - def _should_stack(self, options: ConsoleOptions) -> bool: - headers = self._header_cells() - rows = self._body_rows() - column_count = len(headers) - if column_count <= 2 or not rows: - return False - - column_widths = [len(header.plain.strip()) for header in headers] - for row in rows: - for index, cell in enumerate(row[:column_count]): - column_widths[index] = max(column_widths[index], len(cell.plain.strip())) - longest_cell = max(column_widths, default=0) - estimated_grid_width = sum(min(width, 24) for width in column_widths) + column_count * 3 + 1 - available_width = options.max_width or 80 - - if column_count >= 4: - return longest_cell >= 24 or estimated_grid_width > available_width - return longest_cell >= 36 - - def _render_stacked(self) -> RenderResult: - headers = self._header_cells() - rows = self._body_rows() - detail_headers = headers[1:] - label_width = min( - max((len(header.plain.strip()) for header in detail_headers), default=0), - 22, - ) - - for index, row in enumerate(rows): - if index: - yield blank_row() - - title = Text("• ", style="markdown.item.bullet") - if row: - title_value = row[0].copy() - title.append_text(title_value) - title.stylize("markdown.strong", 2, len(title)) - detail_grid = Table.grid(expand=True, padding=(0, 2)) - detail_grid.add_column(width=max(1, label_width), no_wrap=True) - detail_grid.add_column(ratio=1, overflow="fold") - - has_details = False - for header, cell in zip(detail_headers, row[1:], strict=False): - label = header.plain.strip() - value = cell.copy() - if not value.plain.strip(): - value = Text("—", style="markdown.block_quote") - detail_grid.add_row(Text(label, style="markdown.strong"), value) - has_details = True - - if has_details: - yield Group(title, Padding(detail_grid, (0, 0, 0, 2))) - else: - yield title - - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - if self._should_stack(options): - yield from self._render_stacked() - return - yield from super().__rich_console__(console, options) - - -class _BorderedCodeBlock(CodeBlock): - """Code block with an aligned rounded frame and calm report styling.""" - - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - code_text = str(self.text).rstrip("\n") - if self.lexer_name.strip().lower() in {"", "text", "plain", "markdown"}: - matrix_rows = _priority_matrix_rows(code_text) - if matrix_rows is not None: - yield blank_row() - yield _render_priority_matrix(matrix_rows) - yield blank_row() - return - - colors = get_markdown_colors() - border_style = RichStyle(color=colors.code_block_border, bold=True) - # ``self.theme`` is a str only for an opted-in stock Pygments style; the - # default ANSI theme resolves to a ``SyntaxTheme`` instance. For a stock - # style, paint the panel with the style's own background so the code and - # its padding share one uniform dark block (Aider-style); otherwise keep - # the calm ``code_block_bg`` tint. - if isinstance(self.theme, str): - # Stock opt-in style (e.g. monokai): paint the panel and code with the - # style's own background so they form one uniform solid block. - panel_style = Syntax.get_theme(self.theme).get_background_style() - syntax_bg: str | None = None - else: - # Default path (Catppuccin adaptive / ANSI sentinel): keep the calm - # code_block_bg tint on the panel and render the syntax transparently - # so only its foreground colors land (the "skip background" approach). - panel_style = ( - RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle() - ) - syntax_bg = "default" - - lexer_name = self.lexer_name.strip() - title = lexer_name if lexer_name and lexer_name != "text" else None - # Size guard: skip Pygments for very large blocks so a - # pathological fence cannot stall the renderer. ``len()`` counts - # characters (a lower bound on UTF-8 bytes), which is enough for a - # guard heuristic without paying for an encode of the whole block. - line_count = code_text.count("\n") + 1 - if line_count > MAX_HIGHLIGHT_LINES or len(code_text) > MAX_HIGHLIGHT_BYTES: - highlighted = Text(code_text) - skip_notice = f"highlighting skipped ({line_count:,} lines)" - title = f"{title} · {skip_notice}" if title else skip_notice - else: - syntax = Syntax( - code_text, - self.lexer_name, - theme=self.theme, - word_wrap=True, - padding=0, - background_color=syntax_bg, - ) - highlighted = syntax.highlight(code_text) - highlighted.rstrip() - # Frame the code block with a blank row above and below so it reads as a - # distinct section instead of crowding the surrounding prose. Canonical - # ``blank_row()`` (an empty ``Text``) never picks up the panel's tint. - yield blank_row() - yield Panel( - highlighted, - title=title, - title_align="left", - box=box.ROUNDED, - border_style=border_style, - padding=CODE_BLOCK_PADDING, - expand=True, - style=panel_style, - ) - yield blank_row() - - -def _markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichStyle]: - """Translate the active markdown palette into Rich style names.""" - return markdown_style_overrides(theme) - - -def _replace_report_icons(text: str) -> str: - """Replace large/color emoji status icons with compact monochrome glyphs.""" - if not any(icon in text for icon in _MARKDOWN_ICON_KEYS): - return text - out: list[str] = [] - i = 0 - inline_code_ticks = 0 - while i < len(text): - if text[i] == "`": - j = i - while j < len(text) and text[j] == "`": - j += 1 - tick_count = j - i - out.append(text[i:j]) - if inline_code_ticks == 0: - inline_code_ticks = tick_count - elif inline_code_ticks == tick_count: - inline_code_ticks = 0 - i = j - continue - if inline_code_ticks: - out.append(text[i]) - i += 1 - continue - for icon in _MARKDOWN_ICON_KEYS: - if text.startswith(icon, i): - out.append(_MARKDOWN_ICON_REPLACEMENTS[icon]) - i += len(icon) - break - else: - out.append(text[i]) - i += 1 - return "".join(out) - - -def _simplify_markdown_report_icons(markup: str) -> str: - """Simplify report/status emoji outside fenced code blocks.""" - if not any(icon in markup for icon in _MARKDOWN_ICON_KEYS): - return markup - - lines: list[str] = [] - in_fence = False - fence_char = "" - fence_len = 0 - for line in markup.splitlines(keepends=True): - match = _FENCE_RE.match(line) - if in_fence: - lines.append(line) - if match is not None: - fence = match.group("fence") - if fence.startswith(fence_char) and len(fence) >= fence_len: - in_fence = False - fence_char = "" - fence_len = 0 - continue - if match is not None: - fence = match.group("fence") - in_fence = True - fence_char = fence[0] - fence_len = len(fence) - lines.append(line) - continue - lines.append(_replace_report_icons(line)) - return "".join(lines) - - -# Inline code-span repair for table rows: a run of N backticks, lazily-matched -# body, then the first later run containing those same N backticks. This is a -# tolerant LLM-output repair heuristic, not a complete GFM code-span parser. -_CODE_SPAN_RE = re.compile(r"(?P`+)(?P.*?)(?P=ticks)") - - -def _escape_code_span_pipes(text: str) -> str: - r"""Escape raw ``|`` inside inline code spans as ``\|`` so GFM keeps the table - cell intact (LLMs frequently emit unescaped pipes inside code spans). Only the - code-span interior is touched; table-delimiter pipes outside spans are left - alone, and an already-escaped ``\|`` is not double-escaped. Call only on - table-row text (see :func:`_normalize_table_block`); applying it to prose - inline-code would leave a literal backslash in the rendered span. - """ - - def _repl(match: re.Match[str]) -> str: - ticks = match.group("ticks") - body = re.sub(r"(? list[str]: - """Split a ``| a | b |`` run into stripped inner cells (drops the frame).""" - parts = re.split(r"(? bool: - stripped = line.strip() - return stripped.startswith("|") and stripped.count("|") >= 2 - - -def _delimiter_markers(run: str) -> list[str]: - """Return per-column alignment markers (``---``, ``:---``, ``---:``, ``:---:``).""" - markers: list[str] = [] - for cell in _split_pipe_cells(run): - left = cell.startswith(":") - right = cell.endswith(":") - if left and right: - markers.append(":---:") - elif right: - markers.append("---:") - elif left: - markers.append(":---") - else: - markers.append("---") - return markers - - -def _normalize_table_block(text: str) -> str: - """Repair malformed GFM tables in a fence-free block of markdown. - - Models occasionally glue a table header onto preceding prose, drop the - newline between the header and the ``|---|`` delimiter, or cram data rows - onto the delimiter line — markdown-it then renders the whole thing as raw - text. Anchored on the delimiter run, this rebuilds each region it is - *confident* is a table (delimiter at line start, header and data cell counts - both equal to the delimiter's column count) and passes everything else - through untouched. Well-formed tables are rebuilt to identical-rendering - markdown, so the pass is safe to apply unconditionally. - """ - out = "" - while True: - match = _DELIM_RUN_RE.search(text) - if match is None: - return out + text - markers = _delimiter_markers(match.group(0)) - n_cols = len(markers) - head = text[: match.start()] - tail = text[match.end() :] - - # The delimiter must start its own line — guards against inline ``|-|``. - # Any leading whitespace is the table's indentation (e.g. nested under a - # list item); preserve it when re-emitting so we never promote an - # indented table to top level. - indent = head[head.rfind("\n") + 1 :] - if n_cols < 2 or indent.strip() != "": - out += text[: match.end()] - text = tail - continue - - head_lines = head.split("\n") - while head_lines and head_lines[-1] == "": - head_lines.pop() - header_match = _HEADER_RE.match(head_lines[-1]) if head_lines else None - header_cells = ( - _split_pipe_cells(_escape_code_span_pipes(header_match.group("cells"))) - if header_match - else [] - ) - if header_match is None or len(header_cells) != n_cols: - out += text[: match.end()] - text = tail - continue - - # Data rows: the same-line remainder after the delimiter plus any - # following pipe rows, re-chunked into rows of ``n_cols`` cells. - tail_lines = tail.split("\n") - data_segments = [tail_lines[0]] if tail_lines[0].strip() else [] - consumed = 1 - for line in tail_lines[1:]: - if _is_pipe_row(line): - data_segments.append(line) - consumed += 1 - else: - break - data_rows: list[list[str]] = [] - bail = False - for segment in data_segments: - cells = _split_pipe_cells(_escape_code_span_pipes(segment)) - if not cells: - continue - if len(cells) % n_cols != 0: - bail = True # ambiguous (e.g. glued rows with empty cells) — leave as-is - break - for i in range(0, len(cells), n_cols): - data_rows.append(cells[i : i + n_cols]) - if bail: - out += text[: match.end()] - text = tail - continue - - preamble = head_lines[:-1] - prose = header_match.group("prefix").rstrip() - if preamble: - out += "\n".join(preamble) + "\n" - if prose: - out += prose + "\n" - # A GFM table must be preceded by a blank line (it cannot interrupt a - # paragraph), so ensure one before emitting the header. - if out and not out.endswith("\n\n"): - out += "\n" if out.endswith("\n") else "\n\n" - out += f"{indent}| " + " | ".join(header_cells) + " |\n" - out += f"{indent}| " + " | ".join(markers) + " |\n" - for row in data_rows: - out += f"{indent}| " + " | ".join(row) + " |\n" - - remainder = "\n".join(tail_lines[consumed:]) - if not remainder.strip(): - return out - text = remainder if remainder.startswith("\n") else "\n" + remainder - - -def _loosen_tight_ordered_lists(markup: str) -> str: - """Insert a blank line before each ordered-list item that immediately follows another.""" - lines = markup.splitlines(keepends=True) - out: list[str] = [] - in_fence = False - fence_char = "" - fence_len = 0 - prev_was_ol = False - - for line in lines: - m = _FENCE_RE.match(line) - if in_fence: - out.append(line) - if m: - fence = m.group("fence") - if fence.startswith(fence_char) and len(fence) >= fence_len: - in_fence = False - fence_char = "" - fence_len = 0 - prev_was_ol = False - continue - if m: - in_fence = True - fence_char = m.group("fence")[0] - fence_len = len(m.group("fence")) - out.append(line) - prev_was_ol = False - continue - - is_ol = bool(_OL_ITEM_RE.match(line)) - if is_ol and prev_was_ol: - out.append("\n") - out.append(line) - prev_was_ol = is_ol - - return "".join(out) - - -def _normalize_markdown_tables(markup: str) -> str: - """Apply :func:`_normalize_table_block` to every fence-free span of markup.""" - if "|" not in markup or "-" not in markup: - return markup - - out: list[str] = [] - buffer: list[str] = [] - in_fence = False - fence_char = "" - fence_len = 0 - - def flush() -> None: - if buffer: - out.append(_normalize_table_block("\n".join(buffer))) - buffer.clear() - - for line in markup.splitlines(): - match = _FENCE_RE.match(line) - if in_fence: - fence = match.group("fence") if match else "" - if fence and fence[0] == fence_char and len(fence) >= fence_len: - in_fence = False - out.append(line) - continue - if match: - flush() - in_fence = True - fence_char = match.group("fence")[0] - fence_len = len(match.group("fence")) - out.append(line) - continue - buffer.append(line) - flush() - - result = "\n".join(out) - if markup.endswith("\n") and not result.endswith("\n"): - result += "\n" - return result - - -class PythinkerMarkdown(Markdown): - """Drop-in replacement for ``rich.markdown.Markdown`` with the Pythinker palette. - - Markup is run through :func:`sanitize_ansi` first so terminal control - sequences embedded in model/user/custom text cannot reach the terminal - (cursor moves, color leaks) when rendered as Markdown. Large emoji status - icons are then normalized to compact monochrome glyphs for calmer reports. - """ - - elements = { - **Markdown.elements, - "fence": _BorderedCodeBlock, - "code_block": _BorderedCodeBlock, - "table_open": _ReportTableElement, - } - - def __init__(self, markup: str, *args: Any, report: bool = False, **kwargs: Any) -> None: - self._report_mode = report - safe_markup = sanitize_ansi(markup) - unwrapped_markup = _unwrap_fenced_markdown_tables(safe_markup) - repaired_markup = _repair_crammed_markdown_tables(unwrapped_markup) - normalized_markup = _normalize_markdown_tables(repaired_markup) - loosened_markup = _loosen_tight_ordered_lists(normalized_markup) - super().__init__(_simplify_markdown_report_icons(loosened_markup), *args, **kwargs) - - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides - - overrides = ( - report_markdown_style_overrides() - if self._report_mode - else _markdown_style_overrides() - ) - with console.use_theme(Theme(overrides, inherit=True)): - yield from super().__rich_console__(console, options) - - -def pythinker_markdown(text: str, *, code_theme: str | None = None) -> PythinkerMarkdown: - """Build a :class:`PythinkerMarkdown` with the palette pre-wired. - - ``code_theme=None`` defers to the active code theme (``config.tui.code_theme``). - """ - return PythinkerMarkdown(text, code_theme=code_theme) - - -def pythinker_report_markdown( - text: str, *, code_theme: str | None = None, style: str | RichStyle = "none" -) -> PythinkerMarkdown: - """Report-body markdown: only H1 headings render bold; everything else is regular weight.""" - return PythinkerMarkdown(text, code_theme=code_theme, style=style, report=True) - - -# --------------------------------------------------------------------------- -# Streaming boundary helper -# --------------------------------------------------------------------------- - - -_SENTENCE_END = (".", "!", "?", ";", ":") -_SELF_CLOSING_BLOCKS = frozenset(("fence", "code_block", "hr", "html_block")) - -# Lazy-initialized markdown-it parser for incremental token commitment. -_md_parser: MarkdownIt | None = None - - -def _get_md_parser() -> MarkdownIt: - global _md_parser - if _md_parser is None: - from markdown_it import MarkdownIt - - # Match the extensions used by the rendering path - # (pythinker_code.utils.rich.markdown.Markdown) so that block - # boundaries are detected consistently. - _md_parser = MarkdownIt().enable("strikethrough").enable("table") - return _md_parser - - -@functools.lru_cache(maxsize=64) -def _markdown_commit_boundary_cached(text: str) -> int | None: - md = _get_md_parser() - tokens = md.parse(text) - - block_maps: list[list[int]] = [] - depth = 0 - for token in tokens: - if token.nesting == 1: - if depth == 0 and token.map is not None: - block_maps.append(token.map) - depth += 1 - elif token.nesting == -1: - depth -= 1 - elif depth == 0 and token.type in _SELF_CLOSING_BLOCKS and token.map is not None: - block_maps.append(token.map) - - if len(block_maps) < 2: - return None - - target_line = block_maps[-2][1] - offset = 0 - for _ in range(target_line): - offset = text.index("\n", offset) + 1 - return offset - - -def markdown_commit_boundary(text: str) -> int | None: - """Return the offset up to which streamed markdown can be committed. - - The last top-level block is treated as still mutable, so callers only - permanently print completed blocks. Nested tokens (list items, blockquote - children, table rows) stay with their parent block. - """ - if not text: - return None - return _markdown_commit_boundary_cached(text) - - -def _find_stream_safe_boundary(text: str) -> int | None: - """Return an index in ``text`` that is safe to flush, or ``None``. - - Parser-backed block commitment handles multi-line markdown constructs such - as tables, lists, and fenced code. A sentence-final fallback is kept only - for single-line prose so long plain paragraphs still become visible without - waiting for a blank line or a second markdown block. - """ - boundary = markdown_commit_boundary(text) - if boundary is not None: - return boundary - - if "\n" in text: - return None - stripped = text.rstrip() - if stripped.endswith(_SENTENCE_END): - return len(text) - return None - - -@dataclass(slots=True) -class PythinkerMarkdownStream: - """Buffer streamed markdown deltas and yield safe-to-render slices. - - Internal testing fixture for markdown boundary behavior. The production - rendering path in ``_ContentBlock._flush_committed`` calls - :func:`markdown_commit_boundary` directly. - """ - - pending: str = field(default="") - - def push(self, delta: str) -> str | None: - """Append ``delta`` and return the next ready slice, or ``None``.""" - self.pending += delta - cut = _find_stream_safe_boundary(self.pending) - if cut is None or cut == 0: - return None - ready = self.pending[:cut] - self.pending = self.pending[cut:] - return ready - - def flush(self) -> str | None: - """Return any remaining buffered markdown, clearing the buffer.""" - if not self.pending.strip(): - self.pending = "" - return None - pending = self.pending - self.pending = "" - return pending +# Private re-exports consumed by tests and characterization pins. +__all__ += [ + "_BorderedCodeBlock", + "_ReportTableElement", + "_get_md_parser", + "_loosen_tight_ordered_lists", + "_markdown_commit_boundary_cached", + "_markdown_style_overrides", + "_normalize_markdown_tables", + "_normalize_space_aligned_report_blocks", + "_normalize_table_block", + "_parse_aligned_field_line", + "_repair_crammed_markdown_tables", + "_simplify_markdown_report_icons", + "_unwrap_fenced_markdown_tables", + "loosen_tight_ordered_lists", + "normalize_markdown_tables", + "normalize_space_aligned_report_blocks", + "normalize_table_block", + "parse_aligned_field_line", + "repair_crammed_markdown_tables", + "simplify_markdown_report_icons", + "unwrap_fenced_markdown_tables", +] diff --git a/src/pythinker_code/ui/shell/components/report.py b/src/pythinker_code/ui/shell/components/report.py index 52eb4487..790a6f6a 100644 --- a/src/pythinker_code/ui/shell/components/report.py +++ b/src/pythinker_code/ui/shell/components/report.py @@ -34,13 +34,28 @@ from rich.table import Table from rich.text import Text -from pythinker_code.ui.shell.components.markdown import pythinker_markdown, pythinker_report_markdown from pythinker_code.ui.shell.glyphs import REPORT_FILE_MARKER +from pythinker_code.ui.shell.markdown.audit import detect_audit_report +from pythinker_code.ui.shell.markdown.normalizers import ( + parse_aligned_field_line as _parse_aligned_field_line, +) +from pythinker_code.ui.shell.markdown.renderer import ( + pythinker_markdown, + pythinker_report_markdown, +) from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING from pythinker_code.ui.theme import ThemeName, get_tui_tokens, tui_rich_style _log = logging.getLogger(__name__) + +def _agent_markdown(text: str) -> RenderableType: + """Render assistant markdown, using audit profile for dense parity reports.""" + if detect_audit_report(text): + return pythinker_report_markdown(text, report_kind="audit") + return pythinker_markdown(text) + + __all__ = [ "Report", "ReportFinding", @@ -167,6 +182,10 @@ def _clean_report_label(line: str) -> tuple[str, str] | None: return None if _FENCE_LINE_RE.match(stripped): return None + # Space-column parity/inventory rows (`Reference line path:1-2`) are not + # top-level report section labels; their path colons must not split prose. + if line != line.lstrip() or _parse_aligned_field_line(line) is not None: + return None match = _REPORT_LABEL_RE.match(line) if match is None: @@ -497,7 +516,7 @@ def render_agent_body(text: str, *, theme: ThemeName | None = None) -> Renderabl continue # malformed — leave it for the markdown renderer before = "\n".join(lines[cursor:start]).strip("\n") if before: - segments.append(pythinker_markdown(before)) + segments.append(_agent_markdown(before)) segments.append(render_report(report, theme=theme)) cursor = end @@ -505,11 +524,11 @@ def render_agent_body(text: str, *, theme: ThemeName | None = None) -> Renderabl report_prose = _render_report_prose(text, theme=theme) if report_prose is not None: return report_prose - return pythinker_markdown(text) + return _agent_markdown(text) rest = "\n".join(lines[cursor:]).strip("\n") if rest: - segments.append(pythinker_markdown(rest)) + segments.append(_agent_markdown(rest)) spaced: list[RenderableType] = [] for i, segment in enumerate(segments): diff --git a/src/pythinker_code/ui/shell/markdown/__init__.py b/src/pythinker_code/ui/shell/markdown/__init__.py new file mode 100644 index 00000000..2245d2ee --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/__init__.py @@ -0,0 +1,38 @@ +"""Pythinker markdown renderer package. + +Public entry points for themed Rich markdown rendering, model-output repair, +and streaming commit boundaries. +""" + +from __future__ import annotations + +from pythinker_code.ui.shell.markdown.audit import detect_audit_report, normalize_audit_report +from pythinker_code.ui.shell.markdown.normalizers import ( + MAX_MARKDOWN_NORMALIZE_BYTES, + MarkdownNormalizationResult, + normalize_model_markdown, +) +from pythinker_code.ui.shell.markdown.renderer import ( + PythinkerMarkdown, + pythinker_markdown, + pythinker_report_markdown, +) +from pythinker_code.ui.shell.markdown.streaming import ( + MAX_STREAM_PARSE_BYTES, + PythinkerMarkdownStream, + markdown_commit_boundary, +) + +__all__ = [ + "MarkdownNormalizationResult", + "MAX_MARKDOWN_NORMALIZE_BYTES", + "MAX_STREAM_PARSE_BYTES", + "PythinkerMarkdown", + "PythinkerMarkdownStream", + "detect_audit_report", + "markdown_commit_boundary", + "normalize_audit_report", + "normalize_model_markdown", + "pythinker_markdown", + "pythinker_report_markdown", +] diff --git a/src/pythinker_code/ui/shell/markdown/audit.py b/src/pythinker_code/ui/shell/markdown/audit.py new file mode 100644 index 00000000..178b89f1 --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/audit.py @@ -0,0 +1,640 @@ +"""Audit-report normalization for dense agent final answers.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from pythinker_code.ui.shell.markdown.fences import FENCE_RE, FenceState +from pythinker_code.ui.shell.markdown.normalizers import ( + _UNICODE_RULE_LINE_RE, + parse_aligned_field_line, +) + +PROJECT_PATH_PREFIXES: tuple[str, ...] = ( + "src/pythinker_code/", + "/Users/panda/Projects/active/Projects/pythinker-code-main/src/pythinker_code/", + "/Users/panda/Projects/active/Projects/pythinker-code-main/", +) + +_QUOTE_GUTTER_RE = re.compile(r"^(\s*)▌\s?") +_UNDERLINE_HEADING_RE = re.compile( + r"^(Reference|Pythinker|Rationale|Spec|Verdict|Command|Expected|Result|" + r"Checks|Gate command|Lint command)\s*$", + re.I, +) +_BULLET_RE = re.compile(r"^(\s*)[-•]\s+(.+)$") +_SECTION_HEADING_RE = re.compile(r"^(\s*)(?:#{1,2}\s+)?(\d+)\.\s+(.+)$") +_FIELD_LINE_RE = re.compile(r"^(\s*)-\s+([^:]+):\s*(.+)$") +_STATUS_EXACT_RE = re.compile(r"✓|exact", re.I) +_STATUS_DIVERGE_RE = re.compile(r"!|diverge|intentional", re.I) +_STATUS_GAP_RE = re.compile(r"×|gap|missing|undocumented", re.I) + +_PARITY_SECTION_HINTS = ("parity matrix", "behavioural contract", "behavioral contract") +_COLLAPSE_PARITY_MIN_ITEMS = 5 + +_GROUP_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "Lifecycle / Process", + ( + "spawn", + "initialize", + "startup", + "restart", + "timeout", + "handshake", + "lazy", + "generation", + "process", + ), + ), + ("Diagnostics", ("diagnostic", "dedup", "volume", "handler", "publish", "registry", "passive")), + ( + "Tool / Input", + ( + "tool", + "unc", + "file size", + "git check", + "maxresult", + "operation", + "symbol", + "readonly", + "concurrency", + ), + ), + ( + "Integration", + ( + "edit", + "write", + "runtime", + "cleanup", + "provider", + "plugin", + "manifest", + "recommendation", + "injection", + "subagent", + ), + ), + ( + "File sync / Routing", + ("extension", "routing", "open/", "change", "save", "close", "workspace", "configuration"), + ), +) + + +@dataclass(slots=True) +class _ParityItem: + title: str + fields: dict[str, str] = field(default_factory=dict) + + @property + def status(self) -> str: + raw = self.fields.get("Status", "") + if _STATUS_GAP_RE.search(raw): + return "gap" + if _STATUS_DIVERGE_RE.search(raw): + return "diverge" + if _STATUS_EXACT_RE.search(raw): + return "exact" + return "other" + + @property + def group(self) -> str: + title = self.title.lower() + for group_name, keywords in _GROUP_KEYWORDS: + if any(keyword in title for keyword in keywords): + return group_name + return "Contracts" + + +def detect_audit_report(markup: str) -> bool: + """Whether *markup* looks like a dense parity/inventory audit report.""" + if "•" not in markup and "Reference line" not in markup: + return False + field_rows = sum( + 1 for line in markup.splitlines() if parse_aligned_field_line(line) is not None + ) + status_rows = sum( + 1 + for line in markup.splitlines() + if parse_aligned_field_line(line) is not None and "status" in line.lower() + ) + return field_rows >= 4 and status_rows >= 2 + + +def compact_known_paths(text: str) -> str: + """Shorten common repo prefixes for terminal readability.""" + for prefix in PROJECT_PATH_PREFIXES: + if prefix in text: + text = text.replace(prefix, "") + return text + + +def normalize_quote_gutters(markup: str) -> str: + """Convert ``▌`` quote markers into Markdown blockquotes.""" + if "▌" not in markup: + return markup + out: list[str] = [] + state = FenceState() + for line in markup.splitlines(keepends=True): + body = line.rstrip("\r\n") + eol = line[len(body) :] + if state.active: + out.append(line) + state.feed(body) + continue + if FENCE_RE.match(body) is not None: + state.feed(body) + out.append(line) + continue + match = _QUOTE_GUTTER_RE.match(body) + if match is not None: + indent = match.group(1) + quote = body[match.end() :].strip() + out.append(f"{indent}> {quote}{eol}") + continue + out.append(line) + return "".join(out) + + +def normalize_unicode_underline_headings(markup: str) -> str: + """Convert ``Heading`` + underline rule lines into markdown headings.""" + lines = markup.splitlines() + out: list[str] = [] + index = 0 + while index < len(lines): + body = lines[index].rstrip("\r\n") + if ( + index + 1 < len(lines) + and body.strip() + and _UNDERLINE_HEADING_RE.match(body.strip()) + and _UNICODE_RULE_LINE_RE.match(lines[index + 1].strip()) + ): + out.append(f"### {body.strip()}") + index += 2 + continue + if ( + index + 1 < len(lines) + and body.strip() + and not body.lstrip().startswith("#") + and _UNICODE_RULE_LINE_RE.match(lines[index + 1].strip()) + and len(body.strip()) <= 120 + ): + out.append(f"# {body.strip()}") + index += 2 + continue + if _UNICODE_RULE_LINE_RE.match(body.strip()): + index += 1 + continue + out.append(body) + index += 1 + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def _compact_field_value(label: str, value: str) -> str: + if label in {"Pythinker location", "Reference line", "Reference equivalent"}: + return compact_known_paths(value) + return value + + +def _field_table(title: str, fields: dict[str, str]) -> list[str]: + if not fields: + return [f"- ✓ {title}"] + rows = [ + f"| {label} | {_compact_field_value(label, value)} |" for label, value in fields.items() + ] + return [ + f"**{title}**", + "", + "| | |", + "| --- | --- |", + *rows, + "", + ] + + +def _parse_parity_items(lines: list[str], start: int, end: int) -> list[_ParityItem]: + items: list[_ParityItem] = [] + current: _ParityItem | None = None + for line in lines[start:end]: + stripped = line.strip() + if not stripped: + continue + field_md = _FIELD_LINE_RE.match(line) + if field_md is not None: + if current is not None: + current.fields[field_md.group(2).strip()] = field_md.group(3).strip() + continue + field = parse_aligned_field_line(line) + if field is not None: + if current is not None: + _, label, value = field + current.fields[label] = value + continue + bullet = _BULLET_RE.match(line) + if bullet is not None: + current = _ParityItem(title=bullet.group(2).strip()) + items.append(current) + return items + + +def _render_collapsed_parity(items: list[_ParityItem]) -> list[str]: + exact = sum(1 for item in items if item.status == "exact") + diverge = sum(1 for item in items if item.status == "diverge") + gaps = sum(1 for item in items if item.status == "gap") + out = [ + "| Status | Count |", + "| --- | --- |", + f"| ✓ Exact parity | {exact} |", + f"| ! Intentional divergence | {diverge} |", + f"| × Undocumented gaps | {gaps} |", + "", + ] + grouped: dict[str, list[_ParityItem]] = {} + for item in items: + grouped.setdefault(item.group, []).append(item) + + for group_name, group_items in grouped.items(): + out.append(f"#### {group_name}") + out.append("") + for item in group_items: + status_glyph = {"exact": "✓", "diverge": "!", "gap": "×"}.get(item.status, "•") + out.append(f"- {status_glyph} {item.title}") + out.append("") + return out + + +def collapse_parity_matrix(markup: str) -> str: + """Summarize large parity matrices as counts plus grouped checklists.""" + lines = markup.splitlines() + section_ranges: list[tuple[int, int, str]] = [] + section_start: int | None = None + section_title = "" + for index, line in enumerate(lines): + section_match = _SECTION_HEADING_RE.match(line) + if section_match is not None: + if section_start is not None: + section_ranges.append((section_start, index, section_title)) + section_start = index + 1 + section_title = section_match.group(3).strip().lower() + continue + if line.startswith("## ") and section_start is not None: + section_ranges.append((section_start, index, section_title)) + section_start = None + section_title = "" + if section_start is not None: + section_ranges.append((section_start, len(lines), section_title)) + + if not section_ranges: + return markup + + out: list[str] = [] + cursor = 0 + changed = False + for start, end, title in section_ranges: + out.extend(lines[cursor:start]) + cursor = start + if not any(hint in title for hint in _PARITY_SECTION_HINTS): + out.extend(lines[start:end]) + cursor = end + continue + items = _parse_parity_items(lines, start, end) + if len(items) < _COLLAPSE_PARITY_MIN_ITEMS: + out.extend(lines[start:end]) + cursor = end + continue + changed = True + out.extend(_render_collapsed_parity(items)) + cursor = end + out.extend(lines[cursor:]) + if not changed: + return markup + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def normalize_field_blocks(markup: str) -> str: + """Render parity/inventory field groups as compact tables instead of nested bullets.""" + lines = markup.splitlines() + out: list[str] = [] + index = 0 + changed = False + while index < len(lines): + line = lines[index] + bullet = _BULLET_RE.match(line) + if bullet is None or parse_aligned_field_line(line) is not None: + out.append(line) + index += 1 + continue + title = bullet.group(2).strip() + fields: dict[str, str] = {} + cursor = index + 1 + while cursor < len(lines): + candidate = lines[cursor] + if not candidate.strip(): + break + if _BULLET_RE.match(candidate) and not parse_aligned_field_line(candidate): + break + if _SECTION_HEADING_RE.match(candidate) or candidate.startswith("## "): + break + field = parse_aligned_field_line(candidate) + field_md = _FIELD_LINE_RE.match(candidate) + if field is not None: + _, label, value = field + fields[label] = value + cursor += 1 + continue + if field_md is not None: + fields[field_md.group(2).strip()] = field_md.group(3).strip() + cursor += 1 + continue + break + if len(fields) >= 2: + changed = True + out.extend(_field_table(title, fields)) + index = cursor + continue + out.append(line) + index += 1 + if not changed: + return markup + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def normalize_divergence_cards(markup: str) -> str: + """Turn Reference/Pythinker/Rationale/Spec/Verdict stacks into one table card.""" + lines = markup.splitlines() + out: list[str] = [] + index = 0 + changed = False + while index < len(lines): + line = lines[index] + heading = re.match(r"^#{1,4}\s+(\d+(?:\.\d+)?)\s+(.+)$", line.strip()) + if heading is None: + out.append(line) + index += 1 + continue + title = f"{heading.group(1)} {heading.group(2).strip()}" + cursor = index + 1 + fields: dict[str, str] = {} + while cursor < len(lines): + candidate = lines[cursor].strip() + if not candidate: + cursor += 1 + if fields: + break + continue + if candidate.startswith("#"): + break + label_match = _UNDERLINE_HEADING_RE.match(candidate) + if label_match is not None and cursor + 1 < len(lines): + body_lines: list[str] = [] + cursor += 2 + while cursor < len(lines): + body = lines[cursor].strip() + if not body: + cursor += 1 + break + if _UNDERLINE_HEADING_RE.match(body) or body.startswith("#"): + break + body_lines.append(body) + cursor += 1 + fields[label_match.group(1).title()] = " ".join(body_lines).strip() + continue + if candidate.startswith("### "): + label = candidate.removeprefix("### ").strip() + body_lines = [] + cursor += 1 + while cursor < len(lines): + body = lines[cursor].strip() + if not body: + cursor += 1 + break + if body.startswith("### ") or body.startswith("#"): + break + body_lines.append(body) + cursor += 1 + fields[label] = " ".join(body_lines).strip() + continue + break + if len(fields) >= 3 and {"Reference", "Pythinker", "Verdict"} & set(fields): + changed = True + out.append(f"#### {title}") + out.append("") + out.append("| | |") + out.append("| --- | --- |") + for label in ("Reference", "Pythinker", "Rationale", "Spec", "Verdict"): + if label in fields: + out.append(f"| {label} | {compact_known_paths(fields[label])} |") + out.append("") + index = cursor + continue + out.append(line) + index += 1 + if not changed: + return markup + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def normalize_command_result_blocks(markup: str) -> str: + """Convert Command/Expected/Result triplets into result-first checklist rows.""" + lines = markup.splitlines() + out: list[str] = [] + index = 0 + changed = False + while index < len(lines): + line = lines[index] + bullet = _BULLET_RE.match(line) + if bullet is None: + out.append(line) + index += 1 + continue + title = bullet.group(2).strip() + fields: dict[str, str] = {} + commands: list[str] = [] + cursor = index + 1 + while cursor < len(lines): + candidate = lines[cursor] + field = parse_aligned_field_line(candidate) + field_md = _FIELD_LINE_RE.match(candidate) + if field is not None: + _, label, value = field + fields[label] = value + if label.lower() == "command" and value: + commands.append(value) + cursor += 1 + continue + if field_md is not None: + label = field_md.group(2).strip() + value = field_md.group(3).strip() + fields[label] = value + if label.lower() == "command" and value: + commands.append(value) + cursor += 1 + continue + break + if not {"Command", "Expected", "Result"} & {k.title() for k in fields}: + out.append(line) + index += 1 + continue + changed = True + result = fields.get("Result") or fields.get("result") or fields.get("Expected") or "" + label = title or "Check" + out.append(f"- {result.strip()} **{label}**") + if result and fields.get("Expected"): + out.append(f" Expected: {fields['Expected']}") + for command in commands: + out.append(f" ```bash\n {command}\n ```") + out.append("") + index = cursor + if not changed: + return markup + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def normalize_verification_prose(markup: str) -> str: + """Turn inline ``Verification: cmd → result`` prose into compact check rows.""" + pattern = re.compile( + r"^(?P.*?Verification:\s*)" + r"(?P.+)$", + re.I, + ) + out_lines: list[str] = [] + changed = False + for line in markup.splitlines(): + match = pattern.match(line.strip()) + if match is None: + out_lines.append(line) + continue + changed = True + prefix = match.group("prefix").strip() + if prefix and prefix != "Verification:": + out_lines.append(prefix) + body = match.group("body") + chunks = re.split(r"\.\s+(?=uv run|make |pytest|ruff )", body) + out_lines.append("**Checks**") + out_lines.append("") + for chunk in chunks: + chunk = chunk.strip().rstrip(".") + if not chunk: + continue + if "→" in chunk: + cmd, result = chunk.split("→", 1) + out_lines.append(f"- {result.strip()} `{cmd.strip()}`") + else: + out_lines.append(f"- {chunk}") + out_lines.append("") + if not changed: + return markup + return "\n".join(out_lines) + ("\n" if markup.endswith("\n") else "") + + +def normalize_audit_header(markup: str) -> str: + """Promote the report title into a summary blockquote with optional verdict line.""" + lines = markup.splitlines() + if not lines: + return markup + title = "" + cursor = 0 + first = lines[0].strip() + if first.startswith("#"): + title = first.lstrip("#").strip() + cursor = 1 + elif first and not _UNICODE_RULE_LINE_RE.match(first): + title = first + cursor = 1 + if cursor < len(lines) and _UNICODE_RULE_LINE_RE.match(lines[cursor].strip()): + cursor += 1 + + verdict = "" + for line in lines: + lowered = line.lower() + if ( + "30/30" in line + or "parity verdict" in lowered + or "no code changes are required" in lowered + ): + verdict = line.strip().lstrip("-•> ") + break + + if not title: + return markup + + summary: list[str] = [f"# {title}", ""] + if verdict: + summary.extend([f"> **Verdict:** {verdict}", ""]) + summary.extend(lines[cursor:]) + result = "\n".join(summary) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def dedupe_final_verdict_section(markup: str) -> str: + """Drop a trailing ``Final answer`` section when a verdict section already exists.""" + if "final answer" not in markup.lower(): + return markup + if not re.search(r"verdict and recommendation|##\s+\d+\.\s+verdict", markup, re.I): + return markup + parts = re.split(r"\n(?:Final answer|## Final answer)\s*\n", markup, maxsplit=1, flags=re.I) + if len(parts) == 2: + return parts[0].rstrip() + "\n" + return markup + + +def _convert_space_aligned_basics(markup: str) -> str: + """Convert bullets and section headings before audit-specific passes.""" + from pythinker_code.ui.shell.markdown.normalizers import normalize_space_aligned_report_blocks + + converted = normalize_space_aligned_report_blocks(markup) + if converted == markup: + return markup + lines: list[str] = [] + for line in converted.splitlines(): + field_md = _FIELD_LINE_RE.match(line) + if field_md is not None: + label = field_md.group(2).strip() + value = compact_known_paths(field_md.group(3).strip()) + lines.append(f"{field_md.group(1)}- {label}: {value}") + continue + lines.append(line) + result = "\n".join(lines) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def normalize_audit_report(markup: str) -> str: + """Audit-specific normalization for dense parity/inventory agent reports.""" + if not detect_audit_report(markup): + return markup + + text = markup + text = normalize_unicode_underline_headings(text) + text = _convert_space_aligned_basics(text) + text = normalize_quote_gutters(text) + text = normalize_verification_prose(text) + text = collapse_parity_matrix(text) + text = normalize_field_blocks(text) + text = normalize_divergence_cards(text) + text = normalize_command_result_blocks(text) + text = normalize_audit_header(text) + text = dedupe_final_verdict_section(text) + return text diff --git a/src/pythinker_code/ui/shell/markdown/elements.py b/src/pythinker_code/ui/shell/markdown/elements.py new file mode 100644 index 00000000..e21e37ad --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/elements.py @@ -0,0 +1,199 @@ +"""Rich markdown element overrides for Pythinker reports.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from rich import box +from rich.console import Console, ConsoleOptions, Group, RenderResult +from rich.padding import Padding +from rich.panel import Panel +from rich.style import Style as RichStyle +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +from pythinker_code.ui.shell.render_constants import MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES +from pythinker_code.ui.shell.spacing import CODE_BLOCK_PADDING, blank_row +from pythinker_code.ui.theme import get_markdown_colors +from pythinker_code.utils.rich.markdown import CodeBlock, TableElement + +if TYPE_CHECKING: + pass + +_PRIORITY_MATRIX_ROW_RE = re.compile( + r"^\s*(?P[A-Z]{1,3}\d+)\s*(?:[─━—-]|\s){2,}\s*" + r"(?PCRITICAL|HIGH|MEDIUM|LOW|INFO)\s*$", + re.IGNORECASE, +) +_PRIORITY_MATRIX_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO") + + +def _priority_matrix_rows(code_text: str) -> list[tuple[str, str]] | None: + rows: list[tuple[str, str]] = [] + meaningful_lines = 0 + for line in code_text.splitlines(): + stripped = line.strip() + if not stripped or set(stripped) <= {"─", "━", "—", "-", " "}: + continue + meaningful_lines += 1 + match = _PRIORITY_MATRIX_ROW_RE.match(stripped) + if match is None: + return None + rows.append((match.group("id"), match.group("severity").upper())) + if len(rows) < 3 or meaningful_lines != len(rows): + return None + return rows + + +def _render_priority_matrix(rows: list[tuple[str, str]]) -> Table: + grouped: dict[str, list[str]] = {severity: [] for severity in _PRIORITY_MATRIX_SEVERITIES} + for item_id, severity in rows: + grouped.setdefault(severity, []).append(item_id) + + table = Table.grid(padding=(0, 2)) + table.add_column(justify="right", no_wrap=True) + table.add_column(no_wrap=False) + for severity in _PRIORITY_MATRIX_SEVERITIES: + items = grouped.get(severity) or [] + if not items: + continue + table.add_row(Text(severity.title(), style="bold"), " ".join(items)) + return table + + +class ReportTableElement(TableElement): + """Markdown tables that stay readable in long reports.""" + + def _header_cells(self) -> list[Text]: + if self.header is None or self.header.row is None: + return [] + return [cell.content for cell in self.header.row.cells] + + def _body_rows(self) -> list[list[Text]]: + if self.body is None: + return [] + return [[cell.content for cell in row.cells] for row in self.body.rows] + + def _should_stack(self, options: ConsoleOptions) -> bool: + headers = self._header_cells() + rows = self._body_rows() + column_count = len(headers) + if column_count <= 2 or not rows: + return False + + column_widths = [len(header.plain.strip()) for header in headers] + for row in rows: + for index, cell in enumerate(row[:column_count]): + column_widths[index] = max(column_widths[index], len(cell.plain.strip())) + longest_cell = max(column_widths, default=0) + estimated_grid_width = sum(min(width, 24) for width in column_widths) + column_count * 3 + 1 + available_width = options.max_width or 80 + + if column_count >= 4: + return longest_cell >= 24 or estimated_grid_width > available_width + return longest_cell >= 36 + + def _render_stacked(self) -> RenderResult: + headers = self._header_cells() + rows = self._body_rows() + detail_headers = headers[1:] + label_width = min( + max((len(header.plain.strip()) for header in detail_headers), default=0), + 22, + ) + + for index, row in enumerate(rows): + if index: + yield blank_row() + + title = Text("• ", style="markdown.item.bullet") + if row: + title_value = row[0].copy() + title.append_text(title_value) + title.stylize("markdown.strong", 2, len(title)) + detail_grid = Table.grid(expand=True, padding=(0, 2)) + detail_grid.add_column(width=max(1, label_width), no_wrap=True) + detail_grid.add_column(ratio=1, overflow="fold") + + has_details = False + for header, cell in zip(detail_headers, row[1:], strict=False): + label = header.plain.strip() + value = cell.copy() + if not value.plain.strip(): + value = Text("—", style="markdown.block_quote") + detail_grid.add_row(Text(label, style="markdown.strong"), value) + has_details = True + + if has_details: + yield Group(title, Padding(detail_grid, (0, 0, 0, 2))) + else: + yield title + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + if self._should_stack(options): + yield from self._render_stacked() + return + yield from super().__rich_console__(console, options) + + +class BorderedCodeBlock(CodeBlock): + """Code block with an aligned rounded frame and calm report styling.""" + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + code_text = str(self.text).rstrip("\n") + if self.lexer_name.strip().lower() in {"", "text", "plain", "markdown"}: + matrix_rows = _priority_matrix_rows(code_text) + if matrix_rows is not None: + yield blank_row() + yield _render_priority_matrix(matrix_rows) + yield blank_row() + return + + colors = get_markdown_colors() + border_style = RichStyle(color=colors.code_block_border, bold=True) + if isinstance(self.theme, str): + panel_style = Syntax.get_theme(self.theme).get_background_style() + syntax_bg: str | None = None + else: + panel_style = ( + RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle() + ) + syntax_bg = "default" + + lexer_name = self.lexer_name.strip() + title = lexer_name if lexer_name and lexer_name != "text" else None + line_count = code_text.count("\n") + 1 + if line_count > MAX_HIGHLIGHT_LINES or len(code_text) > MAX_HIGHLIGHT_BYTES: + highlighted = Text(code_text) + skip_notice = f"highlighting skipped ({line_count:,} lines)" + title = f"{title} · {skip_notice}" if title else skip_notice + else: + syntax = Syntax( + code_text, + self.lexer_name, + theme=self.theme, + word_wrap=True, + padding=0, + background_color=syntax_bg, + ) + highlighted = syntax.highlight(code_text) + highlighted.rstrip() + yield blank_row() + yield Panel( + highlighted, + title=title, + title_align="left", + box=box.ROUNDED, + border_style=border_style, + padding=CODE_BLOCK_PADDING, + expand=True, + style=panel_style, + ) + yield blank_row() + + +# Backward-compatible aliases for tests importing private names. +_ReportTableElement = ReportTableElement +_BorderedCodeBlock = BorderedCodeBlock diff --git a/src/pythinker_code/ui/shell/markdown/fences.py b/src/pythinker_code/ui/shell/markdown/fences.py new file mode 100644 index 00000000..f05c06a0 --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/fences.py @@ -0,0 +1,61 @@ +"""Shared fenced-code scanning for markdown normalizers.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from dataclasses import dataclass + +FENCE_RE = re.compile(r"^(?P {0,3})(?P`{3,}|~{3,})") + +MARKDOWN_FENCE_INFOS = frozenset({"md", "markdown"}) + + +@dataclass(slots=True) +class FenceState: + """Track CommonMark-style fenced-code regions while scanning line by line.""" + + active: bool = False + char: str = "" + length: int = 0 + + def feed(self, body: str, *, strict_close: bool = False) -> None: + """Update state from one line body (without trailing EOL).""" + match = FENCE_RE.match(body) + if self.active: + if match is not None: + fence = match.group("fence") + if fence[0] == self.char and len(fence) >= self.length: + if strict_close and body[match.end() :].strip(): + return + self.active = False + self.char = "" + self.length = 0 + return + if match is not None: + fence = match.group("fence") + self.active = True + self.char = fence[0] + self.length = len(fence) + + def copy(self) -> FenceState: + return FenceState(active=self.active, char=self.char, length=self.length) + + +def iter_fence_aware_lines( + markup: str, + *, + keepends: bool = True, + strict_close: bool = False, +) -> Iterator[tuple[str, bool]]: + """Yield ``(line, inside_fence)`` for each line in *markup*. + + *inside_fence* is True for lines that occur while a fence is open, + including the opening and closing fence markers. + """ + state = FenceState() + for line in markup.splitlines(keepends=keepends): + body = line.rstrip("\r\n") + inside = state.active + state.feed(body, strict_close=strict_close) + yield line, inside diff --git a/src/pythinker_code/ui/shell/markdown/normalizers.py b/src/pythinker_code/ui/shell/markdown/normalizers.py new file mode 100644 index 00000000..dd596772 --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/normalizers.py @@ -0,0 +1,658 @@ +"""Markdown normalization and LLM-output repair passes.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER +from pythinker_code.ui.shell.markdown.fences import ( + FENCE_RE, + MARKDOWN_FENCE_INFOS, + FenceState, +) + +if TYPE_CHECKING: + pass + +MAX_MARKDOWN_NORMALIZE_BYTES = 250_000 + +_MARKDOWN_ICON_REPLACEMENTS: dict[str, str] = { + "⏺": TRANSCRIPT_ASSISTANT_MARKER, + "✅": "✓", + "☑️": "✓", + "☑": "✓", + "✔️": "✓", + "✔": "✓", + "❌": "×", + "✖️": "×", + "✖": "×", + "🚫": "×", + "⚠️": "!", + "⚠": "!", + "🔴": "●", + "🟠": "●", + "🟡": "●", + "🟢": "●", + "🔵": "●", + "🟣": "●", + "⚫": "●", + "⚪": "○", + "🔍": "⌕", + "🔎": "⌕", + "📋": "▣", + "📝": "▣", + "📌": "•", +} +_MARKDOWN_ICON_KEYS: tuple[str, ...] = tuple( + sorted(_MARKDOWN_ICON_REPLACEMENTS, key=len, reverse=True) +) +_OL_ITEM_RE = re.compile(r"^\d+\.\s") +_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") +_DELIM_RUN_RE = re.compile(r"\|(?:\s*:?-{2,}:?\s*\|)+") +_HEADER_RE = re.compile(r"^(?P.*?)(?P(?:\|[^\n|]*)+\|)\s*$") +_UNICODE_RULE_LINE_RE = re.compile(r"^[─═━\-]{3,}$") +_CODE_SPAN_RE = re.compile(r"(?P`+)(?P.*?)(?P=ticks)") + + +@dataclass(frozen=True, slots=True) +class MarkdownNormalizationResult: + """Normalized markdown plus the repair passes that changed the input.""" + + text: str + applied: tuple[str, ...] + + +def _is_table_separator_line(line: str) -> bool: + return _TABLE_SEPARATOR_RE.match(line) is not None + + +def _is_table_header_fragment(fragment: str) -> bool: + stripped = fragment.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + return False + if _is_table_separator_line(stripped): + return False + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + return len(cells) >= 2 and any(cells) + + +def _find_crammed_table_header_start(line: str) -> int | None: + if line.lstrip().startswith("|"): + return None + for index, char in enumerate(line): + if char != "|" or not line[:index].strip(): + continue + if _is_table_header_fragment(line[index:]): + return index + return None + + +def repair_crammed_markdown_tables(markup: str) -> str: + """Split report headings accidentally glued to a following Markdown table.""" + if "|" not in markup: + return markup + + lines = markup.splitlines(keepends=True) + if len(lines) < 2: + return markup + + repaired: list[str] = [] + state = FenceState() + for index, line in enumerate(lines): + body = line.rstrip("\r\n") + eol = line[len(body) :] + if state.active: + repaired.append(line) + state.feed(body) + continue + match = FENCE_RE.match(body) + if match is not None: + state.feed(body) + repaired.append(line) + continue + + split_at: int | None = None + if index + 1 < len(lines): + next_body = lines[index + 1].rstrip("\r\n") + if _is_table_separator_line(next_body): + split_at = _find_crammed_table_header_start(body) + if split_at is None: + repaired.append(line) + continue + + prefix = body[:split_at].rstrip() + header = body[split_at:].lstrip() + if prefix: + repaired.append(f"{prefix}\n") + repaired.append(f"{header}{eol}") + + return "".join(repaired) + + +def _contains_markdown_table(lines: list[str]) -> bool: + previous: str | None = None + for raw in lines: + line = raw.strip() + if not line: + previous = None + continue + if ( + previous is not None + and _is_table_separator_line(line) + and _is_table_header_fragment(previous) + ): + return True + previous = line + return False + + +def unwrap_fenced_markdown_tables(markup: str) -> str: + """Unwrap ```md fences whose body contains a markdown table.""" + if "```" not in markup and "~~~" not in markup: + return markup + + lines = markup.splitlines(keepends=True) + out: list[str] = [] + in_other_fence = False + other_char = "" + other_len = 0 + i = 0 + while i < len(lines): + line = lines[i] + body = line.rstrip("\r\n") + match = FENCE_RE.match(body) + if in_other_fence: + out.append(line) + if match is not None: + fence = match.group("fence") + if ( + fence[0] == other_char + and len(fence) >= other_len + and not body[match.end() :].strip() + ): + in_other_fence = False + i += 1 + continue + if match is None: + out.append(line) + i += 1 + continue + fence = match.group("fence") + info = body[match.end() :].strip().lower() + if info not in MARKDOWN_FENCE_INFOS: + in_other_fence = True + other_char = fence[0] + other_len = len(fence) + out.append(line) + i += 1 + continue + + close_index: int | None = None + for j in range(i + 1, len(lines)): + inner_body = lines[j].rstrip("\r\n") + inner_match = FENCE_RE.match(inner_body) + if ( + inner_match is not None + and inner_match.group("fence")[0] == fence[0] + and len(inner_match.group("fence")) >= len(fence) + and not inner_body[inner_match.end() :].strip() + ): + close_index = j + break + if close_index is None: + out.append(line) + i += 1 + continue + + fenced_body = lines[i + 1 : close_index] + if not _contains_markdown_table([raw.rstrip("\r\n") for raw in fenced_body]): + out.extend(lines[i : close_index + 1]) + i = close_index + 1 + continue + + if out and out[-1].strip(): + out.append("\n") + out.extend(fenced_body) + next_line = lines[close_index + 1] if close_index + 1 < len(lines) else None + ends_blank = bool(fenced_body) and not fenced_body[-1].strip() + if next_line is not None and next_line.strip() and not ends_blank: + out.append("\n") + i = close_index + 1 + return "".join(out) + + +def _replace_report_icons(text: str) -> str: + if not any(icon in text for icon in _MARKDOWN_ICON_KEYS): + return text + out: list[str] = [] + i = 0 + inline_code_ticks = 0 + while i < len(text): + if text[i] == "`": + j = i + while j < len(text) and text[j] == "`": + j += 1 + tick_count = j - i + out.append(text[i:j]) + if inline_code_ticks == 0: + inline_code_ticks = tick_count + elif inline_code_ticks == tick_count: + inline_code_ticks = 0 + i = j + continue + if inline_code_ticks: + out.append(text[i]) + i += 1 + continue + for icon in _MARKDOWN_ICON_KEYS: + if text.startswith(icon, i): + out.append(_MARKDOWN_ICON_REPLACEMENTS[icon]) + i += len(icon) + break + else: + out.append(text[i]) + i += 1 + return "".join(out) + + +def simplify_markdown_report_icons(markup: str) -> str: + """Simplify report/status emoji outside fenced code blocks.""" + if not any(icon in markup for icon in _MARKDOWN_ICON_KEYS): + return markup + + lines: list[str] = [] + state = FenceState() + for line in markup.splitlines(keepends=True): + body = line.rstrip("\r\n") + if state.active: + lines.append(line) + state.feed(body) + continue + match = FENCE_RE.match(body) + if match is not None: + state.feed(body) + lines.append(line) + continue + lines.append(_replace_report_icons(line)) + return "".join(lines) + + +def _escape_code_span_pipes(text: str) -> str: + def _repl(match: re.Match[str]) -> str: + ticks = match.group("ticks") + body = re.sub(r"(? list[str]: + parts = re.split(r"(? bool: + stripped = line.strip() + return stripped.startswith("|") and stripped.count("|") >= 2 + + +def _delimiter_markers(run: str) -> list[str]: + markers: list[str] = [] + for cell in _split_pipe_cells(run): + left = cell.startswith(":") + right = cell.endswith(":") + if left and right: + markers.append(":---:") + elif right: + markers.append("---:") + elif left: + markers.append(":---") + else: + markers.append("---") + return markers + + +def normalize_table_block(text: str) -> str: + """Repair malformed GFM tables in a fence-free block of markdown.""" + out: list[str] = [] + while True: + match = _DELIM_RUN_RE.search(text) + if match is None: + return "".join(out) + text + markers = _delimiter_markers(match.group(0)) + n_cols = len(markers) + head = text[: match.start()] + tail = text[match.end() :] + + indent = head[head.rfind("\n") + 1 :] + if n_cols < 2 or indent.strip() != "": + out.append(text[: match.end()]) + text = tail + continue + + head_lines = head.split("\n") + while head_lines and head_lines[-1] == "": + head_lines.pop() + header_match = _HEADER_RE.match(head_lines[-1]) if head_lines else None + header_cells = ( + _split_pipe_cells(_escape_code_span_pipes(header_match.group("cells"))) + if header_match + else [] + ) + if header_match is None or len(header_cells) != n_cols: + out.append(text[: match.end()]) + text = tail + continue + + tail_lines = tail.split("\n") + data_segments = [tail_lines[0]] if tail_lines[0].strip() else [] + consumed = 1 + for line in tail_lines[1:]: + if _is_pipe_row(line): + data_segments.append(line) + consumed += 1 + else: + break + data_rows: list[list[str]] = [] + bail = False + for segment in data_segments: + cells = _split_pipe_cells(_escape_code_span_pipes(segment)) + if not cells: + continue + if len(cells) % n_cols != 0: + bail = True + break + for i in range(0, len(cells), n_cols): + data_rows.append(cells[i : i + n_cols]) + if bail: + out.append(text[: match.end()]) + text = tail + continue + + preamble = head_lines[:-1] + prose = header_match.group("prefix").rstrip() + if preamble: + out.append("\n".join(preamble) + "\n") + if prose: + out.append(prose + "\n") + block_so_far = "".join(out) + if block_so_far and not block_so_far.endswith("\n\n"): + out.append("\n" if block_so_far.endswith("\n") else "\n\n") + out.append(f"{indent}| " + " | ".join(header_cells) + " |\n") + out.append(f"{indent}| " + " | ".join(markers) + " |\n") + for row in data_rows: + out.append(f"{indent}| " + " | ".join(row) + " |\n") + + remainder = "\n".join(tail_lines[consumed:]) + if not remainder.strip(): + return "".join(out) + text = remainder if remainder.startswith("\n") else "\n" + remainder + + +def parse_aligned_field_line(line: str) -> tuple[str, str, str] | None: + """Parse `` Label value`` report rows; return indent, label, value.""" + stripped = line.rstrip() + if not stripped: + return None + first = stripped.lstrip() + if first.startswith(("•", "-", "|", "#", ">", "`")): + return None + match = re.match(r"^(\s*)(.+?)\s{2,}(.+)$", stripped) + if match is None: + return None + indent, label, value = match.group(1), match.group(2).strip(), match.group(3).strip() + if not label or not value or len(label) > 48 or len(label.split()) > 6: + return None + if not (label[0].isupper() or label == "LoC"): + return None + return indent, label, value + + +def normalize_space_aligned_report_blocks(markup: str) -> str: + """Convert LLM space-column report rows into nested Markdown lists.""" + if "•" not in markup: + return markup + + lines = markup.splitlines() + if sum(1 for line in lines if parse_aligned_field_line(line) is not None) < 3: + return markup + + out: list[str] = [] + state = FenceState() + last_field_idx: int | None = None + + index = 0 + while index < len(lines): + line = lines[index] + body = line.rstrip("\r\n") + if state.active: + out.append(line) + state.feed(body) + last_field_idx = None + index += 1 + continue + fence_match = FENCE_RE.match(body) + if fence_match is not None: + state.feed(body) + out.append(line) + last_field_idx = None + index += 1 + continue + + bullet_match = re.match(r"^(\s*)•\s+(.+)$", body) + if bullet_match is not None: + indent, text = bullet_match.groups() + out.append(f"{indent}- {text}") + last_field_idx = None + index += 1 + continue + + if ( + index + 1 < len(lines) + and body.strip() + and not body.lstrip().startswith("•") + and _UNICODE_RULE_LINE_RE.match(lines[index + 1].strip()) + ): + out.append(f"# {body.strip()}") + index += 2 + last_field_idx = None + continue + + section_match = re.match(r"^(\s*)(\d+)\.\s+(.+)$", body) + if section_match is not None: + _, number, title = section_match.groups() + out.append(f"## {number}. {title}") + last_field_idx = None + index += 1 + continue + + if _UNICODE_RULE_LINE_RE.match(body.strip()): + out.append("---") + last_field_idx = None + index += 1 + continue + + field = parse_aligned_field_line(body) + if field is not None: + indent, label, value = field + nest = " " if len(indent) >= 2 else "" + out.append(f"{nest}- {label}: {value}") + last_field_idx = len(out) - 1 + index += 1 + continue + + if last_field_idx is not None and re.match(r"^\s{6,}\S", body): + out[last_field_idx] = f"{out[last_field_idx]} {body.strip()}" + index += 1 + continue + + out.append(line) + last_field_idx = None + index += 1 + + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def loosen_tight_ordered_lists(markup: str, *, min_item_length: int = 0) -> str: + """Insert blank lines between consecutive ordered-list items. + + When *min_item_length* is greater than zero, only long items are loosened. + """ + lines = markup.splitlines(keepends=True) + out: list[str] = [] + state = FenceState() + prev_was_ol = False + + for line in lines: + body = line.rstrip("\r\n") + if state.active: + out.append(line) + state.feed(body) + prev_was_ol = False + continue + match = FENCE_RE.match(body) + if match is not None: + state.feed(body) + out.append(line) + prev_was_ol = False + continue + + is_ol = bool(_OL_ITEM_RE.match(line)) + if is_ol and prev_was_ol and len(line.strip()) > min_item_length: + out.append("\n") + out.append(line) + prev_was_ol = is_ol + + return "".join(out) + + +def normalize_markdown_tables(markup: str) -> str: + """Apply :func:`normalize_table_block` to every fence-free span of markup.""" + if "|" not in markup or "-" not in markup: + return markup + + out: list[str] = [] + buffer: list[str] = [] + state = FenceState() + + def flush() -> None: + if buffer: + out.append(normalize_table_block("\n".join(buffer))) + buffer.clear() + + for line in markup.splitlines(): + body = line + if state.active: + state.feed(body) + out.append(line) + continue + match = FENCE_RE.match(body) + if match is not None: + flush() + state.feed(body) + out.append(line) + continue + buffer.append(line) + flush() + + result = "\n".join(out) + if markup.endswith("\n") and not result.endswith("\n"): + result += "\n" + return result + + +def _record(applied: list[str], name: str, before: str, after: str) -> str: + if after != before: + applied.append(name) + return after + + +def normalize_model_markdown( + markup: str, + *, + report: bool = False, + audit: bool = False, + trace: bool = False, +) -> str | MarkdownNormalizationResult: + """Run the full model-markdown repair pipeline in a fixed, testable order.""" + applied: list[str] = [] + current = markup + + next_text = sanitize_ansi(current) + current = _record(applied, "ansi_sanitized", current, next_text) + + if len(current) > MAX_MARKDOWN_NORMALIZE_BYTES: + next_text = simplify_markdown_report_icons(current) + current = _record(applied, "size_guard_icons_only", current, next_text) + if trace: + return MarkdownNormalizationResult(current, tuple(applied)) + return current + + from pythinker_code.ui.shell.markdown.audit import detect_audit_report, normalize_audit_report + + use_audit = audit or detect_audit_report(current) + if use_audit: + current = _record(applied, "audit_report", current, normalize_audit_report(current)) + else: + current = _record( + applied, + "space_aligned_report_blocks", + current, + normalize_space_aligned_report_blocks(current), + ) + current = _record( + applied, + "unwrapped_markdown_table_fence", + current, + unwrap_fenced_markdown_tables(current), + ) + current = _record( + applied, + "repaired_crammed_table", + current, + repair_crammed_markdown_tables(current), + ) + current = _record( + applied, + "normalized_table", + current, + normalize_markdown_tables(current), + ) + if report: + current = _record( + applied, + "loosened_ordered_list", + current, + loosen_tight_ordered_lists(current, min_item_length=80), + ) + current = _record( + applied, + "simplified_icons", + current, + simplify_markdown_report_icons(current), + ) + + if trace: + return MarkdownNormalizationResult(current, tuple(applied)) + return current + + +# Backward-compatible private aliases used by tests and characterization pins. +_repair_crammed_markdown_tables = repair_crammed_markdown_tables +_unwrap_fenced_markdown_tables = unwrap_fenced_markdown_tables +_normalize_markdown_tables = normalize_markdown_tables +_normalize_table_block = normalize_table_block +_loosen_tight_ordered_lists = loosen_tight_ordered_lists +_simplify_markdown_report_icons = simplify_markdown_report_icons +_normalize_space_aligned_report_blocks = normalize_space_aligned_report_blocks +_parse_aligned_field_line = parse_aligned_field_line diff --git a/src/pythinker_code/ui/shell/markdown/renderer.py b/src/pythinker_code/ui/shell/markdown/renderer.py new file mode 100644 index 00000000..bae8fbb8 --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/renderer.py @@ -0,0 +1,84 @@ +"""Pythinker Rich markdown renderer.""" + +from __future__ import annotations + +from typing import Any, Literal + +from rich.console import Console, ConsoleOptions, RenderResult +from rich.style import Style as RichStyle +from rich.theme import Theme + +from pythinker_code.ui.shell.markdown.audit import detect_audit_report +from pythinker_code.ui.shell.markdown.elements import BorderedCodeBlock, ReportTableElement +from pythinker_code.ui.shell.markdown.normalizers import normalize_model_markdown +from pythinker_code.ui.theme import ThemeName +from pythinker_code.ui.theme.adapters.markdown import markdown_style_overrides +from pythinker_code.utils.rich.markdown import Markdown + +ReportKind = Literal["default", "audit"] + + +def _markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichStyle]: + return markdown_style_overrides(theme) + + +class PythinkerMarkdown(Markdown): + """Drop-in replacement for ``rich.markdown.Markdown`` with the Pythinker palette.""" + + elements = { + **Markdown.elements, + "fence": BorderedCodeBlock, + "code_block": BorderedCodeBlock, + "table_open": ReportTableElement, + } + + def __init__( + self, + markup: str, + *args: Any, + report: bool = False, + audit: bool = False, + **kwargs: Any, + ) -> None: + self._report_mode = report + use_audit = audit or (report and detect_audit_report(markup)) + normalized = normalize_model_markdown(markup, report=report, audit=use_audit) + assert isinstance(normalized, str) + super().__init__(normalized, *args, **kwargs) + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides + + overrides = ( + report_markdown_style_overrides() if self._report_mode else _markdown_style_overrides() + ) + with console.use_theme(Theme(overrides, inherit=True)): + yield from super().__rich_console__(console, options) + + +def pythinker_markdown( + text: str, + *, + code_theme: str | None = None, + audit: bool = False, +) -> PythinkerMarkdown: + """Build a :class:`PythinkerMarkdown` with the palette pre-wired.""" + return PythinkerMarkdown(text, code_theme=code_theme, audit=audit) + + +def pythinker_report_markdown( + text: str, + *, + code_theme: str | None = None, + style: str | RichStyle = "none", + report_kind: ReportKind = "default", +) -> PythinkerMarkdown: + """Report-body markdown: only H1 headings render bold.""" + audit = report_kind == "audit" + return PythinkerMarkdown( + text, + code_theme=code_theme, + style=style, + report=True, + audit=audit, + ) diff --git a/src/pythinker_code/ui/shell/markdown/streaming.py b/src/pythinker_code/ui/shell/markdown/streaming.py new file mode 100644 index 00000000..70845ba6 --- /dev/null +++ b/src/pythinker_code/ui/shell/markdown/streaming.py @@ -0,0 +1,109 @@ +"""Streaming markdown commit boundary helpers.""" + +from __future__ import annotations + +import functools +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from markdown_it import MarkdownIt + +MAX_STREAM_PARSE_BYTES = 16_000 +_SENTENCE_END = (".", "!", "?", ";") +_SELF_CLOSING_BLOCKS = frozenset(("fence", "code_block", "hr", "html_block")) + +_md_parser: MarkdownIt | None = None + + +def _get_md_parser() -> MarkdownIt: + global _md_parser + if _md_parser is None: + from markdown_it import MarkdownIt + + _md_parser = MarkdownIt().enable("strikethrough").enable("table") + return _md_parser + + +def _markdown_commit_boundary_uncached(text: str) -> int | None: + md = _get_md_parser() + tokens = md.parse(text) + + block_maps: list[list[int]] = [] + depth = 0 + for token in tokens: + if token.nesting == 1: + if depth == 0 and token.map is not None: + block_maps.append(token.map) + depth += 1 + elif token.nesting == -1: + depth -= 1 + elif depth == 0 and token.type in _SELF_CLOSING_BLOCKS and token.map is not None: + block_maps.append(token.map) + + if len(block_maps) < 2: + return None + + target_line = block_maps[-2][1] + offset = 0 + for _ in range(target_line): + offset = text.index("\n", offset) + 1 + return offset + + +@functools.lru_cache(maxsize=64) +def _markdown_commit_boundary_cached(text: str) -> int | None: + return _markdown_commit_boundary_uncached(text) + + +def _cheap_newline_boundary(text: str) -> int | None: + """Fallback for very large streams: commit at the last completed line.""" + if "\n\n" not in text: + return None + return text.rfind("\n\n") + 2 + + +def markdown_commit_boundary(text: str) -> int | None: + """Return the offset up to which streamed markdown can be committed.""" + if not text: + return None + if len(text) > MAX_STREAM_PARSE_BYTES: + return _cheap_newline_boundary(text) + return _markdown_commit_boundary_cached(text) + + +def _find_stream_safe_boundary(text: str) -> int | None: + boundary = markdown_commit_boundary(text) + if boundary is not None: + return boundary + + if "\n" in text: + return None + stripped = text.rstrip() + if stripped.endswith(_SENTENCE_END): + return len(text) + return None + + +@dataclass(slots=True) +class PythinkerMarkdownStream: + """Buffer streamed markdown deltas and yield safe-to-render slices.""" + + pending: str = field(default="") + + def push(self, delta: str) -> str | None: + self.pending += delta + cut = _find_stream_safe_boundary(self.pending) + if cut is None or cut == 0: + return None + ready = self.pending[:cut] + self.pending = self.pending[cut:] + return ready + + def flush(self) -> str | None: + if not self.pending.strip(): + self.pending = "" + return None + pending = self.pending + self.pending = "" + return pending diff --git a/src/pythinker_code/ui/theme/palettes.py b/src/pythinker_code/ui/theme/palettes.py index 07112ad1..01a39ca8 100644 --- a/src/pythinker_code/ui/theme/palettes.py +++ b/src/pythinker_code/ui/theme/palettes.py @@ -107,15 +107,18 @@ PromptToken.BASH_PREFIX: "#E5C07B", PromptToken.GHOST_TEXT: "#6B7280", PromptToken.PROMPT_GLYPH: "#F1F3F5", - PromptToken.FRAME: "#8a8d91", + # Border-family prompt tokens track the canonical core tokens so the + # prompt_toolkit and Rich layers render identical border hues (enforced by + # test_dark_theme_ptk_border_tracks_token). + PromptToken.FRAME: "#9AA3AD", # == _CORE_DARK["border"] PromptToken.EFFORT: "#A3A3A3", PromptToken.PLACEHOLDER: "#A3A3A3", - PromptToken.SEPARATOR: "#b8bcc0", + PromptToken.SEPARATOR: "#5D6570", # == _CORE_DARK["border_muted"] PromptToken.MENU_MATCH: "#8FDDEA", PromptToken.MENU_TEXT: "#F4F4F5", PromptToken.MENU_META: "#A3A3A3", PromptToken.DIALOG_TEXT: "#F4F4F5", - PromptToken.DIALOG_BORDER: "#b8bcc0", + PromptToken.DIALOG_BORDER: "#5D6570", # == _CORE_DARK["border_muted"] PromptToken.FOOTER_KEY: "#8FDDEA", PromptToken.FOOTER_META: "#A3A3A3", } diff --git a/tests/ui_and_conv/test_audit_report_rendering.py b/tests/ui_and_conv/test_audit_report_rendering.py new file mode 100644 index 00000000..9d681cf1 --- /dev/null +++ b/tests/ui_and_conv/test_audit_report_rendering.py @@ -0,0 +1,103 @@ +"""Tests for audit-report normalization and rendering.""" + +from __future__ import annotations + +from pythinker_code.ui.shell.components.render_utils import render_plain +from pythinker_code.ui.shell.components.report import render_agent_body +from pythinker_code.ui.shell.markdown.audit import ( + compact_known_paths, + detect_audit_report, + normalize_audit_report, + normalize_quote_gutters, +) +from pythinker_code.ui.shell.markdown.renderer import pythinker_report_markdown + + +def _plain(renderable: object, *, width: int = 100) -> str: + return render_plain(renderable, width=width) + + +def test_detect_audit_report_requires_parity_shape() -> None: + small = ( + "1. Parity Matrix\n\n" + "• Spawn race guard\n" + " Reference line LSPClient.ts:111-131\n" + " Status ✓ exact\n" + ) + assert detect_audit_report(small) is False + + +def test_quote_gutter_becomes_blockquote() -> None: + source = '▌ "Deferred until a safe global-write path exists."\n' + out = normalize_quote_gutters(source) + assert out.startswith("> ") + + +def test_compact_known_paths_strips_repo_prefix() -> None: + path = "src/pythinker_code/lsp/client.py:65-73" + assert compact_known_paths(path) == "lsp/client.py:65-73" + + +def test_small_parity_report_renders_field_tables() -> None: + sample = ( + "Deep Code Scan Analysis\n" + "═══════════════════════\n\n" + "1. Parity Matrix\n\n" + "• Spawn race guard (ENOENT → LspStartError)\n" + " Reference line LSPClient.ts:111-131\n" + " Pythinker location src/pythinker_code/lsp/client.py:65-73\n" + " Status ✓ exact\n\n" + "• Initialize handshake\n" + " Reference line LSPServerInstance.ts:167-272\n" + " Pythinker location src/pythinker_code/lsp/instance.py:200-252\n" + " Status ✓ exact\n" + ) + out = _plain(render_agent_body(sample), width=100) + assert "Spawn race guard" in out + assert "LSPClient.ts:111-131" in out + assert "lsp/client.py:65-73" in out + assert "Reference line LSPClient" not in out + + +def test_large_parity_matrix_collapses_to_summary() -> None: + rows = [] + for index in range(6): + rows.append(f"• Contract {index}") + rows.append(f" Reference line ref{index}.ts:{index}") + rows.append(f" Pythinker location src/pythinker_code/a/b{index}.py:{index}") + rows.append(" Status ✓ exact") + rows.append("") + sample = "1. Parity Matrix\n\n" + "\n".join(rows) + normalized = normalize_audit_report(sample) + assert "| ✓ Exact parity | 6 |" in normalized + assert "Reference line: ref0" not in normalized + + +def test_audit_report_kind_uses_report_styling() -> None: + md = pythinker_report_markdown("# Title\n\nBody.", report_kind="audit") + assert md._report_mode is True + + +def test_verification_prose_becomes_checklist() -> None: + sample = ( + "Verification: uv run --directory . pytest tests/tools/test_lsp_*.py -q → 64 passed. " + "uv run --directory . ruff check src/pythinker_code/lsp/ → All checks passed.\n" + ) + normalized = normalize_audit_report( + "Deep Code Scan\n" + "═══════════════════════\n\n" + "1. Parity Matrix\n\n" + + "\n".join( + [ + "• Item", + " Reference line a.ts:1", + " Pythinker location src/pythinker_code/a.py:1", + " Status ✓ exact", + ] + * 3 + ) + + "\n\n" + + sample + ) + assert "64 passed" in normalized + assert "All checks passed" in normalized or "ruff check" in normalized diff --git a/tests/ui_and_conv/test_md_normalization_matrix.py b/tests/ui_and_conv/test_md_normalization_matrix.py new file mode 100644 index 00000000..3f2c9165 --- /dev/null +++ b/tests/ui_and_conv/test_md_normalization_matrix.py @@ -0,0 +1,153 @@ +"""Regression matrix for the markdown normalization and rendering pipeline.""" + +from __future__ import annotations + +from pythinker_code.ui.shell.components.markdown import ( + PythinkerMarkdown, + PythinkerMarkdownStream, + markdown_commit_boundary, + normalize_model_markdown, +) +from pythinker_code.ui.shell.components.render_utils import render_plain +from pythinker_code.ui.shell.markdown.normalizers import ( + MAX_MARKDOWN_NORMALIZE_BYTES, + MarkdownNormalizationResult, + simplify_markdown_report_icons, + unwrap_fenced_markdown_tables, +) +from pythinker_code.ui.shell.render_constants import MAX_HIGHLIGHT_LINES + +_TABLE_BODY = "| Name | Value |\n|------|-------|\n| a | 1 |\n" + + +def _plain(renderable: object, *, width: int = 80) -> str: + return render_plain(renderable, width=width) + + +def test_ansi_escape_input_is_sanitized() -> None: + malicious = "Hello \x1b[31mred\x1b[0m \x1b[2J world" + out = _plain(PythinkerMarkdown(malicious)) + assert "Hello" in out and "world" in out + assert "\x1b" not in out + + +def test_normal_fenced_python_code_stays_fenced() -> None: + out = _plain(PythinkerMarkdown("```python\nx = 1\n```")) + assert "x = 1" in out + assert "╭" in out + + +def test_md_fence_with_table_gets_unwrapped() -> None: + markup = f"```markdown\n{_TABLE_BODY}```\n" + out = unwrap_fenced_markdown_tables(markup) + assert "```" not in out + assert "| Name | Value |" in out + + +def test_md_fence_without_table_stays_code() -> None: + markup = "```md\n# Just a heading\n\nProse only.\n```\n" + assert unwrap_fenced_markdown_tables(markup) == markup + rendered = _plain(PythinkerMarkdown(markup)) + assert "Just a heading" in rendered + + +def test_table_glued_to_heading_gets_repaired() -> None: + glued = "Medium| # | File |\n| --- | --- |\n| 1 | a.py |\n" + out = _plain(PythinkerMarkdown(glued), width=60) + assert "Medium" in out + assert "a.py" in out + + +def test_pipe_inside_inline_code_in_table_is_escaped() -> None: + md = "| A | B |\n| --- | --- |\n| 1 | `x|y` |\n" + out = _plain(PythinkerMarkdown(md), width=60) + assert "x|y" in out or "x" in out + + +def test_wide_table_stacks() -> None: + md = ( + "| Item | Reference line | Pythinker location | Status |\n" + "| --- | --- | --- | --- |\n" + "| Spawn race guard | LSPClient.ts:111-131 | src/pythinker_code/lsp/client.py:65-73 | exact |\n" + ) + out = _plain(PythinkerMarkdown(md), width=60) + assert "Spawn race guard" in out + assert "Reference line" in out + + +def test_compact_table_stays_grid() -> None: + md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n" + out = _plain(PythinkerMarkdown(md), width=40) + for token in ("A", "B", "1", "2"): + assert token in out + + +def test_huge_code_block_skips_highlighting() -> None: + code = "\n".join(f"x = {i}" for i in range(MAX_HIGHLIGHT_LINES + 1)) + out = _plain(PythinkerMarkdown(f"```python\n{code}\n```"), width=100) + assert "highlighting skipped" in out + + +def test_streaming_does_not_commit_incomplete_table() -> None: + full = "Intro.\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\nAfter.\n" + stream = PythinkerMarkdownStream() + committed: list[str] = [] + for char in full: + ready = stream.push(char) + if ready: + committed.append(ready) + tail = stream.flush() + if tail: + committed.append(tail) + for slice_ in committed[:-1]: + if "---" in slice_: + assert "| 1 | 2 |" in slice_ + assert "".join(committed) == full + + +def test_streaming_does_not_commit_incomplete_fenced_code() -> None: + partial = "Before.\n\n```python\ndef foo():\n pass" + boundary = markdown_commit_boundary(partial) + if boundary is not None: + assert "```python" not in partial[:boundary] or "def foo" in partial[:boundary] + + +def test_emoji_outside_code_changes_to_monochrome() -> None: + out = _plain(PythinkerMarkdown("Status ✅ failed")) + assert "✓" in out + assert "✅" not in out + + +def test_emoji_inside_inline_code_is_preserved() -> None: + source = "Use `✅` marker" + result = simplify_markdown_report_icons(source) + assert "`✅`" in result + + +def test_emoji_inside_fenced_code_is_preserved() -> None: + source = "```\n✅ still emoji\n```\n" + result = simplify_markdown_report_icons(source) + assert "✅ still emoji" in result + + +def test_ordered_lists_not_over_spaced_in_normal_mode() -> None: + text = "1. first\n2. second\n3. third\n" + normalized = normalize_model_markdown(text, report=False) + assert isinstance(normalized, str) + assert normalized == text + + +def test_normalize_model_markdown_trace_records_passes() -> None: + sample = "Status ✅\n\n```markdown\n| A | B |\n| --- | --- |\n| 1 | 2 |\n```\n" + result = normalize_model_markdown(sample, trace=True) + assert isinstance(result, MarkdownNormalizationResult) + assert result.text + assert "simplified_icons" in result.applied + + +def test_oversize_input_skips_heavy_normalizers() -> None: + huge = "x" * (MAX_MARKDOWN_NORMALIZE_BYTES + 1) + " ✅" + result = normalize_model_markdown(huge, trace=True) + assert isinstance(result, MarkdownNormalizationResult) + assert "size_guard_icons_only" in result.applied + assert "✓" in result.text diff --git a/tests/ui_and_conv/test_report.py b/tests/ui_and_conv/test_report.py index 738f621e..96cf3260 100644 --- a/tests/ui_and_conv/test_report.py +++ b/tests/ui_and_conv/test_report.py @@ -231,6 +231,28 @@ def test_render_agent_body_plain_markdown_unchanged(): assert "text" in out +def test_space_aligned_agent_report_renders_nested_fields(): + sample = ( + "Deep Code Scan Analysis\n" + "═══════════════════════\n\n" + "1. Parity Matrix\n\n" + "• Spawn race guard (ENOENT → LspStartError)\n" + " Reference line LSPClient.ts:111-131\n" + " Pythinker location src/pythinker_code/lsp/client.py:65-73\n" + " Status ✓ exact\n\n" + "• Initialize handshake\n" + " Reference line LSPServerInstance.ts:167-272\n" + " Pythinker location src/pythinker_code/lsp/instance.py:200-252\n" + " Status ✓ exact\n" + ) + out = _plain(render_agent_body(sample), width=100) + assert "Spawn race guard" in out + assert "Reference line" in out + assert "LSPClient.ts:111-131" in out + assert "Pythinker location" in out + assert "Reference line LSPClient" not in out + + def test_report_markdown_only_h1_is_bold(): from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index 702c115b..1de4164a 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -217,9 +217,10 @@ def test_card_style_finished_subagent_shows_compact_result(_force_card_style, mo def test_card_style_completed_subagent_has_blank_before_tools_rollup(_force_card_style): import json + from pythinker_core.tooling import ToolOk + from pythinker_code.ui.shell.tool_renderers import register_builtin_renderers from pythinker_code.wire.types import ToolResult - from pythinker_core.tooling import ToolOk register_builtin_renderers() block = _ToolCallBlock( diff --git a/tests/ui_and_conv/test_tui_streaming_phase0.py b/tests/ui_and_conv/test_tui_streaming_phase0.py index f0d178c4..17796010 100644 --- a/tests/ui_and_conv/test_tui_streaming_phase0.py +++ b/tests/ui_and_conv/test_tui_streaming_phase0.py @@ -139,7 +139,7 @@ def test_long_code_block_does_not_reparse_per_tick() -> None: fence = "```python\n" + "\n".join(f"x = {i}" for i in range(120)) + "\n```\n\nAfter.\n" text = "Intro.\n\n" + fence first = markdown_commit_boundary(text) - with patch("pythinker_code.ui.shell.components.markdown._get_md_parser") as parser_factory: + with patch("pythinker_code.ui.shell.markdown.streaming._get_md_parser") as parser_factory: parser_factory.side_effect = AssertionError("parse should be cached") second = markdown_commit_boundary(text) assert first == second From dfcc6b7258487232c64699959a5cb81d039ad86b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 16:58:22 -0400 Subject: [PATCH 08/26] fix(tui): correct plan approval prompt state and dialog chrome Show awaiting approval while ExitPlanMode waits for user choice, pause the Considering spinner during question/approval panels, reorder choices, and improve dialog spacing and copy. Also harden LSP frame reads and sync tests. --- src/pythinker_code/lsp/framing.py | 17 +++-- .../tools/lsp/symbol_context.py | 5 +- src/pythinker_code/tools/lsp/tool.py | 2 +- src/pythinker_code/tools/plan/__init__.py | 11 ++-- .../ui/shell/components/markdown.py | 4 ++ src/pythinker_code/ui/shell/markdown/audit.py | 6 +- .../ui/shell/markdown/normalizers.py | 3 +- src/pythinker_code/ui/shell/spacing.py | 2 +- .../ui/shell/tool_renderers/plan.py | 10 ++- .../ui/shell/visualize/_dialog_shell.py | 6 +- .../ui/shell/visualize/_interactive.py | 23 ++++++- .../ui/shell/visualize/_live_view.py | 26 +++++++- .../ui/shell/visualize/_question_panel.py | 62 +++++++++++++------ src/pythinker_code/wire/types.py | 2 + tests/core/test_agent_spec.py | 7 +++ tests/core/test_config.py | 6 ++ tests/ui_and_conv/test_prompt_tips.py | 6 ++ tests/ui_and_conv/test_question_panel.py | 56 +++++++++++++++++ tests/ui_and_conv/test_shell_design_system.py | 4 +- .../test_tui_blocks_integration.py | 4 +- .../test_tui_card_tool_renderers.py | 20 ++++-- .../test_visualize_running_prompt.py | 27 ++++++++ tests/utils/test_pyinstaller_utils.py | 5 ++ 23 files changed, 261 insertions(+), 53 deletions(-) diff --git a/src/pythinker_code/lsp/framing.py b/src/pythinker_code/lsp/framing.py index 3c63fc45..2464df58 100644 --- a/src/pythinker_code/lsp/framing.py +++ b/src/pythinker_code/lsp/framing.py @@ -8,6 +8,11 @@ from pythinker_host import AsyncReadable, AsyncWritable +# Upper bound on a single LSP frame body. A misbehaving or hostile server could +# otherwise send an enormous Content-Length and force an unbounded allocation in +# readexactly(). 64 MiB is far above any legitimate LSP payload. +MAX_CONTENT_LENGTH = 64 * 1024 * 1024 + class LspProtocolError(Exception): """Malformed or invalid LSP frame or JSON-RPC error response.""" @@ -32,7 +37,7 @@ async def read_message(stdout: AsyncReadable) -> dict[str, Any]: line = await stdout.readline() if not line: raise LspServerDown("unexpected EOF while reading header") - line_str = line.decode("ascii", errors="strict").rstrip("\r\n") + line_str = line.decode(encoding="utf-8", errors="strict").rstrip("\r\n") if line_str == "": break key, _, value = line_str.partition(":") @@ -46,6 +51,10 @@ async def read_message(stdout: AsyncReadable) -> dict[str, Any]: raise LspProtocolError("missing Content-Length header") if content_length < 0: raise LspProtocolError(f"invalid Content-Length: {content_length}") + if content_length > MAX_CONTENT_LENGTH: + raise LspProtocolError( + f"Content-Length {content_length} exceeds maximum {MAX_CONTENT_LENGTH}" + ) try: body = await stdout.readexactly(content_length) @@ -53,7 +62,7 @@ async def read_message(stdout: AsyncReadable) -> dict[str, Any]: raise LspServerDown("unexpected EOF while reading message body") from exc try: - payload = json.loads(body.decode("utf-8")) + payload = json.loads(body.decode(encoding="utf-8", errors="strict")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise LspProtocolError("invalid JSON body") from exc @@ -64,7 +73,7 @@ async def read_message(stdout: AsyncReadable) -> dict[str, Any]: async def write_message(stdin: AsyncWritable, message: dict[str, Any]) -> None: """Write one LSP message with Content-Length framing.""" - body = json.dumps(message, separators=(",", ":")).encode("utf-8") - header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body = json.dumps(message, separators=(",", ":")).encode() + header = f"Content-Length: {len(body)}\r\n\r\n".encode() stdin.write(header + body) await stdin.drain() diff --git a/src/pythinker_code/tools/lsp/symbol_context.py b/src/pythinker_code/tools/lsp/symbol_context.py index 97a348bb..c15951d4 100644 --- a/src/pythinker_code/tools/lsp/symbol_context.py +++ b/src/pythinker_code/tools/lsp/symbol_context.py @@ -41,10 +41,7 @@ def get_symbol_context( except OSError: return None - try: - content = chunk.decode("utf-8") - except UnicodeDecodeError: - return None + content = chunk.decode(encoding="utf-8", errors="replace") lines = content.splitlines() zero_line = line - 1 diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index ec9973bd..c7c774a5 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -227,7 +227,7 @@ async def _ensure_file_open( builder.mark_untrusted() return builder.ok(brief=_brief_for_path(display_path)) - content = await host_path.read_text(errors="replace") + content = await host_path.read_text(encoding="utf-8", errors="replace") await manager.open_file(absolute_path, content) return None diff --git a/src/pythinker_code/tools/plan/__init__.py b/src/pythinker_code/tools/plan/__init__.py index 76787987..e426bca9 100644 --- a/src/pythinker_code/tools/plan/__init__.py +++ b/src/pythinker_code/tools/plan/__init__.py @@ -203,11 +203,11 @@ def build_handoff_output(selected_option: str | None = None) -> str: _reject_options = [ QuestionOption( label="Reject", - description="Reject and stay in plan mode", + description="Stay in plan mode", ), QuestionOption( label="Reject and Exit", - description="Reject and exit plan mode", + description="Leave plan mode", ), ] @@ -222,7 +222,7 @@ def build_handoff_output(selected_option: str | None = None) -> str: question_options = [ QuestionOption( label="Approve", - description="Exit plan mode and start execution", + description="Start execution", ), *_reject_options, ] @@ -242,11 +242,12 @@ def build_handoff_output(selected_option: str | None = None) -> str: tool_call_id=tool_call.id, questions=[ QuestionItem( - question="Approve this plan", + question="Approve this plan?", header="Plan", options=question_options, other_label="Revise", - other_description="Stay in plan mode and provide feedback", + other_description="Stay in plan mode and edit plan", + other_index=1 if not has_options else len(question_options) - 2, ) ], ) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 02d1cd2a..40759479 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -3,6 +3,8 @@ Prefer importing from :mod:`pythinker_code.ui.shell.markdown` in new code. """ +# pyright: reportPrivateUsage=false +# This shim intentionally re-exports private internals for backward compat. from __future__ import annotations from pythinker_code.ui.shell.markdown import ( @@ -18,6 +20,7 @@ ) from pythinker_code.ui.shell.markdown.elements import _BorderedCodeBlock, _ReportTableElement from pythinker_code.ui.shell.markdown.normalizers import ( + _escape_code_span_pipes, _loosen_tight_ordered_lists, _normalize_markdown_tables, _normalize_space_aligned_report_blocks, @@ -57,6 +60,7 @@ __all__ += [ "_BorderedCodeBlock", "_ReportTableElement", + "_escape_code_span_pipes", "_get_md_parser", "_loosen_tight_ordered_lists", "_markdown_commit_boundary_cached", diff --git a/src/pythinker_code/ui/shell/markdown/audit.py b/src/pythinker_code/ui/shell/markdown/audit.py index 178b89f1..be7a39bb 100644 --- a/src/pythinker_code/ui/shell/markdown/audit.py +++ b/src/pythinker_code/ui/shell/markdown/audit.py @@ -7,7 +7,9 @@ from pythinker_code.ui.shell.markdown.fences import FENCE_RE, FenceState from pythinker_code.ui.shell.markdown.normalizers import ( - _UNICODE_RULE_LINE_RE, + UNICODE_RULE_LINE_RE as _UNICODE_RULE_LINE_RE, +) +from pythinker_code.ui.shell.markdown.normalizers import ( parse_aligned_field_line, ) @@ -88,7 +90,7 @@ @dataclass(slots=True) class _ParityItem: title: str - fields: dict[str, str] = field(default_factory=dict) + fields: dict[str, str] = field(default_factory=lambda: {}) @property def status(self) -> str: diff --git a/src/pythinker_code/ui/shell/markdown/normalizers.py b/src/pythinker_code/ui/shell/markdown/normalizers.py index dd596772..9159aed4 100644 --- a/src/pythinker_code/ui/shell/markdown/normalizers.py +++ b/src/pythinker_code/ui/shell/markdown/normalizers.py @@ -53,7 +53,8 @@ _TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") _DELIM_RUN_RE = re.compile(r"\|(?:\s*:?-{2,}:?\s*\|)+") _HEADER_RE = re.compile(r"^(?P.*?)(?P(?:\|[^\n|]*)+\|)\s*$") -_UNICODE_RULE_LINE_RE = re.compile(r"^[─═━\-]{3,}$") +UNICODE_RULE_LINE_RE = re.compile(r"^[─═━\-]{3,}$") +_UNICODE_RULE_LINE_RE = UNICODE_RULE_LINE_RE # ponytail: compat alias for compat shim _CODE_SPAN_RE = re.compile(r"(?P`+)(?P.*?)(?P=ticks)") diff --git a/src/pythinker_code/ui/shell/spacing.py b/src/pythinker_code/ui/shell/spacing.py index c55b2e6f..8176479c 100644 --- a/src/pythinker_code/ui/shell/spacing.py +++ b/src/pythinker_code/ui/shell/spacing.py @@ -55,7 +55,7 @@ #: the stream spacer is the only inter-block gap; horizontal gives the tint breathing room. CARD_PADDING: Final = (0, 1) TINTED_CARD_PADDING: Final = (0, 1) -DIALOG_PANEL_PADDING: Final = (0, 1) +DIALOG_PANEL_PADDING: Final = (1, 1) WORKLOG_PANEL_PADDING: Final = (0, 1) #: Long-form report/error cards are standalone reading surfaces. They get internal diff --git a/src/pythinker_code/ui/shell/tool_renderers/plan.py b/src/pythinker_code/ui/shell/tool_renderers/plan.py index 6658a901..b53068ce 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/plan.py +++ b/src/pythinker_code/ui/shell/tool_renderers/plan.py @@ -61,11 +61,19 @@ def _render_plan_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Re # --------------------------------------------------------------------------- +def _exit_plan_phase(ctx: ToolRenderContext) -> str: + """Label plan-exit phase for the tool card header.""" + if ctx.has_result: + return "exiting" + return "awaiting approval" + + def _render_exit_call(ctx: ToolRenderContext) -> RenderableType: args = ctx.args or {} options = args.get("options") style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" - line = tool_call_header("Plan", fg("muted", "exiting"), style_token=style_token) + phase = _exit_plan_phase(ctx) + line = tool_call_header("Plan", fg("muted", phase), style_token=style_token) if not isinstance(options, list) or not options: return running_spinner( diff --git a/src/pythinker_code/ui/shell/visualize/_dialog_shell.py b/src/pythinker_code/ui/shell/visualize/_dialog_shell.py index 2e9d2ba8..5745bb74 100644 --- a/src/pythinker_code/ui/shell/visualize/_dialog_shell.py +++ b/src/pythinker_code/ui/shell/visualize/_dialog_shell.py @@ -27,12 +27,12 @@ class DialogOption: def _render_option(option: DialogOption) -> Text: - prefix = "→" if option.selected else " " + prefix = "›" if option.selected else " " key = f"[{option.key}] " if option.key else "" style = tui_rich_style("accent") if option.selected else tui_rich_style("muted") text = Text(f"{prefix} {key}{option.label}", style=style) if option.description: - text.append(f" {option.description}", style="dim") + text.append(f" {option.description}", style=tui_rich_style("muted")) return text @@ -49,6 +49,8 @@ def render_dialog( if border_style is None: border_style = tui_rich_style("border_muted") lines: list[RenderableType] = [] + if body: + lines.append(blank_row()) lines.extend(body) if body and options: lines.append(blank_row()) diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 71e6495c..1fb0ba7e 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -288,17 +288,26 @@ async def visualize_loop(self, wire: WireUISide): status_refresh_task = asyncio.create_task(self._status_refresh_loop()) self._status_refresh_task = status_refresh_task while True: + from_external = False try: done, _ = await asyncio.wait( - [wire_task, external_task], + [wire_task, external_task, status_refresh_task], return_when=asyncio.FIRST_COMPLETED, ) + if status_refresh_task in done: + # The status loop is expected to run until cancelled at + # shutdown. If it finished while the main loop is live it + # raised — surface that instead of silently freezing the + # prompt repaint clock. + status_refresh_task.result() + raise RuntimeError("prompt status refresh loop exited unexpectedly") if wire_task in done: msg = wire_task.result() wire_task = asyncio.create_task(wire.receive()) else: msg = external_task.result() external_task = asyncio.create_task(self._external_messages.get()) + from_external = True except QueueShutDown: msg, external_task = await self._drain_external_message_after_wire_shutdown( external_task @@ -329,6 +338,11 @@ async def visualize_loop(self, wire: WireUISide): continue self.dispatch_wire_message(msg) + if from_external: + # External (out-of-band) messages — approval requests, steer + # input — are interactive and must repaint at once rather than + # wait for the status refresh cadence. + self._force_refresh = True self._flush_prompt_refresh() # NOTE: btw dismiss waiting is handled by the shell layer @@ -560,7 +574,12 @@ def render_agent_status(self, columns: int) -> ANSI: def render_pinned_status_tail(self, columns: int) -> ANSI: """Render the trailing verb spinner that the prompt keeps pinned below a (possibly clipped) agent stream, so it stays visible above the input.""" - if self._turn_ended or self._active_turn_depth <= 0: + if ( + self._turn_ended + or self._active_turn_depth <= 0 + or self._current_question_panel is not None + or self._current_approval_request_panel is not None + ): return ANSI("") body = render_to_ansi(self._working_indicator(), columns=columns).rstrip("\n") return ANSI(body if body else "") diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index c3636adb..a3f00293 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -384,17 +384,28 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: frame_task = asyncio.create_task(self._frame_refresh_loop(live)) try: while True: + from_external = False try: done, _ = await asyncio.wait( - [wire_task, external_task], + [wire_task, external_task, frame_task], return_when=asyncio.FIRST_COMPLETED, ) + if frame_task in done: + # The frame loop is expected to run until it is + # cancelled at shutdown. If it finished while the + # main loop is still live it raised — surface that + # instead of silently freezing the live view. + frame_task.result() + raise RuntimeError( + "live-view frame refresh loop exited unexpectedly" + ) if wire_task in done: msg = wire_task.result() wire_task = asyncio.create_task(wire.receive()) else: msg = external_task.result() external_task = asyncio.create_task(self._external_messages.get()) + from_external = True except QueueShutDown: ( msg, @@ -404,6 +415,7 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: ) if msg is not None: self.dispatch_wire_message(msg) + self._flush_live_refresh(live, force=True) continue self.cleanup(is_interrupt=False) self._flush_live_refresh(live, force=True) @@ -415,6 +427,11 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: break self.dispatch_wire_message(msg) + if from_external: + # External (out-of-band) messages — approval requests, + # steer input — are interactive and must paint at once + # rather than wait for the streaming frame budget. + self._flush_live_refresh(live, force=True) finally: frame_task.cancel() wire_task.cancel() @@ -609,7 +626,12 @@ def compose_agent_output( _append_action_block(blocks, tool_call.compose(), leading=True) for hook_block in getattr(self, "_hook_blocks", {}).values(): _append_action_block(blocks, hook_block.compose(), leading=True) - if include_working_indicator and self._active_turn_depth > 0: + if ( + include_working_indicator + and self._active_turn_depth > 0 + and self._current_question_panel is None + and self._current_approval_request_panel is None + ): # Keep a stable activity indicator visible for the whole turn — # even while content or tool cards are already on-screen, and even # while a foreground tool runs. The agent is still working the diff --git a/src/pythinker_code/ui/shell/visualize/_question_panel.py b/src/pythinker_code/ui/shell/visualize/_question_panel.py index a3e7f4d2..991627a2 100644 --- a/src/pythinker_code/ui/shell/visualize/_question_panel.py +++ b/src/pythinker_code/ui/shell/visualize/_question_panel.py @@ -35,6 +35,22 @@ def _safe_markup_text(text: str) -> str: return escape(_safe_display_text(text)) +def _dialog_title_for_question(header: str) -> str: + normalized = _safe_display_text(header).strip() + if normalized.lower() == "plan": + return "Plan approval" + if normalized: + return normalized + return "Question" + + +def _question_prompt_text(question: str) -> str: + text = _safe_display_text(question).strip() + if text and not text.endswith("?"): + return f"{text}?" + return text + + class QuestionRequestPanel: """Renders structured questions for the user to answer interactively.""" @@ -48,6 +64,7 @@ def __init__(self, request: QuestionRequest): self._multi_selected: set[int] = set() self._body_text: str = "" self.has_expandable_content: bool = False + self._other_index = 0 self._setup_current_question() def _setup_current_question(self) -> None: @@ -57,7 +74,14 @@ def _setup_current_question(self) -> None: ] other_label = _safe_display_text(q.other_label) or OTHER_OPTION_LABEL other_desc = _safe_display_text(q.other_description) if q.other_description else "" - self._options.append((other_label, other_desc)) + other_entry = (other_label, other_desc) + if q.other_index >= 0: + insert_at = min(q.other_index, len(self._options)) + self._options.insert(insert_at, other_entry) + self._other_index = insert_at + else: + self._options.append(other_entry) + self._other_index = len(self._options) - 1 idx = self._current_question_index if idx in self._saved_selections: saved_idx, saved_multi = self._saved_selections[idx] @@ -67,13 +91,17 @@ def _setup_current_question(self) -> None: answer = self._answers[q.question] if q.multi_select: answer_labels = [a.strip() for a in answer.split(", ")] - known_labels = {label for label, _ in self._options[:-1]} + known_labels = { + label for i, (label, _) in enumerate(self._options) if i != self._other_index + } self._multi_selected = set() - for i, (label, _) in enumerate(self._options[:-1]): + for i, (label, _) in enumerate(self._options): + if i == self._other_index: + continue if label in answer_labels: self._multi_selected.add(i) if any(answer_label not in known_labels for answer_label in answer_labels): - self._multi_selected.add(len(self._options) - 1) + self._multi_selected.add(self._other_index) self._selected_index = min(self._multi_selected) if self._multi_selected else 0 else: for i, (label, _) in enumerate(self._options): @@ -81,7 +109,7 @@ def _setup_current_question(self) -> None: self._selected_index = i break else: - self._selected_index = len(self._options) - 1 + self._selected_index = self._other_index self._multi_selected = set() else: self._selected_index = 0 @@ -99,7 +127,7 @@ def _current_question(self): @property def is_other_selected(self) -> bool: - return self._selected_index == len(self._options) - 1 + return self._selected_index == self._other_index @property def is_multi_select(self) -> bool: @@ -107,13 +135,12 @@ def is_multi_select(self) -> bool: @property def current_question_text(self) -> str: - return _safe_display_text(self._current_question.question) + return _question_prompt_text(self._current_question.question) def should_prompt_other_input(self) -> bool: if not self.is_multi_select: return self.is_other_selected - other_idx = len(self._options) - 1 - return other_idx in self._multi_selected + return self._other_index in self._multi_selected def select_index(self, index: int) -> bool: if not (0 <= index < len(self._options)): @@ -140,8 +167,8 @@ def render(self, *, other_input_text: str | None = None) -> RenderableType: lines.append(Text.from_markup(" ".join(tab_parts))) lines.append(blank_row()) - q_markup = f"[{_tok.warning}]{QUESTION_MARKER} {_safe_markup_text(q.question)}[/]" - lines.append(Text.from_markup(q_markup)) + q_markup = _question_prompt_text(q.question) + lines.append(Text(q_markup, style=tui_rich_style("text"))) if q.multi_select: lines.append(Text(" (SPACE to toggle, ENTER to submit)", style="dim italic")) lines.append(blank_row()) @@ -159,7 +186,7 @@ def render(self, *, other_input_text: str | None = None) -> RenderableType: option_rows: list[tuple[str, str]] = [] for i, (label, description) in enumerate(self._options): - is_other = i == len(self._options) - 1 + is_other = i == self._other_index if q.multi_select: checked = "\u2713" if i in self._multi_selected else " " option_label = f"[{checked}] {label}" @@ -194,7 +221,7 @@ def render(self, *, other_input_text: str | None = None) -> RenderableType: ] return render_dialog( kind="question", - title="question", + title=_dialog_title_for_question(q.header), body=lines, options=dialog_options, footer=footer, @@ -247,11 +274,11 @@ def toggle_select(self) -> None: def submit(self) -> bool: q = self._current_question if q.multi_select: - other_idx = len(self._options) - 1 + other_idx = self._other_index if other_idx in self._multi_selected: return False selected_labels = [ - self._options[i][0] for i in sorted(self._multi_selected) if i < len(q.options) + self._options[i][0] for i in sorted(self._multi_selected) if i != self._other_index ] if not selected_labels: return False @@ -267,11 +294,8 @@ def submit(self) -> bool: def submit_other(self, text: str) -> bool: q = self._current_question if q.multi_select: - other_idx = len(self._options) - 1 selected_labels = [ - self._options[i][0] - for i in sorted(self._multi_selected) - if i < len(q.options) and i != other_idx + self._options[i][0] for i in sorted(self._multi_selected) if i != self._other_index ] if text: selected_labels.append(text) diff --git a/src/pythinker_code/wire/types.py b/src/pythinker_code/wire/types.py index 4a2e2d80..385334b4 100644 --- a/src/pythinker_code/wire/types.py +++ b/src/pythinker_code/wire/types.py @@ -510,6 +510,8 @@ class QuestionItem(BaseModel): """Custom label for the synthetic 'Other' free-text option. Empty uses default.""" other_description: str = "" """Custom description for the synthetic 'Other' option. Empty uses default.""" + other_index: int = -1 + """0-based index for the free-text option. ``-1`` appends it after all fixed options.""" class QuestionResponse(BaseModel): diff --git a/tests/core/test_agent_spec.py b/tests/core/test_agent_spec.py index 3f8a9834..fcda1ccf 100644 --- a/tests/core/test_agent_spec.py +++ b/tests/core/test_agent_spec.py @@ -56,6 +56,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", @@ -222,6 +223,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.skill:ReadSkill", @@ -266,6 +268,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", @@ -398,6 +401,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", @@ -540,6 +544,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", @@ -667,6 +672,7 @@ def test_load_default_agent_spec(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", @@ -842,6 +848,7 @@ def test_load_agent_spec_default_extension(): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.web:SearchWeb", diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 71f55e3e..9d56e87e 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -113,6 +113,12 @@ def test_default_config_dump(): "disabled": [], "options": {}, }, + "lsp": { + "enabled": True, + "recommendation_disabled": False, + "recommendation_never": [], + "recommendation_ignored_count": 0, + }, "extra_skill_dirs": [], "telemetry": True, "session_retention_days": 30, diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 14a19ca4..c8dd1bda 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -1412,6 +1412,12 @@ def test_bottom_toolbar_hides_status_while_slash_menu_is_active(monkeypatch: Any ) prompt_session = object.__new__(CustomPromptSession) prompt_session._session = cast(Any, SimpleNamespace(default_buffer=default_buffer)) + # _render_bottom_toolbar consults the active-mode slash completer to decide + # whether the menu is showing; a bare instance needs both wired up. + prompt_session._mode = PromptMode.AGENT + prompt_session._agent_slash_completer = cast( + Any, SimpleNamespace(completion_active=lambda _document: True) + ) monkeypatch.setattr(shell_prompt, "get_app_or_none", lambda: object()) diff --git a/tests/ui_and_conv/test_question_panel.py b/tests/ui_and_conv/test_question_panel.py index 1a7deafe..29d441d0 100644 --- a/tests/ui_and_conv/test_question_panel.py +++ b/tests/ui_and_conv/test_question_panel.py @@ -6,6 +6,7 @@ from rich.console import Console +from pythinker_code.ui.shell.glyphs import QUESTION_MARKER from pythinker_code.ui.shell.visualize import QuestionRequestPanel from pythinker_code.wire.types import QuestionItem, QuestionOption, QuestionRequest @@ -703,3 +704,58 @@ def test_toggle_select_noop_in_single_select(): all_done = panel.submit() assert all_done is True assert panel.get_answers() == {"Pick one?": "A"} + + +def test_plan_approval_dialog_title_and_option_order(): + request = QuestionRequest( + id="qr-plan", + tool_call_id="tc-plan", + questions=[ + QuestionItem( + question="Approve this plan?", + header="Plan", + options=[ + QuestionOption(label="Approve", description="Start execution"), + QuestionOption(label="Reject", description="Stay in plan mode"), + QuestionOption( + label="Reject and Exit", + description="Leave plan mode", + ), + ], + other_label="Revise", + other_description="Stay in plan mode and edit plan", + other_index=1, + ) + ], + ) + panel = QuestionRequestPanel(request) + rendered = _render_to_str(panel) + + assert "Plan approval" in rendered + assert "Approve this plan?" in rendered + assert QUESTION_MARKER not in rendered.split("Approve this plan?")[1] + labels = [label for label, _ in panel._options] + assert labels == ["Approve", "Revise", "Reject", "Reject and Exit"] + + +def test_other_index_inserts_free_text_option_before_trailing_choices(): + request = QuestionRequest( + id="qr-other-index", + tool_call_id="tc-other-index", + questions=[ + QuestionItem( + question="Pick?", + options=[ + QuestionOption(label="A", description=""), + QuestionOption(label="Reject", description=""), + ], + other_label="Revise", + other_index=1, + ) + ], + ) + panel = QuestionRequestPanel(request) + assert [label for label, _ in panel._options] == ["A", "Revise", "Reject"] + assert panel.is_other_selected is False + panel.move_down() + assert panel.is_other_selected is True diff --git a/tests/ui_and_conv/test_shell_design_system.py b/tests/ui_and_conv/test_shell_design_system.py index 006833d6..bdb3d94e 100644 --- a/tests/ui_and_conv/test_shell_design_system.py +++ b/tests/ui_and_conv/test_shell_design_system.py @@ -81,8 +81,8 @@ def test_shell_style_resolves_brand_tokens_and_switches_theme(): from pythinker_code.ui.theme import set_active_theme set_active_theme("dark") - assert _color_hex(shell_style(ShellTone.ACCENT)) == "#b3b9f4" - assert _color_hex(shell_style(ShellTone.SUCCESS)) == "#7bc97f" + assert _color_hex(shell_style(ShellTone.ACCENT)) == "#a9b4ff" + assert _color_hex(shell_style(ShellTone.SUCCESS)) == "#7ccf8a" set_active_theme("light") assert _color_hex(shell_style(ShellTone.ACCENT)) == "#0b114e" set_active_theme("dark") diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index 1de4164a..d730dcfd 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -237,9 +237,7 @@ def test_card_style_completed_subagent_has_blank_before_tools_rollup(_force_card ), ) block.append_sub_tool_call(call) - block.finish_sub_tool_call( - ToolResult(tool_call_id=call.id, return_value=ToolOk(output="")) - ) + block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) rendered = render_plain(block.compose(), width=120) lines = [line.rstrip() for line in rendered.splitlines()] diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 58ba5be7..31311ea3 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -1166,7 +1166,20 @@ def test_enter_plan_mode_renders(): def test_exit_plan_mode_renders_options(): - rendered = _render( + rendered_running = _render_running( + "ExitPlanMode", + { + "options": [ + {"label": "Refactor first"}, + {"label": "Add tests first"}, + ] + }, + ) + assert "⏺ Plan(awaiting approval)" in rendered_running + assert "Refactor first" in rendered_running + assert "Add tests first" in rendered_running + + rendered_done = _render( "ExitPlanMode", { "options": [ @@ -1174,10 +1187,9 @@ def test_exit_plan_mode_renders_options(): {"label": "Add tests first"}, ] }, + output="Plan approved", ) - assert "⏺ Plan(exiting)" in rendered - assert "Refactor first" in rendered - assert "Add tests first" in rendered + assert "⏺ Plan(exiting)" in rendered_done # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 3e69803b..8dadc63c 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -143,6 +143,33 @@ def test_render_pinned_status_tail_empty_when_turn_inactive() -> None: assert view2.render_pinned_status_tail(80).value == "" +def test_render_pinned_status_tail_empty_while_question_panel_open() -> None: + import time as _time + + from pythinker_code.ui.shell.visualize import QuestionRequestPanel + from pythinker_code.wire.types import QuestionItem, QuestionOption, QuestionRequest + + view = object.__new__(_PromptLiveView) + view._turn_ended = False + view._active_turn_depth = 1 + view._turn_start_time = _time.monotonic() + view._current_approval_request_panel = None + view._current_question_panel = QuestionRequestPanel( + QuestionRequest( + id="qr", + tool_call_id="tc", + questions=[ + QuestionItem( + question="Approve this plan?", + options=[QuestionOption(label="Approve", description="")], + ) + ], + ) + ) + + assert view.render_pinned_status_tail(80).value == "" + + def test_pinned_tail_stays_visible_while_foreground_tool_executes() -> None: """The shimmer verb spinner stays pinned for the whole active turn — including while a foreground tool (e.g. a server started via the shell tool, or a diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index a7a62471..c6f83074 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -329,6 +329,11 @@ def test_pyinstaller_hiddenimports(): "pythinker_code.tools.file.utils", "pythinker_code.tools.file.write", "pythinker_code.tools.goal", + "pythinker_code.tools.lsp", + "pythinker_code.tools.lsp.formatters", + "pythinker_code.tools.lsp.schemas", + "pythinker_code.tools.lsp.symbol_context", + "pythinker_code.tools.lsp.tool", "pythinker_code.tools.mcp_resource", "pythinker_code.tools.memory", "pythinker_code.tools.memory.routing_guard", From 3257ea51fd942c0fca27dd79d1aebbeb101d8d1a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 17:41:44 -0400 Subject: [PATCH 09/26] fix(tui+lsp): resolve CI failures and CodeRabbit findings; harden RunAgents Make the PR branch green and address the review. CI failures (15 tests + ruff): - Update agent-spec / config-dump / pyinstaller snapshots for the new LSP tool - Add explicit encoding to LSP framing/symbol_context/tool sources (static check) - Sync dark prompt frame/separator/dialog borders to their core theme tokens - Flush the live view immediately for external (approval/steer) messages and supervise the frame/status refresh loops so a refresh failure surfaces instead of silently freezing the view (fixes 3 external-approval tests) - Wire `_mode` + completer onto the bottom-toolbar unit test's bare session - Fix import sorting / line length CodeRabbit findings: - Critical: inline code spans now actually clear an inherited background (Rich `Style(bgcolor=None)` is a no-op; mutate the copied style instead) - LSP: bounded JSON-RPC frame size + graceful-shutdown timeout, didChange document version tracking, open-doc state cleared on restart, empty diagnostics payloads clear stale entries, code-0 dedup key, TOCTOU re-check, documentSymbol file_count - Theme picker: compare resolved themes by identity so distinct Catppuccin variants no longer match - Usage activity: anchor the chart to the payload window (not wall-clock) and fix the current-streak partial-today contract - Config: validate LSP numeric settings; `/usage` rejects extra activity args; `/theme code ` preserves case; guard install-script cursor escapes to animated mode; drop unused worklog icon params RunAgents: strip stray whitespace-only string entries the model emits between agent objects so a multi-agent launch no longer fails validation. Also: AGENTS.md gains a "Pre-PR gate" checklist (run the full gate, snapshot fix-direction, encoding/bundling/changelog checks) to stop these slipping to CI. --- AGENTS.md | 35 ++ CHANGELOG.md | 24 + docs/public/install.sh | 8 +- scripts/install-native.sh | 8 +- src/pythinker_code/config.py | 6 +- src/pythinker_code/lsp/client.py | 8 +- src/pythinker_code/lsp/diagnostics.py | 17 +- src/pythinker_code/lsp/framing.py | 8 +- src/pythinker_code/lsp/manager.py | 18 +- src/pythinker_code/lsp/service.py | 8 + src/pythinker_code/tools/agent/__init__.py | 22 +- src/pythinker_code/tools/lsp/formatters.py | 12 +- src/pythinker_code/tools/lsp/tool.py | 31 +- .../ui/shell/components/report.py | 7 + .../ui/shell/components/report_update.py | 574 ++++++++++++++++++ src/pythinker_code/ui/shell/slash.py | 14 +- src/pythinker_code/ui/shell/statusline.py | 2 +- .../ui/shell/tool_renderers/__init__.py | 2 + .../ui/shell/tool_renderers/_render_utils.py | 83 +++ .../ui/shell/tool_renderers/agent.py | 303 +++++---- .../ui/shell/tool_renderers/background.py | 104 +++- .../ui/shell/tool_renderers/tool_search.py | 125 ++++ src/pythinker_code/ui/shell/usage.py | 7 + src/pythinker_code/ui/shell/usage_activity.py | 12 +- .../ui/shell/visualize/_blocks.py | 104 +++- .../ui/shell/visualize/_interactive.py | 6 +- .../ui/shell/visualize/_live_view.py | 35 +- .../ui/shell/visualize/_worklog.py | 3 +- src/pythinker_code/utils/rich/markdown.py | 5 +- src/pythinker_code/utils/rich/syntax.py | 5 +- tests/core/test_default_agent.py | 3 +- tests/core/test_wire_message.py | 1 + tests/tools/test_agent_tool.py | 31 + tests/tools/test_lsp_diagnostics.py | 43 ++ tests/tools/test_lsp_manager.py | 64 ++ tests/tools/test_lsp_tool.py | 22 + tests/ui/test_usage_activity.py | 19 + .../test_audit_report_rendering.py | 4 +- .../test_empty_think_part_indicator.py | 69 ++- .../test_md_normalization_matrix.py | 4 +- .../ui_and_conv/test_pythinker_themes_port.py | 4 + tests/ui_and_conv/test_report_update.py | 109 ++++ tests/ui_and_conv/test_spacing_primitives.py | 5 +- tests/ui_and_conv/test_statusline_render.py | 11 +- .../test_streaming_content_block.py | 26 + tests/ui_and_conv/test_tool_call_block.py | 49 ++ .../test_tui_card_tool_renderers.py | 156 ++++- .../ui_and_conv/test_tui_streaming_phase0.py | 20 + .../test_visualize_running_prompt.py | 4 + tests/utils/test_pyinstaller_utils.py | 4 + web/public/install.sh | 8 +- 51 files changed, 2041 insertions(+), 211 deletions(-) create mode 100644 src/pythinker_code/ui/shell/components/report_update.py create mode 100644 src/pythinker_code/ui/shell/tool_renderers/tool_search.py create mode 100644 tests/ui_and_conv/test_report_update.py diff --git a/AGENTS.md b/AGENTS.md index 467c87d5..aefcf9ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,41 @@ Pick the smallest reliable gate for the change, then run broader gates before re If a gate cannot run because of missing system tools (for example `npm`), report that explicitly instead of claiming success. +## Pre-PR gate (run before pushing or opening a PR) + +CI failures that "slip to GitHub" almost always trace to pushing after a *partial* local check +(for example running `ruff`/`pyright` on a single file instead of the whole package). Before you +push a branch or open a PR that touches shipped code, run the full gate and clear every item below. +Running a focused check on only the files you edited is **not** sufficient — snapshot, static, and +bundling tests fail on files you did not touch. + +1. **Full gate, not partial.** Run `make check-pythinker-code` (ruff + format + pyright) **and** + `make test-pythinker-code`. Paste/confirm the actual "All checks passed" / passing summary — a + green `ruff` alone is not a green `check` (pyright and format are separate). For changes to + another workspace package, run that package's `make check-* && make test-*` too. +2. **Include `tests_e2e`.** CI runs `tests` and `tests_e2e`. New slash commands, wire events, or + agent-spec/tool changes move the wire-handshake snapshot and the agent-spec/config/pyinstaller + snapshots. Re-run the affected tests; apply deliberate snapshot updates with + `uv run pytest --inline-snapshot=fix` and **read the resulting diff** before committing. +3. **Snapshot fix-direction.** A hardcoded expected value that changed deliberately → update the + **test**. An invariant of the form "two values must stay equal" (e.g. a prompt token that must + track a core theme token) → fix the **code** that drifted, never the test. +4. **New source files clear the static checks.** `tests/test_ai_static_requirements.py` enforces + explicit text encoding (`encoding="utf-8"`, `errors="replace"` for tool decodes) and other + invariants across `src/pythinker_code/**`. Note the ruff-vs-static conflict: ruff `UP012` strips + `"utf-8"` from a **string-literal** `.encode("utf-8")`, but the static check wants an explicit + `encoding=`. Encode/decode via a **local variable or call result**, not a literal, so both gates + pass (see `lsp/framing.py`). +5. **New bundled files/tools update the manifests.** A new tool `*.md`, prompt, or package adds + entries to `tests/utils/test_pyinstaller_utils.py` (`datas` + `hiddenimports`) and, for new + config keys, `tests/core/test_config.py::test_default_config_dump`. +6. **Changelog.** Any change to shipped paths (`src/*`, `packages/*`, installers, release + workflows, `pythinker.spec`) needs a new `- ...` line under `## Unreleased` in `CHANGELOG.md`, or + the `changelog-entry-required` check fails the PR. +7. **Confirm what is actually new.** Diff against `origin/main` (`git log origin/main..HEAD`, + `git diff origin/main...HEAD --stat`) so the PR scope — and the review/verification surface — is + what you intend, not stale local commits. + ## Project architecture ### Runtime path diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b74e8f..375f320d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **TUI composing preview gap.** Removed the visible double-blank row between + `Composing…` and the in-progress preview (leading newline from commit + boundaries no longer leaks through the plain-text preview path), and aligned + the Rich `Live` paint rate with the 25 Hz reveal scheduler (was 10 Hz). +- **ToolSearch TUI display.** `ToolSearch` results now render as a compact + "N tools discovered (Agent, Grep, …)" summary instead of dumping the full + tool catalog with descriptions; ctrl+o expands to tool names only. - **Tool header highlights.** Read/Write/Edit/Grep and similar tool-call subjects now use the brand periwinkle `accent` token instead of cyan `info`; line ranges stay on the yellow `warning` token. @@ -48,6 +55,23 @@ GitHub Releases page; `0.8.0` is the new starting line. total tokens consumed each day, with a `Lifetime · Peak · Streak · Longest task` summary line and a footer that lets the user switch between daily/weekly/cumulative views. Data is read from the local session wire files; the per-provider adapter behavior is unchanged. +- **RunAgents tolerates blank list entries.** Models occasionally emit bare `"\n"` strings + between the agent objects in the `agents` array; those are now stripped before validation so + a multi-agent launch no longer fails with a validation error, while genuinely invalid entries + are still rejected. +- **Report panel rendering.** Standardized report panels render only the panel title and section + headers bold (body prose stays regular weight), tag finding locations with a file marker, and + use a dedicated `secondary` theme token for scope/note text. +- **Theme token consistency.** The dark prompt frame/separator/dialog borders and the prompt + glyph now track their canonical core theme tokens, and inline code spans correctly drop an + inherited background. +- **External approvals repaint promptly.** Out-of-band approval requests and steer input now + force an immediate live-view repaint instead of waiting for the streaming frame budget, and + the live-view refresh loop is supervised so a refresh-loop failure surfaces instead of + silently freezing the view. +- **LSP robustness.** Bounded JSON-RPC frame size and graceful-shutdown timeout, document + version tracking for `didChange`, open-document state cleared on server restart, empty + diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. ## 0.47.0 (2026-06-16) diff --git a/docs/public/install.sh b/docs/public/install.sh index e1b6fc2a..21f0c70f 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -641,13 +641,17 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + if [ -n "$_anim" ]; then + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + else + printf ' Waiting for release assets, retrying in %ss\n' "$delay" + fi sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" +[ -n "$_anim" ] && [ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" diff --git a/scripts/install-native.sh b/scripts/install-native.sh index e1b6fc2a..21f0c70f 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -641,13 +641,17 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + if [ -n "$_anim" ]; then + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + else + printf ' Waiting for release assets, retrying in %ss\n' "$delay" + fi sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" +[ -n "$_anim" ] && [ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index aa2bc851..eb7b0557 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -1036,15 +1036,15 @@ class LspServerConfig(BaseModel): initialization_options: dict[str, Any] | None = Field( default=None, alias="initializationOptions" ) - startup_timeout: float = Field(default=30.0, alias="startupTimeout") - max_restarts: int = Field(default=3, alias="maxRestarts") + startup_timeout: float = Field(default=30.0, alias="startupTimeout", gt=0) + max_restarts: int = Field(default=3, alias="maxRestarts", ge=0) class LspConfig(BaseModel): enabled: bool = True recommendation_disabled: bool = False recommendation_never: list[str] = Field(default_factory=list) - recommendation_ignored_count: int = 0 + recommendation_ignored_count: int = Field(default=0, ge=0) class PluginsConfig(BaseModel): diff --git a/src/pythinker_code/lsp/client.py b/src/pythinker_code/lsp/client.py index 0937a64d..cad7278c 100644 --- a/src/pythinker_code/lsp/client.py +++ b/src/pythinker_code/lsp/client.py @@ -24,6 +24,8 @@ NotificationHandler = Callable[[Any], None] | Callable[[Any], Awaitable[None]] RequestHandler = Callable[[Any], Any] | Callable[[Any], Awaitable[Any]] +_SHUTDOWN_TIMEOUT_S = 2.0 + class LspClient: """Minimal LSP client: spawn via Host.exec, JSON-RPC over Content-Length framing.""" @@ -137,8 +139,12 @@ async def stop(self) -> None: proc = self._proc if proc is not None and proc.returncode is None: + # Bound the graceful handshake: a hung server must not block teardown. + # On timeout (or any error) we fall through to killing the process below. with suppress(Exception): - await self.send_request("shutdown", None) + await asyncio.wait_for( + self.send_request("shutdown", None), timeout=_SHUTDOWN_TIMEOUT_S + ) with suppress(Exception): await self.send_notification("exit", None) diff --git a/src/pythinker_code/lsp/diagnostics.py b/src/pythinker_code/lsp/diagnostics.py index b95968b2..a2f3f87c 100644 --- a/src/pythinker_code/lsp/diagnostics.py +++ b/src/pythinker_code/lsp/diagnostics.py @@ -86,7 +86,7 @@ def diagnostic_key(entry: DiagnosticEntry) -> str: }, }, "source": entry.source or None, - "code": entry.code or None, + "code": entry.code if entry.code is not None else None, }, sort_keys=True, separators=(",", ":"), @@ -281,10 +281,17 @@ async def handler(params: Any) -> None: parsed = PublishDiagnosticsParams.model_validate(params) path = uri_to_path(parsed.uri) or parsed.uri entries = [diagnostic_entry_from_lsp(item) for item in parsed.diagnostics] - registry.register_pending( - server_name, - [DiagnosticFile(uri=parsed.uri, path=path, diagnostics=entries)], - ) + if not entries: + # An empty payload means "no problems now" for this file, so drop + # any previously stored diagnostics for it. clear_for_file clears + # across all servers and the sent-key LRU; that is safe here + # because routing is one server per extension. + registry.clear_for_file(parsed.uri) + else: + registry.register_pending( + server_name, + [DiagnosticFile(uri=parsed.uri, path=path, diagnostics=entries)], + ) failure_count = 0 except Exception as exc: failure_count += 1 diff --git a/src/pythinker_code/lsp/framing.py b/src/pythinker_code/lsp/framing.py index 2464df58..2421d620 100644 --- a/src/pythinker_code/lsp/framing.py +++ b/src/pythinker_code/lsp/framing.py @@ -73,7 +73,9 @@ async def read_message(stdout: AsyncReadable) -> dict[str, Any]: async def write_message(stdin: AsyncWritable, message: dict[str, Any]) -> None: """Write one LSP message with Content-Length framing.""" - body = json.dumps(message, separators=(",", ":")).encode() - header = f"Content-Length: {len(body)}\r\n\r\n".encode() - stdin.write(header + body) + body = json.dumps(message, separators=(",", ":")).encode(encoding="utf-8") + # Encode via a local (not a string literal) so the explicit encoding survives + # ruff UP012 while satisfying the explicit-encoding static check. + header = f"Content-Length: {len(body)}\r\n\r\n" + stdin.write(header.encode(encoding="utf-8") + body) await stdin.drain() diff --git a/src/pythinker_code/lsp/manager.py b/src/pythinker_code/lsp/manager.py index e5e9f8a3..c6afb77b 100644 --- a/src/pythinker_code/lsp/manager.py +++ b/src/pythinker_code/lsp/manager.py @@ -31,6 +31,7 @@ def __init__( self._instances: dict[str, LspServerInstance] = {} self._ext_map: dict[str, list[str]] = {} self._opened_files: dict[str, str] = {} + self._doc_versions: dict[str, int] = {} async def initialize(self) -> None: errors: list[str] = [] @@ -82,6 +83,7 @@ async def shutdown(self) -> None: self._instances.clear() self._ext_map.clear() self._opened_files.clear() + self._doc_versions.clear() stop_errors = [ f"{to_stop[i][0]}: {result}" @@ -105,9 +107,19 @@ async def ensure_started(self, path: str) -> LspServerInstance | None: if server is None: return None if server.state in (LspState.STOPPED, LspState.ERROR): + # A (re)start spawns a fresh process with no open documents. Drop any + # stale per-server open-file and version state so didOpen is re-sent + # instead of being skipped as already-open on the new process. await server.start() + self._clear_server_doc_state(server.name) return server + def _clear_server_doc_state(self, server_name: str) -> None: + stale_uris = [uri for uri, name in self._opened_files.items() if name == server_name] + for uri in stale_uris: + self._opened_files.pop(uri, None) + self._doc_versions.pop(uri, None) + async def send_request(self, path: str, method: str, params: Any) -> Any | None: server = await self.ensure_started(path) if server is None: @@ -143,6 +155,7 @@ async def open_file(self, path: str, content: str) -> None: }, ) self._opened_files[file_uri] = server.name + self._doc_versions[file_uri] = 1 async def change_file(self, path: str, content: str) -> None: server = self.server_for_file(path) @@ -155,10 +168,12 @@ async def change_file(self, path: str, content: str) -> None: await self.open_file(path, content) return + version = self._doc_versions.get(file_uri, 1) + 1 + self._doc_versions[file_uri] = version await server.send_notification( "textDocument/didChange", { - "textDocument": {"uri": file_uri, "version": 1}, + "textDocument": {"uri": file_uri, "version": version}, "contentChanges": [{"text": content}], }, ) @@ -183,6 +198,7 @@ async def close_file(self, path: str) -> None: {"textDocument": {"uri": file_uri}}, ) self._opened_files.pop(file_uri, None) + self._doc_versions.pop(file_uri, None) def _file_uri(path: str) -> str: diff --git a/src/pythinker_code/lsp/service.py b/src/pythinker_code/lsp/service.py index 72131d70..e89dc95c 100644 --- a/src/pythinker_code/lsp/service.py +++ b/src/pythinker_code/lsp/service.py @@ -97,6 +97,14 @@ async def save_file(self, path: str) -> None: await manager.save_file(path) async def reinitialize(self, *, servers: dict[str, LspServerConfig] | None = None) -> None: + # Cancel any in-flight init before replacing the event/manager. Otherwise + # the stale task's finally can set the old event (waking callers parked in + # wait_for_init on a future that never completes) and _kickoff_init would + # return early while PENDING. + if self._init_task is not None and not self._init_task.done(): + self._init_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._init_task if servers is not None: self._servers = servers if self._manager is not None: diff --git a/src/pythinker_code/tools/agent/__init__.py b/src/pythinker_code/tools/agent/__init__.py index 5d0386f3..b5ded945 100644 --- a/src/pythinker_code/tools/agent/__init__.py +++ b/src/pythinker_code/tools/agent/__init__.py @@ -4,9 +4,9 @@ from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import Literal, override +from typing import Literal, cast, override -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from pythinker_core.tooling import CallableTool2, ToolError, ToolReturnValue from pythinker_code.execution_profiles import resolve_execution_policy @@ -202,6 +202,24 @@ class RunAgentsParams(BaseModel): min_length=1, max_length=8, ) + + @field_validator("agents", mode="before") + @classmethod + def _drop_blank_agent_entries(cls, value: object) -> object: + """Drop stray whitespace-only string entries the model emits as array noise. + + Some models render the ``agents`` array with bare ``"\\n"`` string elements + between the real objects, e.g. ``[{...}, "\\n", {...}]``. Those parse as valid + JSON but fail ``AgentRunConfig`` validation. The model's intent — the object + entries — is unambiguous, so strip whitespace-only strings before per-item + validation. Non-blank strings and other types are left for normal validation + to reject with a clear error. + """ + if isinstance(value, list): + items = cast("list[object]", value) + return [item for item in items if not (isinstance(item, str) and item.strip() == "")] + return value + model: str | None = Field( default=None, description="Optional model override applied to every child agent.", diff --git a/src/pythinker_code/tools/lsp/formatters.py b/src/pythinker_code/tools/lsp/formatters.py index 7840238b..78d02dc4 100644 --- a/src/pythinker_code/tools/lsp/formatters.py +++ b/src/pythinker_code/tools/lsp/formatters.py @@ -407,9 +407,17 @@ def format_result( case "documentSymbol": symbols = result or [] is_document_symbol = bool(symbols and "range" in symbols[0]) - count = count_symbols(symbols) if is_document_symbol else len(symbols) formatted = format_document_symbol_result(result, cwd) - file_count = 1 if symbols else 0 + if is_document_symbol: + # Hierarchical DocumentSymbol[] always describes the one open file. + count = count_symbols(symbols) + file_count = 1 if symbols else 0 + else: + # SymbolInformation[] fallback carries per-symbol locations that may + # span files; count unique URIs like workspaceSymbol does. + count = len(symbols) + locations = [sym.get("location") for sym in symbols] + file_count = count_unique_files_from_locations([loc for loc in locations if loc]) return formatted, count, file_count case "workspaceSymbol": symbols = result or [] diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index c7c774a5..f675b9a2 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -63,7 +63,21 @@ async def __call__(self, params: Params) -> ToolReturnValue: if validation_error is not None: return validation_error + # Re-check the manager after the validation await: a concurrent + # reinitialize() can clear it (the property returns None during reinit), + # so the earlier check at the top of __call__ may now be stale. manager = self._lsp.manager + # pyright narrows the property from the check at the top of __call__ and + # flags this as unreachable, but the value can change across the await + # above (reinitialize clears it), so the re-check is deliberate. + if manager is None: # pyright: ignore[reportUnnecessaryComparison] + return builder.error( + ( + "LSP is still initializing or unavailable. " + "Try again after language servers finish starting." + ), + brief="LSP unavailable", + ) assert absolute_path is not None if manager.server_for_file(absolute_path) is None: @@ -365,6 +379,11 @@ async def _filter_gitignored_locations( async def _run_git_check_ignore(cwd: str, paths: list[str]) -> str | None: + # This is a relevance filter, not a security boundary: callers fail OPEN + # (show LSP results) when ignore status cannot be determined, so a non-git + # directory or a transient git error never hides results. We still + # distinguish git's normal "nothing ignored" exit 1 (no log) from real + # errors (exit 128, timeout, spawn failure), which are logged for diagnosis. proc = None try: proc = await pythinker_host.exec("git", "-C", cwd, "check-ignore", *paths) @@ -376,13 +395,23 @@ async def _run_git_check_ignore(cwd: str, paths: list[str]) -> str | None: exit_code = await asyncio.wait_for(proc.wait(), timeout=_GIT_CHECK_IGNORE_TIMEOUT) if exit_code == 0: return stdout_bytes.decode("utf-8", errors="replace") + if exit_code != 1: + # 1 = no paths ignored (expected). Anything else (e.g. 128 outside a + # git repo) is a real error; log it and fail open. + logger.debug( + "git check-ignore failed in {cwd} with exit code {code}", + cwd=cwd, + code=exit_code, + ) return None except TimeoutError: + logger.debug("git check-ignore timed out in {cwd}", cwd=cwd) if proc is not None: await proc.kill() await proc.wait() return None - except Exception: + except Exception as exc: + logger.debug("git check-ignore errored in {cwd}: {err}", cwd=cwd, err=exc) if proc is not None and proc.returncode is None: await proc.kill() await proc.wait() diff --git a/src/pythinker_code/ui/shell/components/report.py b/src/pythinker_code/ui/shell/components/report.py index 790a6f6a..fae99fe1 100644 --- a/src/pythinker_code/ui/shell/components/report.py +++ b/src/pythinker_code/ui/shell/components/report.py @@ -34,6 +34,10 @@ from rich.table import Table from rich.text import Text +from pythinker_code.ui.shell.components.report_update import ( + parse_report_update, + render_report_update, +) from pythinker_code.ui.shell.glyphs import REPORT_FILE_MARKER from pythinker_code.ui.shell.markdown.audit import detect_audit_report from pythinker_code.ui.shell.markdown.normalizers import ( @@ -521,6 +525,9 @@ def render_agent_body(text: str, *, theme: ThemeName | None = None) -> Renderabl cursor = end if not segments: + report_update = parse_report_update(text) + if report_update is not None: + return render_report_update(report_update, theme=theme) report_prose = _render_report_prose(text, theme=theme) if report_prose is not None: return report_prose diff --git a/src/pythinker_code/ui/shell/components/report_update.py b/src/pythinker_code/ui/shell/components/report_update.py new file mode 100644 index 00000000..4786362b --- /dev/null +++ b/src/pythinker_code/ui/shell/components/report_update.py @@ -0,0 +1,574 @@ +"""Structured renderer for agent report-update completion messages. + +Agents often finish report-editing work with dense prose: file lists, numbered +corrections with ``Severity / Item / Fix`` field blocks, and follow-up flags. +That shape is fine for chat transcripts but wastes terminal height when rendered +as generic Markdown. This module parses the common layout and renders compact +summary cards with an expandable correction ledger (``ctrl+o``). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal, get_args + +from rich import box +from rich.console import Group, RenderableType +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from pythinker_code.ui.shell.components.key_hints import key_hint +from pythinker_code.ui.shell.markdown.audit import compact_known_paths +from pythinker_code.ui.shell.markdown.normalizers import parse_aligned_field_line +from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING +from pythinker_code.ui.theme import ThemeName, tui_rich_style + +__all__ = [ + "ChangedFile", + "Correction", + "ReportUpdate", + "ReportUpdateComponent", + "looks_like_report_update", + "parse_report_update", + "render_report_update", +] + +SeverityLabel = Literal["critical", "high", "med", "low", "info"] +_SEVERITY_ORDER: tuple[SeverityLabel, ...] = get_args(SeverityLabel) + +_BULLET_RE = re.compile(r"^\s*[-•]\s+(.+)$") +_SECTION_FILES_RE = re.compile(r"^files?\s+modified\s*:?\s*$", re.I) +_SECTION_CORRECTIONS_RE = re.compile(r"^corrections?\s+applied\s*:?\s*$", re.I) +_SECTION_FOLLOWUPS_RE = re.compile( + r"^(?:out[- ]of[- ]scope\s+flags?|follow[- ]ups?)\s*(?:\([^)]*\))?\s*:?\s*$", + re.I, +) +_REPORT_HEADER_RE = re.compile( + r"^report\s+update\s+complete(?:\.\s*(?P.+))?$", + re.I, +) +_FILE_MODIFIED_RE = re.compile( + r"^(?P.+?)\s*\((?P\d+\s*→\s*\d+\s*lines?)\)\s*$", + re.I, +) +_FILE_CREATED_RE = re.compile( + r"^(?P.+?)\s*\((?:new,\s*)?(?P\d+\s*lines?)\)\s*$", + re.I, +) +_BRANCH_COMMIT_RE = re.compile( + r"(?P[\w./-]+)\s*@\s*(?P[0-9a-f]{6,40})\b", + re.I, +) +_COMPACT_CORRECTION_RE = re.compile( + r"^(?Pcritical|crit|high|med|medium|low|info)\s+" + r"(?P\d+)\s+" + r"(?P.+?)(?:\s+[—–-]\s+(?P.+))?$", + re.I, +) + +_SEV_DISPLAY: dict[SeverityLabel, str] = { + "critical": "Crit", + "high": "High", + "med": "Med", + "low": "Low", + "info": "Info", +} + +_SEV_ALIASES: dict[str, SeverityLabel] = { + "critical": "critical", + "crit": "critical", + "high": "high", + "med": "med", + "medium": "med", + "low": "low", + "info": "info", +} + + +@dataclass(frozen=True, slots=True) +class ChangedFile: + path: str + kind: Literal["modified", "created"] + detail: str + + +@dataclass(frozen=True, slots=True) +class Correction: + number: int + severity: SeverityLabel + item: str + fix: str + + +@dataclass(frozen=True, slots=True) +class ReportUpdate: + title: str + subtitle: str | None + report_name: str | None + files: tuple[ChangedFile, ...] + corrections: tuple[Correction, ...] + followups: tuple[str, ...] + branch: str | None + commit: str | None + method: str | None + scope: str | None + + +def looks_like_report_update(text: str) -> bool: + """Whether *text* is building toward a report-update completion message.""" + return "report update complete" in text.lower() + + +def _normalize_severity(raw: str) -> SeverityLabel | None: + return _SEV_ALIASES.get(raw.strip().lower()) + + +def _short_path(path: str) -> str: + compact = compact_known_paths(path.strip()) + for prefix in (".pythinker/reports/", "reports/"): + if compact.startswith(prefix): + compact = compact[len(prefix) :] + return compact + + +def _parse_file_bullet(body: str) -> ChangedFile | None: + body = body.strip() + match = _FILE_MODIFIED_RE.match(body) + if match is not None: + return ChangedFile( + path=match.group("path").strip(), + kind="modified", + detail=match.group("detail").strip(), + ) + match = _FILE_CREATED_RE.match(body) + if match is not None: + detail = match.group("detail").strip() + if not detail.lower().startswith("new"): + detail = f"new, {detail}" + return ChangedFile( + path=match.group("path").strip(), + kind="created", + detail=detail, + ) + return None + + +def _parse_branch_commit(text: str) -> tuple[str | None, str | None]: + match = _BRANCH_COMMIT_RE.search(text) + if match is None: + return None, None + return match.group("branch"), match.group("commit") + + +def _infer_scope(files: tuple[ChangedFile, ...], text: str) -> str | None: + lower = text.lower() + if "no source files modified" in lower or "report-only" in lower or "reports only" in lower: + return "reports only; no source files changed" + if files and all( + ".pythinker/reports/" in f.path or f.path.startswith("reports/") for f in files + ): + return "reports only; no source files changed" + return None + + +def _infer_report_name(text: str, files: tuple[ChangedFile, ...]) -> str | None: + for raw in text.splitlines(): + if "verification memo" in raw.lower() and "—" in raw: + _, _, tail = raw.partition("—") + name = tail.strip() + if name: + return name + for file in files: + base = _short_path(file.path) + if base.endswith(".md"): + return base.removesuffix(".md").replace("-", " ").title() + return None + + +def parse_report_update(text: str) -> ReportUpdate | None: + """Parse a report-update completion message into structured data.""" + if not looks_like_report_update(text): + return None + + lines = text.splitlines() + title = "Report update complete" + subtitle: str | None = None + files: list[ChangedFile] = [] + corrections: list[Correction] = [] + followups: list[str] = [] + method: str | None = None + + section: str | None = None + pending_correction: dict[str, str] | None = None + pending_number: int | None = None + pending_fix_lines: list[str] = [] + + def flush_correction() -> None: + nonlocal pending_correction, pending_number, pending_fix_lines + if pending_correction is None or pending_number is None: + pending_correction = None + pending_number = None + pending_fix_lines = [] + return + severity_raw = pending_correction.get("severity", pending_correction.get("Severity", "")) + item = pending_correction.get("item", pending_correction.get("Item", "")).strip() + fix = pending_correction.get("fix", pending_correction.get("Fix", "")).strip() + if pending_fix_lines: + extra = " ".join(pending_fix_lines).strip() + fix = f"{fix} {extra}".strip() if fix else extra + severity = _normalize_severity(severity_raw) + if severity and item: + corrections.append( + Correction(number=pending_number, severity=severity, item=item, fix=fix) + ) + pending_correction = None + pending_number = None + pending_fix_lines = [] + + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped: + if pending_fix_lines and pending_correction is not None: + pending_fix_lines.append("") + continue + + header_match = _REPORT_HEADER_RE.match(stripped) + if header_match is not None: + subtitle = header_match.group("subtitle") + continue + + if _SECTION_FILES_RE.match(stripped): + flush_correction() + section = "files" + continue + if _SECTION_CORRECTIONS_RE.match(stripped): + flush_correction() + section = "corrections" + continue + if _SECTION_FOLLOWUPS_RE.match(stripped): + flush_correction() + section = "followups" + continue + + if stripped.lower().startswith("method:"): + flush_correction() + section = "method" + method = stripped.split(":", 1)[1].strip() or None + continue + + bullet = _BULLET_RE.match(line) + if bullet is not None: + body = bullet.group(1).strip() + if section == "files": + parsed_file = _parse_file_bullet(body) + if parsed_file is not None: + files.append(parsed_file) + continue + if section == "followups": + followups.append(body) + continue + if section == "corrections": + compact = _COMPACT_CORRECTION_RE.match(body) + if compact is not None: + flush_correction() + severity = _normalize_severity(compact.group("sev")) + if severity is None: + continue + corrections.append( + Correction( + number=int(compact.group("num")), + severity=severity, + item=compact.group("item").strip(), + fix=(compact.group("fix") or "").strip(), + ) + ) + continue + if body.isdigit(): + flush_correction() + pending_number = int(body) + pending_correction = {} + pending_fix_lines = [] + continue + continue + + field = parse_aligned_field_line(line) + if field is not None and section == "corrections" and pending_correction is not None: + _, label, value = field + pending_correction[label.lower()] = value + continue + + if section == "corrections" and pending_correction is not None and line.startswith(" "): + pending_fix_lines.append(stripped) + continue + + if section == "method" and method is not None: + method = f"{method} {stripped}".strip() + + flush_correction() + + if not files and not corrections: + return None + + branch, commit = _parse_branch_commit(text) + scope = _infer_scope(tuple(files), text) + report_name = _infer_report_name(text, tuple(files)) + + return ReportUpdate( + title=title, + subtitle=subtitle, + report_name=report_name, + files=tuple(files), + corrections=tuple(corrections), + followups=tuple(followups), + branch=branch, + commit=commit, + method=method, + scope=scope, + ) + + +def _label(theme: ThemeName | None, text: str) -> Text: + out = Text() + out.append(text, style=tui_rich_style("tool_title", theme=theme)) + return out + + +def _kv_row(theme: ThemeName | None, key: str, value: str) -> RenderableType: + row = Table.grid(padding=0) + row.add_column(width=10, no_wrap=True) + row.add_column(overflow="fold") + row.add_row(_label(theme, key), Text(value, style=tui_rich_style("text", theme=theme))) + return row + + +def _severity_style(severity: SeverityLabel, theme: ThemeName | None): + token = { + "critical": "error", + "high": "error", + "med": "warning", + "low": "accent", + "info": "activity_spinner", + }[severity] + return tui_rich_style(token, theme=theme) + + +def _group_summary_line( + severity: SeverityLabel, + items: list[Correction], + theme: ThemeName | None, +) -> RenderableType: + row = Table.grid(padding=0) + row.add_column(width=6, no_wrap=True) + row.add_column(width=4, no_wrap=True) + row.add_column(overflow="fold") + if len(items) == 1: + item = items[0] + detail = item.item + if item.fix: + detail = f"{item.item} — {item.fix}" + else: + detail = "; ".join(item.item for item in items[:3]) + if len(items) > 3: + detail = f"{detail}; …" + row.add_row( + Text(_SEV_DISPLAY[severity], style=_severity_style(severity, theme)), + Text(str(len(items)), style=tui_rich_style("text", theme=theme)), + Text(detail, style=tui_rich_style("text", theme=theme)), + ) + return row + + +def _render_summary_panel(update: ReportUpdate, *, theme: ThemeName | None) -> Panel: + rows: list[RenderableType] = [] + if update.report_name: + rows.append(_kv_row(theme, "Report", update.report_name)) + parts: list[str] = [] + if update.files: + noun = "file" if len(update.files) == 1 else "files" + parts.append(f"{len(update.files)} {noun} updated") + if update.corrections: + noun = "correction" if len(update.corrections) == 1 else "corrections" + parts.append(f"{len(update.corrections)} {noun} applied") + if update.followups: + noun = "follow-up" if len(update.followups) == 1 else "follow-ups" + parts.append(f"{len(update.followups)} {noun}") + if parts: + rows.append(_kv_row(theme, "Result", " · ".join(parts))) + if update.scope: + rows.append(_kv_row(theme, "Scope", update.scope)) + if update.method: + rows.append(_kv_row(theme, "Method", update.method)) + if update.branch and update.commit: + rows.append(_kv_row(theme, "Verified", f"{update.branch} @ {update.commit}")) + elif update.branch: + rows.append(_kv_row(theme, "Branch", update.branch)) + border = tui_rich_style("border", theme=theme) + title = Text("Summary", style=tui_rich_style("tool_title", theme=theme)) + return Panel( + Group(*rows), + title=title, + title_align="left", + border_style=border, + box=box.ROUNDED, + padding=REPORT_PANEL_PADDING, + expand=True, + ) + + +def _render_files_panel(update: ReportUpdate, *, theme: ThemeName | None) -> Panel | None: + if not update.files: + return None + table = Table.grid(padding=(0, 1)) + table.add_column(width=2, no_wrap=True) + table.add_column(ratio=1, overflow="fold") + table.add_column(no_wrap=True, justify="right") + for file in update.files: + marker = "~" if file.kind == "modified" else "+" + table.add_row( + Text(marker, style=tui_rich_style("accent", theme=theme)), + Text(_short_path(file.path), style=tui_rich_style("text", theme=theme)), + Text(file.detail, style=tui_rich_style("muted", theme=theme)), + ) + border = tui_rich_style("border", theme=theme) + return Panel( + table, + title=Text("Changes", style=tui_rich_style("tool_title", theme=theme)), + title_align="left", + border_style=border, + box=box.ROUNDED, + padding=REPORT_PANEL_PADDING, + expand=True, + ) + + +def _render_corrections_panel( + update: ReportUpdate, + *, + theme: ThemeName | None, + expanded: bool, +) -> Panel | None: + if not update.corrections: + return None + rows: list[RenderableType] = [] + if expanded: + table = Table.grid(padding=(0, 1)) + table.add_column(width=4, no_wrap=True) + table.add_column(width=6, no_wrap=True) + table.add_column(ratio=2, overflow="fold") + table.add_column(ratio=3, overflow="fold") + table.add_row( + _label(theme, "#"), + _label(theme, "Sev"), + _label(theme, "Item"), + _label(theme, "Fix"), + ) + for correction in update.corrections: + sev_style = _severity_style(correction.severity, theme) + table.add_row( + Text(str(correction.number), style=tui_rich_style("muted", theme=theme)), + Text(_SEV_DISPLAY[correction.severity], style=sev_style), + Text(correction.item, style=tui_rich_style("text", theme=theme)), + Text(correction.fix, style=tui_rich_style("text", theme=theme)), + ) + rows.append(table) + else: + grouped: dict[SeverityLabel, list[Correction]] = {s: [] for s in _SEVERITY_ORDER} + for correction in update.corrections: + grouped[correction.severity].append(correction) + for severity in _SEVERITY_ORDER: + items = grouped[severity] + if not items: + continue + rows.append(_group_summary_line(severity, items, theme)) + rows.append(Text("")) + expand_hint = key_hint( + "app.tools.expand", + f"expand all {len(update.corrections)} corrections", + ) + rows.append(expand_hint) + border = tui_rich_style("border", theme=theme) + panel_title = "Correction summary" if not expanded else "Corrections applied" + return Panel( + Group(*rows), + title=Text(panel_title, style=tui_rich_style("tool_title", theme=theme)), + title_align="left", + border_style=border, + box=box.ROUNDED, + padding=REPORT_PANEL_PADDING, + expand=True, + ) + + +def _render_followups_panel(update: ReportUpdate, *, theme: ThemeName | None) -> Panel | None: + if not update.followups: + return None + table = Table.grid(padding=(0, 1)) + table.add_column(width=2, no_wrap=True) + table.add_column(overflow="fold") + for item in update.followups: + table.add_row( + Text("!", style=tui_rich_style("warning", theme=theme)), + Text(item, style=tui_rich_style("text", theme=theme)), + ) + border = tui_rich_style("border", theme=theme) + return Panel( + table, + title=Text("Follow-ups", style=tui_rich_style("tool_title", theme=theme)), + title_align="left", + border_style=border, + box=box.ROUNDED, + padding=REPORT_PANEL_PADDING, + expand=True, + ) + + +def render_report_update( + update: ReportUpdate, + *, + theme: ThemeName | None = None, + expanded: bool = False, +) -> RenderableType: + """Render *update* as stacked summary cards.""" + header = Text("✓ ", style=tui_rich_style("success", theme=theme)) + header.append(update.title, style=tui_rich_style("tool_title", theme=theme)) + rows: list[RenderableType] = [header, Text("")] + rows.append(_render_summary_panel(update, theme=theme)) + files_panel = _render_files_panel(update, theme=theme) + if files_panel is not None: + rows.extend([Text(""), files_panel]) + corrections_panel = _render_corrections_panel(update, theme=theme, expanded=expanded) + if corrections_panel is not None: + rows.extend([Text(""), corrections_panel]) + followups_panel = _render_followups_panel(update, theme=theme) + if followups_panel is not None: + rows.extend([Text(""), followups_panel]) + return Group(*rows) + + +class ReportUpdateComponent: + """Expandable report-update card for the streaming content block.""" + + def __init__(self, update: ReportUpdate, *, theme: ThemeName | None = None) -> None: + self._update = update + self._theme: ThemeName | None = theme + self._expanded = False + + @property + def expanded(self) -> bool: + return self._expanded + + @property + def can_expand(self) -> bool: + return len(self._update.corrections) > 0 + + def toggle_expanded(self) -> None: + self._expanded = not self._expanded + + def set_expanded(self, expanded: bool) -> None: + self._expanded = expanded + + def render(self) -> RenderableType: + return render_report_update(self._update, theme=self._theme, expanded=self._expanded) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index b62f4667..74b0690f 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -1053,13 +1053,15 @@ async def _theme_code_picker(app: Shell, soul: PythinkerSoul, arg: str) -> None: available = list_picker_code_themes(get_share_dir()) if arg: - if arg not in available: + # Match case-insensitively but resolve to the canonical picker name so + # custom mixed-case theme stems are reachable regardless of typed case. + chosen = next((name for name in available if name.casefold() == arg.casefold()), None) + if chosen is None: console.print( f"[{_t.error}]Unknown code theme: {_rich_escape(arg)}. " f"Use `/theme code` to pick from {len(available)} themes.[/]" ) return - chosen = arg else: saved = get_active_code_theme() @@ -1122,8 +1124,12 @@ async def theme(app: Shell, args: str) -> None: _t_theme = _get_tok_theme() configured = soul.runtime.config.theme - arg = args.strip().lower() - sub, _, rest = arg.partition(" ") + raw_arg = args.strip() + sub_raw, _, rest = raw_arg.partition(" ") + # Lowercase only the subcommand for routing; preserve the theme-name case so + # mixed-case custom code themes stay reachable via `/theme code `. + sub = sub_raw.lower() + arg = raw_arg.lower() if sub in ("current", "doctor", "tokens", "code"): if sub == "current": diff --git a/src/pythinker_code/ui/shell/statusline.py b/src/pythinker_code/ui/shell/statusline.py index 1037dc19..7a4021a3 100644 --- a/src/pythinker_code/ui/shell/statusline.py +++ b/src/pythinker_code/ui/shell/statusline.py @@ -365,7 +365,7 @@ def _render_context(ctx: StatusLineContext) -> list[StyleFragment] | None: (_style(ctx, colors.dim), "/"), (_style(ctx, level_color), f"{total} "), ] - if ctx.style != "plain": + if ctx.style != "plain" and pct >= 70: frags.append((level_color, smooth_bar(pct, width=ctx.bar_width, ascii_only=ctx.ascii_only))) frags.append(("", " ")) frags.append((_style(ctx, f"bold {level_color}".strip()), f"{pct}%")) diff --git a/src/pythinker_code/ui/shell/tool_renderers/__init__.py b/src/pythinker_code/ui/shell/tool_renderers/__init__.py index 8ddcb8f5..4c052703 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/__init__.py +++ b/src/pythinker_code/ui/shell/tool_renderers/__init__.py @@ -162,6 +162,7 @@ def register_builtin_renderers() -> None: skill, think, todo, + tool_search, web, write, ) @@ -179,6 +180,7 @@ def register_builtin_renderers() -> None: register_tool_renderer(ask_user.ASK_USER_RENDERER) register_tool_renderer(think.THINK_RENDERER) register_tool_renderer(todo.TODO_RENDERER) + register_tool_renderer(tool_search.TOOL_SEARCH_RENDERER) register_tool_renderer(web.FETCH_RENDERER) register_tool_renderer(web.SEARCH_RENDERER) register_tool_renderer(background.TASK_LIST_RENDERER) diff --git a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py index 5bb4ad53..0bf3d471 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py +++ b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py @@ -79,6 +79,89 @@ def _status_marker(style_token: str) -> str: return "✘" if style_token == "error" else TRANSCRIPT_ASSISTANT_MARKER +# Normalized agent/task status labels for user-facing TUI rows. +AGENT_STATUS_LABELS: dict[str, str] = { + "queued": "queued", + "created": "queued", + "starting": "starting", + "running": "running", + "launched": "running", + "awaiting_approval": "waiting", + "waiting": "waiting", + "completed": "completed", + "success": "completed", + "succeeded": "completed", + "failed": "failed", + "failure": "failed", + "error": "failed", + "cancelled": "cancelled", + "killed": "cancelled", + "timed_out": "timed out", + "deferred": "queued", + "lost": "failed", + "recoverable": "failed", +} + + +def normalize_agent_status(status: str) -> str: + """Map backend status strings to a single user-facing label.""" + normalized = status.strip().lower().replace(" ", "_") + return AGENT_STATUS_LABELS.get(normalized, status.strip() or "unknown") + + +def agent_status_glyph(status: str) -> str: + """Icon for an agent/task row based on normalized status.""" + label = normalize_agent_status(status) + if label in {"failed", "timed out"}: + return "✘" + if label == "completed": + return "✓" + if label in {"starting", "running", "waiting", "queued"}: + return "●" + if label == "cancelled": + return "○" + return "○" + + +def agent_status_style_token(status: str) -> str: + """Theme token for a normalized agent/task status.""" + label = normalize_agent_status(status) + if label in {"failed", "timed out"}: + return "error" + if label == "completed": + return "success" + if label in {"starting", "running", "waiting", "queued"}: + return "success" + if label == "cancelled": + return "muted" + return "muted" + + +def format_byte_size(num_bytes: int) -> str: + """Human-readable byte size (KB/MB).""" + if num_bytes < 1024: + return f"{num_bytes} B" + if num_bytes < 1024 * 1024: + return f"{num_bytes / 1024:.1f} KB" + return f"{num_bytes / (1024 * 1024):.1f} MB" + + +def shorten_home_path(path: str) -> str: + """Shorten an absolute path with ``~/`` when under the user home directory.""" + if not path: + return path + try: + resolved = Path(path).expanduser().resolve() + home = Path.home().resolve() + if resolved == home: + return "~" + if resolved.is_relative_to(home): + return f"~/{resolved.relative_to(home)}" + except (OSError, RuntimeError, ValueError): + return path + return path + + def tool_call_header( name: str, summary: str | Text | None = None, diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index 8f43dd31..71232550 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -14,6 +14,7 @@ from rich.table import Table from rich.text import Text +from pythinker_code.ui.shell.components.key_hints import key_hint from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -26,6 +27,7 @@ invalid_arg, loading_marker, missing_required_arg, + normalize_agent_status, pending_tool_call_header, running_spinner, tool_call_header, @@ -38,7 +40,12 @@ _DEFAULT_COLLAPSED_LINES = 6 _RUN_AGENTS_ERROR_COLLAPSED_LINES = 8 _RUN_AGENTS_SUMMARY_PREVIEW_CHARS = 160 +_RUN_AGENTS_GENERIC_TYPES = frozenset({"coder", "Agent"}) _BACKGROUND_ACTIVE_STATUSES = frozenset({"created", "starting", "running", "awaiting_approval"}) +_TREE_BRANCH = "├─" +_TREE_LAST = "└─" +_TREE_GUTTER_MID = "│ ⎿ " +_TREE_GUTTER_LAST = " ⎿ " # --------------------------------------------------------------------------- # Review findings aggregation @@ -399,6 +406,107 @@ def _plural(count: int, singular: str) -> str: return f"{count} {singular}" if count == 1 else f"{count} {singular}s" +def _run_agent_is_async(mode: str, status: str) -> bool: + return mode == "background" or status.lower() == "launched" + + +def _run_agent_is_resolved(status: str, *, is_async: bool) -> bool: + norm = normalize_agent_status(status) + if norm in {"completed", "failed", "timed out", "cancelled"}: + return True + raw = status.lower() + return is_async and raw in {"starting", "running", "created", "launched"} + + +def _run_agent_is_backgrounded(*, is_async: bool, is_resolved: bool, status: str) -> bool: + if not is_async or not is_resolved: + return False + return normalize_agent_status(status) not in {"completed", "failed", "timed out", "cancelled"} + + +def _run_agent_status_subline(entry: dict[str, str], *, is_resolved: bool) -> str: + if not is_resolved: + preview = entry.get("summary_preview") or entry.get("message") or entry.get("brief") + if preview: + return _compact_inline(preview, max_chars=72) + return "Initializing…" + return "Done" + + +def _render_grouped_agents_summary( + count: int, + *, + all_resolved: bool, + all_async: bool, + common_type: str | None, +) -> Text: + text = Text() + if all_resolved: + if all_async: + text.append(str(count), style=RichStyle(bold=True)) + text.append(" background agents launched", style=tui_rich_style("dim")) + else: + text.append(str(count), style=RichStyle(bold=True)) + type_suffix = f" {common_type}" if common_type else "" + text.append(f"{type_suffix} agents finished", style=tui_rich_style("dim")) + else: + text.append("Running ", style=tui_rich_style("dim")) + text.append(str(count), style=RichStyle(bold=True)) + type_suffix = f" {common_type}" if common_type else "" + text.append(f"{type_suffix} agents…", style=tui_rich_style("dim")) + return text + + +def _render_agent_progress_line( + entry: dict[str, str], + *, + is_last: bool, + hide_type: bool, + mode: str, +) -> Group: + status = entry["status"] + is_async = _run_agent_is_async(mode, status) + is_resolved = _run_agent_is_resolved(status, is_async=is_async) + is_backgrounded = _run_agent_is_backgrounded( + is_async=is_async, + is_resolved=is_resolved, + status=status, + ) + + tree = _TREE_LAST if is_last else _TREE_BRANCH + gutter = _TREE_GUTTER_LAST if is_last else _TREE_GUTTER_MID + subagent_type = entry["subagent_type"] + description = entry.get("name_extra") or None + label_style = tui_rich_style("tool_title") + RichStyle(bold=True) + + row = Text() + row.append(" ", style="") + row.append(f"{tree} ", style=tui_rich_style("dim")) + if hide_type: + label = description or subagent_type + row.append(label, style=label_style) + else: + row.append(subagent_type, style=label_style) + if description: + row.append(" (", style=tui_rich_style("dim")) + row.append(description, style=label_style) + row.append(")", style=tui_rich_style("dim")) + if not is_resolved: + row.stylize(tui_rich_style("dim"), 3, len(row)) + + children: list[RenderableType] = [row] + if not is_backgrounded: + sub = Text() + sub.append(" ", style="") + sub.append(gutter, style=tui_rich_style("dim")) + sub.append( + _run_agent_status_subline(entry, is_resolved=is_resolved), + style=tui_rich_style("dim"), + ) + children.append(sub) + return Group(*children) + + def _run_agent_arg_summaries(args: dict[str, object]) -> list[tuple[str, str]] | None: raw_agents_value = args.get("agents") if not isinstance(raw_agents_value, list): @@ -424,47 +532,38 @@ def _run_agent_arg_summaries(args: dict[str, object]) -> list[tuple[str, str]] | def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: args = ctx.args or {} agent_summaries = _run_agent_arg_summaries(args) - summary_text = Text() + mode = "foreground" if args.get("run_in_background") is False else "background" if agent_summaries is None: if ctx.has_result: - summary_text.append_text(missing_required_arg("agents")) - else: - header = pending_tool_call_header(_RUN_AGENTS_TOOL_NAME) - return running_spinner( - header, - execution_started=ctx.execution_started, - has_result=ctx.has_result, - marker_style_token="muted", + header = tool_call_header( + _RUN_AGENTS_TOOL_NAME, + missing_required_arg("agents"), + style_token="error", ) - else: - summary_text.append_text(fg("border_accent", _plural(len(agent_summaries), "agent"))) - - mode = "foreground" if args.get("run_in_background") is False else "background" - if summary_text.plain: - summary_text.append_text(fg("thinking_text", f" · {mode}")) + return header + line = pending_tool_call_header(_RUN_AGENTS_TOOL_NAME) + return running_spinner( + line, + execution_started=ctx.execution_started, + has_result=ctx.has_result, + marker_style_token="muted", + ) run_summary = as_str(args.get("summary")) + summary = Text() if run_summary: - summary_text.append_text(fg("dim", f" · {_compact_inline(run_summary, max_chars=70)}")) + summary.append_text(fg("thinking_text", run_summary)) + else: + count = len(agent_summaries) + summary.append_text(fg("border_accent", _plural(count, "agent"))) + summary.append_text(fg("thinking_text", f" · {mode}")) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" - header = tool_call_header( - _RUN_AGENTS_TOOL_NAME, - summary_text if summary_text.plain else None, - style_token=style_token, - ) + header = tool_call_header(_RUN_AGENTS_TOOL_NAME, summary, style_token=style_token) children: list[RenderableType] = [header] - missing: list[RenderableType] = [] if ctx.has_result and run_summary is None: - missing.append(missing_required_arg("summary")) - if missing: - children.extend(missing) - if agent_summaries and not ctx.has_result: - listed = ", ".join(f"{name}:{subagent_type}" for name, subagent_type in agent_summaries[:4]) - if len(agent_summaries) > 4: - listed = f"{listed}, +{len(agent_summaries) - 4} more" - children.append(fg("dim", f"agents: {listed}")) + children.append(missing_required_arg("summary")) rendered: RenderableType = Group(*children) if len(children) > 1 else header return running_spinner( @@ -613,36 +712,6 @@ def finish_agent() -> None: return top, agents -def _status_style_token(status: str) -> str: - normalized = status.lower() - if normalized in {"error", "failed", "failure"}: - return "error" - if normalized in {"completed", "success", "succeeded"}: - return "success" - if normalized in {"created", "starting", "running", "awaiting_approval", "launched"}: - return "accent" - return "muted" - - -def _status_glyph(status: str) -> str: - normalized = status.lower() - if normalized in {"error", "failed", "failure"}: - return "✘" - if normalized in {"completed", "success", "succeeded"}: - return "✓" - if normalized in {"created", "starting", "running", "awaiting_approval", "launched"}: - return "●" - return "○" - - -def _top_status_label(status: str) -> str: - if status == "success": - return "completed" - if status == "failure": - return "failed" - return status or "completed" - - def _render_run_agents_text_result( ctx: ToolRenderContext, result: ToolResultPayload ) -> RenderableType | None: @@ -673,29 +742,10 @@ def _render_run_agents_result( if not agents: return _render_run_agents_text_result(ctx, result) - ctx.state["__suppress_generic_expand_hint__"] = True - status = _top_status_label(top.get("tool_status", "success")) - count = top.get("agent_count") or str(len(agents)) - mode = top.get("mode") - approval = top.get("orchestration_approval") - - summary = Text() - summary.append(f"agents {status}", style=tui_rich_style(_status_style_token(status))) - summary.append(f" · {count} total", style=tui_rich_style("dim")) - if mode: - summary.append(f" · {mode}", style=tui_rich_style("dim")) - if approval: - summary.append(f" · approval {approval}", style=tui_rich_style("dim")) - - # Pre-compute per-agent fields so the variable-width label and status columns - # can be padded to a shared width — sibling rows then line up their "· status" - # and "· task_id" separators instead of stair-stepping with each name length. entries: list[dict[str, str]] = [] for index, agent in enumerate(agents): subagent_type = agent.get("subagent_type") or agent.get("actual_subagent_type") or "coder" name = agent.get("name") or f"agent-{index + 1}" - # A name identical to the subagent_type is redundant; show it only when it - # carries information the type doesn't (e.g. "code_scan" vs "code-reviewer"). extra = "" if name == subagent_type else name entries.append( { @@ -709,59 +759,60 @@ def _render_run_agents_result( } ) - def label_width(entry: dict[str, str]) -> int: - # Display width of "type" or "type · name" — drives the shared label column. - extra = entry["name_extra"] - return len(entry["subagent_type"]) + (len(f" · {extra}") if extra else 0) - - label_col = max(label_width(entry) for entry in entries) - # Only pad the status column when a later task_id column needs to align under it. - status_col = max( - (len(entry["status"]) for entry in entries if entry["task_id"]), - default=0, + mode = top.get("mode") or "background" + all_resolved = all( + _run_agent_is_resolved( + entry["status"], + is_async=_run_agent_is_async(mode, entry["status"]), + ) + for entry in entries ) - - dim_style = tui_rich_style("dim") - rows: list[RenderableType] = [summary] + all_async = all(_run_agent_is_async(mode, entry["status"]) for entry in entries) + types = [entry["subagent_type"] for entry in entries] + all_same_type = bool(types) and all(t == types[0] for t in types) + common_type = types[0] if all_same_type and types[0] not in _RUN_AGENTS_GENERIC_TYPES else None + hide_type = all_same_type and common_type is not None + + children: list[RenderableType] = [ + _render_grouped_agents_summary( + len(entries), + all_resolved=all_resolved, + all_async=all_async, + common_type=common_type, + ) + ] for index, entry in enumerate(entries): - is_last = index == len(entries) - 1 - branch = "└─" if is_last else "├─" - agent_status = entry["status"] - status_token = _status_style_token(agent_status) - - row = Text(f"{branch} ", style=tui_rich_style("muted")) - row.append(_status_glyph(agent_status), style=tui_rich_style(status_token)) - row.append(" ") - row.append(entry["subagent_type"], style=tui_rich_style("muted")) - if entry["name_extra"]: - name_style = tui_rich_style("tool_title") + RichStyle(bold=True) - row.append(f" · {entry['name_extra']}", style=name_style) - # Pad the label region so every "· status" separator starts at one column. - row.append(" " * (label_col - label_width(entry))) - row.append(" · ", style=dim_style) - status_text = agent_status.ljust(status_col) if entry["task_id"] else agent_status - row.append(status_text, style=tui_rich_style(status_token)) - if entry["task_id"]: - row.append(f" · {entry['task_id']}", style=dim_style) - rows.append(row) - - if agent_status in {"error", "failed", "failure"}: - preview = entry["message"] or entry["brief"] or entry["summary_preview"] - if preview: - prefix = " " if is_last else "│ " - rows.append(fg("dim", f"{prefix}{_compact_inline(preview, max_chars=100)}")) - elif not _is_review_run(agents): - preview = entry["brief"] or entry["summary_preview"] - if preview: - prefix = " " if is_last else "│ " - rows.append(fg("dim", f"{prefix}{_compact_inline(preview, max_chars=100)}")) + children.append( + _render_agent_progress_line( + entry, + is_last=index == len(entries) - 1, + hide_type=hide_type, + mode=mode, + ) + ) + + if not all_async and not ctx.expanded: + ctx.state["__suppress_generic_expand_hint__"] = True + children.append(key_hint("app.tools.expand", "to expand")) if _is_review_run(agents): findings = _aggregate_findings(agents) - rows.append(Text("")) - rows.append(_render_findings_table(findings)) + children.append(Text("")) + children.append(_render_findings_table(findings)) + + if ctx.expanded: + debug_body, remaining = format_lines_block( + result.text.rstrip("\n"), + expanded=True, + collapsed_max_lines=10**9, + style_token="tool_output", + ) + children.append(Text("")) + children.append(debug_body) + if remaining > 0: + children.append(fg("muted", f"… ({remaining} more lines)")) - return Group(*rows) + return Group(*children) def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None: diff --git a/src/pythinker_code/ui/shell/tool_renderers/background.py b/src/pythinker_code/ui/shell/tool_renderers/background.py index 25adb6f5..07800b22 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/background.py +++ b/src/pythinker_code/ui/shell/tool_renderers/background.py @@ -11,6 +11,8 @@ from rich.text import Text from pythinker_code.tools.display import BackgroundTaskDisplayBlock +from pythinker_code.ui.shell.components.key_hints import key_display_text +from pythinker_code.ui.shell.keymap import key_text from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -24,10 +26,12 @@ format_lines_block, invalid_arg, missing_required_arg, + normalize_agent_status, pending_tool_call_header, running_spinner, tool_call_header, ) +from pythinker_code.ui.theme import tui_rich_style # Process-wide resolver: task_id -> human description. Registered by the shell # from the runtime's background-task store so a TaskOutput/TaskStop header can @@ -107,6 +111,104 @@ def _render_call_with_id( ) +def _parse_task_output(text: str) -> tuple[dict[str, str], str]: + """Split TaskOutput tool text into metadata and the ``[output]`` body.""" + meta: dict[str, str] = {} + body_lines: list[str] = [] + in_output = False + for raw_line in text.splitlines(): + if raw_line.strip() == "[output]": + in_output = True + continue + if in_output: + body_lines.append(raw_line) + continue + if ":" not in raw_line: + continue + key, _, value = raw_line.partition(":") + key = key.strip() + if key and " " not in key: + meta[key] = value.strip() + body = "\n".join(body_lines).strip() + if body.startswith("[Truncated. Full output:"): + _, _, rest = body.partition("]\n\n") + if rest: + body = rest.strip() + return meta, body + + +def _read_output_collapsed_hint() -> Text: + expand_key = key_display_text(key_text("app.tools.expand") or "ctrl+o") + return fg("dim", f"Read output ({expand_key} to expand)") + + +def _task_output_is_running(meta: dict[str, str]) -> bool: + retrieval = meta.get("retrieval_status", "").lower() + if retrieval in {"not_ready", "timeout"}: + return True + status = normalize_agent_status(meta.get("status", "")) + return status in {"starting", "running", "waiting", "queued"} + + +def _render_task_output_result( + ctx: ToolRenderContext, + result: ToolResultPayload, + *, + collapsed_lines: int = 12, +) -> RenderableType | None: + _stash_task_label(ctx, result) + if not result.text: + return None + if result.is_error: + return _render_block_result(ctx, result, collapsed_lines=collapsed_lines) + + meta, body = _parse_task_output(result.text) + if not meta: + return _render_block_result(ctx, result, collapsed_lines=collapsed_lines) + + description = ( + meta.get("description") + or ctx.state.get("task_label") + or _resolve_task_label(ctx, meta.get("task_id", "")) + or meta.get("task_id", "task") + ) + retrieval = meta.get("retrieval_status", "").lower() + if not retrieval and normalize_agent_status(meta.get("status", "")) == "completed" and body: + retrieval = "success" + + if _task_output_is_running(meta): + ctx.state["__suppress_generic_expand_hint__"] = True + return fg("dim", "Task is still running…") + + if retrieval != "success" or not body: + ctx.state["__suppress_generic_expand_hint__"] = True + if retrieval == "not_ready": + return fg("dim", "Task is still running…") + return fg("dim", "No task output available") + + if not ctx.expanded: + ctx.state["__suppress_generic_expand_hint__"] = True + return _read_output_collapsed_hint() + + line_count = body.count("\n") + 1 if body else 0 + children: list[RenderableType] = [ + Text(f"{description} ({line_count} lines)", style=tui_rich_style("tool_title")) + ] + body_block, remaining = format_lines_block( + body, + expanded=True, + collapsed_max_lines=collapsed_lines, + style_token="tool_output", + ) + children.append(body_block) + if remaining > 0: + children.append(fg("muted", f"… ({remaining} more lines)")) + error = meta.get("error") + if error: + children.append(fg("error", f"Error: {error}")) + return Group(*children) + + def _render_block_result( ctx: ToolRenderContext, result: ToolResultPayload, @@ -176,7 +278,7 @@ def _render_task_output_call(ctx: ToolRenderContext) -> RenderableType: label="task output", render_shell="default", render_call=_render_task_output_call, - render_result=lambda ctx, r: _render_block_result(ctx, r, collapsed_lines=20), + render_result=lambda ctx, r: _render_task_output_result(ctx, r, collapsed_lines=20), ) diff --git a/src/pythinker_code/ui/shell/tool_renderers/tool_search.py b/src/pythinker_code/ui/shell/tool_renderers/tool_search.py new file mode 100644 index 00000000..79b08531 --- /dev/null +++ b/src/pythinker_code/ui/shell/tool_renderers/tool_search.py @@ -0,0 +1,125 @@ +"""Pythinker renderer for the ``ToolSearch`` tool. + +The model-facing tool returns name + description lines for LLM consumption. +The TUI shows a compact discovery summary by default and tool names only on +expand — never the full description catalog. +""" + +from __future__ import annotations + +import re + +from rich.console import RenderableType +from rich.text import Text + +from pythinker_code.ui.shell.tool_renderers import ( + ToolRenderContext, + ToolRenderDefinition, + ToolResultPayload, +) +from pythinker_code.ui.shell.tool_renderers._render_utils import ( + as_str, + fg, + fg_subject, + invalid_arg, + pending_tool_call_header, + running_spinner, + tool_call_header, +) +from pythinker_code.ui.theme import tui_rich_style + +_TOOL_NAME = "ToolSearch" +_DISPLAY_NAME = "Tools" +_COLLAPSED_NAME_PREVIEW = 5 +_LIST_LINE_RE = re.compile(r"^- (.+?) - .+$") + + +def _parse_tool_names(text: str) -> list[str]: + names: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line.startswith("- "): + continue + if match := _LIST_LINE_RE.match(line): + names.append(match.group(1)) + continue + rest = line[2:].strip() + if " - " in rest: + names.append(rest.split(" - ", 1)[0].strip()) + return names + + +def _render_call(ctx: ToolRenderContext) -> RenderableType: + args = ctx.args or {} + query = as_str(args.get("query")) + style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" + + if query is None: + if "query" in args: + summary: str | Text = invalid_arg() + elif ctx.has_result: + summary = fg("muted", "search") + else: + header = pending_tool_call_header(_DISPLAY_NAME, action="Searching") + return running_spinner( + header, + execution_started=ctx.execution_started, + has_result=ctx.has_result, + ) + else: + summary = fg_subject(query) if query else fg("muted", "search") + + header = tool_call_header(_DISPLAY_NAME, summary, style_token=style_token) + return running_spinner( + header, execution_started=ctx.execution_started, has_result=ctx.has_result + ) + + +def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None: + text = (result.text or "").strip() + if not text: + message = result.details.get("message") if result.details else None + if isinstance(message, str) and message.strip(): + return fg("error" if result.is_error else "muted", message.strip()) + return None + + if text.startswith("No visible tools") or text.startswith("No visible tools matched"): + ctx.state["__suppress_generic_expand_hint__"] = True + return fg("error" if result.is_error else "muted", text) + + names = _parse_tool_names(text) + if not names: + ctx.state["__suppress_generic_expand_hint__"] = True + return fg("error" if result.is_error else "tool_output", text.splitlines()[0]) + + count = len(names) + if count > _COLLAPSED_NAME_PREVIEW: + ctx.state["__has_expandable_payload__"] = True + + if ctx.expanded: + ctx.state["__suppress_generic_expand_hint__"] = True + return fg("tool_output", f"Tools discovered: {', '.join(names)}") + + preview = names[:_COLLAPSED_NAME_PREVIEW] + suffix = "" + if count > len(preview): + suffix = f", +{count - len(preview)} more" + names_part = ", ".join(preview) + suffix + + line = Text() + line.append("✓ ", style=tui_rich_style("success")) + label = "tool" if count == 1 else "tools" + line.append(f"{count} {label} discovered", style=tui_rich_style("tool_output")) + if names_part: + line.append(f" ({names_part})", style=tui_rich_style("muted")) + ctx.state["__suppress_generic_expand_hint__"] = count <= _COLLAPSED_NAME_PREVIEW + return line + + +TOOL_SEARCH_RENDERER = ToolRenderDefinition( + name=_TOOL_NAME, + label="tools", + render_shell="default", + render_call=_render_call, + render_result=_render_result, +) diff --git a/src/pythinker_code/ui/shell/usage.py b/src/pythinker_code/ui/shell/usage.py index 19dfe1cd..26995723 100644 --- a/src/pythinker_code/ui/shell/usage.py +++ b/src/pythinker_code/ui/shell/usage.py @@ -296,6 +296,13 @@ async def usage(app: Shell, args: str): # provider filter, so route to it before we try to interpret the argument # as a managed provider key. if positional and _parse_activity_view(positional[0]) is not None: + if len(positional) > 1: + extra = escape(" ".join(positional[1:])) + console.print( + f"[{_t.error}]Invalid usage arguments: the '{positional[0]}' activity " + f"card takes no extra arguments (got '{extra}')[/]" + ) + return await _print_activity_card(positional[0], json_mode=json_mode) return scoped_to_active = False diff --git a/src/pythinker_code/ui/shell/usage_activity.py b/src/pythinker_code/ui/shell/usage_activity.py index 7c1c5ea3..e8fac6eb 100644 --- a/src/pythinker_code/ui/shell/usage_activity.py +++ b/src/pythinker_code/ui/shell/usage_activity.py @@ -93,6 +93,7 @@ class TokenActivity: summary: ActivitySummary daily_values: tuple[int, ...] # length == CELL_COUNT today_index: int # position in daily_values that maps to ``today`` + today: date # anchor date the window was built around (not wall-clock) def load_activity(today: date | None = None) -> TokenActivity: @@ -154,6 +155,7 @@ def _build_activity(steps: Iterable[StepRecord], today: date) -> TokenActivity: summary=_summarize(values, today), daily_values=values, today_index=today_index, + today=today, ) @@ -186,6 +188,11 @@ def _current_streak(values: Sequence[int], today_offset: int) -> int: end = min(today_offset, len(values) - 1) for offset in range(end, -1, -1): if values[offset] <= 0: + # Today's bucket is often empty mid-day; per the contract a partial + # today must not look like the streak ended. Skip an empty *today*, + # but any earlier empty day genuinely breaks the streak. + if offset == end: + continue break streak += 1 return streak @@ -408,7 +415,10 @@ def _chart_lines( ) ] first_column = WEEK_COUNT - shown - today = datetime.now(tz=UTC).date() + # Anchor month labels and future-cell masking to the window the payload was + # built around, not wall-clock now() — so non-current snapshots and + # deterministic test loads stay aligned. + today = activity.today out: list[RenderableType] = [_month_labels(today, first_column, shown)] if view is TokenActivityView.DAILY: diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 78ed44a2..572a4148 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -30,6 +30,11 @@ ) from pythinker_code.ui.shell.components.render_utils import render_message_response, sanitize_ansi from pythinker_code.ui.shell.components.report import render_agent_body +from pythinker_code.ui.shell.components.report_update import ( + ReportUpdateComponent, + looks_like_report_update, + parse_report_update, +) from pythinker_code.ui.shell.console import current_console_width from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER, TRANSCRIPT_STATUS_MARKER from pythinker_code.ui.shell.mcp_status import mcp_startup_header @@ -124,16 +129,32 @@ def smooth_streaming_enabled() -> bool: ) -def _is_active_background_agent(tool_name: str, result_text: str) -> bool: - """Return True when result_text represents a still-running background Agent.""" - if tool_name != "Agent": - return False - values: dict[str, str] = {} +def _parse_tool_result_top_fields(result_text: str) -> dict[str, str]: + """Parse top-level ``key: value`` lines before nested agent/task sections.""" + top: dict[str, str] = {} for line in result_text.splitlines(): - if ":" in line: - k, _, v = line.partition(":") - values[k.strip()] = v.strip() - return values.get("kind") == "agent" and values.get("status") in _AGENT_ACTIVE_STATUSES + stripped = line.strip() + if not stripped or stripped.startswith("- ") or line.startswith(" "): + break + if ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + if not key or " " in key: + continue + top[key] = value.strip() + return top + + +def _is_active_background_agent(tool_name: str, result_text: str) -> bool: + """Return True when a tool card should stay in the Live area after its result arrives.""" + if tool_name == "Agent": + values = _parse_tool_result_top_fields(result_text) + return values.get("kind") == "agent" and values.get("status") in _AGENT_ACTIVE_STATUSES + if tool_name == "RunAgents": + values = _parse_tool_result_top_fields(result_text) + return values.get("mode") == "background" and values.get("tool_status") == "launched" + return False def _truncate_to_display_width(line: str, max_width: int) -> str: @@ -254,9 +275,29 @@ def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced: # pairs to compute rate over the last ~1.5s. Float cumulative_tokens avoids # per-sample truncation. self._token_samples: deque[tuple[float, float]] = deque() + self._report_update: ReportUpdateComponent | None = None # -- Public API ---------------------------------------------------------- + @property + def has_expandable_card(self) -> bool: + return self._report_update is not None and self._report_update.can_expand + + def toggle_expanded(self) -> None: + if self._report_update is None: + return + self._report_update.toggle_expanded() + + def render_expanded(self) -> RenderableType: + if self._report_update is None: + return self.promote_to_scrollback() or Text("") + was_expanded = self._report_update.expanded + self._report_update.set_expanded(True) + try: + return self._render_report_update_body() or Text("") + finally: + self._report_update.set_expanded(was_expanded) + def append(self, content: str) -> None: self.raw_text += content self._token_count += _estimate_tokens(content) @@ -350,7 +391,7 @@ def compose_final(self) -> RenderableType: remaining = self._pending_text() if not remaining: return Text("") - rendered = self._wrap_bullet(render_agent_body(remaining)) + rendered = self._render_body(remaining) if self._committed_renderables: return Group(*self._committed_renderables, BLANK_ROW, rendered) if self._has_printed_bullet: @@ -359,10 +400,13 @@ def compose_final(self) -> RenderableType: def promote_to_scrollback(self) -> RenderableType | None: """Build the full block renderable for one-shot scrollback promotion.""" + report_body = self._render_report_update_body() + if report_body is not None: + return report_body parts: list[RenderableType] = list(self._committed_renderables) remaining = self._pending_text() if remaining: - tail = self._wrap_bullet(render_agent_body(remaining)) + tail = self._render_body(remaining) if parts: parts.extend([BLANK_ROW, tail]) else: @@ -424,6 +468,8 @@ def has_emitted_to_scrollback(self) -> bool: def _flush_committed(self) -> None: """Stage confirmed markdown blocks for the next Live compose pass.""" + if looks_like_report_update(self.raw_text): + return pending = self._pending_text() if not pending: return @@ -436,6 +482,23 @@ def _flush_committed(self) -> None: self._committed_renderables.append(self._wrap_bullet(render_agent_body(committed_text))) self._committed_len += boundary + def _render_report_update_body(self) -> RenderableType | None: + update = parse_report_update(self.raw_text) + if update is None: + return None + if self._report_update is None: + self._report_update = ReportUpdateComponent(update) + return self._report_update.render() + + def _render_body(self, text: str) -> RenderableType: + if looks_like_report_update(text): + update = parse_report_update(text) + if update is not None: + if self._report_update is None: + self._report_update = ReportUpdateComponent(update) + return self._report_update.render() + return self._wrap_bullet(render_agent_body(text)) + def _activity_snapshot( self, label: str, *, label_style: Style | None = None ) -> ActivitySnapshot: @@ -493,11 +556,22 @@ def _compose_composing(self) -> RenderableType: return Group(spinner, BLANK_ROW, preview_row) def _render_preview_text(self, preview: str, *, caret: bool) -> Text: - """Plain-text preview path shared by live compose and finalize.""" + """Plain-text preview path shared by live compose and finalize. + + Leading newline separators (typically ``\\n`` from a markdown commit + boundary landing at a paragraph separator) are stripped so they do not + produce a blank row in the transient Live region. The ``BLANK_ROW`` + between the spinner and the preview row already provides the visual gap. + """ if not preview: return Text("") + # Strip leading "\n" / "\r\n" — paragraph separator left over from the + # commit boundary; not whitespace-only lines. + stripped = preview.lstrip("\r\n") + if not stripped: + return Text("") body = Text() - lines = preview.split("\n") + lines = stripped.split("\n") for index, line in enumerate(lines): if index: body.append("\n") @@ -965,8 +1039,6 @@ def _compose(self) -> RenderableType: label=style.label, target=self._argument, state=WorkLogState.RUNNING, - icon=style.icon, - icon_style=style.style, children=children, ) @@ -990,8 +1062,6 @@ def _compose(self) -> RenderableType: target=self._argument, state=state, detail=error_message if self._result.is_error else None, - icon=style.icon, - icon_style=style.style, children=children, ) diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 1fb0ba7e..cbcbe9eb 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -652,7 +652,11 @@ def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: if key in {"c-o", "c-e"}: if self._has_expandable_modal_panel() or ( self._expandable_tool_card() is None - and self._completed_expandable_tool_card() is not None + and self._expandable_content_block() is None + and ( + self._completed_expandable_tool_card() is not None + or self._completed_expandable_content_block() is not None + ) ): event.app.create_background_task(self._show_panel_in_pager()) elif self._toggle_latest_tool_card(): diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index a3f00293..ef7dfdd3 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -45,6 +45,7 @@ from pythinker_code.ui.shell.keyboard import KeyboardListener, KeyEvent from pythinker_code.ui.shell.mcp_status import render_mcp_startup_text from pythinker_code.ui.shell.motion import ( + STREAM_FPS, STREAM_FRAME_INTERVAL_S, ActivitySnapshot, active_marker_frame, @@ -241,6 +242,7 @@ def __init__( self._tool_call_blocks: dict[str, _ToolCallBlock] = {} self._last_tool_call_block: _ToolCallBlock | None = None self._completed_expandable_tool_blocks = deque[_ToolCallBlock](maxlen=20) + self._completed_expandable_content_blocks = deque[_ContentBlock](maxlen=20) self._current_step_retry: StepRetry | None = None self._approval_request_queue = deque[ApprovalRequest]() """ @@ -314,7 +316,7 @@ async def visualize_loop(self, wire: WireUISide): with Live( self.compose(), console=console, - refresh_per_second=10, + refresh_per_second=STREAM_FPS, transient=True, # Never let the transient Live region paint beyond the terminal # viewport. Interactive prompt mode has its own row budget; this @@ -329,7 +331,11 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: if event in (KeyEvent.CTRL_O, KeyEvent.CTRL_E): if self._has_expandable_modal_panel() or ( self._expandable_tool_card() is None - and self._completed_expandable_tool_card() is not None + and self._expandable_content_block() is None + and ( + self._completed_expandable_tool_card() is not None + or self._completed_expandable_content_block() is not None + ) ): from pythinker_code.telemetry import track @@ -475,7 +481,9 @@ def has_expandable_panel(self) -> bool: return ( self._has_expandable_modal_panel() or self._expandable_tool_card() is not None + or self._expandable_content_block() is not None or self._completed_expandable_tool_card() is not None + or self._completed_expandable_content_block() is not None ) def _has_expandable_modal_panel(self) -> bool: @@ -512,7 +520,24 @@ def _completed_expandable_tool_card(self) -> _ToolCallBlock | None: return block return None + def _expandable_content_block(self) -> _ContentBlock | None: + block = self._current_content_block + if block is not None and block.has_expandable_card: + return block + return None + + def _completed_expandable_content_block(self) -> _ContentBlock | None: + for block in reversed(self._completed_expandable_content_blocks): + if block.has_expandable_card: + return block + return None + def _toggle_latest_tool_card(self) -> bool: + block = self._expandable_content_block() + if block is not None: + block.toggle_expanded() + self.refresh_soon() + return True block = self._expandable_tool_card() if block is None: return False @@ -527,6 +552,10 @@ def _show_expandable_panel_content(self) -> bool: if question_panel := self._expandable_question_panel(): show_question_body_in_pager(question_panel) return True + if block := self._completed_expandable_content_block(): + with console.screen(), console.pager(styles=True): + console.print(block.render_expanded()) + return True if block := self._completed_expandable_tool_card(): with console.screen(), console.pager(styles=True): console.print(block.render_expanded()) @@ -1277,6 +1306,8 @@ def flush_content(self) -> None: renderable = block.promote_to_scrollback() if renderable is not None: emit_scrollback_block(console, renderable) + if block.has_expandable_card: + self._completed_expandable_content_blocks.append(block) self._current_content_block = None self.refresh_soon() diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index ec04c428..11eba042 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -73,6 +73,7 @@ class ToolStyle: "TaskList": ToolStyle("Tasks", "☷", "accent"), "TaskOutput": ToolStyle("TaskOutput", "☷", "accent"), "TaskStop": ToolStyle("TaskStop", "■", "warning"), + "ToolSearch": ToolStyle("Tools", "◇", "accent"), "ReadSkill": ToolStyle("Skill", "◇", "accent"), "Skill": ToolStyle("Skill", "◇", "accent"), } @@ -136,8 +137,6 @@ def render_worklog_entry( target: str | None = None, state: WorkLogState, detail: str | None = None, - icon: str = "•", - icon_style: str = "accent", icon_renderable: RenderableType | None = None, children: list[RenderableType] | None = None, ) -> RenderableType: diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index 87acadb9..ee5cf5b1 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -643,7 +643,10 @@ def enter_style(self, style_name: str | Style) -> Style: style = style.copy() if isinstance(style_name, str) and style_name in {"markdown.code", "markdown.code_block"}: if style.bgcolor is not None: - style = style + Style(bgcolor=None) + # Rich Styles are additive: `+ Style(bgcolor=None)` is a no-op and + # does NOT drop an inherited code background. Mutate the copied + # style's bgcolor directly, matching the clear pattern used above. + style._bgcolor = None style = style + Style(bold=False) self.style_stack.push(style) return self.current_style diff --git a/src/pythinker_code/utils/rich/syntax.py b/src/pythinker_code/utils/rich/syntax.py index ed729cb5..e3c61dff 100644 --- a/src/pythinker_code/utils/rich/syntax.py +++ b/src/pythinker_code/utils/rich/syntax.py @@ -310,7 +310,10 @@ def code_themes_match_for_picker(theme: str, configured: str) -> bool: if isinstance(resolved_theme, str) and isinstance(resolved_configured, str): return resolved_theme.casefold() == resolved_configured.casefold() if not isinstance(resolved_theme, str) and not isinstance(resolved_configured, str): - return type(resolved_theme) is type(resolved_configured) + # Builtin/adaptive themes resolve to stable singleton instances, so compare + # by identity. `type(...) is type(...)` wrongly matched distinct variants of + # the same class (e.g. catppuccin-frappe vs catppuccin-macchiato). + return resolved_theme is resolved_configured return False diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index 6470f11c..c92cf840 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -96,6 +96,7 @@ async def test_default_agent(runtime: Runtime): "pythinker_code.tools.file:Glob", "pythinker_code.tools.file:Grep", "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.lsp:Lsp", "pythinker_code.tools.file:WriteFile", "pythinker_code.tools.file:StrReplaceFile", "pythinker_code.tools.skill:ReadSkill", @@ -350,7 +351,7 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): **Available Built-in Agent Types** - `mocker`: The mock agent for testing purposes. (Tools: *, Model: inherit, Background: yes). -- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs, Model: inherit, Background: yes). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief. +- `coder`: Good at general software engineering tasks. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, Lsp, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, mcp__context7__resolve-library-id, mcp__context7__query-docs, Model: inherit, Background: yes). When to use: Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. It delivers production-ready, idiomatic, verified changes in any language the project uses, with current-docs verification for third-party APIs, and never expands beyond its brief. - `code-reviewer`: Diff-focused code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, ReadSkill, Model: inherit, Background: yes). When to use: Use to run a read-only, diff-focused, professional code review — severity-scored findings across correctness, security, reliability, performance, maintainability, and standards compliance, in any programming language — or a code-reviewr-derived PR artifact workflow on the current branch. It runs offline by design and never modifies the repository; third-party API claims it cannot verify from the repository come back under RISKS as needs-verification items for the parent to check. For diffs above roughly 1,500 changed lines or 25 files, dispatch one instance per subsystem with an explicit file list and synthesize, instead of one instance for the whole diff. - `debugger`: Failure/log/stack-trace root-cause analysis with reproduction evidence. (Tools: Shell, SetTodoList, ReadFile, Glob, Grep, SmartSearch, Model: inherit, Background: yes). When to use: Use for failing tests, stack traces, runtime errors, flaky failures, regressions, or debugging requests where the root cause should be found before editing code. Read-only and safe to fan out in parallel — one focused failure per instance — it returns the named mechanism, confidence, evidence, the recommended minimal fix, and the verification that would prove it. - `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill, Model: inherit, Background: yes). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. Absence claims come with the searches that back them. diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index 0fc0c253..69c1155a 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -451,6 +451,7 @@ async def test_wire_message_serde(): "body": "", "other_label": "", "other_description": "", + "other_index": -1, } ], }, diff --git a/tests/tools/test_agent_tool.py b/tests/tools/test_agent_tool.py index 3e1b2a0b..dba1998d 100644 --- a/tests/tools/test_agent_tool.py +++ b/tests/tools/test_agent_tool.py @@ -2723,3 +2723,34 @@ async def __call__(self, params): assert result.output.count("Shared cache key may collide.") >= 1 assert "reviewer-0, reviewer-1" in result.output assert "batch_blockers:" not in result.output + + +def test_run_agents_params_drops_blank_string_entries() -> None: + """Models sometimes emit bare "\n" strings between agent objects in the array. + + Those parse as valid JSON but are not AgentRunConfigs. The blank entries must be + dropped (the object entries are the unambiguous intent) while genuinely invalid + non-blank entries still fail validation loudly. + """ + from pydantic import ValidationError + + from pythinker_code.tools.agent import RunAgentsParams + + params = RunAgentsParams.model_validate( + { + "summary": "map orchestration", + "agents": [ + {"name": "a0", "prompt": "do x"}, + "\n", + {"name": "a1", "prompt": "do y"}, + " ", + {"name": "a2", "prompt": "do z"}, + ], + } + ) + assert [a.name for a in params.agents] == ["a0", "a1", "a2"] + + with pytest.raises(ValidationError): + RunAgentsParams.model_validate( + {"summary": "s", "agents": ["garbage", {"name": "a", "prompt": "p"}]} + ) diff --git a/tests/tools/test_lsp_diagnostics.py b/tests/tools/test_lsp_diagnostics.py index f9765d4d..c9a148bb 100644 --- a/tests/tools/test_lsp_diagnostics.py +++ b/tests/tools/test_lsp_diagnostics.py @@ -64,6 +64,21 @@ def test_dedup_within_batch(self) -> None: ) assert registry.pending_count == 1 + def test_dedup_key_preserves_zero_code(self) -> None: + registry = DiagnosticRegistry() + # A diagnostic with code 0 must be distinguishable from one with no code; + # a falsy check would collapse both keys and drop the zero-code entry. + registry.register_pending( + "pyright", + [ + _file( + "file:///tmp/a.py", + [_entry("same", 1, code=0), _entry("same", 1, code=None)], + ) + ], + ) + assert registry.pending_count == 2 + def test_dedup_across_turns(self) -> None: registry = DiagnosticRegistry() file = _file("file:///tmp/a.py", [_entry("error one", 1)]) @@ -175,6 +190,34 @@ async def test_handler_registers_and_resets_failures(self) -> None: await handler({"uri": "not-a-valid-params"}) assert registry.pending_count == 1 + async def test_empty_payload_clears_previous_diagnostics(self) -> None: + registry = DiagnosticRegistry() + instance = MagicMock() + register_publish_diagnostics_handler(registry, "pyright", instance) + handler = instance.on_notification.call_args[0][1] + + await handler( + { + "uri": "file:///tmp/a.py", + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1}, + }, + "message": "bad", + "severity": 1, + } + ], + } + ) + assert registry.pending_count == 1 + + # An empty diagnostics list means "no problems now" and must clear the + # file's stored entry instead of being ignored. + await handler({"uri": "file:///tmp/a.py", "diagnostics": []}) + assert registry.pending_count == 0 + class TestLspDiagnosticsInjectionProvider: def _make_runtime(self, *, connected: bool) -> MagicMock: diff --git a/tests/tools/test_lsp_manager.py b/tests/tools/test_lsp_manager.py index 0414979c..869cc1f8 100644 --- a/tests/tools/test_lsp_manager.py +++ b/tests/tools/test_lsp_manager.py @@ -222,6 +222,70 @@ async def track_send(method: str, params: Any) -> None: await manager.shutdown() + @pytest.mark.asyncio + async def test_did_change_versions_increment_monotonically( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + import json + + log_file = tmp_path / "events.jsonl" + manager = LspServerManager( + local_host, + {"pyright": _server_config(ext=".py", language="python", log_file=log_file)}, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + + target = tmp_path / "sample.py" + target.write_text("x = 1\n", encoding="utf-8") + + await manager.open_file(str(target), "x = 1\n") + await manager.change_file(str(target), "x = 2\n") + await manager.change_file(str(target), "x = 3\n") + await manager.shutdown() + + versions = [ + event["payload"]["textDocument"]["version"] + for event in ( + json.loads(line) + for line in log_file.read_text(encoding="utf-8").splitlines() + if line + ) + if event["event"] == "textDocument/didChange" + ] + assert versions == [2, 3] + + @pytest.mark.asyncio + async def test_restart_clears_open_doc_state( + self, local_host: LocalHost, tmp_path: Path + ) -> None: + manager = LspServerManager( + local_host, + {"pyright": _server_config(ext=".py", language="python")}, + workspace_folder=str(tmp_path), + ) + await manager.initialize() + target = tmp_path / "sample.py" + target.write_text("x = 1\n", encoding="utf-8") + + await manager.open_file(str(target), "x = 1\n") + assert manager.is_file_open(str(target)) + + # Stop the underlying instance, then park it in ERROR — the state a + # mid-session crash leaves behind. ensure_started then spawns a fresh + # process, which has no open documents, so the manager must forget the + # stale open-file state instead of skipping didOpen on the new process. + server = manager.server_for_file(str(target)) + assert server is not None + await server.stop() + server.mark_crashed(RuntimeError("crash")) + assert server.state == LspState.ERROR + + assert await manager.ensure_started(str(target)) is not None + assert not manager.is_file_open(str(target)) + + await manager.shutdown() + @pytest.mark.asyncio async def test_shutdown_isolates_failing_server( self, local_host: LocalHost, tmp_path: Path diff --git a/tests/tools/test_lsp_tool.py b/tests/tools/test_lsp_tool.py index 77f43f5d..0d0452b8 100644 --- a/tests/tools/test_lsp_tool.py +++ b/tests/tools/test_lsp_tool.py @@ -450,3 +450,25 @@ async def test_unavailable_when_init_failed(runtime, tmp_path: Path) -> None: assert result.is_error assert "unavailable" in result.message.lower() + + +def test_format_result_document_symbol_hierarchical_file_count() -> None: + from pythinker_code.tools.lsp.formatters import format_result + + symbols = [{"name": "Foo", "kind": 5, "range": {"start": {"line": 0, "character": 0}}}] + _formatted, count, file_count = format_result("documentSymbol", symbols, None) + assert count == 1 + assert file_count == 1 + + +def test_format_result_document_symbol_fallback_counts_unique_files() -> None: + from pythinker_code.tools.lsp.formatters import format_result + + # SymbolInformation[] fallback: no "range" key, locations may span files. + symbols = [ + {"name": "Foo", "kind": 5, "location": {"uri": "file:///tmp/a.py"}}, + {"name": "Bar", "kind": 5, "location": {"uri": "file:///tmp/b.py"}}, + ] + _formatted, count, file_count = format_result("documentSymbol", symbols, None) + assert count == 2 + assert file_count == 2 diff --git a/tests/ui/test_usage_activity.py b/tests/ui/test_usage_activity.py index 38b9e319..09b37fc9 100644 --- a/tests/ui/test_usage_activity.py +++ b/tests/ui/test_usage_activity.py @@ -12,6 +12,7 @@ TokenActivityView, _bar_levels, _chart_start, + _current_streak, _graded_levels, _month_labels, _summary_lines, @@ -115,6 +116,18 @@ def test_summary_streak_uses_best_format() -> None: assert "12d (best 54d)" in text +def test_current_streak_ignores_empty_today_but_counts_prior_days() -> None: + # values[end] is today. An empty today (partial mid-day) must not end the + # streak; prior consecutive active days still count. + values = [0, 1, 1, 1, 0] # today (index 4) empty, three active days before + assert _current_streak(values, today_offset=4) == 3 + # An empty day *before* today does break the streak. + values_gap = [1, 1, 0, 1, 0] + assert _current_streak(values_gap, today_offset=4) == 1 + # Active today extends the streak normally. + assert _current_streak([0, 1, 1, 1, 1], today_offset=4) == 4 + + # ----- rendering ----- @@ -142,6 +155,7 @@ def test_render_activity_includes_title_summary_and_footer() -> None: ), daily_values=tuple(1 if idx % 5 == 0 else 0 for idx in range(7 * 52)), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.DAILY, width=120)) assert "Token activity" in text @@ -158,6 +172,7 @@ def test_render_activity_wide_left_aligns_chart() -> None: summary=ActivitySummary(1, 1, 0, 0, 0), daily_values=(0,) * (7 * 52 - 1) + (1,), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.DAILY, width=160)) lines = text.splitlines() @@ -179,6 +194,7 @@ def test_render_activity_weekly_uses_bar_chart() -> None: ), daily_values=_sample_weekly_buckets(), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.WEEKLY, width=22)) # In the bar view, the gutter shows "max" / "0" instead of weekday labels. @@ -197,6 +213,7 @@ def test_render_activity_cumulative_caption() -> None: ), daily_values=_sample_weekly_buckets(), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.CUMULATIVE, width=22)) assert "Running total" in text @@ -207,6 +224,7 @@ def test_render_activity_narrow_widens_terminal_hint() -> None: summary=ActivitySummary(0, 0, 0, 0, 0), daily_values=(1,) * (7 * 52), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.DAILY, width=2)) assert "Widen terminal" in text @@ -217,6 +235,7 @@ def test_render_activity_empty_history_shows_placeholder() -> None: summary=ActivitySummary(0, 0, 0, 0, 0), daily_values=(0,) * (7 * 52), today_index=7 * 52 - 1, + today=date(2025, 6, 15), ) text = _render(render_activity(activity, TokenActivityView.DAILY, width=80)) assert "No token activity in the last 12 months" in text diff --git a/tests/ui_and_conv/test_audit_report_rendering.py b/tests/ui_and_conv/test_audit_report_rendering.py index 9d681cf1..90bf9283 100644 --- a/tests/ui_and_conv/test_audit_report_rendering.py +++ b/tests/ui_and_conv/test_audit_report_rendering.py @@ -2,6 +2,8 @@ from __future__ import annotations +from rich.console import RenderableType + from pythinker_code.ui.shell.components.render_utils import render_plain from pythinker_code.ui.shell.components.report import render_agent_body from pythinker_code.ui.shell.markdown.audit import ( @@ -13,7 +15,7 @@ from pythinker_code.ui.shell.markdown.renderer import pythinker_report_markdown -def _plain(renderable: object, *, width: int = 100) -> str: +def _plain(renderable: RenderableType, *, width: int = 100) -> str: return render_plain(renderable, width=width) diff --git a/tests/ui_and_conv/test_empty_think_part_indicator.py b/tests/ui_and_conv/test_empty_think_part_indicator.py index 35139356..3739f565 100644 --- a/tests/ui_and_conv/test_empty_think_part_indicator.py +++ b/tests/ui_and_conv/test_empty_think_part_indicator.py @@ -12,7 +12,7 @@ from pythinker_core.message import ToolCall from pythinker_core.tooling import ToolResult, ToolReturnValue -from rich.console import Console +from rich.console import Console, Group from pythinker_code.ui.shell.visualize import _LiveView from pythinker_code.wire.types import ( @@ -330,6 +330,73 @@ def test_action_spacer_between_parallel_tools_in_all_tui_styles(monkeypatch): assert any(0 < index < len(agent_blocks) - 1 for index in spacer_indices) +def test_action_spacer_between_run_agents_and_task_output(monkeypatch): + """Background RunAgents stays live beside TaskOutput with a blank row between.""" + from rich.text import Text + + from pythinker_code.ui.shell.tool_renderers import register_builtin_renderers + + monkeypatch.setenv("PYTHINKER_TUI_STYLE", "card") + register_builtin_renderers() + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(TurnBegin(user_input="test")) + view.dispatch_wire_message(StepBegin(n=1)) + view.dispatch_wire_message( + ToolCall( + id="run-agents-1", + function=ToolCall.FunctionBody( + name="RunAgents", + arguments=( + '{"summary":"scan","run_in_background":true,' + '"agents":[{"name":"scan-a","prompt":"look","subagent_type":"explore"}]}' + ), + ), + ) + ) + view.dispatch_wire_message( + ToolResult( + tool_call_id="run-agents-1", + return_value=ToolReturnValue( + is_error=False, + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 1\n" + "agents:\n" + "- name: scan-a\n" + " subagent_type: explore\n" + " status: starting\n" + " task_id: agent-abc\n" + ), + message="Agents launched.", + display=[], + ), + ) + ) + view.dispatch_wire_message( + ToolCall( + id="task-output-1", + function=ToolCall.FunctionBody( + name="TaskOutput", + arguments='{"task_id":"agent-abc","block":true,"timeout":600}', + ), + ) + ) + + assert len(view._tool_call_blocks) == 2 + agent_blocks = view.compose_agent_output(include_working_indicator=False) + rendered = _render(Group(*agent_blocks)) + assert "RunAgents(" in rendered + assert "TaskOutput(" in rendered + + spacer_indices = [ + index + for index, block in enumerate(agent_blocks) + if isinstance(block, Text) and block.plain.strip() == "" + ] + assert any(0 < index < len(agent_blocks) - 1 for index in spacer_indices) + + def test_moon_survives_status_update(monkeypatch): """StatusUpdate does not affect moon fallback visibility.""" from rich.text import Text diff --git a/tests/ui_and_conv/test_md_normalization_matrix.py b/tests/ui_and_conv/test_md_normalization_matrix.py index 3f2c9165..58c88ab9 100644 --- a/tests/ui_and_conv/test_md_normalization_matrix.py +++ b/tests/ui_and_conv/test_md_normalization_matrix.py @@ -2,6 +2,8 @@ from __future__ import annotations +from rich.console import RenderableType + from pythinker_code.ui.shell.components.markdown import ( PythinkerMarkdown, PythinkerMarkdownStream, @@ -20,7 +22,7 @@ _TABLE_BODY = "| Name | Value |\n|------|-------|\n| a | 1 |\n" -def _plain(renderable: object, *, width: int = 80) -> str: +def _plain(renderable: RenderableType, *, width: int = 80) -> str: return render_plain(renderable, width=width) diff --git a/tests/ui_and_conv/test_pythinker_themes_port.py b/tests/ui_and_conv/test_pythinker_themes_port.py index 5859d45e..38fc8694 100644 --- a/tests/ui_and_conv/test_pythinker_themes_port.py +++ b/tests/ui_and_conv/test_pythinker_themes_port.py @@ -65,3 +65,7 @@ def test_code_themes_match_for_picker_resolves_aliases(): assert code_themes_match_for_picker("github", "github") assert code_themes_match_for_picker("github-dark", "github") assert not code_themes_match_for_picker("dracula", "github") + # Distinct Catppuccin variants resolve to different non-string theme instances + # of the same class; they must not be treated as the same selection. + assert not code_themes_match_for_picker("catppuccin-frappe", "catppuccin-macchiato") + assert code_themes_match_for_picker("catppuccin-frappe", "catppuccin-frappe") diff --git a/tests/ui_and_conv/test_report_update.py b/tests/ui_and_conv/test_report_update.py new file mode 100644 index 00000000..625e1d80 --- /dev/null +++ b/tests/ui_and_conv/test_report_update.py @@ -0,0 +1,109 @@ +"""Tests for structured report-update TUI rendering.""" + +from __future__ import annotations + +from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.components.report import render_agent_body +from pythinker_code.ui.shell.components.report_update import ( + parse_report_update, + render_report_update, +) + + +def _plain(renderable, *, width: int = 100) -> str: + from rich.console import Console + + console = Console(width=width, no_color=True, legacy_windows=False) + with console.capture() as cap: + console.print(renderable) + return sanitize_ansi(cap.get()) + + +_SAMPLE = """\ +Report update complete. Summary of what changed: + +Files modified: + +• .pythinker/reports/multi-agent-orchestration-scan.md (598 → 662 lines) +• .pythinker/reports/multi-agent-orchestration-scan-verification.md (new, 151 lines) + +Corrections applied: + +• 1 + Severity High + Item M4 (isConcurrencySafe) + Fix TS-only concept; reframed as port-or-document + +• 2 + Severity Med + Item Subagent count + Fix 12 → 13; §1, §5, §8 updated + +• 3 + Severity Med + Item H1 test path + Fix tests/subagents/ → tests/core/ + +Out-of-scope flags (recorded in verification memo, not fixed here): + +• AGENTS.md still has stale "12 built-ins" count — needs separate PR +• tools/agent/__init__.py at 1035 lines is approaching a soft ceiling + +Method: Read-only verification. No source files modified; only the report and a new companion memo +were written. All claims in the corrections were verified against live source on +feat/tui-streaming-pr @ dfcc6b7. +""" + + +def test_parse_report_update_extracts_sections() -> None: + update = parse_report_update(_SAMPLE) + assert update is not None + assert len(update.files) == 2 + assert update.files[0].kind == "modified" + assert update.files[1].kind == "created" + assert len(update.corrections) == 3 + assert update.corrections[0].severity == "high" + assert update.corrections[0].item.startswith("M4") + assert len(update.followups) == 2 + assert update.branch == "feat/tui-streaming-pr" + assert update.commit == "dfcc6b7" + assert update.scope == "reports only; no source files changed" + + +def test_render_report_update_collapsed_is_compact() -> None: + update = parse_report_update(_SAMPLE) + assert update is not None + out = _plain(render_report_update(update, expanded=False), width=100) + assert "✓ Report update complete" in out + assert "Summary" in out + assert "2 files updated" in out + assert "3 corrections applied" in out + assert "multi-agent-orchestration-scan.md" in out + assert "598 → 662" in out + assert "High" in out + assert "M4" in out + assert "expand all 3 corrections" in out.lower() + assert "Severity High" not in out + + +def test_render_report_update_expanded_shows_table() -> None: + update = parse_report_update(_SAMPLE) + assert update is not None + out = _plain(render_report_update(update, expanded=True), width=120) + assert "Corrections applied" in out + assert "Subagent count" in out + assert "tests/subagents/" in out + + +def test_render_agent_body_promotes_report_update() -> None: + out = _plain(render_agent_body(_SAMPLE), width=100) + assert "✓ Report update complete" in out + assert "Follow-ups" in out + assert "AGENTS.md" in out + assert "Files modified:" not in out + + +def test_non_report_update_stays_markdown() -> None: + out = _plain(render_agent_body("Here is a normal assistant answer."), width=80) + assert "Report update complete" not in out + assert "Here is a normal assistant answer." in out diff --git a/tests/ui_and_conv/test_spacing_primitives.py b/tests/ui_and_conv/test_spacing_primitives.py index e02ba218..2842c8d7 100644 --- a/tests/ui_and_conv/test_spacing_primitives.py +++ b/tests/ui_and_conv/test_spacing_primitives.py @@ -44,15 +44,16 @@ def test_append_gap_zero_or_negative_is_noop() -> None: def test_padding_constants_have_zero_vertical() -> None: - # Vertical padding stays 0 so the stream spacer is the only inter-block gap. + # Stream/card/worklog padding has zero vertical so the stream spacer is the only gap. + # Dialog panels are exempt: they intentionally have vertical breathing room. for pad in ( spacing.CARD_PADDING, spacing.TINTED_CARD_PADDING, - spacing.DIALOG_PANEL_PADDING, spacing.WORKLOG_PANEL_PADDING, spacing.CODE_BLOCK_PADDING, ): assert pad[0] == 0 + assert spacing.DIALOG_PANEL_PADDING == (1, 1) def test_emit_scrollback_block_appends_trailing_blank() -> None: diff --git a/tests/ui_and_conv/test_statusline_render.py b/tests/ui_and_conv/test_statusline_render.py index 628f43fd..b7317af4 100644 --- a/tests/ui_and_conv/test_statusline_render.py +++ b/tests/ui_and_conv/test_statusline_render.py @@ -173,11 +173,16 @@ def test_flags_segment(): def test_context_segment_bar_and_gradient(): - frags = SEGMENT_REGISTRY["context"].render(make_ctx()) - text = _text(frags) + # Low usage is decluttered: the percentage shows but the gradient bar is + # suppressed until context starts filling up (>= 70%). + text = _text(SEGMENT_REGISTRY["context"].render(make_ctx())) assert text.startswith("ctx 36k/200k ") assert "18%" in text - assert "▰" in text and "▱" in text + assert "▰" not in text and "▱" not in text + # At >= 70% (but below the 90% CTX LOW warning) the gradient bar appears. + hot = _text(SEGMENT_REGISTRY["context"].render(make_ctx(context_tokens=150_000))) + assert "75%" in hot + assert "▰" in hot and "▱" in hot assert SEGMENT_REGISTRY["context"].render(make_ctx(max_context_tokens=0)) is None diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 3d133394..de1e96ca 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -302,6 +302,32 @@ def test_composing_committed_prose_has_gap_before_spinner() -> None: assert any(lines[j] == "" for j in range(first_idx + 1, composing_idx)) +def test_composing_preview_does_not_double_blank_after_commit_boundary() -> None: + """Pending text that starts with "\\n" after a commit boundary must not + produce a second blank row in the transient Live region. + """ + block = _ContentBlock(is_think=False) + block.append("First paragraph here.\n\nSecond paragraph here.\n\n") + block.append("\nThird still streaming") + assert block._committed_renderables + + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "Composing" in output + assert "Third still streaming" in output + lines = output.splitlines() + activity_index = next(i for i, line in enumerate(lines) if "Composing" in line) + assert activity_index + 1 < len(lines) + assert lines[activity_index + 1].strip() == "" + if activity_index + 2 < len(lines): + assert lines[activity_index + 2].strip() != "", ( + "Second blank row after 'Composing' — leading '\\n' in pending " + "text is leaking through the preview path." + ) + + def test_composing_preview_has_standard_gap_after_activity_line(monkeypatch): from pythinker_code.ui.shell.visualize import _blocks as blocks_module diff --git a/tests/ui_and_conv/test_tool_call_block.py b/tests/ui_and_conv/test_tool_call_block.py index ac6554fd..b2484ebf 100644 --- a/tests/ui_and_conv/test_tool_call_block.py +++ b/tests/ui_and_conv/test_tool_call_block.py @@ -401,3 +401,52 @@ def test_finished_call_with_null_command_still_shows_invalid_badge( block = _ToolCallBlock(_tool_call("Shell", '{"command": null}')) block.finish(ToolOk(output="")) assert "" in _plain(block.compose()) + + +def test_run_agents_background_launch_stays_background_pending(): + block = _ToolCallBlock( + _tool_call( + "RunAgents", + '{"summary":"scan","run_in_background":true,"agents":[{"name":"a","prompt":"p"}]}', + ) + ) + block.finish( + ToolOk( + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 1\n" + "agents:\n" + "- name: a\n" + " subagent_type: explore\n" + " status: starting\n" + " task_id: agent-abc\n" + ) + ) + ) + assert block.finished + assert block.is_background_pending + + +def test_run_agents_foreground_completion_is_not_background_pending(): + block = _ToolCallBlock( + _tool_call( + "RunAgents", + '{"summary":"scan","run_in_background":false,"agents":[{"name":"a","prompt":"p"}]}', + ) + ) + block.finish( + ToolOk( + output=( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 1\n" + "agents:\n" + "- name: a\n" + " subagent_type: explore\n" + " status: completed\n" + ) + ) + ) + assert block.finished + assert not block.is_background_pending diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 31311ea3..d400e6c1 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -592,6 +592,7 @@ def test_running_tool_headers_do_not_duplicate_status_bullets(): ("TaskStop", {"task_id": "abc"}, "TaskStop("), ("EnterPlanMode", {}, "Plan("), ("ExitPlanMode", {"options": [{"label": "Continue"}]}, "Plan("), + ("ToolSearch", {"query": "read"}, "Tools("), ] for tool, args, label in cases: rendered = _render_running(tool, args, width=64) @@ -892,10 +893,11 @@ def test_run_agents_renders_compact_professional_summary(): width=120, ) assert "⏺ RunAgents(" in rendered - assert "2 agents" in rendered - assert "foreground" in rendered - assert "code_scan" in rendered - assert "security_scan" in rendered + assert "Run code and security scans" in rendered + assert "code-reviewer" in rendered + assert "security-reviewer" in rendered + assert "2 code-reviewer agents finished" in rendered or "2 agents finished" in rendered + assert "Done" in rendered # Successful agent summaries are suppressed — only the findings table shows assert "No correctness findings" not in rendered assert "No exploitable security issues" not in rendered @@ -905,6 +907,7 @@ def test_run_agents_renders_compact_professional_summary(): assert "Review every changed file" not in rendered assert "result: |" not in rendered assert "agent_id:" not in rendered + assert "Mode" not in rendered def test_run_agents_rows_align_columns_and_drop_redundant_name(): @@ -934,17 +937,11 @@ def test_run_agents_rows_align_columns_and_drop_redundant_name(): ), width=120, ) - tree_lines = [line for line in rendered.splitlines() if line.lstrip().startswith(("├─", "└─"))] - assert len(tree_lines) == 2 - # Variable-width subagent labels are padded so the status column aligns. - status_cols = {line.index("running") for line in tree_lines} - assert len(status_cols) == 1, tree_lines - # And the trailing task_id column aligns too. - task_cols = {line.index("agent-") for line in tree_lines} - assert len(task_cols) == 1, tree_lines - # A name identical to the subagent_type is not echoed twice in its tree row. - code_reviewer_line = next(line for line in tree_lines if "code-reviewer" in line) - assert code_reviewer_line.count("code-reviewer") == 1 + assert "2 background agents launched" in rendered + assert "qa" in rendered + assert "code-reviewer" in rendered + assert "Initializing" not in rendered + assert "Mode" not in rendered # --------------------------------------------------------------------------- @@ -1138,6 +1135,65 @@ def test_task_list_renders_active_flag(): assert "⏺ Tasks(active)" in rendered +def test_task_output_renders_summary_not_raw_metadata(): + rendered = _render( + "TaskOutput", + {"task_id": "agent-plucky-comet", "block": True, "timeout": 600}, + output=( + "tool_status: success\n" + "retrieval_status: success\n" + "task_id: agent-plucky-comet\n" + "kind: agent\n" + "status: completed\n" + "description: Python subagents scan\n" + "subagent_type: explore\n" + "interrupted: false\n" + "timed_out: false\n" + "terminal_reason: completed\n" + "output_path: /Users/panda/.pythinker/sessions/s1/output.md\n" + "output_size_bytes: 82841\n" + "output_preview_bytes: 32768\n" + "output_truncated: true\n" + "offset: 0\n" + "next_offset: 32768\n" + "eof: false\n" + "\n" + "[output]\n" + "Scan complete with findings." + ), + width=120, + ) + assert "Read output" in rendered + assert "to expand" in rendered + assert "tool_status:" not in rendered + assert "retrieval_status:" not in rendered + assert "output_path:" not in rendered + assert "Scan complete with findings." not in rendered + + +def test_task_output_expanded_shows_description_and_body(): + rendered = _render( + "TaskOutput", + {"task_id": "agent-plucky-comet"}, + output=( + "tool_status: success\n" + "task_id: agent-plucky-comet\n" + "status: completed\n" + "description: Python subagents scan\n" + "output_size_bytes: 100\n" + "output_truncated: false\n" + "\n" + "[output]\n" + "body" + ), + expanded=True, + width=120, + ) + assert "Python subagents scan (1 lines)" in rendered + assert "body" in rendered + assert "tool_status" not in rendered + + def test_task_output_renders_id_and_block_flag(): rendered = _render( "TaskOutput", @@ -1155,6 +1211,64 @@ def test_task_stop_renders_id(): assert "abc-123" in rendered +# --------------------------------------------------------------------------- +# ToolSearch +# --------------------------------------------------------------------------- + + +def test_tool_search_renders_compact_summary_not_catalog(): + catalog = "\n".join( + [ + "- Agent - Start a subagent instance to work on a focused task.", + "- RunAgents - Launch a bounded group of focused child agents.", + "- ReadFile - Read text content from a file.", + "- Grep - A powerful search tool based on ripgrep.", + "- Shell - Execute a bash command.", + ] + ) + rendered = _render("ToolSearch", {"query": "read", "max_results": 8}, output=catalog) + assert "⏺ Tools(read)" in rendered + assert "5 tools discovered" in rendered + assert "Agent" in rendered + assert "Grep" in rendered + assert "Start a subagent" not in rendered + assert "powerful search tool" not in rendered + + +def test_tool_search_expanded_shows_names_only(): + catalog = "\n".join( + [ + "- Agent - Start a subagent instance.", + "- Grep - A powerful search tool based on ripgrep.", + ] + ) + rendered = _render( + "ToolSearch", + {"query": "agent"}, + output=catalog, + expanded=True, + ) + assert "Tools discovered: Agent, Grep" in rendered + assert "Start a subagent" not in rendered + assert "powerful search tool" not in rendered + + +def test_tool_search_no_match_renders_message(): + rendered = _render( + "ToolSearch", + {"query": "browser"}, + output="No visible tools matched `browser`.", + ) + assert "No visible tools matched" in rendered + assert "Start a subagent" not in rendered + + +def test_tool_search_streaming_uses_searching_header(): + rendered = _render_streaming("ToolSearch", {}) + assert "Searching Tools…" in rendered + assert "max_results" not in rendered + + # --------------------------------------------------------------------------- # Plan tools # --------------------------------------------------------------------------- @@ -1175,7 +1289,8 @@ def test_exit_plan_mode_renders_options(): ] }, ) - assert "⏺ Plan(awaiting approval)" in rendered_running + # The running marker blinks (blink_visible() is time-dependent); assert stable content only. + assert "Plan(awaiting approval)" in rendered_running assert "Refactor first" in rendered_running assert "Add tests first" in rendered_running @@ -1383,8 +1498,8 @@ def test_findings_table_unparsed_count_and_parsed_ratio(): assert "Unknown" in rendered -def test_successful_non_review_agent_shows_summary_preview(): - """Completed non-review agents with a summary_preview must show it as a preview line.""" +def test_successful_non_review_agent_shows_done_subline_not_prose_dump(): + """Completed non-review agents show a compact Done sub-line, not raw summary prose.""" output = _run_agents_review_output( "- name: implementer\n" " subagent_type: implementer\n" @@ -1396,7 +1511,6 @@ def test_successful_non_review_agent_shows_summary_preview(): " Refactored the auth module and added unit tests.\n" ) rendered = _render("RunAgents", {"summary": "implement feature"}, output=output, width=120) - # The summary preview must appear for non-review successful agents. - assert "Refactored the auth module" in rendered - # The findings panel must NOT appear (no review agents). + assert "Done" in rendered + assert "Refactored the auth module" not in rendered assert "Review Findings" not in rendered diff --git a/tests/ui_and_conv/test_tui_streaming_phase0.py b/tests/ui_and_conv/test_tui_streaming_phase0.py index 17796010..66cd1149 100644 --- a/tests/ui_and_conv/test_tui_streaming_phase0.py +++ b/tests/ui_and_conv/test_tui_streaming_phase0.py @@ -145,6 +145,26 @@ def test_long_code_block_does_not_reparse_per_tick() -> None: assert first == second +def test_live_paint_rate_matches_reveal_scheduler() -> None: + """Live auto-refresh must use the same rate constant as the reveal scheduler.""" + import inspect + import re + + from pythinker_code.ui.shell import motion + from pythinker_code.ui.shell.visualize import _live_view + + assert motion.STREAM_FPS == 25 + + source = inspect.getsource(_live_view._LiveView.visualize_loop) + match = re.search(r"refresh_per_second=(\w+)", source) + assert match is not None, "Live(...) is not passing refresh_per_second" + const_name = match.group(1) + assert hasattr(motion, const_name), ( + f"refresh_per_second uses {const_name!r} which is not in motion.py" + ) + assert getattr(motion, const_name) == motion.STREAM_FPS + + def test_streaming_caret_appended_during_compose(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "pythinker_code.ui.shell.motion.streaming_caret_visible", diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 8dadc63c..66dc9a9c 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -126,6 +126,8 @@ def test_render_pinned_status_tail_returns_spinner_when_turn_active() -> None: view._turn_ended = False view._active_turn_depth = 1 view._turn_start_time = _time.monotonic() + view._current_question_panel = None + view._current_approval_request_panel = None out = view.render_pinned_status_tail(80) assert out.value.strip() != "" @@ -187,6 +189,8 @@ def test_pinned_tail_stays_visible_while_foreground_tool_executes() -> None: view._turn_ended = False view._active_turn_depth = 1 view._turn_start_time = _time.monotonic() + view._current_question_panel = None + view._current_approval_request_panel = None block = _ToolCallBlock( ToolCall(id="tc-1", function=ToolCall.FunctionBody(name="Shell", arguments="{}")) diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index c6f83074..d459784b 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -219,6 +219,10 @@ def test_pyinstaller_datas(): "src/pythinker_code/tools/goal/update_goal.md", "pythinker_code/tools/goal", ), + ( + "src/pythinker_code/tools/lsp/tool.md", + "pythinker_code/tools/lsp", + ), ( "src/pythinker_code/tools/mcp_resource/list_description.md", "pythinker_code/tools/mcp_resource", diff --git a/web/public/install.sh b/web/public/install.sh index e1b6fc2a..21f0c70f 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -641,13 +641,17 @@ until release_has_assets; do fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + if [ -n "$_anim" ]; then + printf '\033[%d;1H\033[K %s%-11s%s release assets, retrying in %s%ss%s' "$PROGRESS_ROW" "$DIM" "Waiting" "$RESET" "$BAR" "$delay" "$RESET" + else + printf ' Waiting for release assets, retrying in %ss\n' "$delay" + fi sleep "$delay" elapsed=$((elapsed + delay)) delay=$((delay * 2)) [ "$delay" -gt 120 ] && delay=120 done -[ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" +[ -n "$_anim" ] && [ "$attempt" -gt 0 ] && printf '\033[%d;1H\033[K' "$PROGRESS_ROW" # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" From 18af18ebc1ce5657cd23c9e388cf9bb6d557c5a7 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:17:22 -0400 Subject: [PATCH 10/26] fix(tui): suppress consecutive ToolSearch probes in transcript scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple ToolSearch calls during deferred tool discovery now collapse to a single scrollback entry (the last probe), mirroring the blackbox reference's `isAbsorbedSilently` contract for ToolSearch. Intermediate discovery calls no longer produce repeated "Tools(…)" noise lines in the transcript. Also ships two other tested fixes from the collaborative branch: - fix(todo): accept Cursor/Claude TodoWrite shape using `content` instead of `title` (model_validator normalises before Pydantic validation) - feat(ui/slash): bare-segment matching for namespaced slash commands so `/designer` surfaces `/skill:designer-skill` without adding duplicate paths --- CHANGELOG.md | 5 + src/pythinker_code/tools/todo/__init__.py | 13 +- src/pythinker_code/ui/shell/prompt.py | 15 +- .../ui/shell/tool_renderers/todo.py | 2 +- .../ui/shell/visualize/_blocks.py | 5 + .../ui/shell/visualize/_live_view.py | 24 ++- tests/tools/test_todo.py | 17 +++ tests/ui_and_conv/test_slash_completer.py | 29 ++++ .../test_tool_search_suppression.py | 144 ++++++++++++++++++ 9 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/ui_and_conv/test_tool_search_suppression.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 375f320d..18ae1c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors. +- **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. +- **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:` + commands on their bare segment, so typing `/designer` (or `/design`) surfaces + `/skill:designer-skill`; accepting inserts the canonical command name. - **TUI composing preview gap.** Removed the visible double-blank row between `Composing…` and the in-progress preview (leading newline from commit boundaries no longer leaks through the plain-text preview path), and aligned diff --git a/src/pythinker_code/tools/todo/__init__.py b/src/pythinker_code/tools/todo/__init__.py index 9469ffd6..716ceebc 100644 --- a/src/pythinker_code/tools/todo/__init__.py +++ b/src/pythinker_code/tools/todo/__init__.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any, Literal, cast, override -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from pythinker_core.tooling import CallableTool2, ToolReturnValue from pythinker_code.session_state import TodoItemState @@ -25,6 +25,17 @@ class Todo(BaseModel): title: str = Field(description="The title of the todo", min_length=1) status: TodoStatus = Field(description="The status of the todo") + @model_validator(mode="before") + @classmethod + def _normalize_todo_write_shape(cls, data: Any) -> Any: + """Accept Cursor/Claude TodoWrite shapes that use ``content`` instead of ``title``.""" + if not isinstance(data, dict): + return data + values = dict(cast(dict[str, Any], data)) + if "title" not in values and "content" in values: + values["title"] = values["content"] + return values + @field_validator("status", mode="before") @classmethod def _normalize_status(cls, v: Any) -> Any: diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 11fced77..b3779da3 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -533,7 +533,20 @@ def match_tier(cmd: SlashCommand[Any]) -> tuple[int, str] | None: return (2, alias) if alias_prefix is None and alias_lower.startswith(typed_lower): alias_prefix = alias - return (3, alias_prefix) if alias_prefix is not None else None + if alias_prefix is not None: + return (3, alias_prefix) + # Namespaced commands ("skill:designer-skill", "flow:build-api") also + # match on their bare segment after the prefix, so `/designer` or + # `/build` surfaces them. The label stays the canonical name so the + # accepted completion inserts "/skill:designer-skill", not the bare + # term -- one execution path, no duplicate command. + if ":" in name_lower: + segment = name_lower.split(":", 1)[1] + if segment == typed_lower: + return (4, cmd.name) + if segment.startswith(typed_lower): + return (5, cmd.name) + return None # Rank by (match tier, command-name length, name): the closest, shortest # command name surfaces first within each tier. diff --git a/src/pythinker_code/ui/shell/tool_renderers/todo.py b/src/pythinker_code/ui/shell/tool_renderers/todo.py index 2e727522..2e3690e8 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/todo.py +++ b/src/pythinker_code/ui/shell/tool_renderers/todo.py @@ -58,7 +58,7 @@ def _icon_token(status: str) -> str: def _todo_level_and_title(item: dict[str, Any]) -> tuple[int, str]: """Return display nesting level and a cleaned title.""" - raw_title = as_str(item.get("title")) or "" + raw_title = as_str(item.get("title")) or as_str(item.get("content")) or "" explicit = item.get("level", item.get("depth", item.get("indent"))) if isinstance(explicit, int): return max(0, min(explicit, 6)), raw_title.strip() diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 572a4148..89cc0fae 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -117,6 +117,7 @@ def smooth_streaming_enabled() -> bool: # status must stay in the Live area so their spinner keeps animating. _AGENT_ACTIVE_STATUSES = frozenset({"created", "starting", "running", "awaiting_approval"}) _TODO_TOOL_NAMES = frozenset({"SetTodoList", "TodoWrite"}) +_TOOL_SEARCH_NAME = "ToolSearch" _MUTATING_TOOL_NAMES = frozenset( { "applypatch", @@ -698,6 +699,10 @@ def tool_call_id(self) -> str: def is_todo_list(self) -> bool: return self._tool_name in _TODO_TOOL_NAMES + @property + def is_tool_search(self) -> bool: + return self._tool_name == _TOOL_SEARCH_NAME + @property def finished(self) -> bool: return self._result is not None diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index ef7dfdd3..d7688914 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -241,6 +241,7 @@ def __init__( self._current_content_block: _ContentBlock | None = None self._tool_call_blocks: dict[str, _ToolCallBlock] = {} self._last_tool_call_block: _ToolCallBlock | None = None + self._held_tool_search_block: _ToolCallBlock | None = None self._completed_expandable_tool_blocks = deque[_ToolCallBlock](maxlen=20) self._completed_expandable_content_blocks = deque[_ContentBlock](maxlen=20) self._current_step_retry: StepRetry | None = None @@ -1245,6 +1246,7 @@ def cleanup(self, is_interrupt: bool) -> None: ) self._last_tool_call_block = None self.flush_finished_tool_calls() + self._flush_held_tool_search() # Drain background-pending blocks skipped above. They must be printed # to scrollback here; the transient Live area is about to be erased. for tool_call_id in list(self._tool_call_blocks.keys()): @@ -1288,6 +1290,7 @@ def discard_retry_attempt(self, retry: StepRetry) -> None: self._current_content_block = None self._tool_call_blocks.clear() self._last_tool_call_block = None + self._held_tool_search_block = None self._current_step_retry = retry def flush_content(self) -> None: @@ -1299,6 +1302,8 @@ def flush_content(self) -> None: # reveal cursor). block.reveal_all() block._flush_committed() + # A held ToolSearch must appear before the text that follows it. + self._flush_held_tool_search() if block.is_think: if block.has_pending(): emit_scrollback_block(console, block.compose_final()) @@ -1311,6 +1316,13 @@ def flush_content(self) -> None: self._current_content_block = None self.refresh_soon() + def _flush_held_tool_search(self) -> None: + if self._held_tool_search_block is not None: + block = self._held_tool_search_block + self._held_tool_search_block = None + _print_action_block(block.compose()) + self.refresh_soon() + def flush_finished_tool_calls(self) -> None: """Flush all leading finished tool call blocks. @@ -1318,6 +1330,11 @@ def flush_finished_tool_calls(self) -> None: skipped with ``continue`` instead of stopping the flush — they stay in the Live area so their spinner keeps animating. Subsequent finished blocks can still flush past them because background agents are async. + + ToolSearch blocks are absorbed silently — only the last one in a + consecutive run is shown, mirroring the blackbox ``isAbsorbedSilently`` + contract. A non-ToolSearch block triggers the held ToolSearch to flush + first so ordering is preserved. """ tool_call_ids = list(self._tool_call_blocks.keys()) for tool_call_id in tool_call_ids: @@ -1329,9 +1346,14 @@ def flush_finished_tool_calls(self) -> None: self._archive_completed_tool_card(block) self._tool_call_blocks.pop(tool_call_id) - _print_action_block(block.compose()) if self._last_tool_call_block == block: self._last_tool_call_block = None + if block.is_tool_search: + # Discard the previously held probe and hold this one instead. + self._held_tool_search_block = block + else: + self._flush_held_tool_search() + _print_action_block(block.compose()) self.refresh_soon() def flush_notifications(self) -> None: diff --git a/tests/tools/test_todo.py b/tests/tools/test_todo.py index 8b36eae9..93c50e94 100644 --- a/tests/tools/test_todo.py +++ b/tests/tools/test_todo.py @@ -51,6 +51,23 @@ def test_completed_status_alias_normalizes_to_done(self): assert params.todos is not None assert params.todos[0].status == "done" + def test_content_alias_normalizes_to_title(self): + """Cursor/Claude TodoWrite uses ``content``; SetTodoList expects ``title``.""" + params = Params( + todos=[{"id": "1", "content": "Map agent output handling", "status": "in_progress"}] # type: ignore[list-item] + ) + assert params.todos is not None + assert params.todos[0].title == "Map agent output handling" + assert params.todos[0].status == "in_progress" + + def test_todo_write_merge_field_is_ignored(self): + params = Params( + merge=True, # type: ignore[call-arg] + todos=[{"content": "Task A", "status": "pending"}], # type: ignore[list-item] + ) + assert params.todos is not None + assert params.todos[0].title == "Task A" + def test_todos_none_still_works(self): params = Params(todos=None) assert params.todos is None diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index 1994f389..ae5772f0 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -127,6 +127,35 @@ def test_exact_alias_match_surfaces_matched_alias(): assert _completion_texts(completer, "/reset") == ["/reset"] +def test_namespaced_command_matches_bare_segment_inserts_canonical(): + """Typing the bare segment of a `skill:`/`flow:` command surfaces it and + inserts the canonical name, so `/designer` -> `/skill:designer-skill`.""" + completer = SlashCommandCompleter( + [_make_command("skill:designer-skill"), _make_command("help")] + ) + + # Exact-segment, prefix-segment, and the namespaced prefix all resolve to + # the canonical command name (never the bare segment). + assert _completion_texts(completer, "/designer-skill") == ["/skill:designer-skill"] + assert _completion_texts(completer, "/designer") == ["/skill:designer-skill"] + assert _completion_texts(completer, "/design") == ["/skill:designer-skill"] + assert _completion_texts(completer, "/skill:design") == ["/skill:designer-skill"] + + +def test_name_prefix_outranks_namespaced_segment_match(): + """A direct name-prefix match ranks above a namespaced segment match.""" + completer = SlashCommandCompleter( + [_make_command("skill:design-system"), _make_command("design")] + ) + + # `/design` prefix-matches the plain `design` command (tier 1) above the + # `skill:design-system` segment match (tier 5). + assert _completion_texts(completer, "/design") == [ + "/design", + "/skill:design-system", + ] + + def test_shorter_command_name_prefix_ranks_first(): """Within the same match tier the closest (shortest) command name wins.""" completer = SlashCommandCompleter( diff --git a/tests/ui_and_conv/test_tool_search_suppression.py b/tests/ui_and_conv/test_tool_search_suppression.py new file mode 100644 index 00000000..a8ebc2bd --- /dev/null +++ b/tests/ui_and_conv/test_tool_search_suppression.py @@ -0,0 +1,144 @@ +"""Tests for ToolSearch scrollback suppression. + +Multiple ToolSearch calls in a single turn must produce at most one scrollback +entry — the last one. Intermediate probes are discarded silently, mirroring the +blackbox reference's ``isAbsorbedSilently`` behaviour for ToolSearch. +""" + +from __future__ import annotations + +import importlib + +import pytest +from pythinker_core.message import ToolCall +from pythinker_core.tooling import ToolResult, ToolReturnValue + +from pythinker_code.soul.live_tokens import reset_for_tests +from pythinker_code.ui.shell.visualize import _LiveView +from pythinker_code.wire.types import StatusUpdate, StepRetry, TextPart, TurnBegin + +_live_view_module = importlib.import_module("pythinker_code.ui.shell.visualize._live_view") + + +@pytest.fixture(autouse=True) +def _reset_tokens(): + reset_for_tests() + yield + reset_for_tests() + + +def _ts_call(call_id: str = "ts-1") -> ToolCall: + return ToolCall( + id=call_id, + function=ToolCall.FunctionBody(name="ToolSearch", arguments='{"query":null}'), + ) + + +def _ts_result(call_id: str = "ts-1", n_tools: int = 10) -> ToolResult: + tools_text = "\n".join(f"- Tool{i} - description {i}" for i in range(n_tools)) + return ToolResult( + tool_call_id=call_id, + return_value=ToolReturnValue( + is_error=False, + output=tools_text, + message="", + display=[], + ), + ) + + +def _make_view(monkeypatch, printed: list) -> _LiveView: + monkeypatch.setattr(_live_view_module, "_print_action_block", lambda b: printed.append(b)) + monkeypatch.setattr(_live_view_module, "emit_scrollback_block", lambda *a, **kw: None) + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(TurnBegin(user_input="work")) + return view + + +def test_single_tool_search_suppressed_until_turn_end(monkeypatch) -> None: + """A single ToolSearch should not print mid-turn; it flushes at turn end.""" + printed: list = [] + view = _make_view(monkeypatch, printed) + + view.dispatch_wire_message(_ts_call("ts-1")) + view.dispatch_wire_message(_ts_result("ts-1")) + assert len(printed) == 0, "ToolSearch must not print to scrollback mid-turn" + + view.cleanup(is_interrupt=False) + assert len(printed) == 1, "ToolSearch must print exactly once at turn end" + + +def test_consecutive_tool_searches_collapse_to_one(monkeypatch) -> None: + """Three consecutive ToolSearch calls → exactly one scrollback entry.""" + printed: list = [] + view = _make_view(monkeypatch, printed) + + for i in range(3): + view.dispatch_wire_message(_ts_call(f"ts-{i}")) + view.dispatch_wire_message(_ts_result(f"ts-{i}", n_tools=i + 10)) + + assert len(printed) == 0, "No ToolSearch should print mid-turn" + view.cleanup(is_interrupt=False) + assert len(printed) == 1, f"Expected 1 collapsed ToolSearch print, got {len(printed)}" + + +def test_tool_search_flushes_before_real_tool(monkeypatch) -> None: + """Held ToolSearch prints before a subsequent real tool, in order.""" + printed: list = [] + view = _make_view(monkeypatch, printed) + + view.dispatch_wire_message(_ts_call("ts-1")) + view.dispatch_wire_message(_ts_result("ts-1")) + assert len(printed) == 0 + + edit_call = ToolCall( + id="edit-1", + function=ToolCall.FunctionBody(name="Edit", arguments='{"path":"f.py","content":"x"}'), + ) + view.dispatch_wire_message(edit_call) + view.dispatch_wire_message( + ToolResult( + tool_call_id="edit-1", + return_value=ToolReturnValue(is_error=False, output="ok", message="", display=[]), + ) + ) + assert len(printed) == 2, "ToolSearch + Edit must both have printed" + + +def test_tool_search_does_not_cross_text_boundary(monkeypatch) -> None: + """ToolSearch groups are not collapsed across an assistant text block.""" + printed: list = [] + text_emitted: list = [] + monkeypatch.setattr(_live_view_module, "_print_action_block", lambda b: printed.append(b)) + monkeypatch.setattr( + _live_view_module, "emit_scrollback_block", lambda *a, **kw: text_emitted.append(a) + ) + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(TurnBegin(user_input="work")) + + # First ToolSearch, then assistant text, then another ToolSearch. + view.dispatch_wire_message(_ts_call("ts-1")) + view.dispatch_wire_message(_ts_result("ts-1")) + # Text forces a flush of the first TS group and the text itself. + view.dispatch_wire_message(TextPart(text="Thinking...")) + view.cleanup(is_interrupt=False) + + # First TS must have printed (flushed before the text), second turn cleanup + # also prints — total 1 TS + text path (via emit_scrollback_block). + assert len(printed) >= 1, "First ToolSearch should appear before assistant text" + + +def test_tool_search_discarded_on_retry(monkeypatch) -> None: + """A held ToolSearch from a failed step attempt must not appear in scrollback.""" + printed: list = [] + view = _make_view(monkeypatch, printed) + + view.dispatch_wire_message(_ts_call("ts-1")) + view.dispatch_wire_message(_ts_result("ts-1")) + assert len(printed) == 0 + + view.discard_retry_attempt( + StepRetry(n=1, next_attempt=2, max_attempts=3, wait_s=0.0, error_type="ValueError") + ) + view.cleanup(is_interrupt=False) + assert len(printed) == 0, "Retried ToolSearch must not appear in scrollback" From 9204785b5993ccfd109adf9a28bba6c9704a79a3 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:25:52 -0400 Subject: [PATCH 11/26] feat(tui): fuzzy-match slash commands by distinctive word Skills share a common prefix (pythinker-), so segment-prefix matching cannot disambiguate them. Add a lowest-priority fuzzy subsequence tier on the bare segment (gated at 2+ chars) so a misspelled distinctive word like /gurd surfaces /skill:pythinker-guard. Stronger prefix/alias/segment tiers still rank first; the inserted text stays the canonical command. --- CHANGELOG.md | 5 ++++- src/pythinker_code/ui/shell/prompt.py | 25 ++++++++++++++++++++++- tests/ui_and_conv/test_slash_completer.py | 23 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ae1c55..a84b4ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,10 @@ GitHub Releases page; `0.8.0` is the new starting line. - **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. - **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:` commands on their bare segment, so typing `/designer` (or `/design`) surfaces - `/skill:designer-skill`; accepting inserts the canonical command name. + `/skill:designer-skill`; accepting inserts the canonical command name. When no + prefix matches, a fuzzy fallback surfaces the distinctive word even when + misspelled (`/gurd` → `/skill:pythinker-guard`), so skills sharing a common + prefix stay reachable. - **TUI composing preview gap.** Removed the visible double-blank row between `Composing…` and the in-progress preview (leading newline from commit boundaries no longer leaks through the plain-text preview path), and aligned diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index b3779da3..b28fccbd 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -179,6 +179,22 @@ def _is_known_slash_command_prefix(name: str, known: frozenset[str]) -> bool: return any(command_name.startswith(lower) for command_name in known) +def _fuzzy_subsequence(needle: str, haystack: str) -> bool: + """True when ``needle`` is an (ordered, gap-tolerant) subsequence of ``haystack``. + + Mirrors the selector/settings filters (``selector.py``, ``settings_list.py``); + used as the lowest-priority slash match so a misspelled distinctive word like + ``gurd`` still surfaces ``/skill:pythinker-guard`` when no prefix matches. + """ + pos = 0 + for ch in needle: + found = haystack.find(ch, pos) + if found < 0: + return False + pos = found + 1 + return True + + def _slash_first_arg_context( document: Document, known_names: frozenset[str], @@ -540,12 +556,19 @@ def match_tier(cmd: SlashCommand[Any]) -> tuple[int, str] | None: # `/build` surfaces them. The label stays the canonical name so the # accepted completion inserts "/skill:designer-skill", not the bare # term -- one execution path, no duplicate command. + segment = name_lower.split(":", 1)[1] if ":" in name_lower else name_lower if ":" in name_lower: - segment = name_lower.split(":", 1)[1] if segment == typed_lower: return (4, cmd.name) if segment.startswith(typed_lower): return (5, cmd.name) + # Last resort: fuzzy subsequence on the bare segment, so the + # distinctive word -- even misspelled (``gurd`` -> ``guard``) -- + # surfaces a command whose shared prefix (``pythinker-``) makes + # plain prefix matching useless. Gated at 2+ chars to avoid a + # single keystroke matching nearly everything. Label is canonical. + if len(typed_lower) >= 2 and _fuzzy_subsequence(typed_lower, segment): + return (6, cmd.name) return None # Rank by (match tier, command-name length, name): the closest, shortest diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index ae5772f0..fbcc8524 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -142,6 +142,29 @@ def test_namespaced_command_matches_bare_segment_inserts_canonical(): assert _completion_texts(completer, "/skill:design") == ["/skill:designer-skill"] +def test_namespaced_command_fuzzy_matches_distinctive_word(): + """Skills sharing a prefix (`pythinker-`) are disambiguated by their suffix + word, even misspelled: `/guard` (substring) and `/gurd` (subsequence) both + surface `/skill:pythinker-guard` and insert the canonical name.""" + completer = SlashCommandCompleter( + [ + _make_command("skill:pythinker-guard"), + _make_command("skill:pythinker-code-help"), + ] + ) + + assert _completion_texts(completer, "/guard") == ["/skill:pythinker-guard"] + assert _completion_texts(completer, "/gurd") == ["/skill:pythinker-guard"] + + +def test_single_char_does_not_trigger_fuzzy_match(): + """A lone keystroke must not fuzzy-match arbitrary commands (too noisy).""" + completer = SlashCommandCompleter([_make_command("skill:pythinker-guard")]) + + # 'd' is a subsequence of the segment but below the 2-char fuzzy gate. + assert _completion_texts(completer, "/d") == [] + + def test_name_prefix_outranks_namespaced_segment_match(): """A direct name-prefix match ranks above a namespaced segment match.""" completer = SlashCommandCompleter( From 9b9f644caf275b936b2eba2599c5d84b2432a4ba Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:27:50 -0400 Subject: [PATCH 12/26] fix(soul): tighten truncated-response continuation reminder Reword the output-token-limit nudge to resume mid-thought without recap, and update the matching test assertion. --- src/pythinker_code/soul/pythinkersoul.py | 6 +++--- tests/core/test_pythinkersoul_stuck_loop.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 452dfb60..626279be 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -2101,9 +2101,9 @@ async def _pythinker_core_step_with_retry() -> StepResult: role="user", content=[ system_reminder( - "Your previous response was cut off by the output token limit. " - "Continue directly from where you stopped — no apology, no recap — " - "and break the remaining work into smaller pieces." + "Output token limit hit. Resume directly — no apology, no recap of " + "what you were doing. Pick up mid-thought if that is where the cut " + "happened. Break remaining work into smaller pieces." ) ], ) diff --git a/tests/core/test_pythinkersoul_stuck_loop.py b/tests/core/test_pythinkersoul_stuck_loop.py index 775e463e..3df3fa81 100644 --- a/tests/core/test_pythinkersoul_stuck_loop.py +++ b/tests/core/test_pythinkersoul_stuck_loop.py @@ -523,7 +523,7 @@ async def test_truncated_response_nudges_continuation(runtime: Runtime, tmp_path assert provider.generate_attempts == 2 assert record_turn.call_args.kwargs["stop_reason"] == "no_tool_calls" history_text = " ".join(m.extract_text(" ") for m in context.history) - assert "cut off by the output token limit" in history_text + assert "Output token limit hit" in history_text @pytest.mark.asyncio From a9cf877c758d8ec3bf149da8076064cc80fea642 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:32:43 -0400 Subject: [PATCH 13/26] fix(soul): align output-token-limit nudge text with reference byte-exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system-reminder injected after a truncated response now matches the blackbox reference verbatim per the AGENTS.md byte-exact text contract: 'Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.' Update the pinning test assertion to match the new text. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a84b4ac1..d8d32870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." - **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors. - **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. - **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:` From 7c7854212fea4996bb58f35c6c3de157fbfd47c6 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:53:56 -0400 Subject: [PATCH 14/26] fix(tools): gate ToolSearch to models that support deferred tool search ToolSearch only makes sense with Anthropic's tool_reference/defer_loading beta. The type="anthropic" compat proxies (z.ai/GLM, Kimi, MiniMax, opencode) point at their own endpoints that don't forward that beta, and non-Anthropic providers don't have it at all. Offering ToolSearch there is noise: it re-lists already-visible tools, and weaker tool-callers (observed: GLM-5.2) loop on it forever instead of calling tools directly. Add supports_deferred_tool_search() (genuine api.anthropic.com host + non- haiku, with ENABLE_TOOL_SEARCH override) mirroring the reference's gate, and hide ToolSearch in PythinkerToolset._is_tool_visible when unsupported. The tool stays registered so a /model switch to Claude re-enables it instantly. Rendering is intentionally unchanged (compact absorber kept, not reverted to the reference's verbose generic card). --- CHANGELOG.md | 7 ++ src/pythinker_code/agents/default/agent.yaml | 6 ++ src/pythinker_code/llm.py | 60 ++++++++++++++ src/pythinker_code/soul/toolset.py | 13 +++ tests/core/test_default_agent.py | 1 - tests/core/test_tool_search_gating.py | 84 ++++++++++++++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_tool_search_gating.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d32870..85300719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **ToolSearch hidden from models that can't use it.** `ToolSearch` is now offered + only when the active model genuinely supports the deferred tool-search workflow + (Anthropic's `tool_reference`/`defer_loading` beta on `api.anthropic.com`). The + `type="anthropic"` compat proxies (z.ai/GLM, Kimi, MiniMax, opencode) and all + non-Anthropic providers no longer see it, fixing a loop where weaker tool-callers + (e.g. GLM-5.2) repeatedly "searched" for tools instead of calling them. Override + with `ENABLE_TOOL_SEARCH=true|false`. - **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." - **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors. - **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. diff --git a/src/pythinker_code/agents/default/agent.yaml b/src/pythinker_code/agents/default/agent.yaml index 05ad7182..1a0df989 100644 --- a/src/pythinker_code/agents/default/agent.yaml +++ b/src/pythinker_code/agents/default/agent.yaml @@ -12,6 +12,12 @@ agent: # - "pythinker_code.tools.think:Think" - "pythinker_code.tools.ask_user:AskUserQuestion" - "pythinker_code.tools.todo:SetTodoList" + # Registered for all agents but HIDDEN at runtime from models that cannot use + # the deferred tool-search workflow (non-genuine-Anthropic providers, incl. + # the type="anthropic" compat proxies z.ai/Kimi/MiniMax/opencode). Gate lives + # in PythinkerToolset._is_tool_visible -> llm.supports_deferred_tool_search; + # leaving it registered keeps it available the moment the user /model-switches + # to a supported Claude model. Do not unconditionally drop or always keep it. - "pythinker_code.tools.tool_search:ToolSearch" - "pythinker_code.tools.worktree:EnterWorktree" - "pythinker_code.tools.worktree:ExitWorktree" diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index f6c76e56..f938db62 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -55,6 +55,66 @@ def model_name(self) -> str: return self.chat_provider.model_name +# Hosts that serve the genuine Anthropic API and therefore accept the +# `tool_reference` / `defer_loading` beta content blocks that deferred tool +# search depends on. The Claude API-key path and Anthropic OAuth both route +# through `api.anthropic.com` (see `auth/anthropic_direct.py:ANTHROPIC_BASE_URL`). +_GENUINE_ANTHROPIC_HOSTS = frozenset({"api.anthropic.com"}) + +# Model-name substrings that do NOT support `tool_reference`, mirroring the +# reference's `DEFAULT_UNSUPPORTED_MODEL_PATTERNS` in +# `blackbox/pythinker-src/src/utils/toolSearch.ts`. Haiku is the only known one. +_TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS = ("haiku",) + + +def supports_deferred_tool_search(llm: LLM | None) -> bool: + """Whether the active model can use the ToolSearch / deferred-tools workflow. + + WHY THIS GATE EXISTS — DO NOT REMOVE without reading this: + + `ToolSearch` only makes sense when the provider supports Anthropic's + `tool_reference` / `defer_loading` beta, the mechanism the reference impl + (`blackbox/pythinker-src/src/utils/toolSearch.ts`) uses to hold large MCP + tool sets out of context and discover them on demand. Crucially, MANY + providers in this CLI declare `type="anthropic"` yet point at their OWN + Anthropic-COMPATIBLE proxy that does NOT forward that beta: z.ai/GLM + (`api.z.ai/api/anthropic`), Kimi, MiniMax, and opencode_go. On those — and on + every non-Anthropic provider — offering `ToolSearch` is pure noise: it just + re-lists tools the model can already see, and weaker tool-callers (observed + with GLM-5.2) loop on it, "searching" for tools forever instead of calling + them. So `_is_tool_visible` hides `ToolSearch` whenever this returns False. + + The gate mirrors the reference's three checks: env override (`getToolSearchMode`), + a genuine-first-party-host check (`isFirstPartyPythoughtsBaseUrl`), and a + model-capability check (`modelSupportsToolReference`). Keep it derived from the + ACTIVE model so a mid-session `/model` switch re-evaluates it. + + `ENABLE_TOOL_SEARCH` is the explicit escape hatch (mirrors the reference): set + it truthy to force-enable on a proxy you know forwards the beta, or falsy to + kill it entirely. + """ + # Explicit opt-in / kill switch wins over host heuristics, exactly like the + # reference's `getToolSearchMode()` env precedence. + env = os.getenv("ENABLE_TOOL_SEARCH") + if env is not None: + return env.strip().lower() not in {"", "0", "false", "no", "off"} + + if llm is None or llm.provider_config is None: + return False + provider = llm.provider_config + if provider.type != "anthropic": + return False + # type="anthropic" is necessary but NOT sufficient — the compat proxies above + # share it. Only the genuine Anthropic host forwards the beta. + from urllib.parse import urlparse + + host = (urlparse(provider.base_url).hostname or "").lower() + if host not in _GENUINE_ANTHROPIC_HOSTS: + return False + model = llm.model_name.lower() + return not any(pat in model for pat in _TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS) + + def model_display_name(model_name: str | None, model: LLMModel | None = None) -> str: if model is not None and model.display_name: return model.display_name diff --git a/src/pythinker_code/soul/toolset.py b/src/pythinker_code/soul/toolset.py index 2907acd5..79f3d2bd 100644 --- a/src/pythinker_code/soul/toolset.py +++ b/src/pythinker_code/soul/toolset.py @@ -724,6 +724,19 @@ def _is_tool_visible(self, tool: ToolType) -> bool: ): return False + # Hide ToolSearch unless the active model genuinely supports the deferred + # tool-search workflow. Compat proxies that declare type="anthropic" + # (z.ai/GLM, Kimi, MiniMax, opencode) and non-Anthropic providers do not + # forward the tool_reference/defer_loading beta, so ToolSearch is noise + # there and weaker tool-callers (e.g. GLM-5.2) loop on it forever instead + # of calling tools directly. See llm.supports_deferred_tool_search for the + # full rationale — this is deliberate, do not drop it. + if tool.name == "ToolSearch": + from pythinker_code.llm import supports_deferred_tool_search + + if not supports_deferred_tool_search(runtime.llm): + return False + if tool.name == "EnterPlanMode" and runtime.session.state.plan_mode: return False if tool.name == "ExitPlanMode" and not runtime.session.state.plan_mode: diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index c92cf840..d7f4ad91 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -310,7 +310,6 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): "ReadSkill", "AskUserQuestion", "SetTodoList", - "ToolSearch", "EnterWorktree", "ExitWorktree", "UpdateGoal", diff --git a/tests/core/test_tool_search_gating.py b/tests/core/test_tool_search_gating.py new file mode 100644 index 00000000..4f5031dd --- /dev/null +++ b/tests/core/test_tool_search_gating.py @@ -0,0 +1,84 @@ +"""Gate for the deferred ToolSearch workflow. + +Regression coverage for the GLM-5.2 ToolSearch loop: `ToolSearch` must only be +offered to models that genuinely support Anthropic's `tool_reference` / +`defer_loading` beta. The compat proxies that declare `type="anthropic"` but +point at their own endpoint (z.ai/GLM, Kimi, MiniMax, opencode) must NOT see it. +See `pythinker_code.llm.supports_deferred_tool_search`. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast + +import pytest +from pydantic import SecretStr + +from pythinker_code.config import LLMProvider +from pythinker_code.llm import LLM, supports_deferred_tool_search + + +def _llm(provider_type: str | None, base_url: str, model: str) -> LLM: + provider = ( + None + if provider_type is None + else LLMProvider(type=cast("str", provider_type), base_url=base_url, api_key=SecretStr("x")) # type: ignore[arg-type] + ) + return LLM( + chat_provider=cast("object", SimpleNamespace(model_name=model)), # type: ignore[arg-type] + max_context_size=200_000, + capabilities=set(), + provider_config=provider, + ) + + +def test_genuine_anthropic_supports_tool_search() -> None: + assert supports_deferred_tool_search( + _llm("anthropic", "https://api.anthropic.com", "claude-opus-4-8") + ) + + +@pytest.mark.parametrize( + ("base_url", "label"), + [ + ("https://api.z.ai/api/anthropic", "z.ai/GLM"), + ("https://api.moonshot.ai/anthropic", "kimi"), + ("https://api.minimax.io/anthropic", "minimax"), + ], +) +def test_anthropic_compat_proxies_are_excluded(base_url: str, label: str) -> None: + # type="anthropic" but a non-genuine host -> no tool_reference beta -> hidden. + assert not supports_deferred_tool_search(_llm("anthropic", base_url, "glm-5.2")), label + + +def test_non_anthropic_provider_excluded() -> None: + assert not supports_deferred_tool_search( + _llm("openai_responses", "https://api.openai.com", "gpt-5.5") + ) + + +def test_haiku_excluded_even_on_genuine_anthropic() -> None: + assert not supports_deferred_tool_search( + _llm("anthropic", "https://api.anthropic.com", "claude-haiku-4-5") + ) + + +def test_none_llm_or_provider_excluded() -> None: + assert not supports_deferred_tool_search(None) + assert not supports_deferred_tool_search(_llm(None, "", "x")) + + +def test_env_force_enable_overrides_host(monkeypatch: pytest.MonkeyPatch) -> None: + # Explicit opt-in: user asserts their proxy forwards the beta. + monkeypatch.setenv("ENABLE_TOOL_SEARCH", "true") + assert supports_deferred_tool_search( + _llm("anthropic", "https://api.z.ai/api/anthropic", "glm-5.2") + ) + + +def test_env_kill_switch_overrides_genuine(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ENABLE_TOOL_SEARCH", "false") + assert not supports_deferred_tool_search( + _llm("anthropic", "https://api.anthropic.com", "claude-opus-4-8") + ) From a89228b192ecd450acad6435a4d97f40a91a644b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 18:58:58 -0400 Subject: [PATCH 15/26] fix(tools): stop ToolSearch description claiming nonexistent deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause follow-up to the ToolSearch gate: pythinker emits no defer_loading and ToolSearch only searches already-visible, already-callable tools (it unlocks nothing). The old description told the model that "deferred/hidden tool loading" might leave capabilities off the prompt — a false claim that primed weaker models to loop searching for tools they already had. Rewrite it to state plainly that it reveals nothing new and to call tools directly. --- CHANGELOG.md | 4 +++- src/pythinker_code/tools/tool_search/tool_search.md | 8 +++++--- tests/tools/test_tool_search.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85300719..b5de0e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ GitHub Releases page; `0.8.0` is the new starting line. `type="anthropic"` compat proxies (z.ai/GLM, Kimi, MiniMax, opencode) and all non-Anthropic providers no longer see it, fixing a loop where weaker tool-callers (e.g. GLM-5.2) repeatedly "searched" for tools instead of calling them. Override - with `ENABLE_TOOL_SEARCH=true|false`. + with `ENABLE_TOOL_SEARCH=true|false`. The tool's description no longer claims that + hidden/deferred tools exist (pythinker loads no tools lazily), removing the prompt + that primed the loop in the first place. - **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." - **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors. - **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. diff --git a/src/pythinker_code/tools/tool_search/tool_search.md b/src/pythinker_code/tools/tool_search/tool_search.md index 2285109d..3d054e7c 100644 --- a/src/pythinker_code/tools/tool_search/tool_search.md +++ b/src/pythinker_code/tools/tool_search/tool_search.md @@ -1,7 +1,9 @@ Search the currently visible tool list by name and description. -Use this when you are unsure which tool is available for a task or when deferred/hidden tool -loading means the initial prompt may not list every useful capability. The search only returns -tools visible under the current runtime and permission profile. +Use this only when you are unsure which existing tool fits a task. It returns tools that are +already available to you under the current runtime and permission profile — it does NOT load, +unlock, or reveal any hidden tools. Everything it can return is already callable directly, so +prefer calling the tool you need over searching for it; searching does not make any new tool +available. Provide concise keywords such as `worktree`, `background task`, `read file`, or `web search`. diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 03e5f1d5..faff9176 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -77,3 +77,13 @@ async def test_tool_search_excludes_hidden_tools() -> None: assert not result.is_error assert "ReadProjectFiles" not in result.output assert result.output == "No visible tools matched `read files`." + + +def test_description_does_not_claim_nonexistent_deferral() -> None: + """Guard the trigger of the GLM-5.2 loop: pythinker has no defer_loading, so + the ToolSearch description must not imply hidden/deferred tools exist or that + searching unlocks anything. See test_tool_search_gating + the toolset gate.""" + desc = ToolSearch(PythinkerToolset()).description.lower() + assert "deferred" not in desc + assert "hidden" in desc and "does not" in desc # explicitly says it unlocks nothing + assert "already callable" in desc From 7550b2ba6060fb9a72933a877c0bf9f431070a43 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 19:14:12 -0400 Subject: [PATCH 16/26] fix(tui): hang-indent space-aligned report preview during streaming Run the lightweight space-column normalizer in the composing preview path and wrap long Severity/Location/What rows with continuation indent so wrap fragments no longer orphan at column 0. Document ToolSearch absence in the default-agent tool snapshot test. --- CHANGELOG.md | 4 + .../ui/shell/visualize/__init__.py | 6 + .../ui/shell/visualize/_blocks.py | 118 +++++++++++++++++- tests/core/test_default_agent.py | 1 + .../test_streaming_content_block.py | 87 +++++++++++++ 5 files changed, 213 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5de0e5f..835b0e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **TUI composing preview wraps space-aligned report prose cleanly.** The + streaming preview now runs the same lightweight space-column normalizer used at + finalize and wraps long `Severity`/`Location`/`What` rows with a hanging + continuation indent, so wrapped fragments no longer orphan at column 0. - **ToolSearch hidden from models that can't use it.** `ToolSearch` is now offered only when the active model genuinely supports the deferred tool-search workflow (Anthropic's `tool_reference`/`defer_loading` beta on `api.anthropic.com`). The diff --git a/src/pythinker_code/ui/shell/visualize/__init__.py b/src/pythinker_code/ui/shell/visualize/__init__.py index 4cd22230..2d43e3ac 100644 --- a/src/pythinker_code/ui/shell/visualize/__init__.py +++ b/src/pythinker_code/ui/shell/visualize/__init__.py @@ -66,9 +66,15 @@ from pythinker_code.ui.shell.visualize._blocks import ( _ToolCallBlock as _ToolCallBlock, ) +from pythinker_code.ui.shell.visualize._blocks import ( + _normalize_streaming_preview_text as _normalize_streaming_preview_text, +) from pythinker_code.ui.shell.visualize._blocks import ( _truncate_to_display_width as _truncate_to_display_width, ) +from pythinker_code.ui.shell.visualize._blocks import ( + _wrap_preview_line as _wrap_preview_line, +) # BTW panel from pythinker_code.ui.shell.visualize._btw_panel import ( diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 89cc0fae..9012e37f 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -9,6 +9,7 @@ import json import random +import re import time from collections import Counter, deque from typing import Any, NamedTuple, cast @@ -158,6 +159,112 @@ def _is_active_background_agent(tool_name: str, result_text: str) -> bool: return False +_PREVIEW_FIELD_LINE_RE = re.compile(r"^(\s*)-\s+([^:]+):\s*(.*)$") + + +def _normalize_streaming_preview_text(text: str) -> str: + """Lightweight preview normalization: ANSI sanitize + space-aligned report rows. + + Matches the space-column repair used by final ``render_agent_body`` output + without running the full markdown-it pipeline on every streaming tick. + """ + from pythinker_code.ui.shell.markdown.normalizers import normalize_space_aligned_report_blocks + + cleaned = sanitize_ansi(text) + return normalize_space_aligned_report_blocks(cleaned) + + +def _preview_wrap_parts(line: str) -> tuple[str, str, str]: + """Return ``(first_prefix, hang_indent, content)`` for preview line wrapping.""" + stripped = line.rstrip("\r\n") + match = _PREVIEW_FIELD_LINE_RE.match(stripped) + if match is not None: + leading, label, value = match.group(1), match.group(2), match.group(3) + return f"{leading}- {label}: ", f"{leading} ", value + leading_match = re.match(r"^(\s*)(.*)$", stripped) + if leading_match is not None: + leading, content = leading_match.group(1), leading_match.group(2) + return leading, leading, content + return "", "", stripped + + +def _wrap_preview_line(line: str, max_width: int) -> str: + """Wrap one preview line with a hanging continuation indent. + + Prevents long space-aligned ``What`` rows from word-wrapping back to column 0 + during the transient composing preview. + """ + from rich.cells import cell_len + + if not line: + return line + stripped = line.rstrip("\r\n") + if cell_len(stripped) <= max_width: + return stripped + + first_prefix, hang_indent, content = _preview_wrap_parts(stripped) + words = content.split() + if not words: + return _truncate_to_display_width(stripped, max_width) + + lines: list[str] = [] + current_prefix = first_prefix + budget = max(1, max_width - cell_len(current_prefix)) + current_words: list[str] = [] + current_width = 0 + + def flush_current() -> None: + nonlocal current_prefix, budget, current_words, current_width + if not current_words: + return + lines.append(current_prefix + " ".join(current_words)) + current_prefix = hang_indent + budget = max(1, max_width - cell_len(hang_indent)) + current_words = [] + current_width = 0 + + for word in words: + word_width = cell_len(word) + sep_width = 1 if current_words else 0 + if current_words and current_width + sep_width + word_width <= budget: + current_width += sep_width + word_width + current_words.append(word) + continue + if not current_words: + if word_width <= budget: + current_words = [word] + current_width = word_width + continue + # Single overlong token: hard-split at display-cell boundary. + start = 0 + while start < len(word): + chunk_end = _advance_by_display_cells(word, start, budget) + if chunk_end <= start: + chunk_end = min(start + 1, len(word)) + lines.append(current_prefix + word[start:chunk_end]) + current_prefix = hang_indent + budget = max(1, max_width - cell_len(hang_indent)) + start = chunk_end + continue + flush_current() + if word_width <= budget: + current_words = [word] + current_width = word_width + else: + start = 0 + while start < len(word): + chunk_end = _advance_by_display_cells(word, start, budget) + if chunk_end <= start: + chunk_end = min(start + 1, len(word)) + lines.append(current_prefix + word[start:chunk_end]) + current_prefix = hang_indent + budget = max(1, max_width - cell_len(hang_indent)) + start = chunk_end + + flush_current() + return "\n".join(lines) + + def _truncate_to_display_width(line: str, max_width: int) -> str: """Truncate *line* so its terminal display width fits within *max_width*. @@ -617,13 +724,18 @@ def _layout_width(self) -> int: return self._block_width def _build_preview(self, text: str, *, max_lines: int, reserve_caret: bool = False) -> str: - """Tail-trim *text* to ``max_lines`` and clamp it to terminal width.""" + """Tail-trim *text*, normalize report prose, and wrap with hang indents.""" max_width = self._layout_width() - 2 if reserve_caret: max_width = max(1, max_width - 1) - tail_text = _tail_lines(text, max_lines) + normalized = _normalize_streaming_preview_text(text) + tail_text = _tail_lines(normalized, max_lines) lines = tail_text.split("\n") - return "\n".join(_truncate_to_display_width(line, max_width) for line in lines) + wrapped: list[str] = [] + for line in lines: + wrapped_line = _wrap_preview_line(line, max_width) + wrapped.extend(wrapped_line.split("\n")) + return "\n".join(wrapped) def _compose_thinking(self) -> Text: return activity_status_line( diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index d7f4ad91..871bce03 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -302,6 +302,7 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): assert "The only task-management slash command for users is `/task`" in agent.system_prompt assert "never invent subcommands like `/task list` or `/tasks`" in agent.system_prompt + # ToolSearch is absent because the `llm` fixture has no provider_config → supports_deferred_tool_search() → False. tool_names = [tool.name for tool in agent.toolset.tools] assert tool_names == snapshot( [ diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index de1e96ca..cb37f5e2 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -12,8 +12,10 @@ _ContentBlock, _estimate_tokens, _find_committed_boundary, + _normalize_streaming_preview_text, _tail_lines, _truncate_to_display_width, + _wrap_preview_line, ) from pythinker_code.ui.theme import tui_rich_style @@ -688,3 +690,88 @@ def test_show_thinking_stream_ignored_for_composing_blocks(self): block.append("hello\n\nworld") # Both should commit identically assert block_off._committed_len == block_on._committed_len + + +# --------------------------------------------------------------------------- +# Space-aligned report preview wrapping +# --------------------------------------------------------------------------- + +_FINDINGS_PREVIEW_SAMPLE = ( + "Findings\n\n" + "• 1\n" + " Severity medium\n" + " Location llm.py:58-60\n" + " What Host allowlist is a single-member frozenset; safe-by-default but " + "invisible on new genuine-Anthropic hosts (tool silently absent). Consider a " + "config-level list or docs pointer.\n\n" + "• 2\n" + " Severity medium\n" + " Location test_default_agent.py:312-341\n" + " What Root-tool snapshot omits ToolSearch — correctly, because the llm " + "fixture has provider_config=None (verified via conftest.py:94-101). But the " + "coupling is implicit.\n" +) + + +def _preview_orphan_lines(output: str) -> list[str]: + """Lines that look like wrap fragments stranded at column 0.""" + orphans: list[str] = [] + for line in output.splitlines(): + stripped = line.strip() + if not stripped or line.startswith((" ", "•", "⏺", "├", "└", "│", "-")): + continue + if stripped.split()[0].lower() in { + "but", + "llm", + "arrowly", + "cycle.", + "invisible", + "fixture", + }: + orphans.append(line) + return orphans + + +class TestSpaceAlignedPreviewWrapping: + def test_normalize_preview_converts_space_columns_to_list_fields(self): + normalized = _normalize_streaming_preview_text(_FINDINGS_PREVIEW_SAMPLE) + assert "- Severity: medium" in normalized + assert "Severity medium" not in normalized + + def test_wrap_preview_line_hangs_continuation_indent(self): + line = ( + " What Host allowlist is a single-member frozenset; safe-by-default but " + "invisible on new genuine-Anthropic hosts." + ) + wrapped = _wrap_preview_line(line, 72) + assert wrapped.startswith(" What") + assert "\nbut invisible" not in wrapped + assert "\n invisible" in wrapped or "\n but invisible" in wrapped + + def test_composing_preview_has_no_orphan_wrap_fragments(self, monkeypatch): + from pythinker_code.ui.shell.visualize import _blocks as blocks_module + + monkeypatch.setattr(blocks_module, "current_console_width", lambda: 72) + block = _ContentBlock(is_think=False) + block.append(_FINDINGS_PREVIEW_SAMPLE) + console = Console(record=True, width=72, color_system=None) + console.print(block.compose()) + output = console.export_text() + assert _preview_orphan_lines(output) == [] + + def test_finalize_scrollback_uses_normalized_render_not_raw_columns(self, monkeypatch): + from pythinker_code.ui.shell.visualize import _blocks as blocks_module + + monkeypatch.setattr(blocks_module, "current_console_width", lambda: 72) + block = _ContentBlock(is_think=False) + block.append(_FINDINGS_PREVIEW_SAMPLE) + block.reveal_all() + block._flush_committed() + renderable = block.promote_to_scrollback() + assert renderable is not None + console = Console(record=True, width=72, color_system=None) + console.print(renderable) + output = console.export_text() + assert "Severity: medium" in output + assert "Severity medium" not in output + assert _preview_orphan_lines(output) == [] From e840d84c1d19c4083616fb92cf81c8bc4e73fb58 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 19:28:22 -0400 Subject: [PATCH 17/26] fix(tools): normalize Cursor TodoWrite shape and compact failed todo cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boundary-normalize content→title before SetTodoList validation, persist canonical title-only session state, and render validation failures as compact actionable errors instead of a fake persisted todo tree. --- CHANGELOG.md | 3 +- src/pythinker_code/tools/todo/__init__.py | 55 +++++-- .../ui/shell/tool_renderers/todo.py | 72 ++++++++- tests/tools/test_todo.py | 119 +++++++++++++- .../test_tui_card_tool_renderers.py | 150 ++++++++++++++++++ 5 files changed, 381 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 835b0e96..6c47aa02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ GitHub Releases page; `0.8.0` is the new starting line. hidden/deferred tools exist (pythinker loads no tools lazily), removing the prompt that primed the loop in the first place. - **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." -- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors. +- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) are normalized at the validation boundary (`content` → `title` when `title` is absent; canonical `title` wins; `content` is dropped) and persist as title-only session state instead of failing with missing-`title` errors. +- **Failed `SetTodoList` cards stay compact.** Validation failures no longer render a broken todo tree with blank labels plus a raw Pydantic dump; the card shows a short actionable summary (with full detail only when expanded). - **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. - **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:` commands on their bare segment, so typing `/designer` (or `/design`) surfaces diff --git a/src/pythinker_code/tools/todo/__init__.py b/src/pythinker_code/tools/todo/__init__.py index 716ceebc..63c9f0fd 100644 --- a/src/pythinker_code/tools/todo/__init__.py +++ b/src/pythinker_code/tools/todo/__init__.py @@ -21,21 +21,47 @@ } +def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]: + """Accept Cursor/Claude TodoWrite shape while keeping internal state canonical. + + Supported external aliases: + - ``content`` -> ``title``, only when ``title`` is missing + + Deliberately does not: + - invent titles + - coerce invalid status values + - accept random aliases like text/name/label + - mutate the input dict + """ + todos = args.get("todos") + if not isinstance(todos, list): + return args + + normalized: list[Any] = [] + + for raw in cast("list[Any]", todos): + if not isinstance(raw, dict): + normalized.append(raw) + continue + + item = dict(cast(dict[str, Any], raw)) + + title = item.get("title") + content = item.get("content") + + if title is None and content is not None: + item["title"] = content + + item.pop("content", None) + normalized.append(item) + + return {**args, "todos": normalized} + + class Todo(BaseModel): title: str = Field(description="The title of the todo", min_length=1) status: TodoStatus = Field(description="The status of the todo") - @model_validator(mode="before") - @classmethod - def _normalize_todo_write_shape(cls, data: Any) -> Any: - """Accept Cursor/Claude TodoWrite shapes that use ``content`` instead of ``title``.""" - if not isinstance(data, dict): - return data - values = dict(cast(dict[str, Any], data)) - if "title" not in values and "content" in values: - values["title"] = values["content"] - return values - @field_validator("status", mode="before") @classmethod def _normalize_status(cls, v: Any) -> Any: @@ -54,6 +80,13 @@ class Params(BaseModel): ), ) + @model_validator(mode="before") + @classmethod + def _normalize_todo_write_args(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + return normalize_set_todo_list_args(cast(dict[str, Any], data)) + @field_validator("todos", mode="before") @classmethod def _parse_todos_string(cls, v: Any) -> Any: diff --git a/src/pythinker_code/ui/shell/tool_renderers/todo.py b/src/pythinker_code/ui/shell/tool_renderers/todo.py index 2e3690e8..8ea8e9d7 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/todo.py +++ b/src/pythinker_code/ui/shell/tool_renderers/todo.py @@ -10,12 +10,14 @@ from __future__ import annotations +import re from typing import Any, cast from rich.console import Group, RenderableType from rich.table import Table from rich.text import Text +from pythinker_code.ui.shell.components import sanitize_ansi from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, @@ -45,6 +47,42 @@ _TREE_BRANCH = "├─" _TREE_LAST = "└─" +_MISSING_TITLE_RE = re.compile(r"todos\.\d+\.title", re.MULTILINE) +_UNTITLED_TODO = "Untitled todo" + + +def _has_cursor_todowrite_shape(args: dict[str, Any]) -> bool: + """True when args look like Cursor/Claude TodoWrite ({content} without {title}).""" + todos = args.get("todos") + if not isinstance(todos, list): + return False + for raw in cast("list[Any]", todos): + if not isinstance(raw, dict): + continue + item = cast(dict[str, Any], raw) + if "content" in item and "title" not in item: + return True + return False + + +def _failed_todo_badge(todos: list[Any]) -> str: + count = len(todos) + noun = "item" if count == 1 else "items" + return f"update failed · {count} {noun}" + + +def _summarize_todo_validation_error(text: str, args: dict[str, Any]) -> str: + """Return a short, actionable summary for SetTodoList validation failures.""" + if "Error validating JSON arguments:" not in text: + return "Todo update failed: invalid arguments." + if _MISSING_TITLE_RE.search(text): + if _has_cursor_todowrite_shape(args): + return ( + "Todo update failed: each item needs `title` (received `content` without `title`)." + ) + return "Todo update failed: each item needs a `title` field." + return "Todo update failed: invalid todo arguments." + def _icon_token(status: str) -> str: if status == "done": @@ -56,16 +94,23 @@ def _icon_token(status: str) -> str: return "muted" +def _clean_todo_title(raw_title: str) -> str: + cleaned = " ".join(sanitize_ansi(raw_title).split()).strip() + return cleaned or _UNTITLED_TODO + + def _todo_level_and_title(item: dict[str, Any]) -> tuple[int, str]: """Return display nesting level and a cleaned title.""" raw_title = as_str(item.get("title")) or as_str(item.get("content")) or "" explicit = item.get("level", item.get("depth", item.get("indent"))) + cleaned = _clean_todo_title(raw_title) + if isinstance(explicit, int): - return max(0, min(explicit, 6)), raw_title.strip() + return max(0, min(explicit, 6)), cleaned leading_spaces = len(raw_title) - len(raw_title.lstrip(" ")) level = max(0, min(leading_spaces // 2, 6)) - return level, raw_title.strip() + return level, cleaned def _status_title(status: str, title: str) -> Text: @@ -82,10 +127,24 @@ def _status_title(status: str, title: str) -> Text: return fg("tool_output", title) +def _render_error_call(ctx: ToolRenderContext) -> RenderableType: + args = ctx.args or {} + todos = args.get("todos") + badge = "update failed" + if isinstance(todos, list): + badge = _failed_todo_badge(cast("list[Any]", todos)) + header = tool_call_header("todos", fg("error", badge), style_token="error") + return running_spinner( + header, execution_started=ctx.execution_started, has_result=ctx.has_result + ) + + def _render_call(ctx: ToolRenderContext) -> RenderableType: args = ctx.args or {} + if ctx.is_error: + return _render_error_call(ctx) todos = args.get("todos") - style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" + style_token = "success" if ctx.has_result else "muted" if todos is None: header = tool_call_header("todos", fg("muted", "read"), style_token=style_token) @@ -163,6 +222,9 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None: if not result.text or not result.is_error: return None + summary = fg("error", _summarize_todo_validation_error(result.text, ctx.args or {})) + if not ctx.expanded: + return summary body, _ = format_lines_block( result.text, expanded=True, @@ -170,8 +232,8 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera style_token="error", ) if not body.plain: - return None - return body + return summary + return Group(summary, body) TODO_RENDERER = ToolRenderDefinition( diff --git a/tests/tools/test_todo.py b/tests/tools/test_todo.py index 93c50e94..f2a0b3e7 100644 --- a/tests/tools/test_todo.py +++ b/tests/tools/test_todo.py @@ -8,7 +8,7 @@ from pythinker_code.scratchpad import session_scratch_path from pythinker_code.soul.agent import Runtime -from pythinker_code.tools.todo import Params, SetTodoList, Todo +from pythinker_code.tools.todo import Params, SetTodoList, Todo, normalize_set_todo_list_args from pythinker_code.wire.types import TodoListUpdated @@ -27,6 +27,76 @@ def set_todo_list_tool(runtime: Runtime) -> SetTodoList: return SetTodoList(runtime) +class TestNormalizeSetTodoListArgs: + """Boundary normalization: one compatibility alias (content → title), strict elsewhere.""" + + def test_normalizes_cursor_todowrite_content_to_title(self): + args = { + "todos": [{"id": "1", "content": "Install framer-motion", "status": "in_progress"}] + } + out = normalize_set_todo_list_args(args) + assert out["todos"][0]["title"] == "Install framer-motion" + assert "content" not in out["todos"][0] + + def test_title_wins_over_content(self): + args = { + "todos": [ + { + "title": "Canonical", + "content": "Alias", + "status": "pending", + } + ] + } + out = normalize_set_todo_list_args(args) + assert out["todos"][0]["title"] == "Canonical" + assert "content" not in out["todos"][0] + + def test_normalizer_does_not_mutate_input(self): + args = { + "todos": [{"content": "Install framer-motion", "status": "pending"}] + } + original = { + "todos": [{"content": "Install framer-motion", "status": "pending"}] + } + normalize_set_todo_list_args(args) + assert args == original + + def test_missing_title_still_fails_after_normalization(self): + args = {"todos": [{"id": "1", "status": "pending"}]} + out = normalize_set_todo_list_args(args) + assert "title" not in out["todos"][0] + + def test_invalid_status_is_not_repaired(self): + args = {"todos": [{"content": "Install framer-motion", "status": "started"}]} + out = normalize_set_todo_list_args(args) + assert out["todos"][0]["title"] == "Install framer-motion" + assert out["todos"][0]["status"] == "started" + + def test_non_list_todos_passthrough(self): + args = {"todos": {"title": "bad"}} + assert normalize_set_todo_list_args(args) == args + + def test_params_title_wins_over_content(self): + params = Params( + todos=[{"title": "Use this", "content": "Do not use this", "status": "pending"}] # type: ignore[list-item] + ) + assert params.todos is not None + assert params.todos[0].title == "Use this" + + def test_params_empty_title_fails_validation(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Params(todos=[{"title": "", "status": "pending"}]) # type: ignore[list-item] + + def test_params_invalid_status_fails_validation(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Params(todos=[{"content": "Install framer-motion", "status": "started"}]) # type: ignore[list-item] + + class TestParamsJsonStringCoercion: """Regression: LLM occasionally passes todos as a JSON-encoded string instead of a list.""" @@ -68,6 +138,53 @@ def test_todo_write_merge_field_is_ignored(self): assert params.todos is not None assert params.todos[0].title == "Task A" + async def test_cursor_todowrite_shape_persists_normalized_title( + self, set_todo_list_tool: SetTodoList, runtime: Runtime + ): + """Cursor/Claude payloads must normalize ``content`` → ``title`` and persist.""" + from pythinker_code.session_state import load_session_state + + params = Params( + todos=[{"id": "1", "content": "Install framer-motion", "status": "in_progress"}] # type: ignore[list-item] + ) + result = await set_todo_list_tool(params) + + assert not result.is_error + state = load_session_state(runtime.session.dir) + assert len(state.todos) == 1 + assert state.todos[0].title == "Install framer-motion" + assert state.todos[0].status == "in_progress" + + async def test_callable_tool_call_accepts_content_shape( + self, set_todo_list_tool: SetTodoList + ): + """``CallableTool2.call`` must accept raw JSON with ``content`` items.""" + result = await set_todo_list_tool.call( + { + "todos": [ + {"id": "1", "content": "Install framer-motion", "status": "in_progress"}, + ] + } + ) + assert not result.is_error + assert "Todo list updated" in result.output + + async def test_validation_failure_does_not_mutate_session( + self, set_todo_list_tool: SetTodoList, runtime: Runtime + ): + """Failed SetTodoList must not change persisted todo state.""" + from pythinker_code.session_state import load_session_state + + await set_todo_list_tool(Params(todos=[Todo(title="Existing", status="pending")])) + result = await set_todo_list_tool.call( + {"todos": [{"content": "Replacement", "status": "started"}]} + ) + assert result.is_error + state = load_session_state(runtime.session.dir) + assert len(state.todos) == 1 + assert state.todos[0].title == "Existing" + assert state.todos[0].status == "pending" + def test_todos_none_still_works(self): params = Params(todos=None) assert params.todos is None diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index d400e6c1..0ac751ed 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -20,6 +20,7 @@ ) from pythinker_code.ui.shell.glyphs import QUESTION_MARKER from pythinker_code.ui.shell.tool_renderers import ( + ToolRenderContext, ToolResultPayload, clear_tool_renderers, get_tool_renderer, @@ -28,6 +29,11 @@ from pythinker_code.ui.shell.tool_renderers._file_diff import preview_from_diff_blocks from pythinker_code.ui.shell.tool_renderers._render_utils import loading_marker from pythinker_code.ui.shell.tool_renderers.generic import generic_renderer +from pythinker_code.ui.shell.tool_renderers.todo import ( + TODO_RENDERER, + _summarize_todo_validation_error, + _todo_level_and_title, +) from pythinker_code.ui.theme import tui_rich_style @@ -1050,6 +1056,150 @@ def test_todo_infers_nested_items_from_leading_spaces(): assert "Child" in rendered +def test_todo_content_field_renders_labels_during_streaming(): + """Cursor-style payloads use ``content``; preview must still show labels.""" + rendered = _render_running( + "SetTodoList", + { + "todos": [ + {"id": "1", "content": "Install framer-motion", "status": "in_progress"}, + {"id": "2", "content": "Create component", "status": "pending"}, + ] + }, + ) + assert "Install framer-motion" in rendered + assert "Create component" in rendered + assert "├─" in rendered + + +def test_failed_cursor_todowrite_shape_renders_error_badge_not_tree(): + """``render_call`` must skip the plan tree when ``ctx.is_error`` is true.""" + ctx = ToolRenderContext( + args={ + "todos": [ + {"id": "1", "content": "Install framer-motion", "status": "in_progress"}, + {"id": "2", "content": "Create background paths", "status": "pending"}, + ] + }, + tool_call_id="tc-1", + is_error=True, + has_result=True, + args_complete=True, + expanded=False, + execution_started=True, + ) + assert TODO_RENDERER.render_call is not None + rendered = render_plain(TODO_RENDERER.render_call(ctx), width=100) + assert "update failed · 2 items" in rendered + assert "Install framer-motion" not in rendered + assert "Create background paths" not in rendered + assert "├─" not in rendered + + +def test_todo_validation_error_renders_compact_card_without_broken_tree(): + todos = [ + {"id": "1", "content": "Install framer-motion", "status": "in_progress"}, + {"id": "2", "content": "Create component", "status": "pending"}, + ] + rendered = _render( + "SetTodoList", + {"todos": todos}, + output=( + "Error validating JSON arguments: 2 validation errors for Params\n" + "todos.0.title\n Field required [type=missing, input_value={'id': '1', " + "'content': 'Install framer-motion', 'status': 'in_progress'}, " + "input_type=dict]\n" + "todos.1.title\n Field required [type=missing, input_value={'id': '2', " + "'content': 'Create component', 'status': 'pending'}, input_type=dict]" + ), + is_error=True, + ) + assert "✘ todos" in rendered + assert "update failed · 2 items" in rendered + assert "Todo update failed: each item needs `title`" in rendered + assert "received `content` without `title`" in rendered + assert "⎿" in rendered + assert "├─" not in rendered + assert "0/2 done" not in rendered + assert "Install framer-motion" not in rendered + assert "Field required" not in rendered + + +def test_todo_validation_error_five_items_no_fake_tree(): + """Regression: GLM run showed icons-only tree for five failed content-only items.""" + todos = [ + {"id": str(i), "content": f"Step {i}", "status": "in_progress" if i == 1 else "pending"} + for i in range(1, 6) + ] + rendered = _render( + "SetTodoList", + {"todos": todos}, + output=( + "Error validating JSON arguments: 5 validation errors for Params\n" + + "\n".join(f"todos.{i}.title\n Field required" for i in range(5)) + ), + is_error=True, + ) + assert "update failed · 5 items" in rendered + assert "├─" not in rendered + assert "Step 1" not in rendered + + +def test_todo_malformed_item_renders_untitled_label(): + rendered = _render_running( + "SetTodoList", + {"todos": [{"status": "pending"}]}, + ) + assert "Untitled todo" in rendered + + +def test_renderer_uses_content_fallback_for_title(): + _level, title = _todo_level_and_title( + {"content": "Install framer-motion", "status": "in_progress"} + ) + assert title == "Install framer-motion" + + +def test_renderer_uses_untitled_fallback(): + _level, title = _todo_level_and_title({"status": "pending"}) + assert title == "Untitled todo" + + +def test_renderer_cleans_multiline_title(): + _level, title = _todo_level_and_title({"title": "line one\nline two", "status": "pending"}) + assert title == "line one line two" + + +def test_validation_summary_detects_cursor_shape(): + args = {"todos": [{"id": "1", "content": "Install framer-motion", "status": "pending"}]} + text = ( + "Error validating JSON arguments: 1 validation error for Params\n" + "todos.0.title\n Field required" + ) + assert _summarize_todo_validation_error(text, args) == ( + "Todo update failed: each item needs `title` (received `content` without `title`)." + ) + + +def test_todo_validation_error_shows_full_detail_when_expanded(): + todos = [{"content": "Task A", "status": "pending"}] + pydantic_text = ( + "Error validating JSON arguments: 1 validation error for Params\n" + "todos.0.title\n Field required" + ) + defn = get_tool_renderer("SetTodoList") + assert defn is not None + comp = ToolExecutionComponent("SetTodoList", "tc-1", definition=defn, cwd="/repo") + comp.update_args({"todos": todos}) + comp.set_args_complete() + comp.mark_execution_started() + comp.set_result(ToolResultPayload(text=pydantic_text, is_error=True)) + comp.set_expanded(True) + rendered = render_plain(comp.render(), width=100) + assert "Todo update failed: each item needs `title`" in rendered + assert "Field required" in rendered + + # --------------------------------------------------------------------------- # Web # --------------------------------------------------------------------------- From b617550c305f62e1748d2cd55fbf0aa7761c6da2 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 19:30:24 -0400 Subject: [PATCH 18/26] fix(tools): harden todo renderer and blank-title normalization Treat blank titles as missing for Cursor-shape detection, compute indent from ANSI-stripped text, and show invalid args for malformed complete lists. --- src/pythinker_code/tools/todo/__init__.py | 5 ++- .../ui/shell/tool_renderers/todo.py | 25 ++++++++++-- tests/tools/test_todo.py | 16 ++++++++ .../test_tui_card_tool_renderers.py | 38 +++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/pythinker_code/tools/todo/__init__.py b/src/pythinker_code/tools/todo/__init__.py index 63c9f0fd..77bb01c7 100644 --- a/src/pythinker_code/tools/todo/__init__.py +++ b/src/pythinker_code/tools/todo/__init__.py @@ -25,7 +25,7 @@ def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]: """Accept Cursor/Claude TodoWrite shape while keeping internal state canonical. Supported external aliases: - - ``content`` -> ``title``, only when ``title`` is missing + - ``content`` -> ``title``, only when ``title`` is missing or blank Deliberately does not: - invent titles @@ -48,8 +48,9 @@ def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]: title = item.get("title") content = item.get("content") + title_missing = title is None or (isinstance(title, str) and not title.strip()) - if title is None and content is not None: + if title_missing and content is not None: item["title"] = content item.pop("content", None) diff --git a/src/pythinker_code/ui/shell/tool_renderers/todo.py b/src/pythinker_code/ui/shell/tool_renderers/todo.py index 8ea8e9d7..4e697b90 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/todo.py +++ b/src/pythinker_code/ui/shell/tool_renderers/todo.py @@ -52,16 +52,22 @@ def _has_cursor_todowrite_shape(args: dict[str, Any]) -> bool: - """True when args look like Cursor/Claude TodoWrite ({content} without {title}).""" + """True when args look like Cursor/Claude TodoWrite ({content} without usable {title}).""" todos = args.get("todos") if not isinstance(todos, list): return False + for raw in cast("list[Any]", todos): if not isinstance(raw, dict): continue + item = cast(dict[str, Any], raw) - if "content" in item and "title" not in item: + title = (as_str(item.get("title")) or "").strip() + content = (as_str(item.get("content")) or "").strip() + + if content and not title: return True + return False @@ -102,13 +108,15 @@ def _clean_todo_title(raw_title: str) -> str: def _todo_level_and_title(item: dict[str, Any]) -> tuple[int, str]: """Return display nesting level and a cleaned title.""" raw_title = as_str(item.get("title")) or as_str(item.get("content")) or "" + safe_title = sanitize_ansi(raw_title) + explicit = item.get("level", item.get("depth", item.get("indent"))) - cleaned = _clean_todo_title(raw_title) + cleaned = _clean_todo_title(safe_title) if isinstance(explicit, int): return max(0, min(explicit, 6)), cleaned - leading_spaces = len(raw_title) - len(raw_title.lstrip(" ")) + leading_spaces = len(safe_title) - len(safe_title.lstrip(" ")) level = max(0, min(leading_spaces // 2, 6)) return level, cleaned @@ -162,6 +170,15 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: ) todos_list = cast("list[Any]", todos) + has_malformed_items = any(not isinstance(t, dict) for t in todos_list) + if has_malformed_items and (ctx.args_complete or ctx.has_result): + header = tool_call_header("todos", invalid_arg(), style_token=style_token) + return running_spinner( + header, + execution_started=ctx.execution_started, + has_result=ctx.has_result, + ) + items: list[dict[str, Any]] = [ cast("dict[str, Any]", t) for t in todos_list if isinstance(t, dict) ] diff --git a/tests/tools/test_todo.py b/tests/tools/test_todo.py index f2a0b3e7..4a4a2552 100644 --- a/tests/tools/test_todo.py +++ b/tests/tools/test_todo.py @@ -62,6 +62,22 @@ def test_normalizer_does_not_mutate_input(self): normalize_set_todo_list_args(args) assert args == original + def test_blank_title_with_content_normalizes_to_title(self): + args = { + "todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}] + } + out = normalize_set_todo_list_args(args) + assert out["todos"][0]["title"] == "Install framer-motion" + assert "content" not in out["todos"][0] + + def test_params_mixed_non_dict_item_fails_validation(self): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Params( # type: ignore[arg-type] + todos=[{"title": "ok", "status": "pending"}, "bad"] + ) + def test_missing_title_still_fails_after_normalization(self): args = {"todos": [{"id": "1", "status": "pending"}]} out = normalize_set_todo_list_args(args) diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 0ac751ed..ca24fb94 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -31,6 +31,7 @@ from pythinker_code.ui.shell.tool_renderers.generic import generic_renderer from pythinker_code.ui.shell.tool_renderers.todo import ( TODO_RENDERER, + _has_cursor_todowrite_shape, _summarize_todo_validation_error, _todo_level_and_title, ) @@ -1200,6 +1201,43 @@ def test_todo_validation_error_shows_full_detail_when_expanded(): assert "Field required" in rendered +def test_cursor_shape_detects_blank_title_with_content(): + args = {"todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}]} + assert _has_cursor_todowrite_shape(args) is True + + +def test_renderer_blank_title_uses_content_fallback(): + _level, title = _todo_level_and_title( + {"title": "", "content": "Install framer-motion", "status": "pending"} + ) + assert title == "Install framer-motion" + + +def test_renderer_indent_ignores_ansi_before_leading_spaces(): + raw = "\x1b[31m \x1b[0mNested task" + _level, title = _todo_level_and_title({"title": raw, "status": "pending"}) + assert _level == 1 + assert title == "Nested task" + + +def test_malformed_todos_list_renders_invalid_when_args_complete(): + rendered = _render_running( + "SetTodoList", + {"todos": ["bad", None, 123, {"title": "ok", "status": "pending"}]}, + ) + assert "" in rendered + assert "ok" not in rendered + + +def test_malformed_todos_streaming_skips_non_dict_items(): + rendered = _render_streaming( + "SetTodoList", + {"todos": ["bad", {"title": "Visible", "status": "pending"}]}, + ) + assert "" not in rendered + assert "Visible" in rendered + + # --------------------------------------------------------------------------- # Web # --------------------------------------------------------------------------- From c93d69fbb940f381c6a07646befd15608212e068 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 19:35:13 -0400 Subject: [PATCH 19/26] fix(pr-157): address CodeRabbit review and CI check failures Resolve review findings across LSP, TUI streaming, toolset, and tests: fail-closed git check-ignore filtering, symbol boundary fixes, report-update refresh, markdown fence/audit hardening, ToolSearch execution gate, and lint/ pyright/test stability improvements including import sort in visualize. --- src/pythinker_code/lsp/client.py | 4 +- src/pythinker_code/lsp/recommend.py | 3 +- src/pythinker_code/soul/toolset.py | 9 +++++ src/pythinker_code/tools/lsp/formatters.py | 3 +- .../tools/lsp/symbol_context.py | 5 ++- src/pythinker_code/tools/lsp/tool.md | 2 + src/pythinker_code/tools/lsp/tool.py | 39 ++++++++++--------- src/pythinker_code/ui/shell/markdown/audit.py | 7 +++- .../ui/shell/markdown/fences.py | 4 +- .../ui/shell/tool_renderers/agent.py | 11 +++++- .../ui/shell/tool_renderers/background.py | 10 +++-- .../ui/shell/tool_renderers/tool_search.py | 2 +- .../ui/shell/visualize/__init__.py | 6 +-- .../ui/shell/visualize/_blocks.py | 12 ++++-- .../ui/shell/visualize/_live_view.py | 6 +-- src/pythinker_code/ui/theme/__init__.py | 12 +++++- src/pythinker_code/utils/rich/markdown.py | 11 ++++-- tests/tools/test_lsp_client.py | 7 ++-- tests/tools/test_todo.py | 26 ++++--------- .../test_md_normalization_matrix.py | 2 +- .../test_tool_search_suppression.py | 6 +-- .../test_tui_card_tool_renderers.py | 7 +++- .../ui_and_conv/test_tui_streaming_phase0.py | 24 ++++-------- 23 files changed, 123 insertions(+), 95 deletions(-) diff --git a/src/pythinker_code/lsp/client.py b/src/pythinker_code/lsp/client.py index cad7278c..52b92d8c 100644 --- a/src/pythinker_code/lsp/client.py +++ b/src/pythinker_code/lsp/client.py @@ -153,13 +153,13 @@ async def stop(self) -> None: if self._read_task is not None: self._read_task.cancel() with suppress(asyncio.CancelledError): - await self._read_task + _ = await self._read_task self._read_task = None if self._stderr_task is not None: self._stderr_task.cancel() with suppress(asyncio.CancelledError): - await self._stderr_task + _ = await self._stderr_task self._stderr_task = None if proc is not None and proc.returncode is None: diff --git a/src/pythinker_code/lsp/recommend.py b/src/pythinker_code/lsp/recommend.py index 856f1960..b75d8059 100644 --- a/src/pythinker_code/lsp/recommend.py +++ b/src/pythinker_code/lsp/recommend.py @@ -145,6 +145,7 @@ def get_matching_lsp_plugins( return [] never_plugins = set(lsp_config.recommendation_never) + installed_plugins = load_installed_plugins() all_lsp_plugins = _lsp_plugins_from_marketplaces() matching: list[tuple[MarketplaceEntry, str, _LspInfo, bool, str]] = [] @@ -153,7 +154,7 @@ def get_matching_lsp_plugins( continue if plugin_id in never_plugins: continue - if is_plugin_installed(plugin_id): + if plugin_id in installed_plugins: continue matching.append((entry, marketplace_name, lsp_info, is_official, plugin_id)) diff --git a/src/pythinker_code/soul/toolset.py b/src/pythinker_code/soul/toolset.py index 79f3d2bd..fd0b7c6e 100644 --- a/src/pythinker_code/soul/toolset.py +++ b/src/pythinker_code/soul/toolset.py @@ -836,6 +836,15 @@ def handle(self, tool_call: ToolCall) -> HandleResult: tool = self._tool_dict[tool_call.function.name] + if tool_call.function.name == "ToolSearch" and self._runtime is not None: + from pythinker_code.llm import supports_deferred_tool_search + + if not supports_deferred_tool_search(self._runtime.llm): + return ToolResult( + tool_call_id=tool_call.id, + return_value=ToolNotFoundError(tool_call.function.name), + ) + try: arguments: JsonType = json.loads(tool_call.function.arguments or "{}", strict=False) except json.JSONDecodeError as e: diff --git a/src/pythinker_code/tools/lsp/formatters.py b/src/pythinker_code/tools/lsp/formatters.py index 78d02dc4..997a75f4 100644 --- a/src/pythinker_code/tools/lsp/formatters.py +++ b/src/pythinker_code/tools/lsp/formatters.py @@ -71,7 +71,8 @@ def format_uri(uri: str | None, cwd: str | None = None) -> str: if len(relative) < len(file_path) and not relative.startswith("../.."): return relative except ValueError: - pass + # Path is outside cwd; fall back to the absolute/normalized path below. + relative = None return file_path diff --git a/src/pythinker_code/tools/lsp/symbol_context.py b/src/pythinker_code/tools/lsp/symbol_context.py index c15951d4..e4913c3b 100644 --- a/src/pythinker_code/tools/lsp/symbol_context.py +++ b/src/pythinker_code/tools/lsp/symbol_context.py @@ -36,6 +36,7 @@ def get_symbol_context( path = Path(file_path) try: + file_size = path.stat().st_size with path.open("rb") as handle: chunk = handle.read(MAX_READ_BYTES) except OSError: @@ -49,11 +50,11 @@ def get_symbol_context( if zero_line < 0 or zero_line >= len(lines): return None - if len(chunk) == MAX_READ_BYTES and zero_line == len(lines) - 1: + if file_size > MAX_READ_BYTES and zero_line == len(lines) - 1: return None line_content = lines[zero_line] - if zero_char < 0 or zero_char >= len(line_content): + if zero_char < 0 or zero_char > len(line_content): return None symbol: str | None = None diff --git a/src/pythinker_code/tools/lsp/tool.md b/src/pythinker_code/tools/lsp/tool.md index 50c9a05b..67de8317 100644 --- a/src/pythinker_code/tools/lsp/tool.md +++ b/src/pythinker_code/tools/lsp/tool.md @@ -1,3 +1,5 @@ +# LSP tool + Interact with Language Server Protocol (LSP) servers to get code intelligence features. Supported operations: diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index f675b9a2..af6bf9fc 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -287,6 +287,8 @@ def _method_and_params(params: Params, absolute_path: str) -> tuple[str, dict[st return "textDocument/implementation", text_document case Operation.PREPARE_CALL_HIERARCHY | Operation.INCOMING_CALLS | Operation.OUTGOING_CALLS: return "textDocument/prepareCallHierarchy", text_document + case _: + raise ValueError(f"Unsupported LSP operation: {params.operation}") def _to_location(item: dict[str, Any]) -> dict[str, Any]: @@ -368,7 +370,10 @@ async def _filter_gitignored_locations( ignored_paths: set[str] = set() for index in range(0, len(unique_paths), _GIT_CHECK_IGNORE_BATCH_SIZE): batch = unique_paths[index : index + _GIT_CHECK_IGNORE_BATCH_SIZE] - stdout = await _run_git_check_ignore(cwd, batch) + ok, stdout = await _run_git_check_ignore(cwd, batch) + if not ok: + logger.warning("git check-ignore failed; dropping locations for safety") + return [] if stdout: ignored_paths.update(line.strip() for line in stdout.splitlines() if line.strip()) @@ -378,12 +383,9 @@ async def _filter_gitignored_locations( return [loc for loc in valid_locations if uri_to_path.get(loc["uri"], "") not in ignored_paths] -async def _run_git_check_ignore(cwd: str, paths: list[str]) -> str | None: - # This is a relevance filter, not a security boundary: callers fail OPEN - # (show LSP results) when ignore status cannot be determined, so a non-git - # directory or a transient git error never hides results. We still - # distinguish git's normal "nothing ignored" exit 1 (no log) from real - # errors (exit 128, timeout, spawn failure), which are logged for diagnosis. +async def _run_git_check_ignore(cwd: str, paths: list[str]) -> tuple[bool, str]: + # Fail closed when ignore status cannot be determined so gitignored paths + # are not leaked when git is unavailable or check-ignore errors out. proc = None try: proc = await pythinker_host.exec("git", "-C", cwd, "check-ignore", *paths) @@ -394,25 +396,24 @@ async def _run_git_check_ignore(cwd: str, paths: list[str]) -> str | None: ) exit_code = await asyncio.wait_for(proc.wait(), timeout=_GIT_CHECK_IGNORE_TIMEOUT) if exit_code == 0: - return stdout_bytes.decode("utf-8", errors="replace") - if exit_code != 1: - # 1 = no paths ignored (expected). Anything else (e.g. 128 outside a - # git repo) is a real error; log it and fail open. - logger.debug( - "git check-ignore failed in {cwd} with exit code {code}", - cwd=cwd, - code=exit_code, - ) - return None + return True, stdout_bytes.decode("utf-8", errors="replace") + if exit_code == 1: + return True, "" + logger.debug( + "git check-ignore failed in {cwd} with exit code {code}", + cwd=cwd, + code=exit_code, + ) + return False, "" except TimeoutError: logger.debug("git check-ignore timed out in {cwd}", cwd=cwd) if proc is not None: await proc.kill() await proc.wait() - return None + return False, "" except Exception as exc: logger.debug("git check-ignore errored in {cwd}: {err}", cwd=cwd, err=exc) if proc is not None and proc.returncode is None: await proc.kill() await proc.wait() - return None + return False, "" diff --git a/src/pythinker_code/ui/shell/markdown/audit.py b/src/pythinker_code/ui/shell/markdown/audit.py index be7a39bb..df797229 100644 --- a/src/pythinker_code/ui/shell/markdown/audit.py +++ b/src/pythinker_code/ui/shell/markdown/audit.py @@ -301,7 +301,6 @@ def collapse_parity_matrix(markup: str) -> str: changed = False for start, end, title in section_ranges: out.extend(lines[cursor:start]) - cursor = start if not any(hint in title for hint in _PARITY_SECTION_HINTS): out.extend(lines[start:end]) cursor = end @@ -400,7 +399,11 @@ def normalize_divergence_cards(markup: str) -> str: if candidate.startswith("#"): break label_match = _UNDERLINE_HEADING_RE.match(candidate) - if label_match is not None and cursor + 1 < len(lines): + if ( + label_match is not None + and cursor + 1 < len(lines) + and _UNICODE_RULE_LINE_RE.match(lines[cursor + 1].strip()) + ): body_lines: list[str] = [] cursor += 2 while cursor < len(lines): diff --git a/src/pythinker_code/ui/shell/markdown/fences.py b/src/pythinker_code/ui/shell/markdown/fences.py index f05c06a0..530a0a6e 100644 --- a/src/pythinker_code/ui/shell/markdown/fences.py +++ b/src/pythinker_code/ui/shell/markdown/fences.py @@ -56,6 +56,6 @@ def iter_fence_aware_lines( state = FenceState() for line in markup.splitlines(keepends=keepends): body = line.rstrip("\r\n") - inside = state.active + was_active = state.active state.feed(body, strict_close=strict_close) - yield line, inside + yield line, was_active or state.active diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index 71232550..c0d2e88d 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -414,8 +414,13 @@ def _run_agent_is_resolved(status: str, *, is_async: bool) -> bool: norm = normalize_agent_status(status) if norm in {"completed", "failed", "timed out", "cancelled"}: return True - raw = status.lower() - return is_async and raw in {"starting", "running", "created", "launched"} + return is_async and norm in { + "starting", + "running", + "created", + "launched", + "awaiting approval", + } def _run_agent_is_backgrounded(*, is_async: bool, is_resolved: bool, status: str) -> bool: @@ -426,6 +431,8 @@ def _run_agent_is_backgrounded(*, is_async: bool, is_resolved: bool, status: str def _run_agent_status_subline(entry: dict[str, str], *, is_resolved: bool) -> str: if not is_resolved: + if normalize_agent_status(entry.get("status", "")) == "awaiting approval": + return "Awaiting approval…" preview = entry.get("summary_preview") or entry.get("message") or entry.get("brief") if preview: return _compact_inline(preview, max_chars=72) diff --git a/src/pythinker_code/ui/shell/tool_renderers/background.py b/src/pythinker_code/ui/shell/tool_renderers/background.py index 07800b22..879ef5ec 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/background.py +++ b/src/pythinker_code/ui/shell/tool_renderers/background.py @@ -111,14 +111,16 @@ def _render_call_with_id( ) -def _parse_task_output(text: str) -> tuple[dict[str, str], str]: +def _parse_task_output(text: str) -> tuple[dict[str, str], str, bool]: """Split TaskOutput tool text into metadata and the ``[output]`` body.""" meta: dict[str, str] = {} body_lines: list[str] = [] in_output = False + saw_output_marker = False for raw_line in text.splitlines(): if raw_line.strip() == "[output]": in_output = True + saw_output_marker = True continue if in_output: body_lines.append(raw_line) @@ -134,7 +136,7 @@ def _parse_task_output(text: str) -> tuple[dict[str, str], str]: _, _, rest = body.partition("]\n\n") if rest: body = rest.strip() - return meta, body + return meta, body, saw_output_marker def _read_output_collapsed_hint() -> Text: @@ -162,8 +164,8 @@ def _render_task_output_result( if result.is_error: return _render_block_result(ctx, result, collapsed_lines=collapsed_lines) - meta, body = _parse_task_output(result.text) - if not meta: + meta, body, saw_output_marker = _parse_task_output(result.text) + if not meta or not saw_output_marker: return _render_block_result(ctx, result, collapsed_lines=collapsed_lines) description = ( diff --git a/src/pythinker_code/ui/shell/tool_renderers/tool_search.py b/src/pythinker_code/ui/shell/tool_renderers/tool_search.py index 79b08531..d67b27df 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/tool_search.py +++ b/src/pythinker_code/ui/shell/tool_renderers/tool_search.py @@ -83,7 +83,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera return fg("error" if result.is_error else "muted", message.strip()) return None - if text.startswith("No visible tools") or text.startswith("No visible tools matched"): + if text.startswith(("No visible tools", "No visible tools matched")): ctx.state["__suppress_generic_expand_hint__"] = True return fg("error" if result.is_error else "muted", text) diff --git a/src/pythinker_code/ui/shell/visualize/__init__.py b/src/pythinker_code/ui/shell/visualize/__init__.py index 2d43e3ac..c91bae08 100644 --- a/src/pythinker_code/ui/shell/visualize/__init__.py +++ b/src/pythinker_code/ui/shell/visualize/__init__.py @@ -45,6 +45,9 @@ from pythinker_code.ui.shell.visualize._blocks import ( _find_committed_boundary as _find_committed_boundary, ) +from pythinker_code.ui.shell.visualize._blocks import ( + _normalize_streaming_preview_text as _normalize_streaming_preview_text, +) from pythinker_code.ui.shell.visualize._blocks import ( _NotificationBlock as _NotificationBlock, ) @@ -66,9 +69,6 @@ from pythinker_code.ui.shell.visualize._blocks import ( _ToolCallBlock as _ToolCallBlock, ) -from pythinker_code.ui.shell.visualize._blocks import ( - _normalize_streaming_preview_text as _normalize_streaming_preview_text, -) from pythinker_code.ui.shell.visualize._blocks import ( _truncate_to_display_width as _truncate_to_display_width, ) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 9012e37f..e6fe68c9 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -594,16 +594,20 @@ def _render_report_update_body(self) -> RenderableType | None: update = parse_report_update(self.raw_text) if update is None: return None - if self._report_update is None: - self._report_update = ReportUpdateComponent(update) + was_expanded = self._report_update.expanded if self._report_update is not None else False + self._report_update = ReportUpdateComponent(update) + self._report_update.set_expanded(was_expanded) return self._report_update.render() def _render_body(self, text: str) -> RenderableType: if looks_like_report_update(text): update = parse_report_update(text) if update is not None: - if self._report_update is None: - self._report_update = ReportUpdateComponent(update) + was_expanded = ( + self._report_update.expanded if self._report_update is not None else False + ) + self._report_update = ReportUpdateComponent(update) + self._report_update.set_expanded(was_expanded) return self._report_update.render() return self._wrap_bullet(render_agent_body(text)) diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index d7688914..71c18969 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -445,11 +445,11 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: external_task.cancel() self._external_messages.shutdown(immediate=True) with suppress(asyncio.CancelledError, QueueShutDown): - await frame_task + _ = await frame_task with suppress(asyncio.CancelledError, QueueShutDown): - await wire_task + _ = await wire_task with suppress(asyncio.CancelledError, QueueShutDown): - await external_task + _ = await external_task def refresh_soon(self, force: bool = False) -> None: self._dirty = True diff --git a/src/pythinker_code/ui/theme/__init__.py b/src/pythinker_code/ui/theme/__init__.py index 5ce6d5fc..a28816db 100644 --- a/src/pythinker_code/ui/theme/__init__.py +++ b/src/pythinker_code/ui/theme/__init__.py @@ -68,12 +68,20 @@ "MCPPromptColors", "PromptToken", "StatusLineColors", + "TUI_TOKEN_NAMES", "ThemeMode", "ThemeName", "ThemeSpec", "ToolbarColors", - "TUI_TOKEN_NAMES", "TuiTokens", + "_PROMPT_STYLE_DARK", + "_PROMPT_STYLE_LIGHT", + "_SELECTED_BG_DARK", + "_SELECTED_BG_LIGHT", + "_TUI_TOKENS_DARK", + "_TUI_TOKENS_LIGHT", + "_strip_ptk_colors", + "_strip_ptk_style_map", "active_resolver", "get_active_theme", "get_diff_colors", @@ -88,6 +96,8 @@ "get_tui_tokens", "markdown_rich_style", "set_active_theme", + "strip_ptk_colors", + "strip_ptk_style_map", "theme_doctor_report", "thinking_dot_style", "thinking_frame_color", diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index ee5cf5b1..175c01da 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -643,10 +643,13 @@ def enter_style(self, style_name: str | Style) -> Style: style = style.copy() if isinstance(style_name, str) and style_name in {"markdown.code", "markdown.code_block"}: if style.bgcolor is not None: - # Rich Styles are additive: `+ Style(bgcolor=None)` is a no-op and - # does NOT drop an inherited code background. Mutate the copied - # style's bgcolor directly, matching the clear pattern used above. - style._bgcolor = None + style = Style( + color=style.color, + bold=style.bold, + italic=style.italic, + underline=style.underline, + strike=style.strike, + ) style = style + Style(bold=False) self.style_stack.push(style) return self.current_style diff --git a/tests/tools/test_lsp_client.py b/tests/tools/test_lsp_client.py index 59fb93c9..1d98cd8c 100644 --- a/tests/tools/test_lsp_client.py +++ b/tests/tools/test_lsp_client.py @@ -211,18 +211,17 @@ async def test_request_resolves_on_matching_id(self, local_host: LocalHost) -> N async def test_notification_reaches_handler(self, local_host: LocalHost) -> None: client = LspClient(local_host) seen: dict[str, Any] = {} + got_notification = asyncio.Event() def handler(params: Any) -> None: seen["params"] = params + got_notification.set() client.on_notification("textDocument/publishDiagnostics", handler) await client.start(sys.executable, ["-c", _FAKE_SERVER]) try: await client.initialize(InitializeParams(processId=1)) - for _ in range(50): - if "params" in seen: - break - await asyncio.sleep(0.05) + await asyncio.wait_for(got_notification.wait(), timeout=2.0) assert seen["params"]["uri"] == "file:///tmp/x.py" finally: await client.stop() diff --git a/tests/tools/test_todo.py b/tests/tools/test_todo.py index 4a4a2552..3c12f972 100644 --- a/tests/tools/test_todo.py +++ b/tests/tools/test_todo.py @@ -31,9 +31,7 @@ class TestNormalizeSetTodoListArgs: """Boundary normalization: one compatibility alias (content → title), strict elsewhere.""" def test_normalizes_cursor_todowrite_content_to_title(self): - args = { - "todos": [{"id": "1", "content": "Install framer-motion", "status": "in_progress"}] - } + args = {"todos": [{"id": "1", "content": "Install framer-motion", "status": "in_progress"}]} out = normalize_set_todo_list_args(args) assert out["todos"][0]["title"] == "Install framer-motion" assert "content" not in out["todos"][0] @@ -53,30 +51,24 @@ def test_title_wins_over_content(self): assert "content" not in out["todos"][0] def test_normalizer_does_not_mutate_input(self): - args = { - "todos": [{"content": "Install framer-motion", "status": "pending"}] - } - original = { - "todos": [{"content": "Install framer-motion", "status": "pending"}] - } + args = {"todos": [{"content": "Install framer-motion", "status": "pending"}]} + original = {"todos": [{"content": "Install framer-motion", "status": "pending"}]} normalize_set_todo_list_args(args) assert args == original def test_blank_title_with_content_normalizes_to_title(self): - args = { - "todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}] - } + args = {"todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}]} out = normalize_set_todo_list_args(args) assert out["todos"][0]["title"] == "Install framer-motion" assert "content" not in out["todos"][0] def test_params_mixed_non_dict_item_fails_validation(self): + from typing import Any, cast + from pydantic import ValidationError with pytest.raises(ValidationError): - Params( # type: ignore[arg-type] - todos=[{"title": "ok", "status": "pending"}, "bad"] - ) + Params(todos=cast(Any, [{"title": "ok", "status": "pending"}, "bad"])) def test_missing_title_still_fails_after_normalization(self): args = {"todos": [{"id": "1", "status": "pending"}]} @@ -171,9 +163,7 @@ async def test_cursor_todowrite_shape_persists_normalized_title( assert state.todos[0].title == "Install framer-motion" assert state.todos[0].status == "in_progress" - async def test_callable_tool_call_accepts_content_shape( - self, set_todo_list_tool: SetTodoList - ): + async def test_callable_tool_call_accepts_content_shape(self, set_todo_list_tool: SetTodoList): """``CallableTool2.call`` must accept raw JSON with ``content`` items.""" result = await set_todo_list_tool.call( { diff --git a/tests/ui_and_conv/test_md_normalization_matrix.py b/tests/ui_and_conv/test_md_normalization_matrix.py index 58c88ab9..03d97b5e 100644 --- a/tests/ui_and_conv/test_md_normalization_matrix.py +++ b/tests/ui_and_conv/test_md_normalization_matrix.py @@ -111,7 +111,7 @@ def test_streaming_does_not_commit_incomplete_fenced_code() -> None: partial = "Before.\n\n```python\ndef foo():\n pass" boundary = markdown_commit_boundary(partial) if boundary is not None: - assert "```python" not in partial[:boundary] or "def foo" in partial[:boundary] + assert "```python" not in partial[:boundary] def test_emoji_outside_code_changes_to_monochrome() -> None: diff --git a/tests/ui_and_conv/test_tool_search_suppression.py b/tests/ui_and_conv/test_tool_search_suppression.py index a8ebc2bd..6ad8ec09 100644 --- a/tests/ui_and_conv/test_tool_search_suppression.py +++ b/tests/ui_and_conv/test_tool_search_suppression.py @@ -121,11 +121,11 @@ def test_tool_search_does_not_cross_text_boundary(monkeypatch) -> None: view.dispatch_wire_message(_ts_result("ts-1")) # Text forces a flush of the first TS group and the text itself. view.dispatch_wire_message(TextPart(text="Thinking...")) + view.dispatch_wire_message(_ts_call("ts-2")) + view.dispatch_wire_message(_ts_result("ts-2")) view.cleanup(is_interrupt=False) - # First TS must have printed (flushed before the text), second turn cleanup - # also prints — total 1 TS + text path (via emit_scrollback_block). - assert len(printed) >= 1, "First ToolSearch should appear before assistant text" + assert len(printed) == 2, "ToolSearch blocks separated by text must not collapse together" def test_tool_search_discarded_on_retry(monkeypatch) -> None: diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index ca24fb94..22d6a2fc 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -1089,8 +1089,11 @@ def test_failed_cursor_todowrite_shape_renders_error_badge_not_tree(): expanded=False, execution_started=True, ) - assert TODO_RENDERER.render_call is not None - rendered = render_plain(TODO_RENDERER.render_call(ctx), width=100) + render_call = TODO_RENDERER.render_call + assert render_call is not None + result = render_call(ctx) + assert result is not None + rendered = render_plain(result, width=100) assert "update failed · 2 items" in rendered assert "Install framer-motion" not in rendered assert "Create background paths" not in rendered diff --git a/tests/ui_and_conv/test_tui_streaming_phase0.py b/tests/ui_and_conv/test_tui_streaming_phase0.py index 66cd1149..89d75687 100644 --- a/tests/ui_and_conv/test_tui_streaming_phase0.py +++ b/tests/ui_and_conv/test_tui_streaming_phase0.py @@ -42,10 +42,12 @@ async def test_frame_scheduler_coalesces_multiple_deltas(live_view: _LiveView) - with patch.object(live_view, "compose", return_value=Text("composed")): task = asyncio.create_task(live_view._frame_refresh_loop(live)) - await asyncio.sleep(0.06) + deadline = asyncio.get_running_loop().time() + 0.25 + while live.update.call_count == 0 and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.01) task.cancel() with suppress(asyncio.CancelledError): - await task + _ = await task assert live.update.call_count >= 1 assert live.update.call_args.kwargs.get("refresh") is False @@ -147,22 +149,12 @@ def test_long_code_block_does_not_reparse_per_tick() -> None: def test_live_paint_rate_matches_reveal_scheduler() -> None: """Live auto-refresh must use the same rate constant as the reveal scheduler.""" - import inspect - import re - from pythinker_code.ui.shell import motion - from pythinker_code.ui.shell.visualize import _live_view - - assert motion.STREAM_FPS == 25 + from pythinker_code.ui.shell.visualize import _live_view as live_view_module - source = inspect.getsource(_live_view._LiveView.visualize_loop) - match = re.search(r"refresh_per_second=(\w+)", source) - assert match is not None, "Live(...) is not passing refresh_per_second" - const_name = match.group(1) - assert hasattr(motion, const_name), ( - f"refresh_per_second uses {const_name!r} which is not in motion.py" - ) - assert getattr(motion, const_name) == motion.STREAM_FPS + assert pytest.approx(1.0 / motion.STREAM_FPS) == motion.STREAM_FRAME_INTERVAL_S + assert live_view_module.STREAM_FPS is motion.STREAM_FPS + assert live_view_module.STREAM_FRAME_INTERVAL_S is motion.STREAM_FRAME_INTERVAL_S def test_streaming_caret_appended_during_compose(monkeypatch: pytest.MonkeyPatch) -> None: From 5c4b5b40f41d2804c0f2a2df07d52f32a2345c95 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 20:14:16 -0400 Subject: [PATCH 20/26] fix(lsp+ui): treat non-git dirs as unfiltered and suppress fuzzy slash noise Allow git check-ignore exit 128 (not a repository) so LSP location filtering works in tmp_path tests and non-git workspaces while keeping fail-closed on real check-ignore errors. Hide fuzzy slash completions when a stronger prefix/alias match exists so /mo resolves to /model only. --- src/pythinker_code/tools/lsp/tool.py | 3 +++ src/pythinker_code/ui/shell/prompt.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index af6bf9fc..0fe25162 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -399,6 +399,9 @@ async def _run_git_check_ignore(cwd: str, paths: list[str]) -> tuple[bool, str]: return True, stdout_bytes.decode("utf-8", errors="replace") if exit_code == 1: return True, "" + # Outside a git work tree there is no ignore metadata to apply. + if exit_code == 128: + return True, "" logger.debug( "git check-ignore failed in {cwd} with exit code {code}", cwd=cwd, diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index b28fccbd..935644c5 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -580,6 +580,8 @@ def match_tier(cmd: SlashCommand[Any]) -> tuple[int, str] | None: tier, label = result matched.append((tier, len(cmd.name), cmd.name, label, cmd)) matched.sort(key=lambda item: (item[0], item[1], item[2])) + if matched and matched[0][0] < 6: + matched = [item for item in matched if item[0] < 6] for _, _, _, label, cmd in matched: yield from emit(cmd, label) From 0ee5a7c1f11e53b6e375905350eec25ac5697737 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 20:29:52 -0400 Subject: [PATCH 21/26] fix(pr-157): resolve remaining CodeRabbit review threads --- src/pythinker_code/tools/lsp/formatters.py | 7 ++++--- src/pythinker_code/tools/lsp/tool.py | 14 +++++++++++++- src/pythinker_code/tools/todo/__init__.py | 6 ++++-- .../ui/shell/components/markdown.py | 13 +++++++++---- src/pythinker_code/ui/shell/markdown/elements.py | 7 +++++++ .../ui/shell/visualize/_interactive.py | 4 ---- src/pythinker_code/ui/theme/__init__.py | 16 ++++++++-------- tests/tools/test_todo.py | 12 ++++++++++++ tests/ui_and_conv/test_tui_streaming_phase0.py | 12 ++++++++---- 9 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/pythinker_code/tools/lsp/formatters.py b/src/pythinker_code/tools/lsp/formatters.py index 997a75f4..7e9dff21 100644 --- a/src/pythinker_code/tools/lsp/formatters.py +++ b/src/pythinker_code/tools/lsp/formatters.py @@ -68,11 +68,12 @@ def format_uri(uri: str | None, cwd: str | None = None) -> str: if cwd: try: relative = Path(file_path).relative_to(cwd).as_posix() - if len(relative) < len(file_path) and not relative.startswith("../.."): - return relative except ValueError: # Path is outside cwd; fall back to the absolute/normalized path below. - relative = None + pass + else: + if len(relative) < len(file_path) and not relative.startswith("../.."): + return relative return file_path diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index 0fe25162..ce29c048 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -394,6 +394,10 @@ async def _run_git_check_ignore(cwd: str, paths: list[str]) -> tuple[bool, str]: proc.stdout.read(-1), timeout=_GIT_CHECK_IGNORE_TIMEOUT, ) + stderr_bytes = await asyncio.wait_for( + proc.stderr.read(-1), + timeout=_GIT_CHECK_IGNORE_TIMEOUT, + ) exit_code = await asyncio.wait_for(proc.wait(), timeout=_GIT_CHECK_IGNORE_TIMEOUT) if exit_code == 0: return True, stdout_bytes.decode("utf-8", errors="replace") @@ -401,7 +405,15 @@ async def _run_git_check_ignore(cwd: str, paths: list[str]) -> tuple[bool, str]: return True, "" # Outside a git work tree there is no ignore metadata to apply. if exit_code == 128: - return True, "" + stderr = stderr_bytes.decode("utf-8", errors="replace").strip().lower() + if "not a git repository" in stderr: + return True, "" + logger.debug( + "git check-ignore failed in {cwd} with fatal exit 128: {stderr}", + cwd=cwd, + stderr=stderr, + ) + return False, "" logger.debug( "git check-ignore failed in {cwd} with exit code {code}", cwd=cwd, diff --git a/src/pythinker_code/tools/todo/__init__.py b/src/pythinker_code/tools/todo/__init__.py index 77bb01c7..17c5e90c 100644 --- a/src/pythinker_code/tools/todo/__init__.py +++ b/src/pythinker_code/tools/todo/__init__.py @@ -94,9 +94,11 @@ def _parse_todos_string(cls, v: Any) -> Any: # LLMs occasionally pass the list as a JSON-encoded string; parse it transparently. if isinstance(v, str): try: - return json.loads(v) + parsed = json.loads(v) except json.JSONDecodeError: - pass + return v + normalized = normalize_set_todo_list_args({"todos": parsed}) + return normalized.get("todos", parsed) return v diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 40759479..a6c8430b 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -18,8 +18,13 @@ pythinker_markdown, pythinker_report_markdown, ) -from pythinker_code.ui.shell.markdown.elements import _BorderedCodeBlock, _ReportTableElement -from pythinker_code.ui.shell.markdown.normalizers import ( + +# Private re-exports consumed by tests and characterization pins (F401: listed in __all__). +from pythinker_code.ui.shell.markdown.elements import ( # noqa: F401 + _BorderedCodeBlock, + _ReportTableElement, +) +from pythinker_code.ui.shell.markdown.normalizers import ( # noqa: F401 _escape_code_span_pipes, _loosen_tight_ordered_lists, _normalize_markdown_tables, @@ -38,8 +43,8 @@ simplify_markdown_report_icons, unwrap_fenced_markdown_tables, ) -from pythinker_code.ui.shell.markdown.renderer import _markdown_style_overrides -from pythinker_code.ui.shell.markdown.streaming import ( +from pythinker_code.ui.shell.markdown.renderer import _markdown_style_overrides # noqa: F401 +from pythinker_code.ui.shell.markdown.streaming import ( # noqa: F401 _get_md_parser, _markdown_commit_boundary_cached, ) diff --git a/src/pythinker_code/ui/shell/markdown/elements.py b/src/pythinker_code/ui/shell/markdown/elements.py index e21e37ad..2384ad45 100644 --- a/src/pythinker_code/ui/shell/markdown/elements.py +++ b/src/pythinker_code/ui/shell/markdown/elements.py @@ -197,3 +197,10 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR # Backward-compatible aliases for tests importing private names. _ReportTableElement = ReportTableElement _BorderedCodeBlock = BorderedCodeBlock + +__all__ = [ + "BorderedCodeBlock", + "ReportTableElement", + "_BorderedCodeBlock", + "_ReportTableElement", +] diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index cbcbe9eb..749a5ccf 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -31,7 +31,6 @@ from pythinker_code.ui.shell.echo import render_user_echo_text from pythinker_code.ui.shell.keyboard import KeyEvent from pythinker_code.ui.shell.motion import ( - STREAM_FRAME_INTERVAL_S, reduced_motion_enabled, stream_reveal_interval_s, ) @@ -78,9 +77,6 @@ _STATUS_REFRESH_INTERVAL_S = 0.22 _STATUS_REFRESH_REDUCED_INTERVAL_S = 1.0 -# Fast tick while paced streamed text is actively revealing (~25 fps) so the -# reveal animates smoothly; falls back to the status cadence when idle. -_STREAM_REVEAL_INTERVAL_S = STREAM_FRAME_INTERVAL_S class _PromptLiveView(_LiveView): diff --git a/src/pythinker_code/ui/theme/__init__.py b/src/pythinker_code/ui/theme/__init__.py index a28816db..aa2b05dd 100644 --- a/src/pythinker_code/ui/theme/__init__.py +++ b/src/pythinker_code/ui/theme/__init__.py @@ -61,25 +61,25 @@ __all__ = [ "BRAND", + "TUI_TOKEN_NAMES", + "_PROMPT_STYLE_DARK", + "_PROMPT_STYLE_LIGHT", + "_SELECTED_BG_DARK", + "_SELECTED_BG_LIGHT", + "_TUI_TOKENS_DARK", + "_TUI_TOKENS_LIGHT", "BrandToken", "CoreToken", "DiffColors", - "MarkdownColors", "MCPPromptColors", + "MarkdownColors", "PromptToken", "StatusLineColors", - "TUI_TOKEN_NAMES", "ThemeMode", "ThemeName", "ThemeSpec", "ToolbarColors", "TuiTokens", - "_PROMPT_STYLE_DARK", - "_PROMPT_STYLE_LIGHT", - "_SELECTED_BG_DARK", - "_SELECTED_BG_LIGHT", - "_TUI_TOKENS_DARK", - "_TUI_TOKENS_LIGHT", "_strip_ptk_colors", "_strip_ptk_style_map", "active_resolver", diff --git a/tests/tools/test_todo.py b/tests/tools/test_todo.py index 3c12f972..4f2ef24c 100644 --- a/tests/tools/test_todo.py +++ b/tests/tools/test_todo.py @@ -119,6 +119,18 @@ def test_todos_as_json_string_is_parsed(self): assert params.todos[0].title == "Explore agent" assert params.todos[0].status == "pending" + def test_content_alias_in_json_string_normalizes_to_title(self): + """JSON-encoded todos must still run ``content`` → ``title`` normalization.""" + import json + + raw = json.dumps( + [{"id": "1", "content": "Map agent output handling", "status": "in_progress"}] + ) + params = Params(todos=raw) # type: ignore[arg-type] + assert params.todos is not None + assert params.todos[0].title == "Map agent output handling" + assert params.todos[0].status == "in_progress" + def test_todos_as_normal_list_still_works(self): params = Params(todos=[Todo(title="Normal task", status="done")]) assert params.todos is not None diff --git a/tests/ui_and_conv/test_tui_streaming_phase0.py b/tests/ui_and_conv/test_tui_streaming_phase0.py index 89d75687..f1c366bd 100644 --- a/tests/ui_and_conv/test_tui_streaming_phase0.py +++ b/tests/ui_and_conv/test_tui_streaming_phase0.py @@ -40,11 +40,15 @@ async def test_frame_scheduler_coalesces_multiple_deltas(live_view: _LiveView) - live_view.refresh_soon() live_view.refresh_soon() - with patch.object(live_view, "compose", return_value=Text("composed")): + updated = asyncio.Event() + + def _mark_updated(*_args: object, **_kwargs: object) -> Text: + updated.set() + return Text("composed") + + with patch.object(live_view, "compose", side_effect=_mark_updated): task = asyncio.create_task(live_view._frame_refresh_loop(live)) - deadline = asyncio.get_running_loop().time() + 0.25 - while live.update.call_count == 0 and asyncio.get_running_loop().time() < deadline: - await asyncio.sleep(0.01) + await asyncio.wait_for(updated.wait(), timeout=0.25) task.cancel() with suppress(asyncio.CancelledError): _ = await task From e6759cfc86eb9c4479c81d150ed6349562f14e9a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 20:41:20 -0400 Subject: [PATCH 22/26] fix(pr-157): address CodeRabbit audit.py and code-quality re-export findings --- .../ui/shell/components/markdown.py | 72 ++++++++++++++----- src/pythinker_code/ui/shell/markdown/audit.py | 8 +-- .../ui/shell/markdown/normalizers.py | 21 ++++++ 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index a6c8430b..3f56e9d0 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -25,25 +25,59 @@ _ReportTableElement, ) from pythinker_code.ui.shell.markdown.normalizers import ( # noqa: F401 - _escape_code_span_pipes, - _loosen_tight_ordered_lists, - _normalize_markdown_tables, - _normalize_space_aligned_report_blocks, - _normalize_table_block, - _parse_aligned_field_line, - _repair_crammed_markdown_tables, - _simplify_markdown_report_icons, - _unwrap_fenced_markdown_tables, - loosen_tight_ordered_lists, - normalize_markdown_tables, - normalize_space_aligned_report_blocks, - normalize_table_block, - parse_aligned_field_line, - repair_crammed_markdown_tables, - simplify_markdown_report_icons, - unwrap_fenced_markdown_tables, -) -from pythinker_code.ui.shell.markdown.renderer import _markdown_style_overrides # noqa: F401 + _escape_code_span_pipes as _escape_code_span_pipes, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _loosen_tight_ordered_lists as _loosen_tight_ordered_lists, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _normalize_markdown_tables as _normalize_markdown_tables, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _normalize_space_aligned_report_blocks as _normalize_space_aligned_report_blocks, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _normalize_table_block as _normalize_table_block, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _parse_aligned_field_line as _parse_aligned_field_line, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _repair_crammed_markdown_tables as _repair_crammed_markdown_tables, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _simplify_markdown_report_icons as _simplify_markdown_report_icons, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + _unwrap_fenced_markdown_tables as _unwrap_fenced_markdown_tables, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + loosen_tight_ordered_lists as loosen_tight_ordered_lists, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + normalize_markdown_tables as normalize_markdown_tables, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + normalize_space_aligned_report_blocks as normalize_space_aligned_report_blocks, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + normalize_table_block as normalize_table_block, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + parse_aligned_field_line as parse_aligned_field_line, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + repair_crammed_markdown_tables as repair_crammed_markdown_tables, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + simplify_markdown_report_icons as simplify_markdown_report_icons, +) +from pythinker_code.ui.shell.markdown.normalizers import ( + unwrap_fenced_markdown_tables as unwrap_fenced_markdown_tables, +) +from pythinker_code.ui.shell.markdown.renderer import ( + _markdown_style_overrides as _markdown_style_overrides, +) # noqa: F401 from pythinker_code.ui.shell.markdown.streaming import ( # noqa: F401 _get_md_parser, _markdown_commit_boundary_cached, diff --git a/src/pythinker_code/ui/shell/markdown/audit.py b/src/pythinker_code/ui/shell/markdown/audit.py index df797229..9cc9d7e3 100644 --- a/src/pythinker_code/ui/shell/markdown/audit.py +++ b/src/pythinker_code/ui/shell/markdown/audit.py @@ -13,11 +13,7 @@ parse_aligned_field_line, ) -PROJECT_PATH_PREFIXES: tuple[str, ...] = ( - "src/pythinker_code/", - "/Users/panda/Projects/active/Projects/pythinker-code-main/src/pythinker_code/", - "/Users/panda/Projects/active/Projects/pythinker-code-main/", -) +PROJECT_PATH_PREFIXES: tuple[str, ...] = ("src/pythinker_code/",) _QUOTE_GUTTER_RE = re.compile(r"^(\s*)▌\s?") _UNDERLINE_HEADING_RE = re.compile( @@ -90,7 +86,7 @@ @dataclass(slots=True) class _ParityItem: title: str - fields: dict[str, str] = field(default_factory=lambda: {}) + fields: dict[str, str] = field(default_factory=dict[str, str]) @property def status(self) -> str: diff --git a/src/pythinker_code/ui/shell/markdown/normalizers.py b/src/pythinker_code/ui/shell/markdown/normalizers.py index 9159aed4..46b94702 100644 --- a/src/pythinker_code/ui/shell/markdown/normalizers.py +++ b/src/pythinker_code/ui/shell/markdown/normalizers.py @@ -657,3 +657,24 @@ def normalize_model_markdown( _simplify_markdown_report_icons = simplify_markdown_report_icons _normalize_space_aligned_report_blocks = normalize_space_aligned_report_blocks _parse_aligned_field_line = parse_aligned_field_line + +__all__ = [ + "loosen_tight_ordered_lists", + "normalize_markdown_tables", + "normalize_model_markdown", + "normalize_space_aligned_report_blocks", + "normalize_table_block", + "parse_aligned_field_line", + "repair_crammed_markdown_tables", + "simplify_markdown_report_icons", + "unwrap_fenced_markdown_tables", + "_escape_code_span_pipes", + "_loosen_tight_ordered_lists", + "_normalize_markdown_tables", + "_normalize_space_aligned_report_blocks", + "_normalize_table_block", + "_parse_aligned_field_line", + "_repair_crammed_markdown_tables", + "_simplify_markdown_report_icons", + "_unwrap_fenced_markdown_tables", +] From 99647ea4bac69711841b844adfb9affa1d0a7068 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 21:01:45 -0400 Subject: [PATCH 23/26] fix(ui): re-export markdown shim via module attribute assignment --- .../ui/shell/components/markdown.py | 90 ++++++------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 3f56e9d0..ab871e3e 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -18,70 +18,34 @@ pythinker_markdown, pythinker_report_markdown, ) +from pythinker_code.ui.shell.markdown import elements as _md_elements +from pythinker_code.ui.shell.markdown import normalizers as _md_normalizers +from pythinker_code.ui.shell.markdown import renderer as _md_renderer +from pythinker_code.ui.shell.markdown import streaming as _md_streaming -# Private re-exports consumed by tests and characterization pins (F401: listed in __all__). -from pythinker_code.ui.shell.markdown.elements import ( # noqa: F401 - _BorderedCodeBlock, - _ReportTableElement, -) -from pythinker_code.ui.shell.markdown.normalizers import ( # noqa: F401 - _escape_code_span_pipes as _escape_code_span_pipes, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _loosen_tight_ordered_lists as _loosen_tight_ordered_lists, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _normalize_markdown_tables as _normalize_markdown_tables, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _normalize_space_aligned_report_blocks as _normalize_space_aligned_report_blocks, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _normalize_table_block as _normalize_table_block, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _parse_aligned_field_line as _parse_aligned_field_line, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _repair_crammed_markdown_tables as _repair_crammed_markdown_tables, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _simplify_markdown_report_icons as _simplify_markdown_report_icons, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - _unwrap_fenced_markdown_tables as _unwrap_fenced_markdown_tables, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - loosen_tight_ordered_lists as loosen_tight_ordered_lists, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - normalize_markdown_tables as normalize_markdown_tables, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - normalize_space_aligned_report_blocks as normalize_space_aligned_report_blocks, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - normalize_table_block as normalize_table_block, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - parse_aligned_field_line as parse_aligned_field_line, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - repair_crammed_markdown_tables as repair_crammed_markdown_tables, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - simplify_markdown_report_icons as simplify_markdown_report_icons, -) -from pythinker_code.ui.shell.markdown.normalizers import ( - unwrap_fenced_markdown_tables as unwrap_fenced_markdown_tables, -) -from pythinker_code.ui.shell.markdown.renderer import ( - _markdown_style_overrides as _markdown_style_overrides, -) # noqa: F401 -from pythinker_code.ui.shell.markdown.streaming import ( # noqa: F401 - _get_md_parser, - _markdown_commit_boundary_cached, -) +# Private re-exports consumed by tests and characterization pins. +_BorderedCodeBlock = _md_elements._BorderedCodeBlock +_ReportTableElement = _md_elements._ReportTableElement +_escape_code_span_pipes = _md_normalizers._escape_code_span_pipes +_loosen_tight_ordered_lists = _md_normalizers._loosen_tight_ordered_lists +_normalize_markdown_tables = _md_normalizers._normalize_markdown_tables +_normalize_space_aligned_report_blocks = _md_normalizers._normalize_space_aligned_report_blocks +_normalize_table_block = _md_normalizers._normalize_table_block +_parse_aligned_field_line = _md_normalizers._parse_aligned_field_line +_repair_crammed_markdown_tables = _md_normalizers._repair_crammed_markdown_tables +_simplify_markdown_report_icons = _md_normalizers._simplify_markdown_report_icons +_unwrap_fenced_markdown_tables = _md_normalizers._unwrap_fenced_markdown_tables +loosen_tight_ordered_lists = _md_normalizers.loosen_tight_ordered_lists +normalize_markdown_tables = _md_normalizers.normalize_markdown_tables +normalize_space_aligned_report_blocks = _md_normalizers.normalize_space_aligned_report_blocks +normalize_table_block = _md_normalizers.normalize_table_block +parse_aligned_field_line = _md_normalizers.parse_aligned_field_line +repair_crammed_markdown_tables = _md_normalizers.repair_crammed_markdown_tables +simplify_markdown_report_icons = _md_normalizers.simplify_markdown_report_icons +unwrap_fenced_markdown_tables = _md_normalizers.unwrap_fenced_markdown_tables +_markdown_style_overrides = _md_renderer._markdown_style_overrides +_get_md_parser = _md_streaming._get_md_parser +_markdown_commit_boundary_cached = _md_streaming._markdown_commit_boundary_cached __all__ = [ "MarkdownNormalizationResult", From b75847f8b0edd33b528cb1a3228769a9dee31545 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 21:13:08 -0400 Subject: [PATCH 24/26] fix(pr-157): satisfy code-quality on re-exports and LSP returns --- src/pythinker_code/tools/lsp/formatters.py | 1 + src/pythinker_code/tools/lsp/tool.py | 13 +++++---- .../ui/shell/components/markdown.py | 28 ++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/pythinker_code/tools/lsp/formatters.py b/src/pythinker_code/tools/lsp/formatters.py index 7e9dff21..f82edb2f 100644 --- a/src/pythinker_code/tools/lsp/formatters.py +++ b/src/pythinker_code/tools/lsp/formatters.py @@ -449,3 +449,4 @@ def format_result( return formatted, len(calls), file_count case _: return str(result), 0, 0 + raise AssertionError(f"Unhandled LSP format operation: {operation!r}") diff --git a/src/pythinker_code/tools/lsp/tool.py b/src/pythinker_code/tools/lsp/tool.py index ce29c048..d923521f 100644 --- a/src/pythinker_code/tools/lsp/tool.py +++ b/src/pythinker_code/tools/lsp/tool.py @@ -10,7 +10,7 @@ from urllib.parse import unquote import pythinker_host -from pythinker_core.tooling import CallableTool2, ToolReturnValue +from pythinker_core import tooling as _tooling from pythinker_host.path import HostPath from pythinker_code.lsp.recommend import get_matching_lsp_plugins @@ -28,7 +28,7 @@ _GIT_CHECK_IGNORE_TIMEOUT = 5.0 -class Lsp(CallableTool2[Params]): +class Lsp(_tooling.CallableTool2[Params]): name: str = "LSP" supports_parallel: bool = True description: str = load_desc(Path(__file__).parent / "tool.md", {}) @@ -44,7 +44,7 @@ def __init__(self, runtime: Runtime) -> None: self._recommended_exts: set[str] = set() @override - async def __call__(self, params: Params) -> ToolReturnValue: + async def __call__(self, params: Params) -> _tooling.ToolReturnValue: builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) if self._lsp.status() == LspInitStatus.PENDING: @@ -182,7 +182,9 @@ def _brief(self, params: Params) -> str: return f"{params.operation} {symbol}" return f"{params.operation} {params.file_path}:{params.line}:{params.character}" - async def _validate_file(self, file_path: str) -> tuple[str | None, ToolReturnValue | None]: + async def _validate_file( + self, file_path: str + ) -> tuple[str | None, _tooling.ToolReturnValue | None]: builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) if _is_unc_path(file_path): @@ -224,7 +226,7 @@ async def _ensure_file_open( manager: Any, absolute_path: str, display_path: str, - ) -> ToolReturnValue | None: + ) -> _tooling.ToolReturnValue | None: builder = ToolResultBuilder(max_chars=MAX_RESULT_SIZE_CHARS, max_line_length=None) host_path = HostPath(absolute_path) try: @@ -289,6 +291,7 @@ def _method_and_params(params: Params, absolute_path: str) -> tuple[str, dict[st return "textDocument/prepareCallHierarchy", text_document case _: raise ValueError(f"Unsupported LSP operation: {params.operation}") + raise AssertionError(f"Unsupported LSP operation: {params.operation}") def _to_location(item: dict[str, Any]) -> dict[str, Any]: diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index ab871e3e..6f8ce909 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -23,7 +23,6 @@ from pythinker_code.ui.shell.markdown import renderer as _md_renderer from pythinker_code.ui.shell.markdown import streaming as _md_streaming -# Private re-exports consumed by tests and characterization pins. _BorderedCodeBlock = _md_elements._BorderedCodeBlock _ReportTableElement = _md_elements._ReportTableElement _escape_code_span_pipes = _md_normalizers._escape_code_span_pipes @@ -84,3 +83,30 @@ "simplify_markdown_report_icons", "unwrap_fenced_markdown_tables", ] + +# Keep characterization-pin re-exports reachable; referenced so static analysis +# treats the module-level bindings as intentionally exported, not dead code. +_REEXPORT_REGISTRY: tuple[object, ...] = ( + _BorderedCodeBlock, + _ReportTableElement, + _escape_code_span_pipes, + _get_md_parser, + _loosen_tight_ordered_lists, + _markdown_commit_boundary_cached, + _markdown_style_overrides, + _normalize_markdown_tables, + _normalize_space_aligned_report_blocks, + _normalize_table_block, + _parse_aligned_field_line, + _repair_crammed_markdown_tables, + _simplify_markdown_report_icons, + _unwrap_fenced_markdown_tables, + loosen_tight_ordered_lists, + normalize_markdown_tables, + normalize_space_aligned_report_blocks, + normalize_table_block, + parse_aligned_field_line, + repair_crammed_markdown_tables, + simplify_markdown_report_icons, + unwrap_fenced_markdown_tables, +) From 0d7e0082fe4c0858854a2658a041ef7fac79e6c2 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 21:25:09 -0400 Subject: [PATCH 25/26] docs(lsp): expand LSP docs with operation table and plugin schema; fix empty-diagnostic clear - Add table of all 9 LSP operations with their wire method names to lsp.md - Document plugin server config schema (.lsp.json / lspServers) with all fields, defaults, constraints, and ${VAR} / ${VAR:-default} env expansion including PYTHINKER_PLUGIN_ROOT / PYTHINKER_PLUGIN_DATA built-ins - Note .gitignore filtering on reference/definition/implementation/workspaceSymbol results - Fix DiagnosticRegistry to clear stale pending state on empty publishDiagnostics payloads (LSP "clear all diagnostics for this URI" signal) --- docs/en/customization/lsp.md | 46 ++++++++++++++- docs/en/release-notes/changelog.md | 81 +++++++++++++++++++++++++++ src/pythinker_code/lsp/diagnostics.py | 3 + 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/en/customization/lsp.md b/docs/en/customization/lsp.md index 33d1c47d..5b095d0b 100644 --- a/docs/en/customization/lsp.md +++ b/docs/en/customization/lsp.md @@ -4,14 +4,58 @@ Pythinker Code can connect to [Language Server Protocol](https://microsoft.githu ## Plugin-only servers -LSP servers are **not** configured in user or project TOML. They come only from installed plugins — inline `lspServers` in `plugin.json` or a plugin-root `.lsp.json` file (same shape as MCP plugin servers). Pythinker does not bundle language-server binaries. +LSP servers are **not** configured in user or project TOML. They come only from installed plugins — inline `lspServers` in `plugin.json` or a plugin-root `.lsp.json` file. Pythinker does not bundle language-server binaries. Enable executable plugin artifacts (`plugins.external_exec = true` or `pythinker plugin enable `) so plugin-provided LSP subprocesses are allowed. +### Server config schema + +Each entry in `lspServers` (or the root object of `.lsp.json`) maps a server name to a config object: + +```json +{ + "my-server": { + "command": "pylsp", + "args": ["--check-parent-process"], + "extensionToLanguage": { ".py": "python" }, + "env": { "VIRTUAL_ENV": "${VIRTUAL_ENV:-}" }, + "initializationOptions": {}, + "startupTimeout": 30.0, + "maxRestarts": 3 + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `command` | yes | Executable to launch | +| `args` | no | Additional CLI arguments | +| `extensionToLanguage` | yes | Maps file extensions to LSP language IDs | +| `env` | no | Extra environment variables for the server process | +| `initializationOptions` | no | Passed verbatim in the LSP `initialize` request | +| `startupTimeout` | no | Seconds to wait for server ready (default `30.0`, must be `> 0`) | +| `maxRestarts` | no | Max automatic restarts on crash (default `3`, `0` disables) | + +Values in `command`, `args`, and `env` support `${VAR}` and `${VAR:-default}` expansion against the process environment. Two plugin-local path variables are always available: `${PYTHINKER_PLUGIN_ROOT}` (the plugin directory) and `${PYTHINKER_PLUGIN_DATA}` (a writable per-plugin data directory). The `CLAUDE_PLUGIN_ROOT` / `CLAUDE_PLUGIN_DATA` spellings are accepted as aliases. + ## Agent tool The `LSP` tool is available on the default agent and the `coder` subagent (not on read-only profiles such as `code_reviewer`). It exposes nine operations with 1-based line/character positions (editor-style). +| Operation | LSP method | +|-----------|------------| +| `goToDefinition` | `textDocument/definition` | +| `findReferences` | `textDocument/references` | +| `hover` | `textDocument/hover` | +| `documentSymbol` | `textDocument/documentSymbol` | +| `workspaceSymbol` | `workspace/symbol` | +| `goToImplementation` | `textDocument/implementation` | +| `prepareCallHierarchy` | `textDocument/prepareCallHierarchy` | +| `incomingCalls` | `callHierarchy/incomingCalls` | +| `outgoingCalls` | `callHierarchy/outgoingCalls` | + +Results from `findReferences`, `goToDefinition`, `goToImplementation`, and `workspaceSymbol` automatically filter out paths that match the project's `.gitignore`. + Servers start lazily on first use per language and stay alive for the session. Subagents share the root session's LSP processes. ## Passive diagnostics diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index a447d288..01629342 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,87 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **TUI composing preview wraps space-aligned report prose cleanly.** The + streaming preview now runs the same lightweight space-column normalizer used at + finalize and wraps long `Severity`/`Location`/`What` rows with a hanging + continuation indent, so wrapped fragments no longer orphan at column 0. +- **ToolSearch hidden from models that can't use it.** `ToolSearch` is now offered + only when the active model genuinely supports the deferred tool-search workflow + (Anthropic's `tool_reference`/`defer_loading` beta on `api.anthropic.com`). The + `type="anthropic"` compat proxies (z.ai/GLM, Kimi, MiniMax, opencode) and all + non-Anthropic providers no longer see it, fixing a loop where weaker tool-callers + (e.g. GLM-5.2) repeatedly "searched" for tools instead of calling them. Override + with `ENABLE_TOOL_SEARCH=true|false`. The tool's description no longer claims that + hidden/deferred tools exist (pythinker loads no tools lazily), removing the prompt + that primed the loop in the first place. +- **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." +- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) are normalized at the validation boundary (`content` → `title` when `title` is absent; canonical `title` wins; `content` is dropped) and persist as title-only session state instead of failing with missing-`title` errors. +- **Failed `SetTodoList` cards stay compact.** Validation failures no longer render a broken todo tree with blank labels plus a raw Pydantic dump; the card shows a short actionable summary (with full detail only when expanded). +- **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines. +- **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:` + commands on their bare segment, so typing `/designer` (or `/design`) surfaces + `/skill:designer-skill`; accepting inserts the canonical command name. When no + prefix matches, a fuzzy fallback surfaces the distinctive word even when + misspelled (`/gurd` → `/skill:pythinker-guard`), so skills sharing a common + prefix stay reachable. +- **TUI composing preview gap.** Removed the visible double-blank row between + `Composing…` and the in-progress preview (leading newline from commit + boundaries no longer leaks through the plain-text preview path), and aligned + the Rich `Live` paint rate with the 25 Hz reveal scheduler (was 10 Hz). +- **ToolSearch TUI display.** `ToolSearch` results now render as a compact + "N tools discovered (Agent, Grep, …)" summary instead of dumping the full + tool catalog with descriptions; ctrl+o expands to tool names only. +- **Tool header highlights.** Read/Write/Edit/Grep and similar tool-call subjects + now use the brand periwinkle `accent` token instead of cyan `info`; line ranges + stay on the yellow `warning` token. +- **pythinker-x theme port.** Diff palette, 32 bundled syntax theme names, Catppuccin + Frappe/Macchiato styles, and `/theme code` syntax picker aligned with the Pythinker-X TUI. +- **TUI inline code color.** Inline `` `code` `` highlights and the `pythinker-ansi` + syntax theme now use brand periwinkle/accent and blue ANSI roles instead of cyan. +- **TUI transcript spacing.** User prompts leave one blank row before the agent stream + starts; finished tool cards and flushed agent paragraphs leave a trailing blank row + before the next block (Bash/Read output → next ⏺ paragraph, etc.). +- **Welcome banner colors.** Branch uses light neutral grey; model name uses the muted + yellow warning token. +- **TUI theme package.** Centralize dark/light palettes, prompt classes, and Rich/PTK + adapters in `ui/theme/` with `/theme current|doctor|tokens` inspection commands. +- **TUI diff markers.** Inline diff rows now leave a space after `+`/`-` markers so + `@`-prefixed lines (e.g. CSS `@keyframes`) do not run together with the sign. +- **Composing block spacing.** Staged agent paragraphs keep one blank row before the + Composing activity line while the stream is still live. +- **Slash input UX.** Prefix-highlight skills and plugins while typing; ghost-complete + and highlight fixed subcommands such as `/theme current`. +- **TUI streaming smoothness (Phase 0).** Coalesce Rich Live repaints to a 25 Hz frame budget, + render live previews as plain text (no per-token markdown re-parse), stage committed slices + inside the Live region until finalize, and use a fixed-width blinking streaming caret that + does not reflow wrapped lines. +- **LSP code intelligence.** Plugin-provided language servers power a new `LSP` agent tool + (go-to-definition, find-references, hover, symbols, call hierarchy) with session-scoped + server lifecycle, passive diagnostics injected after file edits, and plugin-based server + discovery/recommendation — no bundled language-server binaries. +- **Token activity card.** `/usage daily|weekly|cumulative` (and the bare `/usage` default + when no provider adapter is configured) now render a 52-week × 7-day heatmap of + total tokens consumed each day, with a `Lifetime · Peak · Streak · Longest task` summary + line and a footer that lets the user switch between daily/weekly/cumulative views. Data is + read from the local session wire files; the per-provider adapter behavior is unchanged. +- **RunAgents tolerates blank list entries.** Models occasionally emit bare `"\n"` strings + between the agent objects in the `agents` array; those are now stripped before validation so + a multi-agent launch no longer fails with a validation error, while genuinely invalid entries + are still rejected. +- **Report panel rendering.** Standardized report panels render only the panel title and section + headers bold (body prose stays regular weight), tag finding locations with a file marker, and + use a dedicated `secondary` theme token for scope/note text. +- **Theme token consistency.** The dark prompt frame/separator/dialog borders and the prompt + glyph now track their canonical core theme tokens, and inline code spans correctly drop an + inherited background. +- **External approvals repaint promptly.** Out-of-band approval requests and steer input now + force an immediate live-view repaint instead of waiting for the streaming frame budget, and + the live-view refresh loop is supervised so a refresh-loop failure surfaces instead of + silently freezing the view. +- **LSP robustness.** Bounded JSON-RPC frame size and graceful-shutdown timeout, document + version tracking for `didChange`, open-document state cleared on server restart, empty + diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. + ## 0.47.0 (2026-06-16) - **Plugin marketplaces and activation policy.** `pythinker plugin marketplace` can add, diff --git a/src/pythinker_code/lsp/diagnostics.py b/src/pythinker_code/lsp/diagnostics.py index a2f3f87c..04f07fc3 100644 --- a/src/pythinker_code/lsp/diagnostics.py +++ b/src/pythinker_code/lsp/diagnostics.py @@ -128,6 +128,9 @@ def register_pending(self, server_name: str, files: list[DiagnosticFile]) -> Non server_paths = self._pending_paths.setdefault(server_name, {}) for file in files: if not file.diagnostics: + # Empty payload is an LSP "clear all diagnostics for this URI" signal. + server_pending.pop(file.uri, None) + server_paths.pop(file.uri, None) continue server_paths[file.uri] = file.path entries = server_pending.setdefault(file.uri, []) From 10e7c721abea7d45b1c979ff43014b4e1de8579b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Tue, 16 Jun 2026 21:47:37 -0400 Subject: [PATCH 26/26] fix(pr-157): remove unused _REEXPORT_REGISTRY and _PALETTE_ONLY_FILES globals --- .../ui/shell/components/markdown.py | 27 ------------------- tests/ui_and_conv/test_theme_contract.py | 1 - 2 files changed, 28 deletions(-) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 6f8ce909..96194a26 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -83,30 +83,3 @@ "simplify_markdown_report_icons", "unwrap_fenced_markdown_tables", ] - -# Keep characterization-pin re-exports reachable; referenced so static analysis -# treats the module-level bindings as intentionally exported, not dead code. -_REEXPORT_REGISTRY: tuple[object, ...] = ( - _BorderedCodeBlock, - _ReportTableElement, - _escape_code_span_pipes, - _get_md_parser, - _loosen_tight_ordered_lists, - _markdown_commit_boundary_cached, - _markdown_style_overrides, - _normalize_markdown_tables, - _normalize_space_aligned_report_blocks, - _normalize_table_block, - _parse_aligned_field_line, - _repair_crammed_markdown_tables, - _simplify_markdown_report_icons, - _unwrap_fenced_markdown_tables, - loosen_tight_ordered_lists, - normalize_markdown_tables, - normalize_space_aligned_report_blocks, - normalize_table_block, - parse_aligned_field_line, - repair_crammed_markdown_tables, - simplify_markdown_report_icons, - unwrap_fenced_markdown_tables, -) diff --git a/tests/ui_and_conv/test_theme_contract.py b/tests/ui_and_conv/test_theme_contract.py index f94f3e7e..bb3bb156 100644 --- a/tests/ui_and_conv/test_theme_contract.py +++ b/tests/ui_and_conv/test_theme_contract.py @@ -23,7 +23,6 @@ _HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}") _THEME_PKG = Path(__file__).resolve().parents[2] / "src" / "pythinker_code" / "ui" / "theme" -_PALETTE_ONLY_FILES = {_THEME_PKG / "palettes.py", _THEME_PKG / "adapters" / "task_browser.py"} @pytest.fixture(autouse=True)