diff --git a/CHANGELOG.md b/CHANGELOG.md index 088f42bd..08487ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **designer-skill MCP bridge.** Bundled a `designer-skill` stub skill that routes frontend work to the connected designer-skill MCP tools instead of failing ReadSkill; plugin-style names like `designer-skill:designer-skill` resolve correctly, and ReadSkill falls back to a generic MCP bridge (any user-configured server name) when only the MCP server is connected. +- **Always-on best practices.** New `best_practices_always` config option folds the full `/best-practices` engineering guidance into the root session's system prompt at startup, so the guardrails apply to every new session without running the command. Default off. +- **Smarter multi-edit errors.** A `StrReplaceFile` batch that fails schema validation (e.g. edit entries collapsed by a streaming glitch) now returns a precise, actionable error naming the bad entries and steering toward single-edit calls, instead of a wall of validation errors. Valid edits are never partially applied. +- **Agent guardrails.** The default system prompt now requires absence claims ("no em-dashes", "no leftover debug", "matches the source") to be backed by an actual zero-hit scan, and to re-ask rather than act on a self-authored reading of a non-responsive clarifying answer. +- **`/recap on|off`.** Toggle turn recaps from the recap command (mirrors `/settings recap(s)`), with a grey inline autosuggest reflecting the current state. +- **Recap hygiene.** Session recaps strip `` blocks so injected harness context no longer leaks into one-line recaps. +- **Login selector polish.** Configured `/login` providers render with distinct success/state styling; the background working indicator uses the braille spinner, and working tips wrap with a hanging indent under the verb. +- **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. +- **Readable diff context.** Unchanged context lines in file-edit diff snippets now render in the normal body-text color instead of muted grey, so edited-file previews are easier to read; added/removed lines are unchanged. + ## 0.42.0 (2026-06-12) - **Adversarial branch review hardening.** A multi-agent review pass confirmed and fixed: committed-but-clean isolation worktrees are now retained (commits ahead of the creation base count as changes — they were previously orphaned on cleanup); foreground shell commands and relative-path file edits now resolve against the agent's work dir, so worktree isolation actually binds them (host exec gained a `cwd` argument); safe mode now also disables the read-only-command prompt elision; locally parallel-safe MCP/tools run in parallel in the same-step gate; worktree add/remove serializes per repo. Behavior notes: same-step tool calls without `supports_parallel` now serialize deterministically (previously fully concurrent), and text-mode error diagnostics moved to stderr — capture `2>&1` or use `--output-format stream-json` if you scraped stdout. diff --git a/src/pythinker_code/agents/default/system.md b/src/pythinker_code/agents/default/system.md index 477db54e..8fde3f50 100644 --- a/src/pythinker_code/agents/default/system.md +++ b/src/pythinker_code/agents/default/system.md @@ -23,7 +23,7 @@ Eight rules that override convenience, speed, and every other instruction in thi 1. **Read before write.** Never edit a file you have not read this session; confirm the exact lines you are about to modify still match what you read. 2. **Complete code only.** Never write placeholders, stubs, `TODO: implement`, elided bodies, or "rest of the file unchanged" markers into files. If a change is too large for one step, split the work — never abridge the code. (Genuine `TODO:` notes for real technical debt are fine.) -3. **Evidence before claims.** Every "done", "fixed", or "works" names the command you ran and the result you observed. Verification means a passing test, a working repro, or a deterministic command that confirms the intended behavior — compiling or type-checking alone is not verification. This definition is canonical: it is what "verify" means everywhere in this prompt. +3. **Evidence before claims.** Every "done", "fixed", or "works" names the command you ran and the result you observed. Verification means a passing test, a working repro, or a deterministic command that confirms the intended behavior — compiling or type-checking alone is not verification. This definition is canonical: it is what "verify" means everywhere in this prompt. A claim that something is *absent* — no banned strings, no em-dashes, no leftover debug instrumentation, no TODOs, output matches the source — is only true after a scan that returned zero hits; never assert absence from memory. 4. **Re-verify after every edit.** An edit invalidates all prior verification; re-run the smallest check that proves the change is sound before building on top of it. 5. **Honest failure.** When verification fails, report the failing output verbatim under **BLOCKERS**. Never weaken an assertion, skip a test, widen a tolerance, swallow an error, or silently narrow scope to get to green. 6. **Match the codebase.** Existing style, granularity, naming, and idioms beat your preferences. A correct change that fights the codebase's conventions is not done. @@ -57,7 +57,7 @@ State multi-step plans inline as `Step → verify: check`; substantial tasks kee **Report** with `path:line` references over pasted blocks, concise findings, and explicit residual risk — unverified assumptions, untested paths, recommended follow-ups, and unrelated issues noticed but not touched. -**Ask vs. act.** Act without asking when intent is clear, the change is reversible, and it is in scope. Ask one focused question — before implementation, never after mistakes — when interpretations genuinely diverge, an action is irreversible or destructive, credentials are needed, requirements conflict, or scope grows beyond the request. Never ask what a tool call can answer. +**Ask vs. act.** Act without asking when intent is clear, the change is reversible, and it is in scope. Ask one focused question — before implementation, never after mistakes — when interpretations genuinely diverge, an action is irreversible or destructive, credentials are needed, requirements conflict, or scope grows beyond the request. Never ask what a tool call can answer. If an answer to a clarifying question does not actually resolve the ambiguity, say so and re-ask with your default stated — never act on a self-authored interpretation of a non-answer. **Steering.** If the user interjects or redirects mid-task, stop, reconcile the new instruction with the current plan, update the todos, then continue. diff --git a/src/pythinker_code/app.py b/src/pythinker_code/app.py index 5e9f1090..fcc0645d 100644 --- a/src/pythinker_code/app.py +++ b/src/pythinker_code/app.py @@ -352,6 +352,14 @@ async def create( ) _phase_timings_ms["mcp_ms"] = int((time.monotonic() - _phase_t) * 1000) + if runtime.config.best_practices_always: + from pythinker_code.prompts import apply_always_on_best_practices + + agent = dataclasses.replace( + agent, + system_prompt=apply_always_on_best_practices(agent.system_prompt, enabled=True), + ) + if startup_progress is not None: startup_progress("Restoring conversation...") context = Context(session.context_file) diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index ee3a8c95..6c05ec52 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -1203,6 +1203,14 @@ async def _reload_loop(session_id: str | None) -> tuple[str | None, int]: # the most recent _run() call, which may have failed before returning. # last_session is from a *previous* iteration and must not be touched. if _latest_created_session is not None: + with contextlib.suppress(Exception): + from pythinker_code.scratchpad import cleanup_session_scratch + + await cleanup_session_scratch( + _latest_created_session.work_dir, + session_id=_latest_created_session.id, + session_title=_latest_created_session.title, + ) _print_resume_hint(_latest_created_session) if _latest_created_session.is_empty(): with contextlib.suppress(Exception): diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 75c666d5..74516e67 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -1020,6 +1020,14 @@ class Config(BaseModel): ), ) default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode") + best_practices_always: bool = Field( + default=False, + description=( + "When true, fold the full /best-practices engineering guidance into the root " + "session's system prompt at startup, so the guardrails apply without running " + "/best-practices manually. Applies to new sessions; costs context tokens each session." + ), + ) ask_user_question_policy: Literal["always", "ask_except_auto", "never", "auto_deliberate"] = ( Field( default="ask_except_auto", diff --git a/src/pythinker_code/project_memory.py b/src/pythinker_code/project_memory.py index 399394e8..d20dc900 100644 --- a/src/pythinker_code/project_memory.py +++ b/src/pythinker_code/project_memory.py @@ -35,8 +35,8 @@ from pythinker_code.soul.pythinkersoul import PythinkerSoul ENTRY_DELIMITER = "\n§\n" -MEMORY_CHAR_LIMIT = 2200 -USER_CHAR_LIMIT = 1375 +MEMORY_CHAR_LIMIT = 5000 +USER_CHAR_LIMIT = 2500 INJECTION_BUDGET_BYTES = 8 * 1024 _JOURNAL_MAX_ENTRIES = 100 @@ -92,6 +92,9 @@ async def project_key(work_dir: HostPath, *, git_runner: GitRunner | None = None class MemoryOpResult: ok: bool message: str + # True only when the op failed because the store is at capacity. Lets the UI + # layer attach user-facing guidance without string-matching the message. + full: bool = False class ProjectMemoryStore: @@ -130,6 +133,31 @@ def _filename(self, target: Target) -> str: def _char_limit(self, target: Target) -> int: return self._user_limit if target == "user" else self._memory_limit + @staticmethod + def _used(entries: list[str]) -> int: + return len(ENTRY_DELIMITER.join(entries)) + + @staticmethod + def _append_overhead(entries: list[str]) -> int: + """Chars an appended entry costs beyond its own text (the joining delimiter).""" + return len(ENTRY_DELIMITER) if entries else 0 + + @staticmethod + def _inventory(entries: list[str]) -> str: + """Compact, model-readable listing: index, size, and a one-line preview. + + The preview doubles as a copy-paste ``old_text`` substring for remove/replace, + turning a space-rejection into a guided one-step fix instead of a guessing game. + """ + if not entries: + return " (none)" + lines: list[str] = [] + for i, entry in enumerate(entries): + preview = " ".join(entry.split()) + clipped = preview[:60] + ("…" if len(preview) > 60 else "") + lines.append(f" [{i}] {len(entry)} chars — {clipped}") + return "\n".join(lines) + async def _path_for(self, target: Target) -> Path: root = await self._ensure_dir() return root / "memory" / self._filename(target) @@ -219,13 +247,19 @@ async def add(self, target: Target, content: str) -> MemoryOpResult: if content in entries: return MemoryOpResult(True, "Entry already exists (no duplicate added).") limit = self._char_limit(target) - new_total = len(ENTRY_DELIMITER.join([*entries, content])) - if new_total > limit: - current = len(ENTRY_DELIMITER.join(entries)) + if len(ENTRY_DELIMITER.join([*entries, content])) > limit: + used = self._used(entries) + overhead = self._append_overhead(entries) + free = max(0, limit - used - overhead) + need = len(content) + overhead return MemoryOpResult( False, - f"Memory at {current}/{limit} chars; this entry ({len(content)}) " - "exceeds the limit. Replace or remove entries first.", + f"Not enough room: this entry needs {need} chars " + f"(content {len(content)} + {overhead} separator), but only {free} free " + f"({used}/{limit} used). Remove or replace an entry to free space, " + f"or shorten this entry to ≤{free} chars.\n" + f"Current entries:\n{self._inventory(entries)}", + full=True, ) await self._write_entries(target, [*entries, content]) return MemoryOpResult(True, "Entry added.") @@ -265,8 +299,16 @@ async def replace(self, target: Target, old_text: str, new_content: str) -> Memo limit = self._char_limit(target) candidate = list(entries) candidate[idx] = new_content - if len(ENTRY_DELIMITER.join(candidate)) > limit: - return MemoryOpResult(False, f"Replacement would exceed the {limit}-char limit.") + projected = self._used(candidate) + if projected > limit: + over = projected - limit + return MemoryOpResult( + False, + f"Replacement too large by {over} chars: result would be " + f"{projected}/{limit}. Shorten the new text by ≥{over} chars, or remove " + f"another entry first.\nCurrent entries:\n{self._inventory(entries)}", + full=True, + ) await self._write_entries(target, candidate) return MemoryOpResult(True, "Entry replaced.") @@ -289,6 +331,31 @@ async def remove(self, target: Target, old_text: str) -> MemoryOpResult: await self._write_entries(target, entries) return MemoryOpResult(True, "Entry removed.") + async def capacity(self, target: Target) -> tuple[int, int, int]: + """Return ``(used, limit, free)`` chars for ``target`` (free accounts for the + delimiter a new entry would cost). Used by the UI to show/explain capacity.""" + entries = await self.read_entries(target) + limit = self._char_limit(target) + used = self._used(entries) + free = max(0, limit - used - self._append_overhead(entries)) + return used, limit, free + + async def status(self, target: Target) -> str: + """Read-only capacity + inventory snapshot for mid-session introspection. + + Lets the agent see what is stored, at what size, and exactly how much room + is free before attempting a write — so it can remove/consolidate instead of + repeatedly retrying an over-budget add. + """ + entries = await self.read_entries(target) + limit = self._char_limit(target) + used = self._used(entries) + free = max(0, limit - used - self._append_overhead(entries)) + return ( + f"{self._filename(target)}: {used}/{limit} chars across {len(entries)} " + f"entries; {free} free for a new entry.\nCurrent entries:\n{self._inventory(entries)}" + ) + async def append_journal(self, recap: str) -> MemoryOpResult: """Prepend one stable session recap to ``JOURNAL.md`` if it is new.""" recap = recap.strip() diff --git a/src/pythinker_code/prompts/__init__.py b/src/pythinker_code/prompts/__init__.py index b4965819..eb66c35a 100644 --- a/src/pythinker_code/prompts/__init__.py +++ b/src/pythinker_code/prompts/__init__.py @@ -9,3 +9,18 @@ GOAL_SET = (Path(__file__).parent / "goal_set.md").read_text(encoding="utf-8") GOAL_CONTINUATION = (Path(__file__).parent / "goal_continuation.md").read_text(encoding="utf-8") GOAL_WRAP_UP = (Path(__file__).parent / "goal_wrap_up.md").read_text(encoding="utf-8") + + +def apply_always_on_best_practices(system_prompt: str, *, enabled: bool) -> str: + """Append the full best-practices guidance to a system prompt when enabled. + + The `/best-practices` profile is normally opt-in per session. When the + ``best_practices_always`` config flag is set, the root session folds it into + the system prompt at startup so the guardrails apply without running the + command. The profile's opening line is phrased for the manual command ("The + user ran ``/best-practices``."), so strip that lead-in for the always-on path. + """ + if not enabled: + return system_prompt + guidance = BEST_PRACTICES.replace("The user ran `/best-practices`. ", "", 1) + return f"{system_prompt}\n\n{guidance}" diff --git a/src/pythinker_code/session_recap.py b/src/pythinker_code/session_recap.py index 6a0eec5b..06e9d738 100644 --- a/src/pythinker_code/session_recap.py +++ b/src/pythinker_code/session_recap.py @@ -276,11 +276,13 @@ def _last_substantive_thread(items: list[SessionRecapItem]) -> str: re.IGNORECASE, ) _PARENTHETICAL_RE = re.compile(r"\([^)]*\)") +_SYSTEM_REMINDER_BLOCK_RE = re.compile(r".*?", re.DOTALL) def _recap_source_text(text: str) -> str: """Return one-line recap input with bulky structured blocks removed.""" - without_fences = _strip_fenced_blocks(text) + without_reminders = _SYSTEM_REMINDER_BLOCK_RE.sub("", text) + without_fences = _strip_fenced_blocks(without_reminders) without_tables = _strip_markdown_tables(without_fences) without_structure = _strip_markdown_structure(without_tables) without_ticks = without_structure.replace("`", "") diff --git a/src/pythinker_code/skills/designer-skill/SKILL.md b/src/pythinker_code/skills/designer-skill/SKILL.md new file mode 100644 index 00000000..ff3f5bf8 --- /dev/null +++ b/src/pythinker_code/skills/designer-skill/SKILL.md @@ -0,0 +1,43 @@ +--- +name: designer-skill +description: Prescriptive frontend design guidance via the designer-skill MCP server. Use when the user asks to use designer-skill, improve UI/UX, run the anti-slop ship gate, apply a design system, or enhance pages/components with MCP-backed design references — especially for Pythinker docs and marketing surfaces with DESIGN.md/PRODUCT.md. +--- + +# designer-skill (MCP) + +`designer-skill` is **not** a filesystem workflow skill. It is delivered by the connected **designer-skill MCP server**. After reading this stub, call the MCP tools below — do **not** call `ReadSkill` again for the same name. + +## Required MCP tools + +Call these by their registered tool keys (`mcp__designer-skill__`): + +| Step | Tool | Purpose | +| --- | --- | --- | +| 1 | `get_design_system` | Session preflight, precedence rules, routing map, ship-gate overview | +| 2 | `get_reference` | Load the specific reference file the task needs | +| 3 | `dispatch_intent` | Route ambiguous design requests to the right reference/workflow | +| 4 | `apply_designer` | Apply prescriptive design moves to the scoped surface | +| 5 | `anti_slop_checklist` | Mandatory ship gate before declaring frontend work done | + +If a tool is missing, check `/mcp` — the server may still be connecting. + +## Session preflight + +Before editing UI: + +1. Read project design sources when present (`DESIGN.md`, `PRODUCT.md`, or equivalent). +2. Call `get_design_system` first. +3. Pull only the references the scoped surface needs via `get_reference`. +4. Implement changes; run `anti_slop_checklist` before finishing. + +## Common aliases + +These names refer to the same MCP integration — always use MCP tools, not `ReadSkill`: + +- `designer-skill` +- `designer-skill:designer-skill` (plugin-style slash autocomplete) +- "use designer-skill mcp" / "designer mcp" + +## When MCP is unavailable + +If `/mcp` shows designer-skill disconnected, run `pythinker mcp` to configure/auth it. Do not substitute ad-hoc design advice for the MCP workflow when the user explicitly requested designer-skill. diff --git a/src/pythinker_code/soul/slash.py b/src/pythinker_code/soul/slash.py index 4a768f89..5475ccae 100644 --- a/src/pythinker_code/soul/slash.py +++ b/src/pythinker_code/soul/slash.py @@ -9,6 +9,7 @@ from pythinker_host.path import HostPath import pythinker_code.prompts as prompts +from pythinker_code.config import ConfigError, load_config, save_config from pythinker_code.soul import wire_send from pythinker_code.soul.agent import load_agents_md from pythinker_code.soul.context import Context @@ -64,9 +65,37 @@ async def init(soul: PythinkerSoul, args: str) -> None: @registry.command async def recap(soul: PythinkerSoul, args: str) -> None: - """Recap Pythinker sessions. Usage: /recap [today|yesterday|week|YYYY-MM-DD]""" + """Recap Pythinker sessions. Usage: /recap [on|off|today|yesterday|week|YYYY-MM-DD]""" from pythinker_code.session_recap import build_pythinker_recap + mode = args.strip().lower() + if mode in {"on", "off"}: + enabled = mode == "on" + if soul.runtime.config.tui.turn_recaps == enabled: + wire_send(TextPart(text=f"Turn recaps already {mode}.")) + return + soul.runtime.config.tui.turn_recaps = enabled + config_file = soul.runtime.config.source_file + if config_file is None: + wire_send( + TextPart( + text=( + f"Turn recaps {mode} for the current session only " + "(no config file available to persist)." + ) + ) + ) + return + try: + config_for_save = load_config(config_file) + config_for_save.tui.turn_recaps = enabled + save_config(config_for_save, config_file) + except (ConfigError, OSError) as exc: + wire_send(TextPart(text=f"Failed to save recap setting: {exc}")) + return + wire_send(TextPart(text=f"Turn recaps {mode}.")) + return + try: text = await build_pythinker_recap(soul.runtime.work_dir, args) except ValueError as exc: diff --git a/src/pythinker_code/tools/file/replace.py b/src/pythinker_code/tools/file/replace.py index 49c8a987..c0354893 100644 --- a/src/pythinker_code/tools/file/replace.py +++ b/src/pythinker_code/tools/file/replace.py @@ -4,8 +4,9 @@ from pathlib import Path from typing import Any, cast, override -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, ValidationError, model_validator from pythinker_core.tooling import CallableTool2, ToolError, ToolReturnValue +from pythinker_core.utils.typing import JsonType from pythinker_host.path import HostPath from pythinker_code.file_restore import create_file_restore_point @@ -107,6 +108,51 @@ def _normalize_common_edit_shapes(cls, data: Any) -> Any: return values +def _malformed_edit_batch_error(arguments: Any) -> ToolError | None: + """Actionable error when a multi-edit batch fails schema validation. + + A streaming or serialization glitch can collapse a list of edits into entries + that no longer match the ``{old, new}`` shape (e.g. items degrading to + ``{"$text": ...}``). Pydantic then reports a wall of per-arm, per-entry errors + that the model struggles to act on. Instead, name the specific bad entries and + steer it to resend each edit as its own call — which is the reliable recovery. + + Returns ``None`` for anything that is not a malformed *list* batch, so the + caller falls back to the default validation error. + """ + if not isinstance(arguments, dict): + return None + data = cast(dict[str, Any], arguments) + raw = data.get("edit", data.get("edits")) + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(raw, list): + return None + bad: list[int] = [] + for index, item in enumerate(cast(list[Any], raw), start=1): + # Probe each entry through the public schema (the before-validator handles + # alias/shape drift); an entry that fails on its own is genuinely malformed. + try: + Params.model_validate({"path": "_probe_", "edit": item}) + except ValidationError: + bad.append(index) + if not bad: + return None + positions = ", ".join(str(i) for i in bad) + return ToolError( + message=( + f"Malformed `edit` batch: entries {positions} are not valid " + "{{old, new}} objects (likely a serialization glitch). Resend the edits — " + "send each edit as its own StrReplaceFile call with explicit `old` and " + "`new` strings. Do not retry the same batch unchanged." + ), + brief="Malformed edit batch", + ) + + def _crlf_translated_edit(content: str, edit: Edit) -> Edit | None: """CRLF-translated variant of *edit* when the file is CRLF and the LF needle missed. @@ -276,6 +322,16 @@ def _apply_edit(self, content: str, edit: Edit) -> str: else: return content.replace(edit.old, edit.new, 1) + @override + async def call(self, arguments: JsonType) -> ToolReturnValue: + from pythinker_core.tooling.error import ToolValidateError + + try: + params = self.params.model_validate(arguments) + except ValidationError as exc: + return _malformed_edit_batch_error(arguments) or ToolValidateError(str(exc)) + return await self.__call__(params) + @override async def __call__(self, params: Params) -> ToolReturnValue: if not params.path: diff --git a/src/pythinker_code/tools/memory/__init__.py b/src/pythinker_code/tools/memory/__init__.py index e1a03667..23773f2c 100644 --- a/src/pythinker_code/tools/memory/__init__.py +++ b/src/pythinker_code/tools/memory/__init__.py @@ -10,7 +10,7 @@ class Params(BaseModel): - action: Literal["add", "replace", "remove"] = Field(description="The memory operation.") + action: Literal["add", "replace", "remove", "list"] = Field(description="The memory operation.") target: Literal["memory", "user"] = Field(description="Which store to write.") content: str | None = Field(default=None, description="Entry text for add/replace.") old_text: str | None = Field( @@ -43,6 +43,10 @@ async def __call__(self, params: Params) -> ToolReturnValue: message="old_text is required for replace/remove.", brief="memory: no old_text" ) + if params.action == "list": + output = await self._store.status(params.target) + return ToolOk(output=output, message=output, brief="list") + if params.action == "add": result = await self._store.add(params.target, params.content or "") elif params.action == "replace": @@ -53,7 +57,18 @@ async def __call__(self, params: Params) -> ToolReturnValue: result = await self._store.remove(params.target, params.old_text or "") if not result.ok: - return ToolError(message=result.message, brief="memory: rejected") + message = result.message + if result.full: + # Educate the human reading the tool card: what happened, that nothing + # was lost, and the real ways to fix it. Kept short and command-accurate. + message += ( + "\n\nProject memory for this repo is full — nothing was lost, and your " + "task can continue. To free space: ask me to merge or drop stale entries, " + "run /memory to see what's stored, or edit MEMORY.md / USER.md directly. " + "Memory is for durable facts only, so occasional pruning is expected." + ) + brief = "memory: full" if result.full else "memory: rejected" + return ToolError(message=message, brief=brief) rearm = getattr(self._runtime, "rearm_injection", None) if rearm is not None: rearm("project_memory") diff --git a/src/pythinker_code/tools/memory/memory.md b/src/pythinker_code/tools/memory/memory.md index 84837d44..6a1af66e 100644 --- a/src/pythinker_code/tools/memory/memory.md +++ b/src/pythinker_code/tools/memory/memory.md @@ -18,3 +18,14 @@ ACTIONS: - `add`: append a new entry (requires `content`). - `replace`: update an entry (`old_text` = a unique substring of the target entry; `content` = new text). - `remove`: delete an entry (`old_text` = a unique substring of the target entry). +- `list`: show current entries with their sizes and how much room is free (read-only). + +WHEN A WRITE IS REJECTED FOR SPACE: +The error reports the exact free budget and lists existing entries. Do NOT retry the +same write with a slightly shorter entry — instead `remove` or `replace` a stale or +redundant entry to free room, or consolidate two entries into one. Use `list` first if +unsure what is stored. + +Memory writes are best-effort housekeeping: if room cannot be freed in one or two +steps, drop the write and continue the user's actual task. Never loop on a rejected +memory write. diff --git a/src/pythinker_code/tools/skill/__init__.py b/src/pythinker_code/tools/skill/__init__.py index 7032b1b7..5fb25852 100644 --- a/src/pythinker_code/tools/skill/__init__.py +++ b/src/pythinker_code/tools/skill/__init__.py @@ -4,11 +4,13 @@ from pydantic import BaseModel, Field from pythinker_core.tooling import CallableTool2, ToolError, ToolReturnValue -from pythinker_code.skill import ( - normalize_skill_name, - read_skill_text_with_local_specialization, -) +from pythinker_code.skill import read_skill_text_with_local_specialization from pythinker_code.soul.agent import Runtime +from pythinker_code.tools.skill._mcp_bridge import ( + find_mcp_server_for_skill_name, + mcp_skill_bridge_content, + skill_lookup_keys, +) from pythinker_code.tools.utils import load_desc NAME = "ReadSkill" @@ -32,11 +34,42 @@ async def __call__(self, params: Params) -> ToolReturnValue: if not skill_name: return ToolError(message="Skill name is required.", brief="Missing skill name") - skill = self._runtime.skills.get(normalize_skill_name(skill_name)) + lookup_keys = skill_lookup_keys(skill_name) + skill = None + for key in lookup_keys: + skill = self._runtime.skills.get(key) + if skill is not None: + break + + mcp_match = find_mcp_server_for_skill_name(skill_name, self._runtime.mcp_tools) + if skill is None: + if mcp_match is not None: + server, tools = mcp_match + content = mcp_skill_bridge_content(server, tools) + return ToolReturnValue( + is_error=False, + output=f"skill: {server} (MCP bridge)\n\n{content}", + message=f"Resolved {skill_name} to MCP server {server}.", + display=[], + ) + available = ", ".join(sorted(s.name for s in self._runtime.skills.values())) or "(none)" + mcp_hint = "" + if mcp_match is None and self._runtime.mcp_tools: + servers = sorted( + { + key.split("__", 2)[1] + for key in self._runtime.mcp_tools + if key.startswith("mcp__") and key.count("__") >= 2 + } + ) + if servers: + mcp_hint = f" Connected MCP servers: {', '.join(servers)}." return ToolError( - message=f"Skill not found: {skill_name}. Available skills: {available}", + message=( + f"Skill not found: {skill_name}. Available skills: {available}.{mcp_hint}" + ), brief="Skill not found", ) diff --git a/src/pythinker_code/tools/skill/_mcp_bridge.py b/src/pythinker_code/tools/skill/_mcp_bridge.py new file mode 100644 index 00000000..fdf829f5 --- /dev/null +++ b/src/pythinker_code/tools/skill/_mcp_bridge.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from pythinker_code.skill import normalize_skill_name + + +def skill_lookup_keys(skill_name: str) -> tuple[str, ...]: + """Return normalized lookup keys, including plugin-style suffixes.""" + raw = skill_name.strip() + if not raw: + return () + keys: list[str] = [raw] + if ":" in raw: + suffix = raw.rsplit(":", 1)[-1].strip() + if suffix and suffix not in keys: + keys.append(suffix) + prefix = raw.split(":", 1)[0].strip() + if prefix and prefix not in keys: + keys.append(prefix) + return tuple(normalize_skill_name(key) for key in keys) + + +def _index_mcp_servers(mcp_tools: Mapping[str, object]) -> dict[str, list[str]]: + servers: dict[str, list[str]] = {} + for key in mcp_tools: + if not key.startswith("mcp__"): + continue + parts = key.split("__", 2) + if len(parts) != 3: + continue + _, server, tool = parts + servers.setdefault(server, []).append(tool) + return {server: sorted(tools) for server, tools in servers.items()} + + +def find_mcp_server_for_skill_name( + skill_name: str, + mcp_tools: Mapping[str, object], +) -> tuple[str, list[str]] | None: + """Match a skill name (or plugin alias) to a connected MCP server.""" + servers = _index_mcp_servers(mcp_tools) + if not servers: + return None + + candidates: list[str] = [] + raw = skill_name.strip() + if raw: + candidates.append(raw) + if ":" in raw: + suffix = raw.rsplit(":", 1)[-1].strip() + if suffix: + candidates.append(suffix) + prefix = raw.split(":", 1)[0].strip() + if prefix: + candidates.append(prefix) + + seen: set[str] = set() + for candidate in candidates: + norm = normalize_skill_name(candidate) + if norm in seen: + continue + seen.add(norm) + for server, tools in servers.items(): + if normalize_skill_name(server) == norm: + return server, tools + return None + + +def mcp_skill_bridge_content(server: str, tools: list[str]) -> str: + """Instructions when a name maps to MCP tools but no filesystem skill exists.""" + tool_lines = "\n".join(f"- `mcp__{server}__{tool}`" for tool in tools) + return f"""# MCP skill bridge: {server} + +**Do not call ReadSkill again for `{server}`.** This name is served by a connected +MCP server, not a filesystem SKILL.md. + +## Connected MCP tools +{tool_lines} + +## How to use +1. Invoke the MCP tools above directly — they are already registered in your toolset. +2. Read each tool's description to choose the right entry point for the user's request. +3. If a tool is missing, check `/mcp` — the server may still be connecting or need + auth (`pythinker mcp auth {server}`). + +User-added MCP servers work the same way: configure them in `mcp.json`, then call +`mcp____` by name. +""" diff --git a/src/pythinker_code/tools/skill/description.md b/src/pythinker_code/tools/skill/description.md index 86afae69..8e5f5155 100644 --- a/src/pythinker_code/tools/skill/description.md +++ b/src/pythinker_code/tools/skill/description.md @@ -5,6 +5,7 @@ Read the full instructions for an available skill by name. **When NOT to use:** - For a one-off task with no matching skill — do not read skills speculatively just to fill context. +- For a connected MCP server — invoke its `mcp____` tools directly. If you mistakenly call ReadSkill with a server name (e.g. `tavily`, `designer-skill`), it resolves to an MCP bridge listing that server's tools when no filesystem skill exists. **Tips:** - If a skill `` has a `-local` companion, the returned content includes the local specialization after the core skill; apply the local part last. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 4bc2267b..90484086 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -934,6 +934,11 @@ def _bg_task_counts() -> BgTaskCounts: if isinstance(self.soul, PythinkerSoul) else "" ), + turn_recaps_provider=lambda: ( + self.soul.runtime.config.tui.turn_recaps + if isinstance(self.soul, PythinkerSoul) + else False + ), plan_mode_toggle_callback=_plan_mode_toggle, thinking_effort_cycle_callback=_thinking_effort_cycle, history_enabled=( diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index de593c0a..7e3fc54c 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -72,11 +72,10 @@ from pythinker_code.ui.shell import placeholders as prompt_placeholders from pythinker_code.ui.shell.console import console from pythinker_code.ui.shell.glyphs import ( - TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_PROMPT_MARKER, TRANSCRIPT_TOOL_GUTTER, ) -from pythinker_code.ui.shell.motion import blink_visible, shimmer_prompt_fragments +from pythinker_code.ui.shell.motion import active_marker_frame, shimmer_prompt_fragments from pythinker_code.ui.shell.placeholders import ( PromptPlaceholderManager, normalize_pasted_text, @@ -316,6 +315,10 @@ def get_line(lineno: int) -> StyleAndTextTuples: return get_line +def _no_exact_suggestions() -> dict[str, str]: + return {} + + class SlashCommandAutoSuggest(AutoSuggest): """Inline ghost-text completion for a partially typed slash command. @@ -328,8 +331,14 @@ class SlashCommandAutoSuggest(AutoSuggest): plumbing; the Tab binding is added in CustomPromptSession. """ - def __init__(self, known_names: Callable[[], frozenset[str]]) -> None: + def __init__( + self, + known_names: Callable[[], frozenset[str]], + *, + exact_suggestions: Callable[[], dict[str, str]] | None = None, + ) -> None: self._known_names = known_names + self._exact_suggestions = exact_suggestions or _no_exact_suggestions @override def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: @@ -338,6 +347,9 @@ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | Non return None typed = token[1:] typed_lower = typed.lower() + exact = self._exact_suggestions().get(typed_lower) + if exact is not None and typed_lower in self._known_names(): + return Suggestion(exact) matches = sorted( name for name in self._known_names() @@ -369,12 +381,6 @@ def __init__( self._annotate_meta = annotate_meta self._command_scope = command_scope self._is_task_running = is_task_running - self._command_lookup: dict[str, list[SlashCommand[Any]]] = {} - - for cmd in self._available_commands: - self._command_lookup.setdefault(cmd.name, []).append(cmd) - for alias in cmd.aliases: - self._command_lookup.setdefault(alias, []).append(cmd) @staticmethod def should_complete(document: Document) -> bool: @@ -411,18 +417,34 @@ def emit(cmd: SlashCommand[Any]) -> Iterable[Completion]: yield from emit(cmd) return - exact: list[SlashCommand[Any]] = [] - prefix: list[SlashCommand[Any]] = [] - for candidate, commands in self._command_lookup.items(): - candidate_lower = candidate.lower() - if candidate_lower == typed_lower: - exact.extend(commands) - elif candidate_lower.startswith(typed_lower): - prefix.extend(commands) + def match_tier(cmd: SlashCommand[Any]) -> int | None: + """Lower tier = stronger match. Name matches rank above alias matches + so typing toward a command name (e.g. ``/report`` → ``/reports``) + wins over a command that only matches via an exact alias.""" + name_lower = cmd.name.lower() + if name_lower == typed_lower: + return 0 + if name_lower.startswith(typed_lower): + return 1 + alias_prefix = False + for alias in cmd.aliases: + alias_lower = alias.lower() + if alias_lower == typed_lower: + return 2 + if alias_lower.startswith(typed_lower): + alias_prefix = True + return 3 if alias_prefix else None + + # Rank by (match tier, command-name length, name): the closest, shortest + # command name surfaces first within each tier. + matched: list[tuple[int, int, str, SlashCommand[Any]]] = [] + for cmd in self._available_commands: + tier = match_tier(cmd) + if tier is not None: + matched.append((tier, len(cmd.name), cmd.name, cmd)) + matched.sort(key=lambda item: (item[0], item[1], item[2])) - for cmd in exact: - yield from emit(cmd) - for cmd in prefix: + for _, _, _, cmd in matched: yield from emit(cmd) def _disabled_during_task(self, cmd: SlashCommand[Any]) -> bool: @@ -2058,6 +2080,7 @@ def __init__( agent_mode_slash_commands: Sequence[SlashCommand[Any]] = (), shell_mode_slash_commands: Sequence[SlashCommand[Any]], editor_command_provider: Callable[[], str] = lambda: "", + turn_recaps_provider: Callable[[], bool] = lambda: False, plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None, thinking_effort_cycle_callback: Callable[[], Awaitable[str | None]] | None = None, history_enabled: bool = True, @@ -2098,6 +2121,7 @@ def __init__( self._fast_refresh_provider = fast_refresh_provider self._background_task_count_provider = background_task_count_provider self._editor_command_provider = editor_command_provider + self._turn_recaps_provider = turn_recaps_provider self._plan_mode_toggle_callback = plan_mode_toggle_callback self._thinking_effort_cycle_callback = thinking_effort_cycle_callback self._model_capabilities = model_capabilities @@ -2171,7 +2195,8 @@ def __init__( self._shell_command_names if self._mode == PromptMode.SHELL else self._agent_command_names - ) + ), + exact_suggestions=self._exact_slash_suggestions, ) # Build key bindings @@ -2709,6 +2734,9 @@ def _thinking_prompt_prefix_style(self) -> str: """Keep the prompt marker on the normal prompt color, independent of thinking effort.""" return "class:compact-input.prompt" + def _exact_slash_suggestions(self) -> dict[str, str]: + return {"recap": " off" if self._turn_recaps_provider() else " on"} + def _uses_native_thinking(self) -> bool: return model_uses_native_thinking(getattr(self, "_model_capabilities", None)) @@ -3101,6 +3129,16 @@ def _render_agent_status(self, columns: int) -> FormattedText: if running is not None and isinstance(running, AgentStatusProvider): rendered = to_formatted_text(running.render_agent_status(columns)) if any(fragment for _, fragment, *_ in rendered): + # A blocking foreground TaskOutput card can be visible while the + # actual background agent is still running. If the live view does + # not expose a pinned tail for that state, keep the background + # verb spinner visible above the prompt instead of showing only + # the footer count. + if not self._render_pinned_status_tail(columns): + background = self._render_background_working_status(columns) + if background: + ensure_prompt_newline(rendered) + rendered.extend(background) # The prompt layer owns the gap below the agent stream: one blank # row under the spinner verb (the stream's tail) before the input, # mirroring the blank row above it inside the stream. @@ -3240,9 +3278,12 @@ def _render_background_working_status(self, columns: int) -> FormattedText: samples.clear() return FormattedText([]) now = time.monotonic() - if getattr(self, "_bg_status_started_at", None) is None: + started_at = getattr(self, "_bg_status_started_at", None) + if started_at is None: + started_at = now self._bg_status_started_at = now - frame = TRANSCRIPT_ACTIVE_MARKER if blink_visible(now) else " " + elapsed = max(0.0, now - started_at) + frame = active_marker_frame(elapsed) tokens = _get_tui_tokens() muted_style = f"fg:{tokens.muted}" if tokens.muted else "" frame_style = f"fg:{tokens.activity_spinner}" if tokens.activity_spinner else muted_style diff --git a/src/pythinker_code/ui/shell/selector.py b/src/pythinker_code/ui/shell/selector.py index 0c7097c7..56d21f06 100644 --- a/src/pythinker_code/ui/shell/selector.py +++ b/src/pythinker_code/ui/shell/selector.py @@ -54,6 +54,8 @@ class SelectorItem[T]: value: Returned by :func:`run_selector` when this item is chosen. label: Bold primary column, e.g. ``"dark"`` or ``"claude-opus-4-7"``. description: Optional muted secondary column shown to the right. + description_icon_style: Optional style for the first description character. + description_text_style: Optional style for the rest of the description. is_current: Marks the item as the active selection — pre-selected on open and labelled ``(current)`` in the description. """ @@ -61,6 +63,8 @@ class SelectorItem[T]: value: T label: str description: str = "" + description_icon_style: str = "" + description_text_style: str = "" is_current: bool = False @@ -139,11 +143,27 @@ def _format_item_line[T]( remaining = max(0, width - marker_width - cell_width(label) - gap_width) description = truncate_to_width(description, remaining, ellipsis="") pad = max(0, width - marker_width - cell_width(label) - gap_width - cell_width(description)) + description_fragments: StyleAndTextTuples + if item.description_icon_style and description: + icon_style = ( + f"{item.description_icon_style}.current" if is_selected else item.description_icon_style + ) + text_style = ( + f"{item.description_text_style}.current" + if is_selected and item.description_text_style + else item.description_text_style or meta_style + ) + description_fragments = [ + (icon_style, description[0]), + (text_style, description[1:]), + ] + else: + description_fragments = [(meta_style, description)] return [ (marker_style, marker), (label_style, label), (row_bg, gap), - (meta_style, description), + *description_fragments, (row_bg, " " * pad), ("", "\n"), ] diff --git a/src/pythinker_code/ui/shell/selectors/oauth.py b/src/pythinker_code/ui/shell/selectors/oauth.py index 620d9295..21a3dd13 100644 --- a/src/pythinker_code/ui/shell/selectors/oauth.py +++ b/src/pythinker_code/ui/shell/selectors/oauth.py @@ -36,20 +36,36 @@ def _format_status_indicator(status: OAuthProviderStatus) -> str: return f"✓ {status.label or 'configured'}" +def _status_icon_style(status: OAuthProviderStatus) -> str: + if status.source == "unconfigured": + return "" + return "class:slash-completion-menu.meta.success" + + +def _status_text_style(status: OAuthProviderStatus) -> str: + if status.source == "unconfigured": + return "" + return "class:slash-completion-menu.meta.warning" + + def _build_oauth_config( providers: list[OAuthProviderEntry], get_status: Callable[[str], OAuthProviderStatus], *, action: Literal["login", "logout"] = "login", ) -> SelectorConfig[str]: - items = [ - SelectorItem( - value=provider.id, - label=provider.name, - description=_format_status_indicator(get_status(provider.id)), + items: list[SelectorItem[str]] = [] + for provider in providers: + status = get_status(provider.id) + items.append( + SelectorItem( + value=provider.id, + label=provider.name, + description=_format_status_indicator(status), + description_icon_style=_status_icon_style(status), + description_text_style=_status_text_style(status), + ) ) - for provider in providers - ] title = "Select provider to log in" if action == "login" else "Select provider to log out" return SelectorConfig(title=title, items=items) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index a30b78a0..7843a3ba 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -1335,8 +1335,8 @@ def print_settings_table() -> None: print_settings_table() return mode_parts = mode.split() - if mode_parts and mode_parts[0] == "recaps": - value = mode.removeprefix("recaps").strip() + if mode_parts and mode_parts[0] in {"recap", "recaps"}: + value = " ".join(mode_parts[1:]).strip() if value not in {"on", "off"}: console.print(f"[{_t_set.warning}]Usage: /settings recaps on|off[/]") return @@ -1364,7 +1364,7 @@ def print_settings_table() -> None: console.print(f"[{_t_set.success}]Turn recaps {value}. Reloading...[/]") raise Reload(session_id=soul.runtime.session.id) if mode: - console.print(f"[{_t_set.warning}]Usage: /settings [show|recaps on|off][/]") + console.print(f"[{_t_set.warning}]Usage: /settings [show|recap(s) on|off][/]") return config_file = config.source_file @@ -2016,7 +2016,7 @@ async def show_memory(app: Shell, args: str): soul = ensure_pythinker_soul(app) if soul is None: return - from pythinker_code.project_memory import ProjectMemoryStore + from pythinker_code.project_memory import ProjectMemoryStore, Target store = ProjectMemoryStore(soul.runtime.work_dir) parts = args.split() @@ -2062,6 +2062,25 @@ async def show_memory(app: Shell, args: str): return console.print(block) + # Capacity line + education: surface how full each store is so the user + # understands the "memory full" rejection and how to act on it. + near_full = False + cap_parts: list[str] = [] + targets: tuple[tuple[Target, str], ...] = (("memory", "Project"), ("user", "User")) + for target, label in targets: + used, limit, free = await store.capacity(target) + cap_parts.append(f"{label} {used}/{limit} ({free} free)") + if limit and used / limit >= 0.85: + near_full = True + console.print(f"\n[dim]Capacity — {' · '.join(cap_parts)}[/dim]") + if near_full: + console.print( + "[yellow]Memory is nearly full.[/yellow] When full, new facts are rejected " + "(nothing is lost). To make room: ask the agent to merge or drop stale entries, " + "or edit MEMORY.md / USER.md directly. Memory holds only durable facts, so " + "occasional pruning is expected." + ) + @registry.command(name="update", aliases=["upgrade"]) async def update_command(app: Shell, args: str): diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 84b65f73..9e1d87c9 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -31,6 +31,7 @@ from pythinker_code.tools.display import DiffDisplayBlock, TodoDisplayBlock, TodoDisplayItem from pythinker_code.ui.shell.components.render_utils import ( cell_width, + render_message_response, sanitize_ansi, truncate_to_width, ) @@ -666,10 +667,9 @@ def _working_indicator(self) -> RenderableType: # During longer waits, surface a rotating CLI-feature tip under the verb. if elapsed < _WORKING_TIP_MIN_ELAPSED_S: return line - tip = Text(" ⎿ ", style=tui_rich_style("muted")) - tip.append("Tip: ", style=tui_rich_style("dim")) - tip.append(current_tip(now), style=tui_rich_style("dim")) - return Group(line, tip) + tip_content = Text("Tip: ", style=tui_rich_style("dim")) + tip_content.append(current_tip(now), style=tui_rich_style("dim")) + return Group(line, render_message_response(tip_content)) def _turn_token_rate(self, now: float) -> int | None: """Stable recent tokens/sec for the running turn, or None until known. diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index f426f667..ab9442fc 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -187,9 +187,13 @@ def _task_browser_style_light() -> PTKStyle: "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:#2B3A52", @@ -234,9 +238,13 @@ def _task_browser_style_light() -> PTKStyle: "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", @@ -630,7 +638,7 @@ class TuiTokens: tool_output="#D4D4D4", tool_diff_added="#81C784", tool_diff_removed="#E57373", - tool_diff_context="#B8B8B8", + tool_diff_context="", # match normal body text (terminal default fg), not muted grey bash_mode="#7BC97F", code_block_bg="#1f2030", ) @@ -666,7 +674,7 @@ class TuiTokens: tool_output="#666666", tool_diff_added="#2C7A39", tool_diff_removed="#C0392B", - tool_diff_context="#666666", + tool_diff_context="#213853", # match normal body text (theme `text`), not muted grey bash_mode="#2C7A39", code_block_bg="#f1f5f9", ) diff --git a/tests/core/test_best_practices_slash.py b/tests/core/test_best_practices_slash.py index 8cb96926..1978c331 100644 --- a/tests/core/test_best_practices_slash.py +++ b/tests/core/test_best_practices_slash.py @@ -52,6 +52,21 @@ def sent(monkeypatch: pytest.MonkeyPatch) -> list[TextPart]: return captured +def test_apply_always_on_best_practices_disabled_is_noop() -> None: + base = "Test system prompt." + assert prompts.apply_always_on_best_practices(base, enabled=False) == base + + +def test_apply_always_on_best_practices_appends_and_rephrases() -> None: + base = "Test system prompt." + result = prompts.apply_always_on_best_practices(base, enabled=True) + assert result.startswith(base + "\n\n") + # Full guidance is folded in... + assert "Engineering best practices are now in effect" in result + # ...but the manual-command lead-in is stripped for the always-on path. + assert "The user ran `/best-practices`." not in result + + def test_best_practices_prompt_asset_loads() -> None: assert "Engineering best practices" in prompts.BEST_PRACTICES # Core profile sections. diff --git a/tests/core/test_builtin_authoring_skills.py b/tests/core/test_builtin_authoring_skills.py index d3e4f5f2..9819182e 100644 --- a/tests/core/test_builtin_authoring_skills.py +++ b/tests/core/test_builtin_authoring_skills.py @@ -13,7 +13,7 @@ @pytest.mark.asyncio -@pytest.mark.parametrize("name", ["agent-creator", "customize-pythinker"]) +@pytest.mark.parametrize("name", ["agent-creator", "customize-pythinker", "designer-skill"]) async def test_authoring_skill_is_discovered_builtin(name: str) -> None: skills = await discover_skills( HostPath.unsafe_from_local_path(get_builtin_skills_dir()), scope="builtin" diff --git a/tests/core/test_config.py b/tests/core/test_config.py index fbcaa27e..e1e7add6 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -36,6 +36,7 @@ def test_default_config_dump(): "default_thinking_effort": None, "agent_execution_profile": "default", "default_yolo": False, + "best_practices_always": False, "ask_user_question_policy": "ask_except_auto", "auto_deliberate_destructive_actions": False, "default_plan_mode": False, diff --git a/tests/core/test_project_memory.py b/tests/core/test_project_memory.py index be753295..d77d4735 100644 --- a/tests/core/test_project_memory.py +++ b/tests/core/test_project_memory.py @@ -158,8 +158,54 @@ async def test_add_success_dedup_guard_and_limit(tmp_path, monkeypatch): r = await store.add("memory", " ") assert not r.ok + # Over-budget add: the message must disclose the exact free space (delimiter + # included) and list current entries so the model can free room in one step, + # instead of blind-shrinking against an invisible boundary. r = await store.add("memory", "x" * 60) - assert not r.ok and "limit" in r.message.lower() + assert not r.ok + # used=11 ("uses pytest"), overhead=3 (delimiter), free = 40 - 11 - 3 = 26. + assert "26 free" in r.message + assert "11/40" in r.message + assert "uses pytest" in r.message # inventory preview present + + +async def test_add_rejection_accounts_for_delimiter_overhead(tmp_path, monkeypatch): + """An entry that fits the raw budget but not the delimiter must still be rejected, + and the message must state the true free budget (limit - used - delimiter).""" + store = _store(tmp_path, monkeypatch) # limit 40 + await store.add("memory", "x" * 35) # used = 35 + # Raw free = 5, but a new entry also costs the 3-char delimiter, so only 2 + # content chars actually fit. A 4-char entry (35+3+4=42 > 40) must be rejected. + r = await store.add("memory", "abcd") + assert not r.ok + assert "2 free" in r.message + # And the largest entry that DOES fit (35+3+2=40) is accepted. + r = await store.add("memory", "ab") + assert r.ok + + +async def test_status_reports_capacity_and_inventory(tmp_path, monkeypatch): + store = _store(tmp_path, monkeypatch) # limit 40 + + empty = await store.status("memory") + assert "0/40" in empty + assert "(none)" in empty + + await store.add("memory", "uses pytest") + s = await store.status("memory") + assert "11/40" in s + assert "26 free" in s # 40 - 11 - 3 + assert "uses pytest" in s + + +async def test_replace_over_limit_reports_overage(tmp_path, monkeypatch): + store = _store(tmp_path, monkeypatch) # limit 40 + await store.add("memory", "short") # used = 5 + + r = await store.replace("memory", "short", "y" * 50) + assert not r.ok + assert "by 10 chars" in r.message # 50 - 40 + assert "short" in r.message # inventory present async def test_replace_matches_substring_and_errors(tmp_path, monkeypatch): diff --git a/tests/core/test_session.py b/tests/core/test_session.py index 5c080ad0..f9304661 100644 --- a/tests/core/test_session.py +++ b/tests/core/test_session.py @@ -650,6 +650,24 @@ async def test_exception_cleanup_none_session(): # --------------------------------------------------------------------------- +async def _simulate_exception_cleanup(session: Session | None) -> None: + """Replicate exception-path cleanup from cli/__init__.py _reload_loop.""" + import contextlib + + if session is None: + return + with contextlib.suppress(Exception): + from pythinker_code.scratchpad import cleanup_session_scratch + + await cleanup_session_scratch( + session.work_dir, + session_id=session.id, + session_title=session.title, + ) + if session.is_empty(): + await session.delete() + + async def test_exception_cleanup_deletes_empty_current_session( isolated_share_dir: Path, work_dir: HostPath ): @@ -660,12 +678,30 @@ async def test_exception_cleanup_deletes_empty_current_session( assert session.is_empty() _latest_created_session: Session | None = session - if _latest_created_session is not None and _latest_created_session.is_empty(): - await _latest_created_session.delete() + await _simulate_exception_cleanup(_latest_created_session) assert not session_dir.exists() +async def test_exception_cleanup_removes_session_scratch_file( + isolated_share_dir: Path, + work_dir: HostPath, +): + """Exception handler deletes the per-session scratch file like _post_run.""" + from pythinker_code.scratchpad import session_scratch_path + + session = await Session.create(work_dir) + _write_context_records(session.context_file, {"role": "_system_prompt", "content": "p"}) + scratch_path = session_scratch_path(work_dir, session_id=session.id) + scratch_path.parent.mkdir(parents=True, exist_ok=True) + scratch_path.write_text("# scratch\n", encoding="utf-8") + assert scratch_path.is_file() + + await _simulate_exception_cleanup(session) + + assert not scratch_path.exists() + + async def test_exception_cleanup_preserves_nonempty_current_session( isolated_share_dir: Path, work_dir: HostPath ): @@ -677,12 +713,32 @@ async def test_exception_cleanup_preserves_nonempty_current_session( assert not session.is_empty() _latest_created_session: Session | None = session - if _latest_created_session is not None and _latest_created_session.is_empty(): - await _latest_created_session.delete() + await _simulate_exception_cleanup(_latest_created_session) assert session_dir.exists(), "Non-empty session must survive exception cleanup" +async def test_exception_cleanup_removes_scratch_even_for_nonempty_session( + isolated_share_dir: Path, + work_dir: HostPath, +): + """Scratch cleanup mirrors _post_run: always delete the session scratch file.""" + from pythinker_code.scratchpad import session_scratch_path + + session = await Session.create(work_dir) + _write_context_message(session.context_file, "real work") + _write_wire_turn(session.dir, "real") + session_dir = session.dir + scratch_path = session_scratch_path(work_dir, session_id=session.id) + scratch_path.parent.mkdir(parents=True, exist_ok=True) + scratch_path.write_text("# scratch\n", encoding="utf-8") + + await _simulate_exception_cleanup(session) + + assert session_dir.exists() + assert not scratch_path.exists() + + async def test_exception_cleanup_targets_current_not_previous( isolated_share_dir: Path, work_dir: HostPath ): @@ -706,8 +762,7 @@ async def test_exception_cleanup_targets_current_not_previous( # Exception handler only looks at _latest_created_session (B), # NOT at last_session (A from previous Reload). _latest_created_session: Session | None = session_b - if _latest_created_session is not None and _latest_created_session.is_empty(): - await _latest_created_session.delete() + await _simulate_exception_cleanup(_latest_created_session) assert not session_b_dir.exists(), "Empty session B from failed _run() should be deleted" assert session_a_dir.exists(), "Non-empty session A from previous iteration must be preserved" diff --git a/tests/core/test_slash_recap.py b/tests/core/test_slash_recap.py new file mode 100644 index 00000000..10a70bcd --- /dev/null +++ b/tests/core/test_slash_recap.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pythinker_core.tooling.empty import EmptyToolset + +from pythinker_code.config import get_default_config +from pythinker_code.soul.agent import Agent, Runtime +from pythinker_code.soul.context import Context +from pythinker_code.soul.pythinkersoul import PythinkerSoul +from pythinker_code.soul.slash import recap as recap_slash +from pythinker_code.wire.types import TextPart + + +def _make_soul(runtime: Runtime, tmp_path: Path) -> PythinkerSoul: + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + return PythinkerSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + + +async def _run_recap(soul: PythinkerSoul, args: str = "") -> None: + await recap_slash(soul, args) + + +async def test_recap_on_persists_and_updates_runtime( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = (tmp_path / "config.toml").resolve() + runtime.config.source_file = config_path + runtime.config.tui.turn_recaps = False + soul = _make_soul(runtime, tmp_path) + sent: list[TextPart] = [] + + config_for_save = get_default_config() + monkeypatch.setattr("pythinker_code.soul.slash.load_config", Mock(return_value=config_for_save)) + save_mock = Mock() + monkeypatch.setattr("pythinker_code.soul.slash.save_config", save_mock) + monkeypatch.setattr("pythinker_code.soul.slash.wire_send", lambda msg: sent.append(msg)) + + await _run_recap(soul, "on") + + assert runtime.config.tui.turn_recaps is True + assert config_for_save.tui.turn_recaps is True + save_mock.assert_called_once_with(config_for_save, config_path) + assert any("Turn recaps on" in msg.text for msg in sent) + + +async def test_recap_off_without_config_file_updates_runtime_only( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.source_file = None + runtime.config.tui.turn_recaps = True + soul = _make_soul(runtime, tmp_path) + sent: list[TextPart] = [] + save_mock = Mock() + + monkeypatch.setattr("pythinker_code.soul.slash.save_config", save_mock) + monkeypatch.setattr("pythinker_code.soul.slash.wire_send", lambda msg: sent.append(msg)) + + await _run_recap(soul, "off") + + assert runtime.config.tui.turn_recaps is False + save_mock.assert_not_called() + assert any("current session only" in msg.text for msg in sent) diff --git a/tests/test_session_recap.py b/tests/test_session_recap.py index 466ec454..d386f3f7 100644 --- a/tests/test_session_recap.py +++ b/tests/test_session_recap.py @@ -87,6 +87,16 @@ def test_build_turn_recap_line_strips_report_blocks() -> None: assert line == ("※ recap: Deep scan completed. · 28 steps (disable recaps in /config)") +def test_build_turn_recap_line_ignores_internal_system_reminder_request() -> None: + line = build_turn_recap_line( + request="Background tasks completed while you were idle.", + assistant_text="Server is up (PID 468, LISTENING on 3020) and the post returns HTTP 200.", + step_count=3, + ) + + assert line is None + + def test_build_turn_recap_line_strips_markdown_tables_from_request() -> None: line = build_turn_recap_line( request=( diff --git a/tests/tools/test_mcp_skill_bridge.py b/tests/tools/test_mcp_skill_bridge.py new file mode 100644 index 00000000..a72b6a74 --- /dev/null +++ b/tests/tools/test_mcp_skill_bridge.py @@ -0,0 +1,29 @@ +"""Unit tests for MCP skill bridge helpers.""" + +from __future__ import annotations + +from pythinker_code.tools.skill._mcp_bridge import ( + find_mcp_server_for_skill_name, + skill_lookup_keys, +) + + +def test_skill_lookup_keys_includes_plugin_suffix() -> None: + assert skill_lookup_keys("designer-skill:designer-skill") == ( + "designer-skill:designer-skill", + "designer-skill", + ) + + +def test_find_mcp_server_for_skill_name_matches_server() -> None: + mcp_tools = { + "mcp__designer-skill__get_design_system": object(), + "mcp__designer-skill__anti_slop_checklist": object(), + } + + match = find_mcp_server_for_skill_name("designer-skill", mcp_tools) + + assert match is not None + server, tools = match + assert server == "designer-skill" + assert tools == ["anti_slop_checklist", "get_design_system"] diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index 9565e4db..c1015a52 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -58,6 +58,21 @@ async def test_memory_tool_add_and_read_back(tmp_path, monkeypatch): assert calls == ["project_memory"] +async def test_memory_tool_list_reports_status(tmp_path, monkeypatch): + from pythinker_code.tools.memory import Params + + tool = _make_tool(tmp_path, monkeypatch) + await tool._store.add("memory", "uses pytest") + + # `list` is read-only: needs no content/old_text and must not rearm injection. + rearmed: list[str] = [] + tool._runtime.rearm_injection = rearmed.append + res = await tool(Params(action="list", target="memory")) + assert res.is_error is False + assert "uses pytest" in res.output + assert rearmed == [] + + async def test_memory_tool_missing_content_errors(tmp_path, monkeypatch): from pythinker_code.tools.memory import Params diff --git a/tests/tools/test_skill_tool.py b/tests/tools/test_skill_tool.py index 3ca3339d..b9392052 100644 --- a/tests/tools/test_skill_tool.py +++ b/tests/tools/test_skill_tool.py @@ -54,6 +54,56 @@ async def test_read_skill_reports_missing_skill(runtime) -> None: assert result.brief == "Skill not found" +async def test_read_skill_resolves_plugin_style_alias(runtime, tmp_path: Path) -> None: + skill_dir = tmp_path / "designer-skill" + skill_dir.mkdir() + skill_path = skill_dir / "SKILL.md" + skill_path.write_text("Use MCP tools for design.", encoding="utf-8") + runtime.skills = { + "designer-skill": _skill("designer-skill", skill_path, scope="builtin"), + } + + result = await ReadSkill(runtime)(ReadSkill.params(skill_name="designer-skill:designer-skill")) + + assert not result.is_error + assert isinstance(result.output, str) + assert "skill: designer-skill" in result.output + assert "Use MCP tools for design." in result.output + + +async def test_read_skill_mcp_bridge_when_filesystem_skill_missing(runtime) -> None: + runtime.skills = {} + runtime.mcp_tools = { + "mcp__designer-skill__get_design_system": object(), + "mcp__designer-skill__get_reference": object(), + "mcp__designer-skill__anti_slop_checklist": object(), + } + + result = await ReadSkill(runtime)(ReadSkill.params(skill_name="designer-skill")) + + assert not result.is_error + assert isinstance(result.output, str) + assert "MCP bridge" in result.output + assert "mcp__designer-skill__get_design_system" in result.output + assert "anti_slop_checklist" in result.output + + +async def test_read_skill_mcp_bridge_works_for_user_added_server(runtime) -> None: + runtime.skills = {} + runtime.mcp_tools = { + "mcp__my-research__search": object(), + "mcp__my-research__extract": object(), + } + + result = await ReadSkill(runtime)(ReadSkill.params(skill_name="my-research")) + + assert not result.is_error + assert isinstance(result.output, str) + assert "# MCP skill bridge: my-research" in result.output + assert "mcp__my-research__search" in result.output + assert "get_design_system" not in result.output + + async def test_read_skill_appends_resource_manifest(runtime, tmp_path: Path) -> None: # skills-1: a subdirectory skill referencing scripts/ and references/ must # surface those bundled files at runtime so the model knows they exist and diff --git a/tests/tools/test_str_replace_file.py b/tests/tools/test_str_replace_file.py index ec2db1f3..68b0f13b 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -139,6 +139,54 @@ async def test_replace_accepts_edit_aliases( assert await file_path.read_text() == "new content" +async def test_malformed_edit_batch_returns_actionable_error( + str_replace_file_tool: StrReplaceFile, temp_work_dir: HostPath +): + """A list batch with collapsed entries gets a clear resend-as-singles error. + + Mirrors a streaming glitch where edit entries degrade to ``{"$text": ...}`` + instead of ``{old, new}``. The file must be left untouched. + """ + file_path = temp_work_dir / "test.txt" + await file_path.write_text("alpha beta gamma") + + result = await str_replace_file_tool.call( + { + "path": str(file_path), + "edit": [ + {"old": "alpha", "new": "one"}, + {"$text": "beta"}, + {"$text": "false"}, + ], + } + ) + + assert result.is_error + assert "Malformed `edit` batch" in result.message + assert "entries 2, 3" in result.message + assert "its own StrReplaceFile call" in result.message + # No partial application: the one valid entry must NOT have been applied. + assert await file_path.read_text() == "alpha beta gamma" + + +async def test_valid_edit_batch_is_unaffected_by_malformed_guard( + str_replace_file_tool: StrReplaceFile, temp_work_dir: HostPath +): + """The malformed-batch guard never trips on a fully valid list batch.""" + file_path = temp_work_dir / "test.txt" + await file_path.write_text("alpha beta gamma") + + result = await str_replace_file_tool.call( + { + "path": str(file_path), + "edit": [{"old": "alpha", "new": "one"}, {"old": "beta", "new": "two"}], + } + ) + + assert not result.is_error + assert await file_path.read_text() == "one two gamma" + + async def test_replace_multiline_content( str_replace_file_tool: StrReplaceFile, temp_work_dir: HostPath ): diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index d768863f..17d7e010 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -251,6 +251,28 @@ def test_working_indicator_pins_todos_under_spinner(monkeypatch): assert "Tip:" not in rendered +def test_working_indicator_tip_wraps_with_hanging_indent(monkeypatch) -> None: + now = 1000.0 + monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) + long_tip = ( + "Type /login to configure providers and /model to switch models quickly " + "without leaving the shell." + ) + monkeypatch.setattr(live_view_module, "current_tip", lambda _now: long_tip) + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view._turn_start_time = now - 10.0 + + console = Console(width=36, record=True, highlight=False) + console.print(view._working_indicator()) + lines = [line.rstrip() for line in console.export_text().splitlines() if line.strip()] + + assert any("Tip:" in line for line in lines) + assert len(lines) >= 2 + for line in lines[1:]: + assert line.startswith(" "), f"wrapped tip line lost hanging indent: {line!r}" + + def test_working_indicator_keeps_done_todos_pinned(monkeypatch): now = 1000.0 monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) diff --git a/tests/ui_and_conv/test_memory_slash.py b/tests/ui_and_conv/test_memory_slash.py index aa4272e0..65e73716 100644 --- a/tests/ui_and_conv/test_memory_slash.py +++ b/tests/ui_and_conv/test_memory_slash.py @@ -57,6 +57,24 @@ async def _boom(*args, **kwargs): assert called["scan"] is False +async def test_memory_shows_capacity_line(tmp_path, monkeypatch, capsys): + """Bare `/memory` prints a capacity summary so the user can see how full it is.""" + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path / "share")) + from pythinker_code.project_memory import ProjectMemoryStore + from pythinker_code.ui.shell import slash + + soul = _fake_soul(tmp_path, consolidation=False) + monkeypatch.setattr(slash, "ensure_pythinker_soul", lambda app: soul) + + store = ProjectMemoryStore(soul.runtime.work_dir) + await store.add("memory", "uses pytest") + + await _run("", SimpleNamespace()) + out = capsys.readouterr().out + assert "Capacity" in out + assert "/5000" in out # raised project-memory limit + + async def test_memory_inbox_enabled_invokes_scan(tmp_path, monkeypatch, capsys): """With the flag on, `/memory inbox scan` reaches the consolidation path.""" from pythinker_code.ui.shell import slash diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 6351f287..cc34b5d2 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -14,6 +14,7 @@ from pythinker_code.llm import ModelCapability from pythinker_code.soul import StatusSnapshot from pythinker_code.ui.shell import prompt as shell_prompt +from pythinker_code.ui.shell.glyphs import SPINNER_FRAMES from pythinker_code.ui.shell.prompt import ( _GIT_STATUS_TTL, PROMPT_SYMBOL, @@ -560,17 +561,18 @@ def test_bottom_toolbar_shows_agent_badge_alone_when_no_bash(monkeypatch: Any) - assert "◇ agent: 2" in lines[1], f"agent badge missing: {lines[1]!r}" -def test_background_working_status_uses_pulsing_circle(monkeypatch: Any) -> None: +def test_background_working_status_uses_braille_spinner(monkeypatch: Any) -> None: prompt_session = _make_toolbar_session() prompt_session._background_task_count_provider = lambda: BgTaskCounts(bash=0, agent=1) - monkeypatch.setattr(shell_prompt.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(shell_prompt.time, "monotonic", lambda: 10.0) first = "".join(text for _, text, *_ in prompt_session._render_background_working_status(80)) - monkeypatch.setattr(shell_prompt.time, "monotonic", lambda: 0.9) + monkeypatch.setattr(shell_prompt.time, "monotonic", lambda: 10.08) second = "".join(text for _, text, *_ in prompt_session._render_background_working_status(80)) assert first != second - assert first.startswith("● ") + assert first[0] in SPINNER_FRAMES + assert second[0] in SPINNER_FRAMES assert "background agent" not in first # footer owns the count assert "background agent" not in second # footer owns the count @@ -1432,6 +1434,41 @@ def test_modal_prompt_keeps_input_buffer_when_text_input_is_allowed() -> None: assert prompt_session._should_render_input_buffer() is True +def test_recap_slash_exact_suggestion_reflects_recaps_state() -> None: + command = SlashCommand( + name="recap", + description="Recap sessions", + func=_dummy_slash_func, + aliases=[], + ) + recaps_enabled = True + prompt_session = CustomPromptSession( + status_provider=lambda: StatusSnapshot(context_usage=0.0), + model_capabilities=set(), + model_name=None, + thinking=False, + agent_mode_slash_commands=[command], + shell_mode_slash_commands=[], + turn_recaps_provider=lambda: recaps_enabled, + ) + document = shell_prompt.Document(text="/recap", cursor_position=len("/recap")) + + suggestion = prompt_session._slash_auto_suggest.get_suggestion( + prompt_session._session.default_buffer, + document, + ) + assert suggestion is not None + assert suggestion.text == " off" + + recaps_enabled = False + suggestion = prompt_session._slash_auto_suggest.get_suggestion( + prompt_session._session.default_buffer, + document, + ) + assert suggestion is not None + assert suggestion.text == " on" + + def test_modal_prompt_suspends_and_restores_existing_draft_when_input_is_hidden() -> None: prompt_session = CustomPromptSession( status_provider=lambda: StatusSnapshot(context_usage=0.0), diff --git a/tests/ui_and_conv/test_selectors_simple.py b/tests/ui_and_conv/test_selectors_simple.py index 4d1f3147..f37ea6cc 100644 --- a/tests/ui_and_conv/test_selectors_simple.py +++ b/tests/ui_and_conv/test_selectors_simple.py @@ -8,6 +8,7 @@ from pythinker_code.ui.shell.selector import ( SelectorItem, + _format_item_line, # type: ignore[reportPrivateUsage] _SelectorState, # type: ignore[reportPrivateUsage] ) @@ -216,6 +217,27 @@ def test_oauth_selector_status_configured(): assert "✓" in _format_status_indicator(OAuthProviderStatus(source="configured")) +def test_oauth_selector_configured_checkmark_uses_success_style(): + from pythinker_code.ui.shell.selectors.oauth import ( + OAuthProviderEntry, + OAuthProviderStatus, + _build_oauth_config, + ) + + config = _build_oauth_config( + [OAuthProviderEntry(id="openai", name="OpenAI", auth_type="oauth")], + lambda _: OAuthProviderStatus(source="configured"), + action="login", + ) + item = config.items[0] + assert isinstance(item, SelectorItem) + + rendered = _format_item_line(item, is_selected=False, width=80) + + assert ("class:slash-completion-menu.meta.success", "✓") in rendered + assert ("class:slash-completion-menu.meta.warning", " configured") in rendered + + def test_oauth_selector_status_unconfigured(): from pythinker_code.ui.shell.selectors.oauth import ( OAuthProviderStatus, diff --git a/tests/ui_and_conv/test_settings_recaps_slash.py b/tests/ui_and_conv/test_settings_recaps_slash.py index 7d5f7446..7498a1b5 100644 --- a/tests/ui_and_conv/test_settings_recaps_slash.py +++ b/tests/ui_and_conv/test_settings_recaps_slash.py @@ -59,6 +59,26 @@ async def test_recaps_on_persists_and_reloads( assert config_for_save.tui.turn_recaps is True +@pytest.mark.asyncio +async def test_recap_singular_on_persists_and_reloads( + runtime: Runtime, tmp_path: Path, monkeypatch +) -> None: + config_path = (tmp_path / "config.toml").resolve() + runtime.config.source_file = config_path + runtime.config.tui.turn_recaps = False + app = _make_shell_app(runtime, tmp_path) + + config_for_save = get_default_config() + monkeypatch.setattr(shell_slash, "load_config", Mock(return_value=config_for_save)) + monkeypatch.setattr(shell_slash, "save_config", Mock()) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + + with pytest.raises(Reload): + await _run_settings(app, "recap on") + + assert config_for_save.tui.turn_recaps is True + + @pytest.mark.asyncio async def test_recaps_off_persists(runtime: Runtime, tmp_path: Path, monkeypatch) -> None: config_path = (tmp_path / "config.toml").resolve() diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index ffcb17e6..4590d916 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -81,6 +81,39 @@ def test_exact_alias_match_keeps_completions_visible(): assert "/help" in texts +def test_command_name_prefix_outranks_exact_alias_match(): + """Typing toward a command name surfaces that command first, even when a + different command claims the typed text as an exact alias. ``/report`` lists + ``/reports`` above ``/report_error`` (whose alias is ``report``).""" + completer = SlashCommandCompleter( + [ + _make_command("report_error", aliases=["report-error", "report"]), + _make_command("reports"), + ] + ) + + texts = _completion_texts(completer, "/report") + + assert texts == ["/reports", "/report_error"] + + +def test_shorter_command_name_prefix_ranks_first(): + """Within the same match tier the closest (shortest) command name wins.""" + completer = SlashCommandCompleter( + [ + _make_command("settings"), + _make_command("set"), + _make_command("setup-wizard"), + ] + ) + + assert _completion_texts(completer, "/set") == [ + "/set", + "/settings", + "/setup-wizard", + ] + + def test_should_complete_only_for_root_slash_token(): assert SlashCommandCompleter.should_complete(Document(text="/", cursor_position=1)) assert SlashCommandCompleter.should_complete(Document(text=" /he", cursor_position=5)) @@ -96,6 +129,15 @@ def _suggestion_text(names: frozenset[str], text: str) -> str | None: return suggestion.text if suggestion else None +def _suggestion_text_with_exact( + names: frozenset[str], exact: dict[str, str], text: str +) -> str | None: + suggest = SlashCommandAutoSuggest(lambda: names, exact_suggestions=lambda: exact) + document = Document(text=text, cursor_position=len(text)) + suggestion = suggest.get_suggestion(Buffer(), document) + return suggestion.text if suggestion else None + + def test_auto_suggest_completes_best_prefix_match(): """Typing a slash prefix ghost-renders the remainder of the first matching command (alphabetical), which Tab accepts inline.""" @@ -127,6 +169,13 @@ def test_auto_suggest_inactive_outside_root_slash_token(): assert _suggestion_text(names, "plain text") is None +def test_auto_suggest_exact_recap_toggle_hint(): + names = frozenset({"recap", "help"}) + assert _suggestion_text_with_exact(names, {"recap": " off"}, "/recap") == " off" + assert _suggestion_text_with_exact(names, {"recap": " off"}, "please /recap") == " off" + assert _suggestion_text_with_exact(names, {"recap": " off"}, "/recap today") is None + + def test_auto_suggest_is_case_insensitive_on_typed_prefix(): names = frozenset({"help"}) assert _suggestion_text(names, "/He") == "lp" diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 2c1dabc7..4aa32a53 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -386,6 +386,29 @@ def render_agent_status(self, columns: int): # noqa: ARG002 assert "1 background agent" not in text +def test_prompt_status_keeps_background_spinner_during_blocking_task_output() -> None: + session = object.__new__(CustomPromptSession) + session._background_task_count_provider = lambda: BgTaskCounts(agent=2) + session._status_block_provider = None + session._latest_todos = () + + class _BlockingTaskOutputDelegate: + def render_agent_status(self, columns: int): # noqa: ARG002 + return "TaskOutput(agent-reviewer · block, timeout 600s)" + + def render_pinned_status_tail(self, columns: int): # noqa: ARG002 + return "" + + session._running_prompt_delegate = cast(Any, _BlockingTaskOutputDelegate()) + + rendered = CustomPromptSession._render_agent_status(session, 80) + text = "".join(item[1] for item in rendered) + + assert "TaskOutput(agent-reviewer" in text + assert "…" in text + assert "2 background agents" not in text + + def test_prompt_status_keeps_todos_visible_during_background_tasks() -> None: session = object.__new__(CustomPromptSession) session._running_prompt_delegate = None diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 1d9e3091..ac7a80ef 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -128,6 +128,10 @@ def test_pyinstaller_datas(): "src/pythinker_code/skills/create-pr/SKILL.md", "pythinker_code/skills/create-pr", ), + ( + "src/pythinker_code/skills/designer-skill/SKILL.md", + "pythinker_code/skills/designer-skill", + ), ( "src/pythinker_code/skills/diagnose-ci-failures/SKILL.md", "pythinker_code/skills/diagnose-ci-failures", @@ -319,6 +323,7 @@ def test_pyinstaller_hiddenimports(): "pythinker_code.tools.scratchpad", "pythinker_code.tools.shell", "pythinker_code.tools.skill", + "pythinker_code.tools.skill._mcp_bridge", "pythinker_code.tools.suggest", "pythinker_code.tools.test", "pythinker_code.tools.think", diff --git a/vis/package-lock.json b/vis/package-lock.json index cbb602e0..123eaf3c 100644 --- a/vis/package-lock.json +++ b/vis/package-lock.json @@ -20,8 +20,8 @@ "clsx": "^2.1.1", "lucide-react": "^0.561.0", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-virtuoso": "4.17.0", "shadcn": "^3.7.0", "streamdown": "^2.3.0", @@ -31,7 +31,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "typescript": "~5.9.3", @@ -87,6 +87,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -640,27 +641,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -938,6 +918,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3078,25 +3059,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "inBundle": true, @@ -3304,16 +3266,18 @@ "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3324,6 +3288,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3592,6 +3557,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4476,6 +4442,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5079,6 +5046,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7573,24 +7541,26 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.7" } }, "node_modules/react-refresh": { @@ -8597,6 +8567,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8891,6 +8862,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -9186,6 +9158,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/vis/package.json b/vis/package.json index 374ab620..2a2f7a89 100644 --- a/vis/package.json +++ b/vis/package.json @@ -22,8 +22,8 @@ "clsx": "^2.1.1", "lucide-react": "^0.561.0", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-virtuoso": "4.17.0", "shadcn": "^3.7.0", "streamdown": "^2.3.0", @@ -33,7 +33,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "typescript": "~5.9.3", diff --git a/vis/src/App.tsx b/vis/src/App.tsx index 59daee6f..8de1fda4 100644 --- a/vis/src/App.tsx +++ b/vis/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { SessionsExplorer } from "@/features/sessions-explorer/sessions-explorer"; import { StatisticsView } from "@/features/statistics/statistics-view"; +import { UsageView } from "@/features/usage/usage-view"; import { WireViewer } from "@/features/wire-viewer/wire-viewer"; import { ContextViewer } from "@/features/context-viewer/context-viewer"; import { StateViewer } from "@/features/state-viewer/state-viewer"; @@ -19,6 +20,7 @@ import { } from "@/lib/api"; import { isErrorEvent } from "@/features/wire-viewer/wire-event-card"; import { + Activity, ArrowLeft, BarChart3, Bot, @@ -27,6 +29,7 @@ import { Copy, Download, FolderOpen, + LineChart, List, Moon, RefreshCw, @@ -289,7 +292,9 @@ export function App() { return params.get("session"); }); const [activeTab, setActiveTab] = useState("wire"); - const [explorerView, setExplorerView] = useState<"sessions" | "statistics">("sessions"); + const [explorerView, setExplorerView] = useState< + "sessions" | "statistics" | "usage" + >("sessions"); const [showShortcutHelp, setShowShortcutHelp] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [refreshing, setRefreshing] = useState(false); @@ -389,20 +394,41 @@ export function App() { return (
{/* Header */} -
-

+

+ {sessionId ? ( + + ) : ( + + + + )} + + + Pythinker Agent Tracing + + {!sessionId && ( + + Monitor sessions, tools, tokens, and project activity + + )} + +
{/* Explorer content */} - {explorerView === "sessions" ? ( + {explorerView === "sessions" && ( - ) : ( - )} + {explorerView === "statistics" && } + {explorerView === "usage" && } )} diff --git a/vis/src/components/metric-card.tsx b/vis/src/components/metric-card.tsx new file mode 100644 index 00000000..0fca99b9 --- /dev/null +++ b/vis/src/components/metric-card.tsx @@ -0,0 +1,44 @@ +import { type LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export interface MetricCardProps { + label: string; + value: string; + helper?: string; + icon: LucideIcon; + className?: string; +} + +/** Premium stat card: label, large value, helper line, and an accent icon tile. */ +export function MetricCard({ + label, + value, + helper, + icon: Icon, + className, +}: MetricCardProps) { + return ( +
+
+
+

{label}

+

+ {value} +

+
+
+ +
+
+ {helper && ( +

{helper}

+ )} +
+ ); +} diff --git a/vis/src/components/ui/card.tsx b/vis/src/components/ui/card.tsx new file mode 100644 index 00000000..29c7107d --- /dev/null +++ b/vis/src/components/ui/card.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/vis/src/features/sessions-explorer/explorer-toolbar.tsx b/vis/src/features/sessions-explorer/explorer-toolbar.tsx index 3eba99bc..90845a83 100644 --- a/vis/src/features/sessions-explorer/explorer-toolbar.tsx +++ b/vis/src/features/sessions-explorer/explorer-toolbar.tsx @@ -69,22 +69,27 @@ export function ExplorerToolbar({
onSearchChange(e.target.value)} placeholder="Search sessions..." data-session-search - className="w-full rounded border bg-background pl-7 pr-7 py-1 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="h-8 w-full rounded-md border bg-background pl-8 pr-8 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" /> - {search && ( + {search ? ( + ) : ( + + / + )}
@@ -112,7 +117,8 @@ export function ExplorerToolbar({ {/* Imported filter toggle */} diff --git a/vis/src/features/sessions-explorer/session-card.tsx b/vis/src/features/sessions-explorer/session-card.tsx index b230f379..f72760b3 100644 --- a/vis/src/features/sessions-explorer/session-card.tsx +++ b/vis/src/features/sessions-explorer/session-card.tsx @@ -178,7 +178,7 @@ export function SessionCard({ session, onSelect, compact, searchQuery, onDeleted <> + ))} +
+ ); +} + +function ActivityHeatmapCard({ daily }: { daily: DailyUsage[] }) { + const [metric, setMetric] = useState("turns"); + + return ( + + +
+ Activity Heatmap + + Daily {metric} over the last 30 days + +
+ +
+ + + +
+

+ Shown in your local timezone +

+ +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Insights panel */ +/* ------------------------------------------------------------------ */ + +function InsightRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function UsageInsightsCard({ + peak, + activeDays, + quietDays, + avgActiveTurns, +}: { + peak: DailyUsage | null; + activeDays: number; + quietDays: number; + avgActiveTurns: number; +}) { + return ( + + +
+ Usage Insights + Summary for the last 30 days +
+
+ + 0 ? peak.date.slice(5) : "None"} + /> + + + + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Main UsageView */ +/* ------------------------------------------------------------------ */ + +export function UsageView() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + getAggregateStats() + .then(setStats) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + }, []); + + const derived = useMemo(() => { + const daily = stats?.daily_usage ?? []; + const totalTurns = daily.reduce((s, d) => s + d.turns, 0); + const totalSessions = daily.reduce((s, d) => s + d.sessions, 0); + const activeDays = daily.filter((d) => d.turns > 0).length; + const quietDays = daily.length - activeDays; + const avgActiveTurns = activeDays > 0 ? Math.round(totalTurns / activeDays) : 0; + const peak = daily.reduce( + (best, d) => (best === null || d.turns > best.turns ? d : best), + null, + ); + return { + daily, + totalTurns, + totalSessions, + activeDays, + quietDays, + avgActiveTurns, + peak, + }; + }, [stats]); + + if (loading) { + return ( +
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ Failed to load usage data +

+

{error}

+
+
+ ); + } + + if (!stats || derived.totalTurns === 0) { + return ( +
+
+
+ +
+

No usage recorded yet

+

+ Activity will appear here once sessions record turns. Run{" "} + + pythinker + {" "} + to get started. +

+
+
+ ); + } + + return ( +
+
+ {/* Summary cards */} +
+ + + + 0 + ? `Highest usage on ${derived.peak.date.slice(5)}` + : "No peak yet" + } + icon={Flame} + /> +
+ + {/* Heatmap + Insights */} +
+ + +
+ + {/* Trend */} + + +
+ Turn Trend + + Turn volume per day across the last 30 days + +
+
+ + + +
+
+
+ ); +} diff --git a/vis/src/index.css b/vis/src/index.css index c6b6a1b0..d10631be 100644 --- a/vis/src/index.css +++ b/vis/src/index.css @@ -13,8 +13,8 @@ --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(1 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); @@ -24,7 +24,7 @@ --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.552 0.016 285.938); + --ring: oklch(0.623 0.214 259.815); --destructive-foreground: oklch(1 0 0); } @@ -35,8 +35,8 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.18 0.005 285.823); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.75 0.02 285.885); - --primary-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); @@ -46,7 +46,7 @@ --destructive: oklch(0.704 0.191 22.216); --border: oklch(0.274 0.006 286.033); --input: oklch(0.274 0.006 286.033); - --ring: oklch(0.552 0.016 285.938); + --ring: oklch(0.623 0.214 259.815); --destructive-foreground: oklch(0.141 0.005 285.823); } @@ -164,6 +164,17 @@ body { font-weight: 600; } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + ::-webkit-scrollbar { width: 6px; height: 6px; diff --git a/web/package-lock.json b/web/package-lock.json index 495e646c..6d61f59c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -44,8 +44,8 @@ "motion": "^12.23.24", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-error-boundary": "^6.0.0", "react-resizable-panels": "^4.0.7", "react-scan": "^0.5.7", @@ -69,7 +69,7 @@ "@types/js-md5": "^0.8.0", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "rollup-plugin-visualizer": "6.0.3", @@ -187,6 +187,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -881,6 +882,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1048,27 +1050,6 @@ "effect": "^4.0.0-beta.70" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1084,7 +1065,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "license": "MIT", - "peer": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -1103,7 +1083,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -1116,7 +1095,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1126,7 +1104,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", @@ -1141,7 +1118,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^1.2.1" }, @@ -1154,7 +1130,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1167,7 +1142,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } @@ -1177,7 +1151,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" @@ -1259,7 +1232,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/types": "^0.15.0" }, @@ -1272,7 +1244,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", @@ -1287,7 +1258,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -1297,7 +1267,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1311,7 +1280,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1693,6 +1661,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1789,6 +1758,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1810,6 +1780,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1825,6 +1796,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", @@ -1858,6 +1830,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", @@ -1875,6 +1848,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -5357,25 +5331,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "inBundle": true, @@ -5912,8 +5867,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/katex": { "version": "0.16.8", @@ -5952,11 +5906,12 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", - "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5967,6 +5922,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6195,6 +6151,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6216,7 +6173,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -6727,6 +6683,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7439,6 +7396,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7848,6 +7806,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8020,8 +7979,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -8318,6 +8276,7 @@ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.70.tgz", "integrity": "sha512-8AwGTRiNriirHGEYHrOS0E9fzdhIqCdZjiHP1YXmNo2UyPGS43ILsymsSHT7V0DJS+8dvlKq2RxnrDBUhDNZHg==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", @@ -8377,7 +8336,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -8639,7 +8599,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8656,7 +8615,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8669,7 +8627,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -8681,15 +8638,13 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", @@ -8720,7 +8675,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -8770,7 +8724,6 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8857,6 +8810,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8967,15 +8921,13 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.2", @@ -9062,7 +9014,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -9130,7 +9081,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -9143,8 +9093,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", @@ -9824,6 +9773,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9975,7 +9925,6 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -10477,8 +10426,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -10508,8 +10456,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -10562,7 +10509,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -10598,7 +10544,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -11371,6 +11316,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -12007,7 +11953,8 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/micromatch": { "version": "4.0.8", @@ -12339,8 +12286,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", @@ -12649,7 +12595,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -13273,6 +13218,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -13283,7 +13229,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -13658,10 +13603,11 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13705,15 +13651,16 @@ "license": "MIT" }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.7" } }, "node_modules/react-error-boundary": { @@ -14337,6 +14284,7 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" @@ -15413,7 +15361,6 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -15595,6 +15542,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -15782,7 +15730,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -15792,7 +15739,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -15970,6 +15916,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -16195,7 +16142,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16383,6 +16329,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 43eb83c3..a98aff0e 100644 --- a/web/package.json +++ b/web/package.json @@ -51,8 +51,8 @@ "motion": "^12.23.24", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-error-boundary": "^6.0.0", "react-resizable-panels": "^4.0.7", "react-scan": "^0.5.7", @@ -76,7 +76,7 @@ "@types/js-md5": "^0.8.0", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "rollup-plugin-visualizer": "6.0.3",